fix(fifa17): serve the match lifecycle instead of proxying it to a dead upstream

"There was an error creating your game session. Please try again." on advancing
past the starting XI. The host log names it exactly:

  utas-host ERROR passthrough to Python failed: … /ut/game/fifa17/match
  utas-host owner=PYTHON_FALLBACK method=POST path=/ut/game/fifa17/match status=502

Neither `match` nor `match/end` was claimed by either classifier, so both fell to
Passthrough. This is the third instance of one defect: `season/list` and
`watchList` were the first two, and like `watchList` the handler already existed
and was simply unreachable — `EconomyRoute::MatchEnd` was produced ONLY by
`POST /ut/delete/game/<sku>/match`, a URL the retail client never sends. The
adapter's `match_wire::create_response` had zero callers.

The family is now classified by PATH SUFFIX and is deliberately VERB-AGNOSTIC:
the strings "PUT" and "DELETE" do not occur anywhere in cardsdll.dll, so verb
selection happens outside the DLL and cannot be pinned statically. Matching on a
verb is precisely how these came to be proxied. All three arms live in the
ECONOMY classifier, because `/match/end` credits coins and `try_handle_economy`
is the barrier guaranteeing a claimed route can never also reach Python — and
because create and end must share the in-flight match id, splitting the family
across two classifiers is what let them diverge.

`POST …/match` is both FutCreateMatch and FutPlayGame, discriminated by an
integer `matchId` in the body exactly as the client serializes them; play acks
`{}` and must not mint a second session. `squad` is omitted from the create
response: nested, half-read, the documented freeze mode.

MATCH IDS GET THEIR OWN IDENTITY SCOPE. The oracle mints them from the same
counter as owned items, which is why an observed match id looks like an item id,
but that is an artifact of a single-counter save file. Here the identity store
keeps a real reverse map, so an item-scoped match id would make
`owned_id_for_wire` resolve a match to a bogus owned card and corrupt quick-sell
and move. A new `(game, "match")` scope costs one constant — the store is
already generic over the pair — and an integration assertion now pins that a
match id never appears in the owned-item reverse map.

ECONOMY: `/match/end` is NOT a new authority. It renders Core's single
exactly-once `complete_match` transaction, the same one the legacy
`/matches/result` path was closed in favour of earlier today, and it still omits
`expire_loans`/`advance_season` so FIFA 17 keeps its own seasons and loans.

THE LATENT BUG THIS EXPOSED, which would have been a silent permanent
under-credit the moment the route became reachable: the per-match identity fell
back to a hash of the request body. Every abandoned match sends a BYTE-IDENTICAL
body (`matchReportId:0`, empty items/matchData/telemetry, flags 0), so all of
them collapsed onto one identity and Core's UNIQUE(profile_id, match_identity)
would refuse every DNF after the first — `applied=false`, nothing awarded, no
error. The identity is now the id minted at create, which is unique per match by
construction; the fingerprint remains only as a floor for an end with no create.
It also removes a durable dependency on `DefaultHasher`, which has no
cross-version stability guarantee yet was being persisted.

One bug of my own, caught by driving the real dispatch rather than the handler:
taking the in-flight id on end looked tidy but sent a REPLAYED `/match/end` down
the fingerprint path — a different identity — so Core paid a second time
(measured: a second +75 for one abandoned match). The id is now read and held,
so a replay reuses one identity and the next create overwrites it.

Verified end to end on the restored club: create → ready → play → end returns the
reversed reward shape (`boostConis` included, `bidTokens`/`qualifiedChampionEventId`
never emitted), a DNF credits once, two replays credit zero, and a second match
with a byte-identical body credits again. Host 115 lib + 36 host_test + 7
economy_integration + concurrency/differential/failure, adapter 217 + 25, all green.

