269 lines
12 KiB
Markdown
269 lines
12 KiB
Markdown
# Unity dedicated server — match lifecycle (escrow + always finish)
|
||
|
||
Contract for the Kick Kings dedicated server build against the matchmaker API.
|
||
Header on all `/internal/*` calls: `X-Dedicated-Server-Secret: <secret from settings>`.
|
||
|
||
## Goals
|
||
|
||
1. Collect entry fees into a **per-match escrow** only after **both** players are connected.
|
||
2. Pay the winner (and house fee) **only from that escrow**.
|
||
3. **Never leave a match unfinished** — always report a winner, including disconnect forfeits.
|
||
|
||
## Sequence
|
||
|
||
```
|
||
1. Match row exists (matchmaking or rematch) with match_id, user_red, user_blue, entry_fee
|
||
2. Red connects → PATCH /internal/match/:matchId { "red_joined_at": "<ISO-8601 UTC>" }
|
||
3. Blue connects → PATCH /internal/match/:matchId { "blue_joined_at": "<ISO-8601 UTC>" }
|
||
→ When both join timestamps are set, the API collects entry fees into escrow
|
||
4. Start the match only after collect succeeds (see responses below)
|
||
5. Match ends (score / disconnect / crash path) → PATCH /internal/match/:matchId/winner
|
||
{ "winner": "red" | "blue", "red_connected": true | false, "blue_connected": true | false,
|
||
"red_score": 0-3, "blue_score": 0-3, "forfeit": true | false }
|
||
```
|
||
|
||
## Join PATCH
|
||
|
||
`PATCH /internal/match/:matchId`
|
||
|
||
Example bodies:
|
||
|
||
```json
|
||
{ "red_joined_at": "2026-08-12T18:00:00.000Z" }
|
||
```
|
||
|
||
```json
|
||
{ "blue_joined_at": "2026-08-12T18:00:01.000Z" }
|
||
```
|
||
|
||
### Success when both have joined (collect ran)
|
||
|
||
```json
|
||
{
|
||
"ok": true,
|
||
"id": 123,
|
||
"entries_collected": true,
|
||
"already_collected": false,
|
||
"escrow_user_id": 456
|
||
}
|
||
```
|
||
|
||
### Success when only one side joined yet
|
||
|
||
```json
|
||
{
|
||
"ok": true,
|
||
"id": 123,
|
||
"entries_collected": false
|
||
}
|
||
```
|
||
|
||
### Collect failure (fatal — do not start the match)
|
||
|
||
Typical cases:
|
||
|
||
| HTTP | Meaning |
|
||
|------|---------|
|
||
| 402 | One or both players lack enough RC for `entry_fee` |
|
||
| 409 | Players/joins incomplete (should not happen if you set both joins) |
|
||
| 503 | DB / economy RPCs not installed on the server |
|
||
|
||
On any non-2xx after the second join, **abort kickoff**, disconnect both clients, and do **not** call winner (fees were not escrowed).
|
||
|
||
Idempotent: repeating the join PATCH after a successful collect returns `entries_collected: true` (or `already_collected: true`).
|
||
|
||
## Winner PATCH
|
||
|
||
`PATCH /internal/match/:matchId/winner`
|
||
|
||
`participant_cc` (100 CC) is **not** a join/participation prize for everyone in the match. Award it only to players who **finish the match still connected and did not forfeit**. Players who leave, disconnect, skip-forfeit (auto-forfeit), or forfeit in any other way must not receive it. The opponent who stayed and did not forfeit still gets CC if they were connected.
|
||
|
||
RC prize / escrow payout is unchanged: still paid to the reported `winner`.
|
||
|
||
```json
|
||
{
|
||
"winner": "red",
|
||
"red_connected": true,
|
||
"blue_connected": true,
|
||
"red_score": 3,
|
||
"blue_score": 1,
|
||
"forfeit": false
|
||
}
|
||
```
|
||
|
||
| Field | Type | Meaning |
|
||
|-------|------|---------|
|
||
| `winner` | `"red"` \| `"blue"` | Match winner (unchanged). RC prize goes to this side. |
|
||
| `red_connected` | bool | Dedicated server: red still had a live connection at settle. |
|
||
| `blue_connected` | bool | Dedicated server: blue still had a live connection at settle. |
|
||
| `red_score` | int | Rounds/sets won by red (first-to-3). Send on every settle. |
|
||
| `blue_score` | int | Rounds/sets won by blue. Send on every settle. |
|
||
| `forfeit` | bool | `true` for leave, disconnect, last-disconnect-wins, **and skip/auto-forfeit** (two skipped turns). `false` only for a normal score win. |
|
||
|
||
Set a side to `false` when:
|
||
|
||
- Mirror reports that client disconnected and the reconnect grace expired (or both left)
|
||
- That player pressed Leave (voluntary forfeit) — even if the TCP connection is still up for a moment
|
||
|
||
`true` means that player was still in the match when the outcome was reported (score win, opponent forfeit, or skip-forfeit while they stayed connected). Skip-forfeit still sends `forfeit: true` even if that side is `*_connected: true`.
|
||
|
||
Hidden MMR (admin-only, not shown to players) uses score and forfeit:
|
||
|
||
| Result | MMR % of the Clash-style base |
|
||
|--------|-------------------------------|
|
||
| `forfeit: true` **and** losing side disconnected, or both disconnected | 20% |
|
||
| `forfeit: true` **and** both still connected (skip/auto-forfeit) | score table below |
|
||
| Loser scored 0 (3–0) | 100% |
|
||
| Loser scored 1 (3–1) | 90% |
|
||
| Loser scored 2+ (3–2) | 75% |
|
||
|
||
Skip-forfeit (two skipped turns, both still connected) sends `forfeit: true`. Hidden MMR still uses the score table, not 20%. Old dedicated builds that omit scores and connected flags are treated as 3–0 (100%). 3–1 / 3–2 multipliers apply only once this build sends scores.
|
||
|
||
### Award rules
|
||
|
||
Let `amount` = existing `participant_cc` setting (100). Do **not** infer connectedness from `winner`. The winner can be a disconnected player (last-disconnect-wins); that player must not get CC.
|
||
|
||
Skip-forfeit / auto-forfeit (two skipped turns): send `forfeit: true`. The skipper is the loser and **does not** get CC, even if they are still connected. The opponent gets CC if they were connected.
|
||
|
||
On any forfeit (`forfeit: true` or a disconnected loser), CC is never paid to the losing side.
|
||
|
||
| `red_connected` | `blue_connected` | `forfeit` | CC credit |
|
||
|-----------------|------------------|-----------|-----------|
|
||
| true | true | false | +amount to **both** users |
|
||
| true | true | true | +amount to the **winner only** (skip/auto-forfeit) |
|
||
| true | false | true | +amount to **red only** |
|
||
| false | true | true | +amount to **blue only** |
|
||
| false | false | true | **nobody** gets participation CC |
|
||
|
||
### Backward compatibility
|
||
|
||
New dedicated servers always send both bools.
|
||
|
||
If `red_connected` / `blue_connected` are **omitted** (old dedicated builds):
|
||
|
||
- Keep awarding CC to **both** players (previous behaviour)
|
||
|
||
Do not treat omitted fields as `false`; that would stop CC for every match until the dedicated build is deployed.
|
||
|
||
### Success response
|
||
|
||
Requires entry fees already collected.
|
||
|
||
```json
|
||
{
|
||
"ok": true,
|
||
"id": 123,
|
||
"winner": "red",
|
||
"winner_id": 456,
|
||
"already_settled": false,
|
||
"economy": {
|
||
"entry_fee_rc": 21,
|
||
"rc_prize": 34,
|
||
"participant_cc": 100,
|
||
"cc_awarded_red": true,
|
||
"cc_awarded_blue": false
|
||
},
|
||
"mmr_delta": 27,
|
||
"forfeit": false
|
||
}
|
||
```
|
||
|
||
| Field | Meaning |
|
||
|-------|---------|
|
||
| `winner` | `"red"` or `"blue"` for the **stored** outcome. Honor this, not the `winner` you sent. |
|
||
| `winner_id` | User id of that side. |
|
||
| `already_settled` | `true` if this call did not settle (retry or second forfeit). |
|
||
| `participant_cc` | Amount per finisher (still 100). Not “total CC paid”. |
|
||
| `cc_awarded_red` | Red’s user received `participant_cc` on this settle. |
|
||
| `cc_awarded_blue` | Blue’s user received `participant_cc` on this settle. |
|
||
| `mmr_delta` | Hidden MMR the winner gained (loser lost). Do not show to players. |
|
||
| `forfeit` | Whether the match ended by forfeit (leave, disconnect, last-disconnect, or skip/auto-forfeit). |
|
||
|
||
| HTTP | Meaning |
|
||
|------|---------|
|
||
| 200 | Settled, already settled for the same winner, **or** a second `forfeit: true` that lost the race (`already_settled: true`; use response `winner` / `winner_id`) |
|
||
| 409 | Entries not collected yet, or winner already set to the other side on a **non-forfeit** conflict (score win vs forfeit, or two score wins that disagree) |
|
||
| 500 | Escrow inconsistency (should be rare; escalate) |
|
||
|
||
Call winner **exactly once** when the match outcome is known. Retries with the **same** side are safe if the first call succeeded: they must not pay CC twice, and they repeat the same `cc_awarded_*` flags from the first settle.
|
||
|
||
Simultaneous Leave / skip from both sides may fire two `forfeit: true` PATCHes with opposite `winner` values. That is also safe: the first settle stands; the second is 200 `already_settled` with the stored winner. Apply the **response** `winner`, not the side you requested. Do not treat both players as losers.
|
||
|
||
### Examples
|
||
|
||
Both finish (score 3–1):
|
||
|
||
```json
|
||
{ "winner": "red", "red_connected": true, "blue_connected": true, "red_score": 3, "blue_score": 1, "forfeit": false }
|
||
```
|
||
|
||
→ RC to red, 100 CC to red **and** blue. Hidden MMR uses 90% (3–1).
|
||
|
||
Blue left / disconnected, red wins:
|
||
|
||
```json
|
||
{ "winner": "red", "red_connected": true, "blue_connected": false, "red_score": 1, "blue_score": 0, "forfeit": true }
|
||
```
|
||
|
||
→ RC to red, 100 CC to red only. Hidden MMR uses 20% (forfeit).
|
||
|
||
Both disconnected, last disconnect was blue:
|
||
|
||
```json
|
||
{ "winner": "blue", "red_connected": false, "blue_connected": false, "red_score": 0, "blue_score": 0, "forfeit": true }
|
||
```
|
||
|
||
→ RC to blue, **no** participation CC. Hidden MMR uses 20% (forfeit).
|
||
|
||
Blue skip-forfeit / auto-forfeit (two skipped turns), still connected:
|
||
|
||
```json
|
||
{ "winner": "red", "red_connected": true, "blue_connected": true, "red_score": 1, "blue_score": 0, "forfeit": true }
|
||
```
|
||
|
||
→ RC to red, 100 CC to **red only** (blue forfeited). Hidden MMR uses the score table (100% here, blue scored 0), not 20%.
|
||
|
||
## Always finish — disconnect / forfeit rules
|
||
|
||
Matches must **never** end without a winner call after fees were collected.
|
||
|
||
Track disconnect order on the dedicated server (timestamps or a queue).
|
||
|
||
1. **One player disconnects mid-match**
|
||
Remaining player wins. Set the disconnected side to `false` and `forfeit: true`.
|
||
Example: blue disconnects → `{ "winner": "red", "red_connected": true, "blue_connected": false, "forfeit": true }`.
|
||
|
||
2. **Both players disconnect**
|
||
The **last player who disconnected is the winner**. Both sides are disconnected, so neither gets participation CC.
|
||
Example: red disconnects first, then blue → blue was last to leave → `{ "winner": "blue", "red_connected": false, "blue_connected": false, "forfeit": true }`.
|
||
|
||
3. **Process crash / forced shutdown**
|
||
If fees were collected, choose a winner with the same rules (remaining player, or last disconnect), send accurate `*_connected` flags, and call winner **before** exit whenever possible.
|
||
|
||
4. **Normal score / time win**
|
||
Report the actual winning side, scores, `forfeit: false`, and `true` for any player still connected.
|
||
|
||
5. **Skip-forfeit / auto-forfeit (two skipped turns)**
|
||
Not a disconnect. Both `*_connected` stay `true` if they are still in the match. Send `forfeit: true`. The skipper is the loser and does **not** get CC. The opponent gets CC if connected. Hidden MMR uses the score table, not 20%.
|
||
|
||
6. **Simultaneous forfeits (both Leave / skip at once)**
|
||
Each handler may PATCH with the opponent as `winner` and `forfeit: true`. The API keeps the **first** settle (first forfeiter loses). The second returns **200** `already_settled: true` with the real `winner` / `winner_id` — not 409. Apply the **response** winner. Do not show both players as losers. Do not invent a draw. Score-win vs forfeit disagreement is still 409.
|
||
|
||
Do **not** leave escrow locked with no winner. Do **not** invent a draw path that skips the winner endpoint.
|
||
|
||
## Rematch
|
||
|
||
`POST /internal/rematch` still only creates the room/match row. Entry fees are **not** taken there. Collect still happens when both `*_joined_at` are set on the new `match_id`.
|
||
|
||
## Checklist for the Unity build
|
||
|
||
- [ ] Set `red_joined_at` / `blue_joined_at` in UTC ISO when each client is fully connected
|
||
- [ ] Treat 402/4xx/5xx on the second join as fatal — no kickoff
|
||
- [ ] Gate gameplay start on `entries_collected: true`
|
||
- [ ] Always call winner after collect (score, forfeit, or last-disconnect-wins)
|
||
- [ ] Send `red_connected` / `blue_connected` (do not infer from `winner`; last-disconnect winner with both `false` still gets RC, not CC)
|
||
- [ ] Send `red_score` / `blue_score` on every settle (3–1 / 3–2 MMR multipliers need these)
|
||
- [ ] Send `forfeit: true` on leave, disconnect, last-disconnect, **and skip/auto-forfeit**
|
||
- [ ] Persist disconnect order until winner is acknowledged
|
||
- [ ] Dual Leave/skip: honor 200 `already_settled` `winner` even if it differs from the side you sent
|