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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user