feat(fifa17): route match completion to Core exactly-once economy

Adapter: new fut/match_wire.rs owns the FIFA17 match wire — endReason
enum -> canonical result token (win/draw/loss/dnf/no_contest), match-end
payload parse (goals from myMatchStats[0], omitted on DNF/QUIT), and the
reward-response projection (only reversed fields; never bidTokens/
qualifiedChampionEventId). Match logic removed from economy_policy.rs
(kept pack/fee); registered match_wire in fut/mod.rs.

Host: handle_match_end now applies the match to Core's authoritative
complete_match (POST /matches/complete) fail-closed — any Core error is a
503, never a Python fallback — and renders Core's authoritative coins.
Per-match identity from matchReportId or a body fingerprint keys Core's
durable idempotency. New CoreEconomy::complete_match transport +
CoreMatchCompletion/CoreMatchReceipt.

Tests: adapter endReason/parse/projection; host shaping, fail-closed,
malformed, identity dedupe; integration replay + rebased balance chains
(match now also grants XP/level-up/achievement coins).
This commit is contained in:
funman300
2026-08-20 17:30:17 +00:00
parent 25f4ad12bc
commit 9ddd80993c
9 changed files with 524 additions and 132 deletions
@@ -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<u64> {
@@ -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());
@@ -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<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,
})
}
#[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);
}
}
+1
View File
@@ -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;