# FIFA 17 FUT Match Lifecycle

Design + contract reference for the **FUT match loop** as OpenFUT implements it on
the backend/responder side. Consolidates knowledge previously scattered across
`docs/PROJECT_STATE.md`, `fifa17-recon/tools/utas_server.py` (`match_route`),
`openfut-utas-host` (Rust economy END leg), `fifa17-recon/tools/test_match_lifecycle.py`,
and the vault (`Protocol Findings.md`, `Project State.md`, `Known Issues.md`).

Evidence labels: **OBSERVED** (live), **PROVEN** (test/static-analysis),
**HYPOTHESIS** (reasoned, not yet live-confirmed).

> ## Status caveat (read first)
> **No football match has ever started or completed in FIFA against this stack.**
> The lifecycle below is validated by CardsDLL static analysis (RPC descriptor
> blocks) + isolated persistence replay (`test_match_lifecycle.py`), **not** in-game
> acceptance. The reward amounts are FUT-plausible env-tunable defaults, **not**
> reversed values. Two blockers keep `FUT_MODES` **off by default** (see
> [Open questions](#open-questions--blockers)).

## Scope

This documents the **UTAS responder handshake + economy reward** for a match — the
HTTP calls CardsDLL makes around a match and the state they mutate. It does **not**
cover the actual football simulation (Blaze game-server side, "past the FUT hub"),
which remains unverified.

## The loop: a four-call state machine

CardsDLL issues six match RPCs; four form the playable loop. All share the base
path `ut/<sku>/match`; the operation is discriminated by **suffix + body**, not by
HTTP verb (verb selection lives outside CardsDLL, so the classifier is verb-agnostic).

```mermaid
stateDiagram-v2
    [*] --> Created: POST /match (no matchId)
    Created --> Ready: POST|PUT /match/ready {matchId}
    Ready --> Playing: POST /match {matchId}   (bare path + int matchId)
    Playing --> Ended: POST|PUT|DELETE /match/end {matchId, endReason, ...}
    Ended --> [*]: rewards credited, W/D/L + matchesPlayed persisted
```

### Call classifier (`_match_call`, utas_server.py:3325)

The discriminator (**PROVEN** from CardsDLL RPC descriptors):

1. path ends `/match/end` → **END** (routed as `FutDestroyMatch` regardless of verb).
2. body has integer `matchId` on the **bare** `/match` path → **PLAY** (`FutPlayGame`).
   CREATE and PLAY share `ut/<sku>/match`; the presence of an int `matchId` is the
   only discriminator — this is why a bare `/match` carrying `matchId` must NOT
   allocate a new match.
3. path ends `/match/ready` → **READY** (`FutMatchReady`).
4. otherwise → **CREATE** (`FutCreateMatch`).

## Per-call contracts

### CREATE — `POST /ut/<sku>/match` (empty/no `matchId`)
CardsDLL: `FutCreateMatch` @ `0x180120380`; deserializes `startDateTime`(740,int),
`reportIdEnabled`(641,bool). `squad`(717,nested) is a **FREEZE-RISK** and is omitted
(SKIP-safe).

Response (`match_route`, utas_server.py:3399):
```json
{"startDateTime": <unix_ts:int>, "reportIdEnabled": false, "id": <matchId:int>}
```
Effect: allocates a match id; **advances `nextItemId` by 1** (PROVEN,
`test_match_lifecycle.py:51`).

### READY — `POST|PUT /ut/<sku>/match/ready` (`{matchId}`)
CardsDLL: `FutMatchReady` — no deserializer at all on the request; the server
response parser has two scalar members + one nested member.

Response (`match_ready_body`, utas_server.py:3353):
```json
{"matchId": <int>, "opponentPersonaId": <int, default 0>}
```
- `opponentPersonaId` defaults to `0` — a neutral placeholder, **never** the
  logged-in user's persona.
- The parser also has a nested `items` member (the opponent squad). It is **omitted
  deliberately** until the opponent-squad item contract is recovered from a live
  capture; unrecognized/absent members are skip-safe. **This omission is one of the
  two blockers.**

### PLAY — `POST /ut/<sku>/match` (bare path, `{matchId:int}`)
CardsDLL: `FutPlayGame` — no request deserializer.

Response: `{}` (empty). Effect: **none** — must NOT allocate a match or advance
`nextItemId` (PROVEN, `test_match_lifecycle.py:62-63`). This is purely a client
keepalive/transition ack.

### END — `POST|PUT|DELETE /ut/<sku>/match/end` (`{matchId, endReason, myMatchStats, opponentMatchStats}`)
CardsDLL: `FutDestroyMatch` @ `0x180121b60` — **the rewards call**. Routed as
DestroyMatch regardless of verb (`FUT_MATCH_END`, default ON).

> **Wire path (Rust vs Python).** The Rust-owned reward is classified by `classify_economy`
> on **`POST /ut/delete/game/<sku>/match`** — EA/CardsDLL tunnels DELETE-semantics ops through the
> `/ut/delete/game/` prefix (`FutDestroyMatch` = `DELETE ut/%s/match/{id}`). Python's `match_route`
> additionally accepts `/ut/game/<sku>/match/end`. Which exact form the retail client emits is
> unverified (no match ever played); the Rust economy owns the `/ut/delete/game` form, and a
> `/ut/game/.../match/end` would fall to Python. Both credit the same reward shape.

Request fields that matter:
- `endReason` (atom 260) — **STRING enum, the AUTHORITATIVE result signal**. A score
  comparison is NOT how the client reports the outcome. Nine values, mapped to a
  win/draw/loss bucket:

  | endReason | bucket |
  |---|---|
  | `WIN`, `DNF_WIN` | won |
  | `DRAW`, `DNF_DRAW`, `NO_CONTEST` | draw |
  | `LOSS`, `DNF_LOSS`, `DNF`, `QUIT` | loss |
  | (missing/unknown) | draw (neutral fallback — credits without inventing a win) |

- `myMatchStats` / `opponentMatchStats` — literal-keyed objects, 15 int fields each,
  first is `goals`. **Omitted by the client when `endReason` is `DNF`/`QUIT`**, so
  nothing may require them. Used only as a fallback outcome probe if `endReason` is
  absent.

Response (`destroy_match_body` / Rust `build_match_reward_body`) — every field a
**top-level scalar** (zero freeze risk) except the deliberately nested reward:
```json
{
  "allCoins": <post-credit balance:int>,
  "matchCoins": <per-result coins:int>,
  "seasonCoins": 0,
  "tournamentCoins": 0,
  "boostConis": 0,                       // EA's typo — exact key required
  "participationAward": <int>,
  "teamOfTournamentWinner": false,
  "gameModeAward": { "coins": <total award:int> }
}
```
> **Critical correction (2026-08-04):** the reward `coins` (atom 149) is read by the
> deserializer **only inside `gameModeAward`**, never as a top-level key. An earlier
> top-level `"coins"` was silently skipped and never reached the client — the one
> field most obviously named "the reward" was the one going nowhere.

Effect (persisted to the active FUT save): credit coins; increment the matching
`record.{won,draw,loss}`; increment `matchesPlayed` (PROVEN,
`test_match_lifecycle.py:74-81`).

## Reward policy

Env-tunable defaults (FUT-plausible, **not reversed** — `economy_policy.rs:20-36`,
`utas_server.py:3201-3206`):

| Result | Match coins | Participation | Total |
|---|---|---|---|
| Win | 400 (`FUT_MATCH_COINS_WIN`) | 0 (`FUT_MATCH_PARTICIPATION`) | 400 |
| Draw | 200 (`FUT_MATCH_COINS_DRAW`) | 0 | 200 |
| Loss | 100 (`FUT_MATCH_COINS_LOSS`) | 0 | 100 |

`allCoins` = post-credit balance; `gameModeAward.coins` = per-result + participation.

## Ownership split (Rust vs Python)

The match loop is **partially migrated**. Only the coin-crediting END leg is
economy state, so only it is Rust/Core-owned; the CREATE/READY/PLAY legs are still
served by the Python oracle.

| Call | Owner | Where |
|---|---|---|
| CREATE | Python | `utas_server.py::match_route` (via host `Route::Passthrough`) |
| READY | Python | `utas_server.py::match_route` |
| PLAY | Python | `utas_server.py::match_route` |
| END (reward) | **Rust/Core** (on `POST /ut/delete/game/<sku>/match`) | `EconomyRoute::MatchEnd` → `handle_match_end` → `build_match_reward_body`; outcome via `economy_policy::match_result_from_reason`, coins via `match_result_coins`/`match_reward_total`. 503 on Core error, never Python. Python also accepts `/ut/game/<sku>/match/end`. |

END is classified in `classify_economy` (`openfut-utas-host/src/lib.rs`) and dispatched
by the economy authority barrier ahead of the general classifier — so it can never
also reach the Python passthrough (NEVER-BOTH). The Rust END writes the coin reward
through the single Core economy transaction (`grant_reward`); W/D/L record + match
count still live in the Python save until CREATE/READY/PLAY migrate.

## Test coverage

`fifa17-recon/tools/test_match_lifecycle.py` (PROVEN, isolated — temp profile, no
live server): drives CREATE→READY→PLAY→END and asserts:
- CREATE returns `reportIdEnabled:false` + advances `nextItemId` by 1.
- READY returns exactly `{matchId, opponentPersonaId:0}`.
- PLAY returns `{}` and does **not** advance `nextItemId`.
- END credits `MATCH_COINS["won"] + MATCH_PARTICIPATION`, persists
  `record == {won:1,draw:0,loss:0}` and `matchesPlayed == 1`.

## Open questions / blockers

1. **READY `items` contract (opponent squad)** — the nested `items` member of
   `FutMatchReadyServerResponse` is unserved pending a live capture. Whether the
   client requires it present/non-empty to enter a match is unknown. **Blocker.**
2. **Client mode-entry gate** — reaching a match from the FUT hub UI is unverified;
   `FUT_MODES` stays **off by default** (the season/tournament routes return `{}`).
3. **No in-game acceptance** — every claim here is static-analysis + isolated replay.
   The first live match is expected to reveal whether the static read was complete
   (every match body is logged for exactly this reason).
4. **Football simulation** — the actual gameplay (Blaze game-server) is out of scope
   here and unverified.

## Sources

- `docs/PROJECT_STATE.md` (match lifecycle status), `docs/direction.md` (Tier-2 loop).
- `fifa17-recon/tools/utas_server.py`: `_match_call` (3325), `match_ready_body` (3346),
  `match_route` (3357), `destroy_match_body` (3281), `_match_result` (3236),
  `MATCH_COINS`/`MATCH_PARTICIPATION` (3201), `_END_REASON` (3229); CardsDLL RPC
  descriptor block (3183-3197).
- `openfut-adapter-fifa17/src/fut/economy_policy.rs` (reward policy + outcome map).
- `openfut-utas-host/src/lib.rs`: `EconomyRoute::MatchEnd`, `handle_match_end`,
  `build_match_reward_body`.
- `fifa17-recon/tools/test_match_lifecycle.py` (lifecycle regression).
- Vault: `02 Reverse Engineering/FIFA 17/Protocol Findings.md`,
  `06 Agent Memory/{Project State,Known Issues}.md`.
