//! 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, }) } /// 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); } }