Files
OpenFUT/openfut-adapter-fifa17/src/fut/match_wire.rs
T
funman300 33300f2ad1 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.
2026-08-21 18:00:28 +00:00

267 lines
11 KiB
Rust

//! FIFA 17 `/match` + `/match/end` wire ↔ Core match-economy mapping.
//!
//! This module owns the FIFA 17-specific match protocol: the `endReason` enum
//! (wire atom 260), the `PUT …/match/end` payload shape, and the reward-response
//! body. It is pure — no state, no Core calls. The host wires it to OpenFUT
//! Core's authoritative `complete_match` transaction:
//!
//! 1. [`parse_match_end`] turns the client payload into a [`MatchEnd`].
//! 2. [`MatchResult::core_token`] gives Core the canonical, game-independent
//! result string — Core never sees a FIFA `endReason`.
//! 3. Core applies the economy exactly once and returns the authoritative coin
//! numbers, which the host renders back through [`reward_response`].
//!
//! Keeping every FIFA 17 constant here (never in Core) is the layering contract:
//! a second title's adapter maps its own wire onto the same canonical tokens.
use serde_json::{json, Value};
/// Participation award added to every match reward. FIFA 17 economy parameter
/// (oracle `MATCH_PARTICIPATION`, production default `0`). Core owns the coin
/// balance; this is only the wire body's cosmetic `participationAward` field.
pub const MATCH_PARTICIPATION: i64 = 0;
/// Canonical match result. The FIFA 17 `endReason` enum (atom 260) is the
/// AUTHORITATIVE source [STATIC_REVERSED]; this is the normalized shape the host
/// forwards to Core.
///
/// * `Win` / `Draw` / `Loss` — a decided match.
/// * `Dnf` — the reporting player abandoned/quit (`DNF`/`QUIT`). Economically a
/// loss (LIVE_PROVEN: `endReason=DNF` → loss reward), tracked in its own Core
/// statistics bucket.
/// * `NoContest` — a voided match; zero economic effect.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchResult {
Win,
Draw,
Loss,
Dnf,
NoContest,
}
impl MatchResult {
/// The canonical Core result token — the ONLY match datum the adapter hands
/// Core. Matches `openfut_core::models::match_result::MatchResultKind`'s serde
/// representation exactly (`win`/`draw`/`loss`/`dnf`/`no_contest`).
pub fn core_token(self) -> &'static str {
match self {
MatchResult::Win => "win",
MatchResult::Draw => "draw",
MatchResult::Loss => "loss",
MatchResult::Dnf => "dnf",
MatchResult::NoContest => "no_contest",
}
}
}
/// Map the FIFA 17 `endReason` enum onto a canonical [`MatchResult`].
///
/// The enum is authoritative when present [STATIC_REVERSED]. `DNF`/`QUIT` are the
/// reporting player's abandon (→ `Dnf`, loss economics, LIVE_PROVEN); the
/// `DNF_WIN`/`DNF_DRAW`/`DNF_LOSS` variants carry a decided outcome (the opponent
/// abandoned) and map to that outcome. `NO_CONTEST` voids the match. An
/// absent/unknown reason is a conservative `Draw`, matching the oracle default.
pub fn result_from_end_reason(end_reason: Option<&str>) -> MatchResult {
match end_reason.unwrap_or("").to_ascii_uppercase().as_str() {
"WIN" => MatchResult::Win,
"DRAW" => MatchResult::Draw,
"LOSS" => MatchResult::Loss,
"DNF" | "QUIT" => MatchResult::Dnf,
"DNF_WIN" => MatchResult::Win,
"DNF_DRAW" => MatchResult::Draw,
"DNF_LOSS" => MatchResult::Loss,
"NO_CONTEST" => MatchResult::NoContest,
_ => MatchResult::Draw,
}
}
/// A parsed `PUT …/match/end` payload: the fields the host needs to drive Core.
/// Unknown fields are ignored.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MatchEnd {
/// The raw `endReason` string, if the client sent one.
pub end_reason: Option<String>,
/// Canonical result derived from `end_reason`.
pub result: MatchResult,
/// `matchReportId` from the payload (observed `0` on the live path).
pub match_report_id: i64,
/// Goals scored by the reporting player — `myMatchStats[0]` (goals is the
/// first of the 15 ints). Absent on `DNF`/`QUIT` (stats omitted) → `0`.
pub goals_for: i64,
/// Opponent goals — `opponentMatchStats[0]`. Absent on `DNF`/`QUIT` → `0`.
pub goals_against: i64,
}
/// Parse the FIFA 17 match-end body. Returns `None` for a body that is not a
/// JSON object (malformed). A well-formed object with a missing/unknown
/// `endReason` still parses — the result defaults to `Draw`.
pub fn parse_match_end(body: &[u8]) -> Option<MatchEnd> {
let v: Value = serde_json::from_slice(body).ok()?;
if !v.is_object() {
return None;
}
let end_reason = v
.get("endReason")
.and_then(Value::as_str)
.map(str::to_string);
let result = result_from_end_reason(end_reason.as_deref());
let match_report_id = v.get("matchReportId").and_then(Value::as_i64).unwrap_or(0);
Some(MatchEnd {
end_reason,
result,
match_report_id,
goals_for: first_stat(&v, "myMatchStats"),
goals_against: first_stat(&v, "opponentMatchStats"),
})
}
/// `myMatchStats`/`opponentMatchStats` are 15 ints with goals first
/// [STATIC_REVERSED]; the arrays are OMITTED on DNF/QUIT, so a missing array is
/// `0` goals, not an error.
fn first_stat(v: &Value, key: &str) -> i64 {
v.get(key)
.and_then(Value::as_array)
.and_then(|a| a.first())
.and_then(Value::as_i64)
.unwrap_or(0)
}
/// Build the FIFA 17 match-reward response body (the `destroy_match_body` shape,
/// [STATIC_REVERSED]).
///
/// `all_coins` is Core's AUTHORITATIVE post-credit balance; `match_coins` is the
/// amount Core granted for THIS match (mirrored into `gameModeAward.coins`, where
/// the client reads it). Emits ONLY the reversed fields — it NEVER emits
/// `bidTokens` or `qualifiedChampionEventId`, which are client freeze traps.
pub fn reward_response(all_coins: i64, match_coins: i64) -> Value {
json!({
"allCoins": all_coins,
"matchCoins": match_coins,
"seasonCoins": 0,
"tournamentCoins": 0,
"boostConis": 0, // EA's misspelling (atom 96), preserved on the wire.
"participationAward": MATCH_PARTICIPATION,
"teamOfTournamentWinner": false,
"gameModeAward": { "coins": match_coins },
})
}
/// Build the FIFA 17 `POST …/match` create ack. Zero economic effect: it only
/// hands the client a match id + start time. `id` doubles as the per-match
/// identity the host later keys Core's exactly-once completion on.
pub fn create_response(id: i64, start_epoch: i64) -> Value {
json!({
"startDateTime": start_epoch,
"reportIdEnabled": false,
"id": id,
})
}
/// 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::*;
#[test]
fn every_end_reason_maps_to_canonical_result() {
assert_eq!(result_from_end_reason(Some("WIN")), MatchResult::Win);
assert_eq!(result_from_end_reason(Some("DRAW")), MatchResult::Draw);
assert_eq!(result_from_end_reason(Some("LOSS")), MatchResult::Loss);
assert_eq!(result_from_end_reason(Some("DNF")), MatchResult::Dnf);
assert_eq!(result_from_end_reason(Some("QUIT")), MatchResult::Dnf);
assert_eq!(
result_from_end_reason(Some("NO_CONTEST")),
MatchResult::NoContest
);
assert_eq!(result_from_end_reason(Some("DNF_WIN")), MatchResult::Win);
assert_eq!(result_from_end_reason(Some("DNF_DRAW")), MatchResult::Draw);
assert_eq!(result_from_end_reason(Some("DNF_LOSS")), MatchResult::Loss);
// Case-insensitive.
assert_eq!(result_from_end_reason(Some("dnf_win")), MatchResult::Win);
// Unknown / absent → conservative draw.
assert_eq!(result_from_end_reason(Some("weird")), MatchResult::Draw);
assert_eq!(result_from_end_reason(None), MatchResult::Draw);
}
#[test]
fn core_tokens_match_core_serde() {
assert_eq!(MatchResult::Win.core_token(), "win");
assert_eq!(MatchResult::Draw.core_token(), "draw");
assert_eq!(MatchResult::Loss.core_token(), "loss");
assert_eq!(MatchResult::Dnf.core_token(), "dnf");
assert_eq!(MatchResult::NoContest.core_token(), "no_contest");
}
#[test]
fn parse_match_end_reads_reason_and_goals() {
let body = br#"{"matchReportId":7,"endReason":"WIN","myMatchStats":[3,1,2,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]}"#;
let end = parse_match_end(body).expect("parses");
assert_eq!(end.result, MatchResult::Win);
assert_eq!(end.match_report_id, 7);
assert_eq!(end.goals_for, 3);
assert_eq!(end.goals_against, 1);
}
#[test]
fn parse_match_end_dnf_omits_stats_as_zero() {
// The live DNF payload: stats arrays omitted entirely.
let body = br#"{"matchReportId":0,"endReason":"DNF","items":[],"matchData":"","matchStatusFlags":0}"#;
let end = parse_match_end(body).expect("parses");
assert_eq!(end.result, MatchResult::Dnf);
assert_eq!(end.goals_for, 0);
assert_eq!(end.goals_against, 0);
}
#[test]
fn parse_match_end_unknown_reason_defaults_draw() {
let end = parse_match_end(br#"{"endReason":"BANANA"}"#).expect("parses");
assert_eq!(end.result, MatchResult::Draw);
assert_eq!(end.end_reason.as_deref(), Some("BANANA"));
}
#[test]
fn parse_match_end_rejects_malformed() {
assert!(parse_match_end(b"not json").is_none());
assert!(parse_match_end(b"[]").is_none());
assert!(parse_match_end(b"42").is_none());
}
#[test]
fn reward_response_has_only_reversed_fields() {
let body = reward_response(29_876_876, 100);
assert_eq!(body["allCoins"], 29_876_876);
assert_eq!(body["matchCoins"], 100);
assert_eq!(body["gameModeAward"]["coins"], 100);
assert_eq!(body["seasonCoins"], 0);
assert_eq!(body["tournamentCoins"], 0);
assert_eq!(body["boostConis"], 0);
assert_eq!(body["participationAward"], 0);
assert_eq!(body["teamOfTournamentWinner"], false);
// The freeze traps must never appear.
assert!(body.get("bidTokens").is_none());
assert!(body.get("qualifiedChampionEventId").is_none());
}
#[test]
fn create_response_shape() {
let body = create_response(100_004_838, 1_700_000_000);
assert_eq!(body["id"], 100_004_838);
assert_eq!(body["reportIdEnabled"], false);
assert_eq!(body["startDateTime"], 1_700_000_000);
}
}