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:
@@ -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
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -31,7 +31,9 @@ S2 live-staging defect where the v2 Store BUY escaped to Python.
|
||||
| Quick-sell (path) | `DELETE …/item/<digits>` | `QuickSellPath` |
|
||||
| Quick-sell (body) | `POST (/ut/delete/game\|/ut/v2/delete/game)/<sku>/item` | `QuickSellBody` |
|
||||
| Move | `PUT …/item` | `MoveItems` |
|
||||
| Match end | `POST (/ut/delete/game\|/ut/v2/delete/game)/<sku>/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)/<sku>/match` | `MatchEnd` |
|
||||
| Market list | `POST …/auctionhouse` \| `…/transfermarket` | `MarketList` |
|
||||
| Market query | `GET …/tradePile` **and** `…/tradePile/counts` (CASE-INSENSITIVE: `tradepile` too) | `MarketQuery` |
|
||||
| Market buy | `…/trade/<id>` | `MarketBuy` |
|
||||
@@ -76,7 +78,9 @@ per-route authority (all economy routes owner = Rust, Python proxy = NO):
|
||||
| `/item/<id>` (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/<id>` (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`
|
||||
|
||||
+248
-16
@@ -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/<sku>/match` — match END (the coin-crediting call).
|
||||
/// `…/match/end` (any verb) — match END, the coin-crediting call.
|
||||
/// Also reachable as `POST /ut/delete/game/<sku>/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<EconomyRoute> {
|
||||
}
|
||||
|
||||
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<i64> {
|
||||
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<i64>,
|
||||
) -> 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<i64>,
|
||||
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<Mutex<SessionStore>>,
|
||||
/// 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<PlMutex<Option<i64>>>,
|
||||
/// 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:<id>`, 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<Value> = 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<Value> = 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"),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user