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 //! 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 //! feeds to Core economy authority. They own NO state — Core owns balances and
//! inventory; these are the FIFA-specific numbers/derivations. Values are the //! inventory; these are the FIFA-specific numbers/derivations. Pack prices come
//! current OpenFUT economy (match rewards are the Python oracle's //! from the Store catalogue; the transfer-market fee is the FUT-era 5%.
//! `MATCH_COINS`/`MATCH_PARTICIPATION` at production defaults); pack prices come //!
//! from the Store catalogue. //! Match result mapping + reward-body shaping live in [`crate::fut::match_wire`].
use crate::fut::store_catalog::pack_by_id; 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, /// The Store buy-now price for a pack id (`None` for unknown/owned-only packs,
/// which are never purchasable). /// which are never purchasable).
pub fn pack_price(pack_id: u64) -> Option<u64> { pub fn pack_price(pack_id: u64) -> Option<u64> {
@@ -100,24 +61,6 @@ pub fn seller_proceeds(gross: i64) -> i64 {
mod tests { mod tests {
use super::*; 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] #[test]
fn pack_price_rejects_unknown_and_owned_only() { fn pack_price_rejects_unknown_and_owned_only() {
assert!(pack_price(1).is_some()); 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 economy_policy;
pub mod entities; pub mod entities;
pub mod item; pub mod item;
pub mod match_wire;
pub mod non_economy; pub mod non_economy;
pub mod owned_query; pub mod owned_query;
pub mod pack_content; pub mod pack_content;
+11 -1
View File
@@ -464,7 +464,10 @@ mod tests {
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::{AtomicI64, AtomicU32, Ordering}; use std::sync::atomic::{AtomicI64, AtomicU32, Ordering};
use crate::{EconomyEntitlement, EconomyPurchase, EconomySale, EconomySaleReceipt}; use crate::{
CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyPurchase, EconomySale,
EconomySaleReceipt,
};
// ── Recording economy double ──────────────────────────────────────────── // ── Recording economy double ────────────────────────────────────────────
@@ -599,6 +602,13 @@ mod tests {
// Sale settlement is not exercised by the Store/quick-sell paths. // Sale settlement is not exercised by the Store/quick-sell paths.
Err(CoreError::Status(501)) Err(CoreError::Status(501))
} }
fn complete_match(
&self,
_m: &CoreMatchCompletion<'_>,
) -> Result<CoreMatchReceipt, CoreError> {
// Match completion is not exercised by the Store/quick-sell paths.
Err(CoreError::Status(501))
}
} }
// ── Identity / entity / lookup doubles ────────────────────────────────── // ── Identity / entity / lookup doubles ──────────────────────────────────
+165 -37
View File
@@ -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::club_stats::{club_stats_body, ClubStatInput, ContextField};
use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind; use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind;
use openfut_adapter_fifa17::fut::economy_policy::{ use openfut_adapter_fifa17::fut::match_wire;
match_reward_total, result_from_end_reason, MatchResult,
};
use openfut_adapter_fifa17::fut::entities::{Fifa17Entities, ReverseEntityResolver}; use openfut_adapter_fifa17::fut::entities::{Fifa17Entities, ReverseEntityResolver};
use openfut_adapter_fifa17::fut::non_economy; use openfut_adapter_fifa17::fut::non_economy;
use openfut_adapter_fifa17::fut::owned_query::{ use openfut_adapter_fifa17::fut::owned_query::{
@@ -1079,6 +1077,31 @@ pub struct EconomySaleReceipt {
pub squad_slots_freed: u64, 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 /// The host's authoritative economy transport to Core. Every method is a single
/// durable Core transaction. **Fail-closed:** on any transport/status/parse /// durable Core transaction. **Fail-closed:** on any transport/status/parse
/// error the caller MUST surface a controlled error and NEVER fall back to /// 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 /// does not own the item, so a replayed settlement is refused, never
/// double-paid. /// double-paid.
fn settle_sale(&self, sale: &EconomySale<'_>) -> Result<EconomySaleReceipt, CoreError>; fn settle_sale(&self, sale: &EconomySale<'_>) -> Result<EconomySaleReceipt, CoreError>;
/// 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<CoreMatchReceipt, CoreError>;
} }
impl HttpCoreClient { impl HttpCoreClient {
@@ -1143,6 +1171,23 @@ impl HttpCoreClient {
} }
resp.json().map_err(|e| CoreError::Parse(e.to_string())) 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<Value, CoreError> {
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<i64, CoreError> { fn json_i64(v: &Value, key: &str) -> Result<i64, CoreError> {
@@ -1264,6 +1309,27 @@ impl CoreEconomy for HttpCoreClient {
squad_slots_freed: json_u64(&v, "squad_slots_freed")?, squad_slots_freed: json_u64(&v, "squad_slots_freed")?,
}) })
} }
fn complete_match(&self, m: &CoreMatchCompletion<'_>) -> Result<CoreMatchReceipt, CoreError> {
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. /// 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 true
} }
/// Build the `destroy_match_body` reward response (oracle shape). `total` is the /// Derive the per-match economic identity from a persona + the match-end body.
/// post-credit balance; `result_coins` is the per-result amount. /// This is the key for Core's durable exactly-once guard — NOT a FIFA HTTP
pub fn build_match_reward_body(total: i64, result: MatchResult) -> Value { /// receipt id. When the client sends a non-zero `matchReportId` it keys on that;
let result_coins = openfut_adapter_fifa17::fut::economy_policy::match_result_coins(result); /// otherwise (the observed live path reports `0`) it keys on a stable fingerprint
let total_award = match_reward_total(result); /// of the body so an identical network retry dedupes in Core, while distinct
json!({ /// decided matches (whose stat arrays differ) get distinct identities.
"allCoins": total, fn match_identity(persona: i64, end: &match_wire::MatchEnd, body: &[u8]) -> String {
"matchCoins": result_coins, if end.match_report_id != 0 {
"seasonCoins": 0, return format!("{persona}:report:{}", end.match_report_id);
"tournamentCoins": 0, }
"boostConis": 0, use std::hash::{Hash, Hasher};
"participationAward": openfut_adapter_fifa17::fut::economy_policy::MATCH_PARTICIPATION, let mut h = std::collections::hash_map::DefaultHasher::new();
"teamOfTournamentWinner": false, body.hash(&mut h);
"gameModeAward": { "coins": total_award }, format!("{persona}:fp:{:016x}", h.finish())
})
} }
/// Handle the coin-crediting `/match` end call: derive the outcome from /// Handle the coin-crediting match-end call: parse the FIFA wire, then apply the
/// `endReason`, credit the reward through Core `grant_reward`, and render the /// match to OpenFUT Core's authoritative, atomic, exactly-once `complete_match`
/// oracle-shaped body. Fail-closed on Core error (503, never Python). /// transaction and render Core's authoritative coin numbers back onto the wire.
pub fn handle_match_end(econ: &dyn CoreEconomy, body: &[u8]) -> WireResponse { /// FAIL-CLOSED: a malformed body is a 400 and ANY Core error is a 503 — the
let end_reason = serde_json::from_slice::<Value>(body).ok().and_then(|v| { /// economy is never applied by Python (a second writer would break exactly-once).
v.get("endReason") pub fn handle_match_end(econ: &dyn CoreEconomy, persona: i64, body: &[u8]) -> WireResponse {
.and_then(Value::as_str) let Some(end) = match_wire::parse_match_end(body) else {
.map(str::to_string) return error_response(400, "bad_match_end");
}); };
let result = result_from_end_reason(end_reason.as_deref()); let identity = match_identity(persona, &end, body);
match econ.grant_reward(match_reward_total(result)) { let completion = CoreMatchCompletion {
Ok(total) => json_response(&build_match_reward_body(total, result)), 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"), Err(_) => error_response(503, "core_unavailable"),
} }
} }
@@ -3149,7 +3225,7 @@ impl Server {
}; };
handle_quick_sell_body(body, &deps) 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 => { EconomyRoute::MoveItems => {
let (bridge, piles, resolver, market) = ( let (bridge, piles, resolver, market) = (
svc.bridge.clone(), svc.bridge.clone(),
@@ -4322,6 +4398,26 @@ mod tests {
} }
Ok(self.balance) Ok(self.balance)
} }
fn complete_match(
&self,
m: &CoreMatchCompletion<'_>,
) -> Result<CoreMatchReceipt, CoreError> {
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( fn purchase_item(
&self, &self,
_cost: i64, _cost: i64,
@@ -4500,26 +4596,58 @@ mod tests {
#[test] #[test]
fn match_reward_credits_core_and_shapes_body() { fn match_reward_credits_core_and_shapes_body() {
let econ = FakeEconomy::ok(5400, 0); // grant_reward echoes balance let econ = FakeEconomy::ok(5400, 0);
let resp = handle_match_end(&econ, br#"{"endReason":"WIN"}"#); 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); assert_eq!(resp.status, 200);
let body: Value = serde_json::from_slice(&resp.body).unwrap(); 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["allCoins"], 5400);
assert_eq!(body["matchCoins"], 400); // win assert_eq!(body["matchCoins"], 400); // win
assert_eq!(body["gameModeAward"]["coins"], 400); assert_eq!(body["gameModeAward"]["coins"], 400);
assert_eq!(body["seasonCoins"], 0); 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 = 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); assert_eq!(draw["matchCoins"], 200);
} }
#[test] #[test]
fn match_reward_fails_closed_on_core_error() { fn match_end_fails_closed_on_core_error() {
let resp = handle_match_end(&FakeEconomy::failing(), br#"{"endReason":"WIN"}"#); // 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); 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] #[test]
fn special_filter_keeps_only_specials_and_paginates_filtered_set() { fn special_filter_keeps_only_specials_and_paginates_filtered_set() {
let mk = |id: i64, rf: i64| serde_json::json!({ "id": id, "rareflag": rf }); let mk = |id: i64, rf: i64| serde_json::json!({ "id": id, "rareflag": rf });
+9 -1
View File
@@ -805,7 +805,8 @@ pub async fn handle_move_items(
mod tests { mod tests {
use super::*; use super::*;
use crate::{ use crate::{
EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt, CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyGrantItem,
EconomyPurchase, EconomySale, EconomySaleReceipt,
}; };
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering}; use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering};
@@ -959,6 +960,13 @@ mod tests {
squad_slots_freed: 0, squad_slots_freed: 0,
}) })
} }
fn complete_match(
&self,
_m: &CoreMatchCompletion<'_>,
) -> Result<CoreMatchReceipt, CoreError> {
// Match completion is not exercised through the market double.
Err(CoreError::Status(501))
}
} }
// ---- SquadWireResolver double ----------------------------------------- // ---- SquadWireResolver double -----------------------------------------
+31 -10
View File
@@ -484,20 +484,39 @@ fn case_d_two_market_buyers(h: &Harness) -> String {
) )
} }
/// E: match REWARD + Store BUY concurrently → final balance is one legal /// E: match REWARD + Store BUY concurrently → one legal serialization (no lost
/// serialization (no lost update). Reward (+400) and buy (400) commute, so the /// update). The two commute, so the final balance must equal exactly one serial
/// final balance must equal the start exactly. /// 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 { 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; let start = 8_000i64;
for _ in 0..ITERS { for i in 0..ITERS {
set_balance(&h.client, start); set_balance(&h.client, start);
// A DISTINCT match per iteration (unique matchReportId) so the
// exactly-once reward applies every time.
let rs = fire( let rs = fire(
&h.server, &h.server,
vec![ vec![
( (
"POST", "POST",
"/ut/delete/game/fifa17/match".into(), "/ut/delete/game/fifa17/match".into(),
b"{\"endReason\":\"WIN\"}".to_vec(), format!("{{\"matchReportId\":{i},\"endReason\":\"WIN\"}}").into_bytes(),
), ),
( (
"PUT", "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!(rs.iter().all(|r| r.status == 200), "both ops succeed");
assert_eq!( let all = bj(&rs[0])["allCoins"].as_i64().expect("allCoins");
h.client.balance().unwrap(), let after = h.client.balance().unwrap();
start, // Both writers serialized: `after` is one of the two legal orderings.
"reward(+400) and buy(-400) both applied: no lost update" 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 /// F: MOVE + QUICK-SELL of the same item → one coherent final state (item sold
+9 -3
View File
@@ -30,9 +30,9 @@ use openfut_utas_host::async_bridge::AsyncBridge;
use openfut_utas_host::market_store::MarketStore; use openfut_utas_host::market_store::MarketStore;
use openfut_utas_host::pile_store::PileStore; use openfut_utas_host::pile_store::PileStore;
use openfut_utas_host::{ use openfut_utas_host::{
build_content_pool, CoreAccess, CoreEconomy, CoreError, EconomyEntitlement, EconomyGrantItem, build_content_pool, CoreAccess, CoreEconomy, CoreError, CoreMatchCompletion, CoreMatchReceipt,
EconomyPurchase, EconomySale, EconomySaleReceipt, EconomyServices, Fifa17IdentityResolver, EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt,
HttpCoreClient, PassClient, Server, WireResponse, EconomyServices, Fifa17IdentityResolver, HttpCoreClient, PassClient, Server, WireResponse,
}; };
use parking_lot::Mutex; use parking_lot::Mutex;
use serde_json::Value; use serde_json::Value;
@@ -140,6 +140,12 @@ impl CoreEconomy for FaultEconomy {
} }
self.inner.settle_sale(sale) self.inner.settle_sale(sale)
} }
fn complete_match(&self, m: &CoreMatchCompletion<'_>) -> Result<CoreMatchReceipt, CoreError> {
if self.trip("complete_match") {
return Err(Self::injected());
}
self.inner.complete_match(m)
}
} }
/// An `ExternalIdentityStore` that forwards to a real `JsonIdentityStore` but can /// An `ExternalIdentityStore` that forwards to a real `JsonIdentityStore` but can
+46 -19
View File
@@ -140,8 +140,8 @@ fn pack_ids(pg: &Value) -> Vec<u64> {
} }
/// Seed via the real Core HTTP API, then exercise the host handlers + transport. /// Seed via the real Core HTTP API, then exercise the host handlers + transport.
/// Returns nothing; panics on any mismatch. /// Returns the final Core balance so the restart phase can assert persistence.
fn seed_and_exercise(base: &str) { fn seed_and_exercise(base: &str) -> i64 {
wait_ready(base); wait_ready(base);
let http = reqwest::blocking::Client::new(); 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(); let credits: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap();
assert_eq!(credits["currencies"][0]["funds"], 5000, "seeded balance"); assert_eq!(credits["currencies"][0]["funds"], 5000, "seeded balance");
// Match-reward WRITER: win credits +400 via Core grant_reward, end to end. // Match-reward WRITER: a WIN applies its reward through Core's authoritative,
let m = handle_match_end(&client, br#"{"endReason":"WIN"}"#); // 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); assert_eq!(m.status, 200);
let mb: Value = serde_json::from_slice(&m.body).unwrap(); let mb: Value = serde_json::from_slice(&m.body).unwrap();
assert_eq!(mb["allCoins"], 5400, "match reward credited in Core"); assert_eq!(mb["matchCoins"], 400, "flat match coins");
assert_eq!(client.balance().unwrap(), 5400); 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). // Buy a numeric entitlement "70" through the Core economy API (debit 600).
post( post(
@@ -169,11 +187,16 @@ fn seed_and_exercise(base: &str) {
"/economy/purchase-entitlement", "/economy/purchase-entitlement",
json!({ "cost": 600, "definition_id": "70" }), 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. // credits reflects the debit through the same Core state.
let credits2: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap(); 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. // purchasegroup full-gen shows the owned pack 70 and NO sentinel.
let pg: Value = let pg: Value =
@@ -194,25 +217,27 @@ fn seed_and_exercise(base: &str) {
client.balance().unwrap(), client.balance().unwrap(),
client.entitlements().unwrap().len(), 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. // Invariant: credits coins == userMassInfo coins == Core balance.
assert_eq!( assert_eq!(
credits2["currencies"][0]["funds"], credits2["currencies"][0]["funds"],
mass["userInfo"]["currencies"][0]["funds"] mass["userInfo"]["currencies"][0]["funds"]
); );
after_buy
} }
/// After a Core restart from the same DB file, all economy state persists. /// 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); wait_ready(base);
let client = HttpCoreClient::new(base, "fifa17"); let client = HttpCoreClient::new(base, "fifa17");
assert_eq!( assert_eq!(
client.balance().unwrap(), client.balance().unwrap(),
4800, expected_balance,
"coins persisted across restart" "coins persisted across restart"
); );
let credits: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap(); 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 = let pg: Value =
serde_json::from_slice(&handle_purchasegroup(&client, StoreMode::Sentinel).body).unwrap(); serde_json::from_slice(&handle_purchasegroup(&client, StoreMode::Sentinel).body).unwrap();
assert!( assert!(
@@ -239,12 +264,12 @@ async fn economy_end_to_end_and_restart_persistence() {
let b1 = base1.clone(); let b1 = base1.clone();
let r = tokio::task::spawn_blocking(move || seed_and_exercise(&b1)).await; let r = tokio::task::spawn_blocking(move || seed_and_exercise(&b1)).await;
h1.abort(); h1.abort();
r.expect("exercise phase"); let expected_balance = r.expect("exercise phase");
// --- Core instance #2: same on-disk DB, prove persistence --- // --- Core instance #2: same on-disk DB, prove persistence ---
let (h2, base2) = start_core(&db_url).await; let (h2, base2) = start_core(&db_url).await;
let b2 = base2.clone(); 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(); h2.abort();
r2.expect("restart phase"); r2.expect("restart phase");
@@ -422,7 +447,8 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
"totalCredits == absolute Core balance" "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 before_match = client.balance().unwrap();
let mm = server let mm = server
.try_handle_economy( .try_handle_economy(
@@ -434,10 +460,11 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
) )
.expect("match routed"); .expect("match routed");
assert_eq!(mm.status, 200); assert_eq!(mm.status, 200);
assert_eq!( let mmb: Value = serde_json::from_slice(&mm.body).unwrap();
client.balance().unwrap(), assert_eq!(mmb["matchCoins"], 400, "flat match coins");
before_match + 400, assert!(
"WIN credited +400 via Core" 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 -> // 5) MARKET buy-now (async handlers via the bridge): list -> query -> buy ->