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:
+165
-37
@@ -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<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 {
|
||||
@@ -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<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> {
|
||||
@@ -1264,6 +1309,27 @@ impl CoreEconomy for HttpCoreClient {
|
||||
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.
|
||||
@@ -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::<Value>(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<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(
|
||||
&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 });
|
||||
|
||||
Reference in New Issue
Block a user