diff --git a/openfut-adapter-fifa17/src/fut/economy_policy.rs b/openfut-adapter-fifa17/src/fut/economy_policy.rs index 73809b1..011a774 100644 --- a/openfut-adapter-fifa17/src/fut/economy_policy.rs +++ b/openfut-adapter-fifa17/src/fut/economy_policy.rs @@ -2,52 +2,13 @@ //! //! These translate FIFA 17 wire semantics into the generic amounts the host //! feeds to Core economy authority. They own NO state — Core owns balances and -//! inventory; these are the FIFA-specific numbers/derivations. Values are the -//! current OpenFUT economy (match rewards are the Python oracle's -//! `MATCH_COINS`/`MATCH_PARTICIPATION` at production defaults); pack prices come -//! from the Store catalogue. +//! inventory; these are the FIFA-specific numbers/derivations. Pack prices come +//! from the Store catalogue; the transfer-market fee is the FUT-era 5%. +//! +//! Match result mapping + reward-body shaping live in [`crate::fut::match_wire`]. use crate::fut::store_catalog::pack_by_id; -/// Normalized match outcome for reward purposes. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MatchResult { - Win, - Draw, - Loss, -} - -/// Participation award added to every match reward (oracle `MATCH_PARTICIPATION` -/// default = 0). -pub const MATCH_PARTICIPATION: i64 = 0; - -/// Per-result match coins (oracle `MATCH_COINS`: won 400 / draw 200 / loss 100). -pub fn match_result_coins(result: MatchResult) -> i64 { - match result { - MatchResult::Win => 400, - MatchResult::Draw => 200, - MatchResult::Loss => 100, - } -} - -/// Total match reward = per-result coins + participation. -pub fn match_reward_total(result: MatchResult) -> i64 { - match_result_coins(result) + MATCH_PARTICIPATION -} - -/// Derive the outcome from the match `endReason` enum (the oracle's primary -/// signal, `_END_REASON`). Unknown/absent reasons default to `Draw`, matching -/// the oracle's conservative default. Score-based derivation is a fallback the -/// oracle also supports; the enum is authoritative when present. -pub fn result_from_end_reason(end_reason: Option<&str>) -> MatchResult { - match end_reason.unwrap_or("").to_ascii_uppercase().as_str() { - "WIN" | "DNF_WIN" => MatchResult::Win, - "LOSS" | "QUIT" | "DNF" | "DNF_LOSS" => MatchResult::Loss, - // "DRAW", "DNF_DRAW", "NO_CONTEST", unknown -> draw. - _ => MatchResult::Draw, - } -} - /// The Store buy-now price for a pack id (`None` for unknown/owned-only packs, /// which are never purchasable). pub fn pack_price(pack_id: u64) -> Option { @@ -100,24 +61,6 @@ pub fn seller_proceeds(gross: i64) -> i64 { mod tests { use super::*; - #[test] - fn match_rewards_match_oracle() { - assert_eq!(match_reward_total(MatchResult::Win), 400); - assert_eq!(match_reward_total(MatchResult::Draw), 200); - assert_eq!(match_reward_total(MatchResult::Loss), 100); - } - - #[test] - fn end_reason_maps_to_outcome() { - assert_eq!(result_from_end_reason(Some("WIN")), MatchResult::Win); - assert_eq!(result_from_end_reason(Some("dnf_win")), MatchResult::Win); - assert_eq!(result_from_end_reason(Some("LOSS")), MatchResult::Loss); - assert_eq!(result_from_end_reason(Some("QUIT")), MatchResult::Loss); - assert_eq!(result_from_end_reason(Some("DRAW")), MatchResult::Draw); - assert_eq!(result_from_end_reason(None), MatchResult::Draw); - assert_eq!(result_from_end_reason(Some("weird")), MatchResult::Draw); - } - #[test] fn pack_price_rejects_unknown_and_owned_only() { assert!(pack_price(1).is_some()); diff --git a/openfut-adapter-fifa17/src/fut/match_wire.rs b/openfut-adapter-fifa17/src/fut/match_wire.rs new file mode 100644 index 0000000..c04abf6 --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/match_wire.rs @@ -0,0 +1,248 @@ +//! 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, + /// 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 { + 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, + }) +} + +#[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); + } +} diff --git a/openfut-adapter-fifa17/src/fut/mod.rs b/openfut-adapter-fifa17/src/fut/mod.rs index 3be4daa..fc5d4a3 100644 --- a/openfut-adapter-fifa17/src/fut/mod.rs +++ b/openfut-adapter-fifa17/src/fut/mod.rs @@ -12,6 +12,7 @@ pub mod economy; pub mod economy_policy; pub mod entities; pub mod item; +pub mod match_wire; pub mod non_economy; pub mod owned_query; pub mod pack_content; diff --git a/openfut-utas-host/src/economy_store.rs b/openfut-utas-host/src/economy_store.rs index 5344c51..7b7ceec 100644 --- a/openfut-utas-host/src/economy_store.rs +++ b/openfut-utas-host/src/economy_store.rs @@ -464,7 +464,10 @@ mod tests { use std::collections::HashMap; use std::sync::atomic::{AtomicI64, AtomicU32, Ordering}; - use crate::{EconomyEntitlement, EconomyPurchase, EconomySale, EconomySaleReceipt}; + use crate::{ + CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyPurchase, EconomySale, + EconomySaleReceipt, + }; // ── Recording economy double ──────────────────────────────────────────── @@ -599,6 +602,13 @@ mod tests { // Sale settlement is not exercised by the Store/quick-sell paths. Err(CoreError::Status(501)) } + fn complete_match( + &self, + _m: &CoreMatchCompletion<'_>, + ) -> Result { + // Match completion is not exercised by the Store/quick-sell paths. + Err(CoreError::Status(501)) + } } // ── Identity / entity / lookup doubles ────────────────────────────────── diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index d7c5e1e..0336747 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -55,9 +55,7 @@ use openfut_adapter_fifa17::fut::club_response::{ }; use openfut_adapter_fifa17::fut::club_stats::{club_stats_body, ClubStatInput, ContextField}; use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind; -use openfut_adapter_fifa17::fut::economy_policy::{ - match_reward_total, result_from_end_reason, MatchResult, -}; +use openfut_adapter_fifa17::fut::match_wire; use openfut_adapter_fifa17::fut::entities::{Fifa17Entities, ReverseEntityResolver}; use openfut_adapter_fifa17::fut::non_economy; use openfut_adapter_fifa17::fut::owned_query::{ @@ -1079,6 +1077,31 @@ pub struct EconomySaleReceipt { pub squad_slots_freed: u64, } +/// A finished match to apply to Core's authoritative, exactly-once +/// `complete_match` transaction. `match_identity` is the durable per-match +/// idempotency key (persona-scoped); `result` is the canonical Core token +/// (`win`/`draw`/`loss`/`dnf`/`no_contest`) the adapter derived from `endReason`. +pub struct CoreMatchCompletion<'a> { + pub match_identity: &'a str, + pub result: &'a str, + pub squad_id: &'a str, + pub opponent_name: &'a str, + pub goals_for: i64, + pub goals_against: i64, + pub mode: &'a str, +} + +/// Core's authoritative answer for a match completion. `applied` is `false` on an +/// idempotent replay; the coin figures are Core's, rendered straight onto the +/// wire reward body. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoreMatchReceipt { + pub applied: bool, + pub result: String, + pub coins_awarded: i64, + pub coins_balance: i64, +} + /// The host's authoritative economy transport to Core. Every method is a single /// durable Core transaction. **Fail-closed:** on any transport/status/parse /// error the caller MUST surface a controlled error and NEVER fall back to @@ -1108,6 +1131,11 @@ pub trait CoreEconomy: Send + Sync { /// does not own the item, so a replayed settlement is refused, never /// double-paid. fn settle_sale(&self, sale: &EconomySale<'_>) -> Result; + /// Apply a finished match to Core's authoritative, atomic, exactly-once + /// `complete_match` transaction. Core is the sole economy writer here — a + /// replay/duplicate returns `applied = false` with the canonical result, and + /// any error is surfaced (never a Python fallback). + fn complete_match(&self, m: &CoreMatchCompletion<'_>) -> Result; } impl HttpCoreClient { @@ -1143,6 +1171,23 @@ impl HttpCoreClient { } resp.json().map_err(|e| CoreError::Parse(e.to_string())) } + + /// POST to a non-`/economy/` Core endpoint (e.g. `matches/complete`), same + /// game header + status/parse handling as [`Self::economy_post`]. + fn core_post(&self, tail: &str, body: &Value) -> Result { + let resp = self + .client + .post(format!("{}/{}", self.base_url, tail)) + .header("X-OpenFUT-Game", &self.game) + .json(body) + .send() + .map_err(|e| CoreError::Http(e.to_string()))?; + let status = resp.status().as_u16(); + if !(200..300).contains(&status) { + return Err(CoreError::Status(status)); + } + resp.json().map_err(|e| CoreError::Parse(e.to_string())) + } } fn json_i64(v: &Value, key: &str) -> Result { @@ -1264,6 +1309,27 @@ impl CoreEconomy for HttpCoreClient { squad_slots_freed: json_u64(&v, "squad_slots_freed")?, }) } + + fn complete_match(&self, m: &CoreMatchCompletion<'_>) -> Result { + let v = self.core_post( + "matches/complete", + &json!({ + "match_identity": m.match_identity, + "result": m.result, + "squad_id": m.squad_id, + "opponent_name": m.opponent_name, + "goals_for": m.goals_for, + "goals_against": m.goals_against, + "mode": m.mode, + }), + )?; + Ok(CoreMatchReceipt { + applied: v.get("applied").and_then(Value::as_bool).unwrap_or(false), + result: json_str(&v, "result")?, + coins_awarded: json_i64(&v, "coins_awarded")?, + coins_balance: json_i64(&v, "coins_balance")?, + }) + } } /// Serialize an [`EconomySale`] into the `POST /economy/settle-sale` JSON body. @@ -2365,35 +2431,45 @@ pub fn overlay_massinfo_economy(root: &mut Value, coins: i64, unopened_count: us true } -/// Build the `destroy_match_body` reward response (oracle shape). `total` is the -/// post-credit balance; `result_coins` is the per-result amount. -pub fn build_match_reward_body(total: i64, result: MatchResult) -> Value { - let result_coins = openfut_adapter_fifa17::fut::economy_policy::match_result_coins(result); - let total_award = match_reward_total(result); - json!({ - "allCoins": total, - "matchCoins": result_coins, - "seasonCoins": 0, - "tournamentCoins": 0, - "boostConis": 0, - "participationAward": openfut_adapter_fifa17::fut::economy_policy::MATCH_PARTICIPATION, - "teamOfTournamentWinner": false, - "gameModeAward": { "coins": total_award }, - }) +/// Derive the per-match economic identity from a persona + the match-end body. +/// 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 { + if end.match_report_id != 0 { + return format!("{persona}:report:{}", end.match_report_id); + } + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + body.hash(&mut h); + format!("{persona}:fp:{:016x}", h.finish()) } -/// Handle the coin-crediting `/match` end call: derive the outcome from -/// `endReason`, credit the reward through Core `grant_reward`, and render the -/// oracle-shaped body. Fail-closed on Core error (503, never Python). -pub fn handle_match_end(econ: &dyn CoreEconomy, body: &[u8]) -> WireResponse { - let end_reason = serde_json::from_slice::(body).ok().and_then(|v| { - v.get("endReason") - .and_then(Value::as_str) - .map(str::to_string) - }); - let result = result_from_end_reason(end_reason.as_deref()); - match econ.grant_reward(match_reward_total(result)) { - Ok(total) => json_response(&build_match_reward_body(total, result)), +/// Handle the coin-crediting match-end call: parse the FIFA wire, then apply the +/// match to OpenFUT Core's authoritative, atomic, exactly-once `complete_match` +/// 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 { + 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 completion = CoreMatchCompletion { + match_identity: &identity, + result: end.result.core_token(), + squad_id: "", + opponent_name: "", + goals_for: end.goals_for, + goals_against: end.goals_against, + mode: "seasons", + }; + match econ.complete_match(&completion) { + Ok(receipt) => { + json_response(&match_wire::reward_response(receipt.coins_balance, receipt.coins_awarded)) + } Err(_) => error_response(503, "core_unavailable"), } } @@ -3149,7 +3225,7 @@ impl Server { }; handle_quick_sell_body(body, &deps) } - EconomyRoute::MatchEnd => handle_match_end(svc.econ.as_ref(), body), + EconomyRoute::MatchEnd => handle_match_end(svc.econ.as_ref(), self.persona_id, body), EconomyRoute::MoveItems => { let (bridge, piles, resolver, market) = ( svc.bridge.clone(), @@ -4322,6 +4398,26 @@ mod tests { } Ok(self.balance) } + fn complete_match( + &self, + m: &CoreMatchCompletion<'_>, + ) -> Result { + if self.fail { + return Err(CoreError::Status(503)); + } + let coins = match m.result { + "win" => 400, + "draw" => 200, + "loss" | "dnf" => 100, + _ => 0, + }; + Ok(CoreMatchReceipt { + applied: true, + result: m.result.to_string(), + coins_awarded: coins, + coins_balance: self.balance, + }) + } fn purchase_item( &self, _cost: i64, @@ -4500,26 +4596,58 @@ mod tests { #[test] fn match_reward_credits_core_and_shapes_body() { - let econ = FakeEconomy::ok(5400, 0); // grant_reward echoes balance - let resp = handle_match_end(&econ, br#"{"endReason":"WIN"}"#); + let econ = FakeEconomy::ok(5400, 0); + let resp = handle_match_end( + &econ, + 42, + 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); let body: Value = serde_json::from_slice(&resp.body).unwrap(); + // Core's authoritative balance -> allCoins; granted coins -> matchCoins. assert_eq!(body["allCoins"], 5400); assert_eq!(body["matchCoins"], 400); // win assert_eq!(body["gameModeAward"]["coins"], 400); assert_eq!(body["seasonCoins"], 0); - // Draw default on unknown reason. + // The freeze traps are never emitted. + assert!(body.get("bidTokens").is_none()); + 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, br#"{"foo":1}"#).body).unwrap(); + serde_json::from_slice(&handle_match_end(&econ, 42, br#"{"foo":1}"#).body).unwrap(); assert_eq!(draw["matchCoins"], 200); } #[test] - fn match_reward_fails_closed_on_core_error() { - let resp = handle_match_end(&FakeEconomy::failing(), br#"{"endReason":"WIN"}"#); + 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"}"#); 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"); + assert_eq!(resp.status, 400); + } + + #[test] + fn match_identity_dedupes_retries_and_separates_matches() { + // An identical body (a network retry of the same match) yields the SAME + // identity so Core dedupes it; two decided matches with different stats + // get DIFFERENT identities so both credit. + let a = br#"{"matchReportId":0,"endReason":"WIN","myMatchStats":[3,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]}"#; + 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)); + // 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"); + } + #[test] fn special_filter_keeps_only_specials_and_paginates_filtered_set() { let mk = |id: i64, rf: i64| serde_json::json!({ "id": id, "rareflag": rf }); diff --git a/openfut-utas-host/src/market.rs b/openfut-utas-host/src/market.rs index 76e32a6..b09ffcd 100644 --- a/openfut-utas-host/src/market.rs +++ b/openfut-utas-host/src/market.rs @@ -805,7 +805,8 @@ pub async fn handle_move_items( mod tests { use super::*; use crate::{ - EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt, + CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyGrantItem, + EconomyPurchase, EconomySale, EconomySaleReceipt, }; use std::collections::HashMap; use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering}; @@ -959,6 +960,13 @@ mod tests { squad_slots_freed: 0, }) } + fn complete_match( + &self, + _m: &CoreMatchCompletion<'_>, + ) -> Result { + // Match completion is not exercised through the market double. + Err(CoreError::Status(501)) + } } // ---- SquadWireResolver double ----------------------------------------- diff --git a/openfut-utas-host/tests/economy_concurrency.rs b/openfut-utas-host/tests/economy_concurrency.rs index cdd8168..07c9c96 100644 --- a/openfut-utas-host/tests/economy_concurrency.rs +++ b/openfut-utas-host/tests/economy_concurrency.rs @@ -484,20 +484,39 @@ fn case_d_two_market_buyers(h: &Harness) -> String { ) } -/// E: match REWARD + Store BUY concurrently → final balance is one legal -/// serialization (no lost update). Reward (+400) and buy (−400) commute, so the -/// final balance must equal the start exactly. +/// E: match REWARD + Store BUY concurrently → one legal serialization (no lost +/// update). The two commute, so the final balance must equal exactly one serial +/// outcome: the match's reported post-credit balance (`allCoins`), or that minus +/// the buy's debit — never a torn value from a clobbered write. Amount-agnostic, +/// so it holds even when a WIN also triggers an XP level-up bonus. fn case_e_reward_and_buy(h: &Harness) -> String { + // Measure the store BUY's deterministic debit once. + set_balance(&h.client, 50_000); + let pre_probe = h.client.balance().unwrap(); + let probe = fire( + &h.server, + vec![( + "PUT", + "/ut/game/fifa17/store/transaction".into(), + b"{\"packId\":1}".to_vec(), + )], + ); + assert_eq!(probe[0].status, 200, "probe buy ok"); + let buy_debit = pre_probe - h.client.balance().unwrap(); + assert!(buy_debit > 0, "store buy must debit a positive price"); + let start = 8_000i64; - for _ in 0..ITERS { + for i in 0..ITERS { set_balance(&h.client, start); + // A DISTINCT match per iteration (unique matchReportId) so the + // exactly-once reward applies every time. let rs = fire( &h.server, vec![ ( "POST", "/ut/delete/game/fifa17/match".into(), - b"{\"endReason\":\"WIN\"}".to_vec(), + format!("{{\"matchReportId\":{i},\"endReason\":\"WIN\"}}").into_bytes(), ), ( "PUT", @@ -507,13 +526,15 @@ fn case_e_reward_and_buy(h: &Harness) -> String { ], ); assert!(rs.iter().all(|r| r.status == 200), "both ops succeed"); - assert_eq!( - h.client.balance().unwrap(), - start, - "reward(+400) and buy(-400) both applied: no lost update" + let all = bj(&rs[0])["allCoins"].as_i64().expect("allCoins"); + let after = h.client.balance().unwrap(); + // Both writers serialized: `after` is one of the two legal orderings. + assert!( + after == all || after == all - buy_debit, + "no lost update: after={after}, match allCoins={all}, buy_debit={buy_debit}" ); } - format!("E reward+buy: {ITERS} iters, final==start ({start}) every time (no lost update)") + format!("E reward+buy: {ITERS} iters, no lost update (buy_debit={buy_debit})") } /// F: MOVE + QUICK-SELL of the same item → one coherent final state (item sold diff --git a/openfut-utas-host/tests/economy_failure.rs b/openfut-utas-host/tests/economy_failure.rs index 93e2d29..1a0d7eb 100644 --- a/openfut-utas-host/tests/economy_failure.rs +++ b/openfut-utas-host/tests/economy_failure.rs @@ -30,9 +30,9 @@ use openfut_utas_host::async_bridge::AsyncBridge; use openfut_utas_host::market_store::MarketStore; use openfut_utas_host::pile_store::PileStore; use openfut_utas_host::{ - build_content_pool, CoreAccess, CoreEconomy, CoreError, EconomyEntitlement, EconomyGrantItem, - EconomyPurchase, EconomySale, EconomySaleReceipt, EconomyServices, Fifa17IdentityResolver, - HttpCoreClient, PassClient, Server, WireResponse, + build_content_pool, CoreAccess, CoreEconomy, CoreError, CoreMatchCompletion, CoreMatchReceipt, + EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt, + EconomyServices, Fifa17IdentityResolver, HttpCoreClient, PassClient, Server, WireResponse, }; use parking_lot::Mutex; use serde_json::Value; @@ -140,6 +140,12 @@ impl CoreEconomy for FaultEconomy { } self.inner.settle_sale(sale) } + fn complete_match(&self, m: &CoreMatchCompletion<'_>) -> Result { + if self.trip("complete_match") { + return Err(Self::injected()); + } + self.inner.complete_match(m) + } } /// An `ExternalIdentityStore` that forwards to a real `JsonIdentityStore` but can diff --git a/openfut-utas-host/tests/economy_integration.rs b/openfut-utas-host/tests/economy_integration.rs index ceb6aad..fa31906 100644 --- a/openfut-utas-host/tests/economy_integration.rs +++ b/openfut-utas-host/tests/economy_integration.rs @@ -140,8 +140,8 @@ fn pack_ids(pg: &Value) -> Vec { } /// Seed via the real Core HTTP API, then exercise the host handlers + transport. -/// Returns nothing; panics on any mismatch. -fn seed_and_exercise(base: &str) { +/// Returns the final Core balance so the restart phase can assert persistence. +fn seed_and_exercise(base: &str) -> i64 { wait_ready(base); let http = reqwest::blocking::Client::new(); @@ -155,12 +155,30 @@ fn seed_and_exercise(base: &str) { let credits: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap(); assert_eq!(credits["currencies"][0]["funds"], 5000, "seeded balance"); - // Match-reward WRITER: win credits +400 via Core grant_reward, end to end. - let m = handle_match_end(&client, br#"{"endReason":"WIN"}"#); + // Match-reward WRITER: a WIN applies its reward through Core's authoritative, + // exactly-once complete_match transaction, end to end. The reward is at least + // 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"}"#); assert_eq!(m.status, 200); let mb: Value = serde_json::from_slice(&m.body).unwrap(); - assert_eq!(mb["allCoins"], 5400, "match reward credited in Core"); - assert_eq!(client.balance().unwrap(), 5400); + assert_eq!(mb["matchCoins"], 400, "flat match coins"); + let after_match = client.balance().unwrap(); + assert!(after_match >= before_match + 400, "match credited at least +400"); + assert_eq!( + mb["allCoins"].as_i64().unwrap(), + 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"}"#); + assert_eq!(replay.status, 200); + assert_eq!( + client.balance().unwrap(), + after_match, + "replay must not re-credit" + ); // Buy a numeric entitlement "70" through the Core economy API (debit 600). post( @@ -169,11 +187,16 @@ fn seed_and_exercise(base: &str) { "/economy/purchase-entitlement", json!({ "cost": 600, "definition_id": "70" }), ); - assert_eq!(client.balance().unwrap(), 4800, "debit applied atomically"); + let after_buy = after_match - 600; + assert_eq!( + client.balance().unwrap(), + after_buy, + "debit applied atomically" + ); // credits reflects the debit through the same Core state. let credits2: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap(); - assert_eq!(credits2["currencies"][0]["funds"], 4800); + assert_eq!(credits2["currencies"][0]["funds"], after_buy); // purchasegroup full-gen shows the owned pack 70 and NO sentinel. let pg: Value = @@ -194,25 +217,27 @@ fn seed_and_exercise(base: &str) { client.balance().unwrap(), client.entitlements().unwrap().len(), ); - assert_eq!(mass["userInfo"]["currencies"][0]["funds"], 4800); + assert_eq!(mass["userInfo"]["currencies"][0]["funds"], after_buy); // Invariant: credits coins == userMassInfo coins == Core balance. assert_eq!( credits2["currencies"][0]["funds"], mass["userInfo"]["currencies"][0]["funds"] ); + + after_buy } /// After a Core restart from the same DB file, all economy state persists. -fn verify_after_restart(base: &str) { +fn verify_after_restart(base: &str, expected_balance: i64) { wait_ready(base); let client = HttpCoreClient::new(base, "fifa17"); assert_eq!( client.balance().unwrap(), - 4800, + expected_balance, "coins persisted across restart" ); let credits: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap(); - assert_eq!(credits["currencies"][0]["funds"], 4800); + assert_eq!(credits["currencies"][0]["funds"], expected_balance); let pg: Value = serde_json::from_slice(&handle_purchasegroup(&client, StoreMode::Sentinel).body).unwrap(); assert!( @@ -239,12 +264,12 @@ async fn economy_end_to_end_and_restart_persistence() { let b1 = base1.clone(); let r = tokio::task::spawn_blocking(move || seed_and_exercise(&b1)).await; h1.abort(); - r.expect("exercise phase"); + let expected_balance = r.expect("exercise phase"); // --- Core instance #2: same on-disk DB, prove persistence --- let (h2, base2) = start_core(&db_url).await; let b2 = base2.clone(); - let r2 = tokio::task::spawn_blocking(move || verify_after_restart(&b2)).await; + let r2 = tokio::task::spawn_blocking(move || verify_after_restart(&b2, expected_balance)).await; h2.abort(); r2.expect("restart phase"); @@ -422,7 +447,8 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult { "totalCredits == absolute Core balance" ); - // 4) Match END reward through dispatch (WIN = +400). + // 4) Match END reward through dispatch. The WIN credits at least the flat + // match coins (Core may also add XP level-up / first-win achievement coins). let before_match = client.balance().unwrap(); let mm = server .try_handle_economy( @@ -434,10 +460,11 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult { ) .expect("match routed"); assert_eq!(mm.status, 200); - assert_eq!( - client.balance().unwrap(), - before_match + 400, - "WIN credited +400 via Core" + let mmb: Value = serde_json::from_slice(&mm.body).unwrap(); + assert_eq!(mmb["matchCoins"], 400, "flat match coins"); + assert!( + client.balance().unwrap() >= before_match + 400, + "WIN credited at least +400 via Core" ); // 5) MARKET buy-now (async handlers via the bridge): list -> query -> buy ->