diff --git a/openfut-adapter-fifa17/src/fut/catalog.rs b/openfut-adapter-fifa17/src/fut/catalog.rs index d396145..fb8e706 100644 --- a/openfut-adapter-fifa17/src/fut/catalog.rs +++ b/openfut-adapter-fifa17/src/fut/catalog.rs @@ -74,6 +74,29 @@ impl Fifa17WireItemIdPolicy { pub fn owned_item_base_floor() -> i64 { Self::OWNED_ITEM_BASE + 1 } + + /// Identity scope for MATCH session ids. + /// + /// A match id is deliberately NOT drawn from the owned-item scope. The + /// oracle mints both from one counter, which is why an observed match id + /// looks like an item id — but that is an artifact of a single-counter save + /// file, not a client requirement. 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. The store is generic over `(game, kind)`, so a + /// separate scope costs one constant and cannot collide with, or advance, + /// the owned-item watermark. + pub const MATCH_KIND: &'static str = "match"; + + /// Base for match session ids. Clear of the owned-item range + /// (`100_000_000+`) and of every synthetic overlay range the responder + /// reserves (`≥ 9e8`). The client only requires a non-zero int. + pub const MATCH_BASE: i64 = 200_000_000; + + /// First match wire id (`200_000_001`). + pub fn match_base_floor() -> i64 { + Self::MATCH_BASE + 1 + } } /// Highest representable asset id (24 bits); above this `version` would be diff --git a/openfut-adapter-fifa17/src/fut/match_wire.rs b/openfut-adapter-fifa17/src/fut/match_wire.rs index ed876ed..c8e998f 100644 --- a/openfut-adapter-fifa17/src/fut/match_wire.rs +++ b/openfut-adapter-fifa17/src/fut/match_wire.rs @@ -157,6 +157,21 @@ pub fn create_response(id: i64, start_epoch: i64) -> Value { }) } +/// Build the FIFA 17 `…/match/ready` ack (FutMatchReady). Zero economic effect. +/// +/// Two scalars only. The response type also has an optional nested item list, +/// which is deliberately omitted: a nested value the client half-reads is the +/// documented freeze mode, and nothing needs it here. `opponent_persona_id` is +/// echoed from the request when the client supplies one and is otherwise `0` — +/// an offline AI opponent has no persona, and it must NEVER default to the +/// player's own persona, which would claim the user is their own opponent. +pub fn ready_response(match_id: i64, opponent_persona_id: i64) -> Value { + json!({ + "matchId": match_id, + "opponentPersonaId": opponent_persona_id, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/openfut-utas-host/ROUTE_AUTHORITY.md b/openfut-utas-host/ROUTE_AUTHORITY.md index 9f2f42d..6da4a0f 100644 --- a/openfut-utas-host/ROUTE_AUTHORITY.md +++ b/openfut-utas-host/ROUTE_AUTHORITY.md @@ -31,7 +31,9 @@ S2 live-staging defect where the v2 Store BUY escaped to Python. | Quick-sell (path) | `DELETE …/item/` | `QuickSellPath` | | Quick-sell (body) | `POST (/ut/delete/game\|/ut/v2/delete/game)//item` | `QuickSellBody` | | Move | `PUT …/item` | `MoveItems` | -| Match end | `POST (/ut/delete/game\|/ut/v2/delete/game)//match` | `MatchEnd` | +| Match create / play | `…/match` (any verb; `matchId` in body = FutPlayGame) | `MatchCreate` | +| Match ready | `…/match/ready` (any verb) | `MatchReady` | +| Match end | `…/match/end` (any verb) — also `POST (/ut/delete/game\|/ut/v2/delete/game)//match` | `MatchEnd` | | Market list | `POST …/auctionhouse` \| `…/transfermarket` | `MarketList` | | Market query | `GET …/tradePile` **and** `…/tradePile/counts` (CASE-INSENSITIVE: `tradepile` too) | `MarketQuery` | | Market buy | `…/trade/` | `MarketBuy` | @@ -76,7 +78,9 @@ per-route authority (all economy routes owner = Rust, Python proxy = NO): | `/item/` (DELETE) | R | -/R | -/R | - | - | - | - | NO | `handle_quick_sell_path` → `sell_item` | | `/ut/delete/…/item` (POST) | R | -/R | -/R | - | - | - | - | NO | `handle_quick_sell_body` → `sell_item` | | `/item` (PUT) | R | - | R/- | - | -/R | - | - | NO | `handle_move_items` (PileStore) | -| `/ut/delete/…/match` (POST) | R | -/R | - | - | - | - | - | NO | `handle_match_end` → `grant_reward` | +| `/match` (any verb) | R | - | - | - | - | - | - | NO | `handle_match_create` (mints the session id; no economy) | +| `/match/ready` (any verb) | R | - | - | - | - | - | - | NO | `handle_match_ready` | +| `/match/end` (any verb) | R | -/R | - | - | - | - | - | NO | `handle_match_end` → Core `complete_match` | | `/auctionhouse`,`/transfermarket` | R | R/- | - | - | - | -/R | - | NO | `handle_market_list` (MarketStore) | | `/tradePile` (GET) | R | R/- | - | - | - | R/- | - | NO | `handle_market_query` | | `/trade/` (POST/PUT/GET) | R | R/R | -/R | - | - | R/R | - | NO | `handle_market_buy` → `purchase_item` | @@ -177,8 +181,11 @@ Landed (Core authority + transport + several handlers; classifier NOT yet flippe full-gen (`handle_purchasegroup`, no Python body dependency), `userMassInfo` economy overlay (`overlay_massinfo_economy`). Invariant test: all three read one Core state. -- **Writer handler**: `/match` reward (`handle_match_end` → Core `grant_reward`, - oracle `destroy_match_body` shape). +- **Writer handler**: `/match/end` reward (`handle_match_end` → Core + `complete_match`, the single exactly-once transaction — NOT `grant_reward`). + The per-match identity is the id minted by `POST …/match`, which is what makes + two abandoned matches (whose bodies are byte-identical) both payable while a + replay of either is refused. - **Adapter policy mappers** (`181bd94`): match reward, pack price. - All Core-backed, fail-closed (503, never Python), `FakeEconomy`-tested. - **Store/item writers** (`4d2b8b9`, `economy_store.rs`, unrouted): `handle_store_buy` diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index b09d6be..ed1c86e 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -46,6 +46,7 @@ pub mod sold_experiment; use std::io::{BufRead, BufReader, Write}; use std::net::{TcpListener, TcpStream}; +use parking_lot::Mutex as PlMutex; use std::sync::{Arc, Mutex}; use std::time::{Instant, SystemTime, UNIX_EPOCH}; @@ -359,8 +360,17 @@ pub enum EconomyRoute { QuickSellBody, /// `PUT …/item` — FutMoveCard pile move. MoveItems, - /// `POST /ut/delete/game//match` — match END (the coin-crediting call). + /// `…/match/end` (any verb) — match END, the coin-crediting call. + /// Also reachable as `POST /ut/delete/game//match`, which the retail + /// client does not send; `/match/end` is the real one, from the RPC + /// descriptor block. MatchEnd, + /// `…/match` (any verb) — FutCreateMatch, and FutPlayGame on the same path + /// discriminated by an integer `matchId` in the body. Zero economy: it only + /// mints the session id that `/match/end` later uses as its exactly-once key. + MatchCreate, + /// `…/match/ready` (any verb) — FutMatchReady. Zero economy. + MatchReady, /// `…/auctionhouse` | `…/transfermarket` — list-for-sale / browse. MarketList, /// `GET …/tradePile` — the user's own active listings. @@ -530,6 +540,22 @@ pub fn classify_economy(method: &str, path: &str) -> Option { } match ut_tail(path) { + // The match family, kept together and deliberately VERB-AGNOSTIC. The + // strings "PUT" and "DELETE" do not appear anywhere in cardsdll.dll, so + // verb selection happens outside the DLL and cannot be pinned + // statically; the RPC descriptor block discriminates by PATH SUFFIX on + // the `ut/%s/match` template. Matching on the verb is how these came to + // be proxied to a dead upstream, which is what the client reported as + // "There was an error creating your game session". + // + // They live in the ECONOMY classifier, not the plain one, because + // `/match/end` credits coins and `try_handle_economy` is the barrier + // that guarantees a matched route can never also fall through to + // Python. Create and end must share the in-flight match id, so keeping + // the family in one classifier is what stops them diverging again. + Some("match/end") => Some(EconomyRoute::MatchEnd), + Some("match/ready") => Some(EconomyRoute::MatchReady), + Some("match") => Some(EconomyRoute::MatchCreate), Some("user/credits") if get => Some(EconomyRoute::Credits), Some(t) if get && t.starts_with("store/purchasegroup") => Some(EconomyRoute::PurchaseGroup), Some(t) if put && is_store_transaction_tail(t) => Some(EconomyRoute::StoreBuy), @@ -1715,6 +1741,29 @@ impl Fifa17IdentityResolver { } } } + + /// Allocate a MATCH session id in its own identity scope. + /// + /// Kept here so one type still owns every wire-id allocation, but under + /// `MATCH_KIND` rather than the owned-item scope, so a match can never + /// appear in the owned-item reverse map. `core_id` must be unique per match + /// — `resolve_or_allocate` is idempotent per `core_id`, so reusing one would + /// hand back the same id and silently merge two matches into one economic + /// identity. + pub fn allocate_match_id(&self, core_id: &str) -> Option { + match self.store.resolve_or_allocate( + Fifa17WireItemIdPolicy::GAME, + Fifa17WireItemIdPolicy::MATCH_KIND, + core_id, + Fifa17WireItemIdPolicy::match_base_floor(), + ) { + Ok(wire) => Some(wire), + Err(error) => { + eprintln!("utas-host ERROR match id alloc failed for {core_id}: {error}"); + None + } + } + } } impl ItemIdentityResolver for Fifa17IdentityResolver { @@ -2568,13 +2617,35 @@ pub fn overlay_massinfo_economy(root: &mut Value, coins: i64, unopened_count: us true } -/// Derive the per-match economic identity from a persona + the match-end body. +/// Derive the per-match economic identity from a persona and the match session. /// This is the key for Core's durable exactly-once guard — NOT a FIFA HTTP -/// receipt id. When the client sends a non-zero `matchReportId` it keys on that; -/// otherwise (the observed live path reports `0`) it keys on a stable fingerprint -/// of the body so an identical network retry dedupes in Core, while distinct -/// decided matches (whose stat arrays differ) get distinct identities. -fn match_identity(persona: i64, end: &match_wire::MatchEnd, body: &[u8]) -> String { +/// receipt id. +/// +/// Precedence, strongest first: +/// +/// 1. `created` — the id minted by `POST …/match` for THIS session. This is the +/// only source that is unique per match by construction, and it is what makes +/// a second abandoned match credit at all. +/// 2. A non-zero `matchReportId` from the body. +/// 3. A fingerprint of the body, for an end with no preceding create (a hand +/// probe). Retained only as a floor. +/// +/// The fingerprint MUST NOT be the primary key: the live DNF body is +/// byte-identical for every abandoned match (`matchReportId:0`, empty items, +/// empty matchData, empty telemetry, flags 0), so it collapses every DNF onto +/// one identity. Core's `UNIQUE(profile_id, match_identity)` would then refuse +/// each one after the first, returning `applied=false` and awarding nothing — +/// a silent, permanent under-credit. It is also `DefaultHasher`, whose output +/// has no cross-version stability guarantee yet would be persisted durably. +fn match_identity( + persona: i64, + end: &match_wire::MatchEnd, + body: &[u8], + created: Option, +) -> String { + if let Some(id) = created { + return format!("{persona}:match:{id}"); + } if end.match_report_id != 0 { return format!("{persona}:report:{}", end.match_report_id); } @@ -2589,11 +2660,16 @@ fn match_identity(persona: i64, end: &match_wire::MatchEnd, body: &[u8]) -> Stri /// transaction and render Core's authoritative coin numbers back onto the wire. /// FAIL-CLOSED: a malformed body is a 400 and ANY Core error is a 503 — the /// economy is never applied by Python (a second writer would break exactly-once). -pub fn handle_match_end(econ: &dyn CoreEconomy, persona: i64, body: &[u8]) -> WireResponse { +pub fn handle_match_end( + econ: &dyn CoreEconomy, + persona: i64, + created: Option, + body: &[u8], +) -> WireResponse { let Some(end) = match_wire::parse_match_end(body) else { return error_response(400, "bad_match_end"); }; - let identity = match_identity(persona, &end, body); + let identity = match_identity(persona, &end, body, created); let completion = CoreMatchCompletion { match_identity: &identity, result: end.result.core_token(), @@ -2841,6 +2917,11 @@ pub struct Server { /// The economy (coins, unopened packs, BUY) stays Python's; this owns only /// session/capability state — Rust reads Python's live body, never writes it. sessions: Arc>, + /// The match id minted by `POST …/match`, consumed by `…/match/end` as that + /// match's exactly-once economic identity. FIFA plays one match at a time, + /// and `try_handle_economy` holds the economy gate across the whole + /// dispatch, so create and end cannot interleave. + current_match: Arc>>, /// Monotonic clock origin for the session/pending TTLs. start: Instant, /// FIFA17 economy authority services (Core transport + durable listing/pile @@ -2882,6 +2963,7 @@ impl Server { clientdata: Arc::new(ClientDataStore::open(ephemeral_clientdata_path())), economy_gate: Arc::new(Mutex::new(())), sbc_post_commit_fault: SbcPostCommitFault::Off, + current_match: Arc::new(PlMutex::new(None)), } } @@ -3363,7 +3445,20 @@ impl Server { }; handle_quick_sell_body(body, &deps) } - EconomyRoute::MatchEnd => handle_match_end(svc.econ.as_ref(), self.persona_id, body), + EconomyRoute::MatchCreate => self.handle_match_create(body), + EconomyRoute::MatchReady => self.handle_match_ready(body), + // The id is READ, never taken. Clearing it here looked tidy and was + // a double-credit bug: a replayed `/match/end` then fell through to + // the body fingerprint, which is a DIFFERENT identity from + // `persona:match:`, so Core saw a brand-new match and paid + // again. Measured on staging as a second +75 for one abandoned + // match. Holding the id means a replay reuses the same identity and + // Core's UNIQUE(profile_id, match_identity) refuses it; the next + // `POST …/match` overwrites the slot with a fresh id. + EconomyRoute::MatchEnd => { + let created = *self.current_match.lock(); + handle_match_end(svc.econ.as_ref(), self.persona_id, created, body) + } EconomyRoute::MoveItems => { let (bridge, piles, resolver, market) = ( svc.bridge.clone(), @@ -4073,6 +4168,75 @@ impl Server { ) } + /// `…/match` — FutCreateMatch, and FutPlayGame on the SAME path. + /// + /// The two are discriminated by the body, exactly as the client serializes + /// them: an operation on an EXISTING match carries an integer `matchId`, + /// a create does not. FutPlayGame's response parses no fields at all, so it + /// is a bare `{}` ack and must NOT mint a second id for a match already in + /// flight. + /// + /// The create response deliberately omits `squad` (atom 717): it is a nested + /// value the client only half-reads, which is the documented freeze mode, + /// and nothing needs it. + fn handle_match_create(&self, body: &[u8]) -> WireResponse { + let parsed: Option = serde_json::from_slice(body).ok(); + let existing = parsed + .as_ref() + .and_then(|v| v.get("matchId")) + .and_then(Value::as_i64); + if let Some(match_id) = existing { + eprintln!("utas-host owner=RUST route=match-play id={match_id} status=200"); + return json_status(200, &json!({})); + } + + // A fresh core id per create: `resolve_or_allocate` is idempotent per + // core id, so reusing one would hand back the previous match's id and + // merge two matches into a single economic identity. + let core_id = format!( + "{}:match:{}", + self.persona_id, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + ); + let Some(id) = self.resolver.allocate_match_id(&core_id) else { + // Fail closed: without an id there is no exactly-once key, and a + // match played without one would either double-credit or not credit. + eprintln!("utas-host ERROR route=match-create status=503 identity_alloc_failed"); + return error_response(503, "match_id_unavailable"); + }; + *self.current_match.lock() = Some(id); + let start_epoch = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + eprintln!("utas-host owner=RUST route=match-create id={id} status=200"); + json_status(200, &match_wire::create_response(id, start_epoch)) + } + + /// `…/match/ready` — FutMatchReady. Two scalars, zero economy. + /// + /// The opponent persona is echoed from the request when present and is + /// otherwise `0`: an offline AI opponent has no persona, and defaulting to + /// the user's own would claim they are their own opponent. + fn handle_match_ready(&self, body: &[u8]) -> WireResponse { + let parsed: Option = serde_json::from_slice(body).ok(); + let field = |key: &str| { + parsed + .as_ref() + .and_then(|v| v.get(key)) + .and_then(Value::as_i64) + }; + let match_id = field("matchId") + .or_else(|| *self.current_match.lock()) + .unwrap_or(0); + let opponent = field("opponentPersonaId").unwrap_or(0); + eprintln!("utas-host owner=RUST route=match-ready id={match_id} status=200"); + json_status(200, &match_wire::ready_response(match_id, opponent)) + } + /// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and the /// club-identity read service are off, so return `{}` (parity with the /// flag-off Python oracle). Club rename is handled separately by @@ -4808,6 +4972,7 @@ mod tests { let resp = handle_match_end( &econ, 42, + Some(200_000_001), br#"{"endReason":"WIN","myMatchStats":[2,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"opponentMatchStats":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}"#, ); assert_eq!(resp.status, 200); @@ -4822,20 +4987,21 @@ mod tests { assert!(body.get("qualifiedChampionEventId").is_none()); // Draw default on a well-formed body without a known endReason. let draw: Value = - serde_json::from_slice(&handle_match_end(&econ, 42, br#"{"foo":1}"#).body).unwrap(); + serde_json::from_slice(&handle_match_end(&econ, 42, None, br#"{"foo":1}"#).body) + .unwrap(); assert_eq!(draw["matchCoins"], 200); } #[test] fn match_end_fails_closed_on_core_error() { // A Core failure NEVER falls back to Python — controlled 503. - let resp = handle_match_end(&FakeEconomy::failing(), 42, br#"{"endReason":"WIN"}"#); + let resp = handle_match_end(&FakeEconomy::failing(), 42, None, br#"{"endReason":"WIN"}"#); assert_eq!(resp.status, 503); } #[test] fn malformed_match_end_is_rejected() { - let resp = handle_match_end(&FakeEconomy::ok(1000, 0), 42, b"not json"); + let resp = handle_match_end(&FakeEconomy::ok(1000, 0), 42, None, b"not json"); assert_eq!(resp.status, 400); } @@ -4848,12 +5014,50 @@ mod tests { let b = br#"{"matchReportId":0,"endReason":"WIN","myMatchStats":[2,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"opponentMatchStats":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}"#; let ea = match_wire::parse_match_end(a).unwrap(); let eb = match_wire::parse_match_end(b).unwrap(); - assert_eq!(match_identity(42, &ea, a), match_identity(42, &ea, a)); - assert_ne!(match_identity(42, &ea, a), match_identity(42, &eb, b)); + assert_eq!( + match_identity(42, &ea, a, None), + match_identity(42, &ea, a, None) + ); + assert_ne!( + match_identity(42, &ea, a, None), + match_identity(42, &eb, b, None) + ); // A non-zero report id keys on the report, independent of body bytes. let r = br#"{"matchReportId":99,"endReason":"WIN"}"#; let er = match_wire::parse_match_end(r).unwrap(); - assert_eq!(match_identity(7, &er, r), "7:report:99"); + assert_eq!(match_identity(7, &er, r, None), "7:report:99"); + } + + /// THE regression that matters for a real session: every abandoned match + /// sends a BYTE-IDENTICAL body, so a body fingerprint gives them all one + /// identity and Core's unique guard silently refuses to credit any but the + /// first. The id minted by `POST …/match` is what separates them. + #[test] + fn identical_dnf_bodies_are_separated_by_their_created_match_id() { + let dnf = br#"{"matchReportId":0,"endReason":"DNF","items":[],"matchData":"","matchPerfTelemetry01":"","matchStatusFlags":0}"#; + let end = match_wire::parse_match_end(dnf).unwrap(); + + // Without a created id the two abandoned matches collide — the bug. + assert_eq!( + match_identity(42, &end, dnf, None), + match_identity(42, &end, dnf, None), + "identical bodies fingerprint identically, which is precisely why the \ + fingerprint cannot be the primary key" + ); + // With one, they are distinct and both credit. + let first = match_identity(42, &end, dnf, Some(200_000_001)); + let second = match_identity(42, &end, dnf, Some(200_000_002)); + assert_ne!(first, second, "two abandoned matches must both be payable"); + assert_eq!(first, "42:match:200000001"); + // A replay of ONE match still dedupes. + assert_eq!(first, match_identity(42, &end, dnf, Some(200_000_001))); + // The created id outranks a report id: it is unique by construction. + let reported = br#"{"matchReportId":99,"endReason":"DNF"}"#; + let re = match_wire::parse_match_end(reported).unwrap(); + assert_eq!( + match_identity(7, &re, reported, Some(200_000_009)), + "7:match:200000009" + ); } #[test] @@ -5254,6 +5458,34 @@ mod tests { classify_economy("POST", "/ut/delete/game/fifa17/match"), Some(MatchEnd) ); + // THE PATHS THE RETAIL CLIENT ACTUALLY SENDS. These fell through to + // Passthrough — i.e. the dead Python upstream — which the client + // reported as "There was an error creating your game session". Verb + // agnostic on purpose: the verb is not statically determinable from + // cardsdll.dll, so the path suffix is the discriminator. + for verb in ["POST", "PUT", "GET", "DELETE"] { + assert_eq!( + classify_economy(verb, "/ut/game/fifa17/match"), + Some(MatchCreate), + "{verb} /match must never reach Python" + ); + assert_eq!( + classify_economy(verb, "/ut/game/fifa17/match/end"), + Some(MatchEnd), + "{verb} /match/end must never reach Python" + ); + assert_eq!( + classify_economy(verb, "/ut/game/fifa17/match/ready"), + Some(MatchReady), + "{verb} /match/ready must never reach Python" + ); + } + // …and the sibling tails keep their own owners. + assert_eq!(classify_economy("PUT", "/ut/game/fifa17/match/reset"), None); + assert_eq!( + classify_economy("POST", "/ut/game/fifa17/match/keepalive"), + None + ); // delete family under v2 prefix too (defense-in-depth symmetry). assert_eq!( classify_economy("POST", "/ut/v2/delete/game/fifa17/item"), diff --git a/openfut-utas-host/tests/economy_integration.rs b/openfut-utas-host/tests/economy_integration.rs index 2e5ec90..8bc9035 100644 --- a/openfut-utas-host/tests/economy_integration.rs +++ b/openfut-utas-host/tests/economy_integration.rs @@ -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::(&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::(&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(),