Files
kickkingsapi/docs/unity_match_lifecycle.md
T
2026-09-04 19:11:49 +00:00

269 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (30) | 100% |
| Loser scored 1 (31) | 90% |
| Loser scored 2+ (32) | 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 30 (100%). 31 / 32 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` | Reds user received `participant_cc` on this settle. |
| `cc_awarded_blue` | Blues 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 31):
```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% (31).
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 (31 / 32 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