Note for the record: a DNF pays Core's COINS_LOSS (75), not the oracle's 100.
Nothing on the wire settles the number — the client renders whatever we send, and
the oracle's own comment says its values were never reversed — so the declared
Rust authority's table wins rather than being bent to match Python.
This commit is contained in:
funman300
2026-08-21 18:00:28 +00:00
parent 12ad04c9d4
commit 33300f2ad1
5 changed files with 403 additions and 24 deletions
+106 -4
View File
@@ -160,7 +160,7 @@ fn seed_and_exercise(base: &str) -> i64 {
// the match coins; Core may also grant XP-driven level-up and first-win
// achievement coins, so assert the flat match coins + a relative delta.
let before_match = client.balance().unwrap();
let m = handle_match_end(&client, 1, br#"{"endReason":"WIN"}"#);
let m = handle_match_end(&client, 1, Some(200_000_001), br#"{"endReason":"WIN"}"#);
assert_eq!(m.status, 200);
let mb: Value = serde_json::from_slice(&m.body).unwrap();
assert_eq!(mb["matchCoins"], 400, "flat match coins");
@@ -174,14 +174,24 @@ fn seed_and_exercise(base: &str) -> i64 {
after_match,
"response echoes the authoritative Core balance"
);
// Idempotent replay: the SAME match-end body does NOT double-credit.
let replay = handle_match_end(&client, 1, br#"{"endReason":"WIN"}"#);
// Idempotent replay: the SAME match session does NOT double-credit.
let replay = handle_match_end(&client, 1, Some(200_000_001), br#"{"endReason":"WIN"}"#);
assert_eq!(replay.status, 200);
assert_eq!(
client.balance().unwrap(),
after_match,
"replay must not re-credit"
);
// A DIFFERENT session with a BYTE-IDENTICAL body is a different match and
// must credit again. Keyed on the body alone it would not, which is the
// silent under-credit every abandoned match would have hit.
let second = handle_match_end(&client, 1, Some(200_000_002), br#"{"endReason":"WIN"}"#);
assert_eq!(second.status, 200);
let after_second = client.balance().unwrap();
assert!(
after_second >= after_match + 400,
"a second, distinct match must credit: {after_second} vs {after_match}"
);
// Buy a numeric entitlement "70" through the Core economy API (debit 600).
post(
@@ -190,7 +200,7 @@ fn seed_and_exercise(base: &str) -> i64 {
"/economy/purchase-entitlement",
json!({ "cost": 600, "definition_id": "70" }),
);
let after_buy = after_match - 600;
let after_buy = after_second - 600;
assert_eq!(
client.balance().unwrap(),
after_buy,
@@ -659,6 +669,98 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
.owned_id_for_wire(move_wire)
.expect("moved item reverses to a Core id");
// 8) THE MATCH LIFECYCLE, through the REAL dispatch rather than the handler.
// `POST …/match` and `PUT …/match/end` were not classified at all and were
// proxied to Python, which the client reported as "There was an error
// creating your game session".
let created = server
.try_handle_economy(
"POST",
"/ut/game/fifa17/match",
&[],
br#"{"squadId":0,"type":"OFFLINE","seasonId":1,"divisionId":10}"#,
None,
)
.expect("match create must be claimed by the economy dispatch, never proxied");
assert_eq!(created.status, 200);
let created_body: Value = serde_json::from_slice(&created.body).unwrap();
let match_id = created_body["id"].as_i64().expect("a match id is minted");
assert!(match_id > 0, "the client needs a non-zero session id");
assert_eq!(created_body["reportIdEnabled"], false);
assert!(
created_body.get("squad").is_none(),
"`squad` is nested and a documented freeze risk — it must be omitted"
);
// The match id lives in its OWN identity scope: it must not reverse-map to
// an owned card, or quick-sell and move would resolve a match as an item.
assert!(
resolver.owned_id_for_wire(match_id).is_none(),
"a match id must never appear in the owned-item reverse map"
);
// FutPlayGame reuses the create path, discriminated by an integer matchId.
// It must ack without minting a second session.
let play = server
.try_handle_economy(
"POST",
"/ut/game/fifa17/match",
&[],
format!(r#"{{"matchId":{match_id}}}"#).as_bytes(),
None,
)
.expect("play routed");
assert_eq!(
serde_json::from_slice::<Value>(&play.body).unwrap(),
serde_json::json!({}),
"FutPlayGame parses no fields"
);
// Every abandoned match sends a BYTE-IDENTICAL body. The first credits…
let dnf = br#"{"matchReportId":0,"endReason":"DNF","items":[],"matchData":"","matchPerfTelemetry01":"","matchStatusFlags":0}"#;
let before_dnf = client.balance().unwrap();
let ended = server
.try_handle_economy("PUT", "/ut/game/fifa17/match/end", &[], dnf, None)
.expect("match end must be claimed, never proxied");
assert_eq!(ended.status, 200);
let after_dnf = client.balance().unwrap();
assert!(after_dnf > before_dnf, "the abandoned match credited");
// …a REPLAY of that same match must not. Holding (not taking) the in-flight
// id is what makes the replay reuse one identity; taking it sent the replay
// down the body-fingerprint path, a different identity, and Core paid twice.
server
.try_handle_economy("PUT", "/ut/game/fifa17/match/end", &[], dnf, None)
.expect("replay routed");
assert_eq!(
client.balance().unwrap(),
after_dnf,
"a replayed match end must NOT credit again"
);
// …and a NEW session with the identical body is a different match, which
// must credit. Keyed on the body alone every abandoned match after the
// first would silently pay nothing.
let created2 = server
.try_handle_economy(
"POST",
"/ut/game/fifa17/match",
&[],
br#"{"squadId":0,"type":"OFFLINE"}"#,
None,
)
.expect("second create routed");
let second_id = serde_json::from_slice::<Value>(&created2.body).unwrap()["id"]
.as_i64()
.unwrap();
assert_ne!(second_id, match_id, "each match gets its own id");
server
.try_handle_economy("PUT", "/ut/game/fifa17/match/end", &[], dnf, None)
.expect("second end routed");
assert!(
client.balance().unwrap() > after_dnf,
"a second, distinct abandoned match must credit"
);
SeqResult {
final_balance: client.balance().unwrap(),
sold_listing: trade_id.to_string(),