Compare commits
7 Commits
e5d356e8be
...
d8d704d441
| Author | SHA1 | Date | |
|---|---|---|---|
| d8d704d441 | |||
| 07d4a92309 | |||
| afa5f620bd | |||
| 56bd9ddc85 | |||
| 9ddd80993c | |||
| 25f4ad12bc | |||
| d37a9d5b5e |
@@ -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,251 @@
|
||||
//! 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;
|
||||
|
||||
@@ -286,10 +286,11 @@ mod tests {
|
||||
times_completed: 1,
|
||||
};
|
||||
|
||||
let repeatable_view = challenges_body(CHALLENGES[0].set_id, &[repeatable.clone()]);
|
||||
let repeatable_view =
|
||||
challenges_body(CHALLENGES[0].set_id, std::slice::from_ref(&repeatable));
|
||||
assert_eq!(repeatable_view["challenges"][0]["timesCompleted"], 0);
|
||||
assert_eq!(repeatable_view["challenges"][0]["status"], "OPEN");
|
||||
let once_view = challenges_body(CHALLENGES[1].set_id, &[once.clone()]);
|
||||
let once_view = challenges_body(CHALLENGES[1].set_id, std::slice::from_ref(&once));
|
||||
assert_eq!(once_view["challenges"][0]["timesCompleted"], 1);
|
||||
|
||||
let sets = sets_body(&[repeatable, once]);
|
||||
|
||||
@@ -141,6 +141,13 @@ pub struct ProposedSquad {
|
||||
/// never used to derive slot layout.
|
||||
pub formation: Option<String>,
|
||||
pub slots: Vec<ProposedSlot>,
|
||||
/// The owned instance assigned as the squad's **manager**, reverse-resolved
|
||||
/// from the wire `manager` ref to a Core `owned_card_id` (so the assignment
|
||||
/// is ownership-backed, never a dangling wire id). `None` when the save
|
||||
/// carries no manager. A manager wire id the resolver cannot map is reported
|
||||
/// in `unresolved_wire_ids` — a save is refused rather than assigning a
|
||||
/// manager the club does not own.
|
||||
pub manager_owned_card_id: Option<String>,
|
||||
/// Occupied wire item ids the resolver could not map. A caller MUST refuse the
|
||||
/// replacement if this is non-empty — a save must never silently drop an
|
||||
/// owned player it failed to identify.
|
||||
@@ -180,12 +187,14 @@ pub fn parse_squad_put(body: &[u8]) -> Result<Fifa17SquadPut, SquadError> {
|
||||
|
||||
/// Resolve a parsed save into a **canonical** [`ProposedSquad`]: drop empty
|
||||
/// (`id == 0`) slots, reverse-map each occupied slot's wire id to a Core
|
||||
/// `owned_card_id`, flag the captain, and derive the bench split from the fixed
|
||||
/// 23-slot array. FIFA-only state (`custom`, manager, kicktakers, kit numbers,
|
||||
/// squadType) and client-reported evaluation are NOT canonical — they are built
|
||||
/// separately into [`crate::fut::squad_ext::Fifa17SquadExtensionV1`]. The
|
||||
/// formation token is carried verbatim (never mapped). Unresolvable occupied ids
|
||||
/// are reported, never guessed or dropped.
|
||||
/// `owned_card_id`, flag the captain, derive the bench split from the fixed
|
||||
/// 23-slot array, and reverse-resolve the manager ref to an owned instance
|
||||
/// (the manager assignment is ownership-backed canonical state, migration 0023).
|
||||
/// The remaining FIFA-only state (`custom`, kicktakers, kit numbers, squadType)
|
||||
/// and client-reported evaluation are NOT canonical — they are built separately
|
||||
/// into [`crate::fut::squad_ext::Fifa17SquadExtensionV1`]. The formation token is
|
||||
/// carried verbatim (never mapped). Unresolvable occupied ids are reported,
|
||||
/// never guessed or dropped.
|
||||
pub fn to_proposed(put: &Fifa17SquadPut, resolver: &dyn SquadWireResolver) -> ProposedSquad {
|
||||
let captain = put.captain.unwrap_or(0);
|
||||
let mut slots = Vec::new();
|
||||
@@ -205,11 +214,22 @@ pub fn to_proposed(put: &Fifa17SquadPut, resolver: &dyn SquadWireResolver) -> Pr
|
||||
None => unresolved.push(p.item_data.id),
|
||||
}
|
||||
}
|
||||
// Manager: the first non-zero manager ref, reverse-resolved to an owned
|
||||
// instance. An unresolvable manager is an unresolved wire id (refused), not a
|
||||
// silently dropped assignment — you cannot manage with a card you don't own.
|
||||
let mut manager_owned_card_id = None;
|
||||
if let Some(wire) = put.manager.iter().map(|m| m.id).find(|&id| id != 0) {
|
||||
match resolver.owned_id_for_wire(wire) {
|
||||
Some(owned) => manager_owned_card_id = Some(owned),
|
||||
None => unresolved.push(wire),
|
||||
}
|
||||
}
|
||||
ProposedSquad {
|
||||
squad_id: put.id,
|
||||
name: put.squad_name.clone(),
|
||||
formation: put.formation.clone(),
|
||||
slots,
|
||||
manager_owned_card_id,
|
||||
unresolved_wire_ids: unresolved,
|
||||
}
|
||||
}
|
||||
@@ -237,9 +257,11 @@ mod tests {
|
||||
}
|
||||
fn full_resolver() -> MapResolver {
|
||||
// indices 0..=10 (11 starters); the rest of the 23 slots are id==0 (empty).
|
||||
// 100000427 is the fixture's manager ref — the host resolves it like any
|
||||
// other owned instance, so the manager assignment is ownership-backed.
|
||||
let ids = [
|
||||
100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009,
|
||||
100000001, 100000002, 100000025,
|
||||
100000001, 100000002, 100000025, 100000427,
|
||||
];
|
||||
MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect())
|
||||
}
|
||||
@@ -283,6 +305,8 @@ mod tests {
|
||||
assert_eq!(caps[0].owned_card_id, "oc-100000001");
|
||||
assert_eq!(caps[0].index, 8);
|
||||
assert_eq!(caps[0].kit_number, 8);
|
||||
// The manager ref is reverse-resolved to an owned instance (canonical).
|
||||
assert_eq!(sq.manager_owned_card_id.as_deref(), Some("oc-100000427"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! | `custom` | opaque 33-int string; meaning UNKNOWN, round-tripped verbatim |
|
||||
//! | `squad_type` | an observed FIFA wire token; no matching generic Core concept |
|
||||
//! | `kit_numbers` | keyed by **`owned_card_id`** — evidence: kit follows the player |
|
||||
//! | `manager` | a FIFA manager item ref; not a squad player, semantics opaque |
|
||||
//! | ~~manager~~ | MOVED to ownership-backed canonical Core state (migration 0023 `squad_managers`); resolved to an `owned_card_id`, no longer opaque here |
|
||||
//! | `kicktakers` | role→item refs; relationship to captain UNKNOWN, kept opaque |
|
||||
//! | `client_reported` | chemistry/rating/starRating — client shadow, NOT authority |
|
||||
//!
|
||||
@@ -30,7 +30,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::fut::squad::{ClientReportedSquadEval, Fifa17SquadPut, ProposedSquad, SquadEntityRef};
|
||||
use crate::fut::squad::{ClientReportedSquadEval, Fifa17SquadPut, ProposedSquad};
|
||||
|
||||
/// Opaque scope key Core files this extension under (`game_entity_ext.namespace`).
|
||||
pub const EXT_NAMESPACE: &str = "fifa17.squad";
|
||||
@@ -49,15 +49,6 @@ pub struct WireItemRef {
|
||||
pub dream: bool,
|
||||
}
|
||||
|
||||
impl From<&SquadEntityRef> for WireItemRef {
|
||||
fn from(r: &SquadEntityRef) -> Self {
|
||||
WireItemRef {
|
||||
id: r.id,
|
||||
dream: r.dream,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A kicktaker slot preserved verbatim. `index` is the role slot (0..=4 observed);
|
||||
/// `item` is the referenced FIFA wire item. The role→player meaning and any
|
||||
/// relationship to the captain are UNKNOWN, so this is stored opaquely and never
|
||||
@@ -83,9 +74,9 @@ pub struct Fifa17SquadExtensionV1 {
|
||||
/// proves the kit number follows the player across swaps and formation change.
|
||||
#[serde(default)]
|
||||
pub kit_numbers: BTreeMap<String, i64>,
|
||||
/// Manager item ref(s), opaque. Not a squad player; not shaped as an item.
|
||||
#[serde(default)]
|
||||
pub manager: Vec<WireItemRef>,
|
||||
// NOTE: the squad manager is NO LONGER carried here. It is ownership-backed
|
||||
// canonical Core state (migration 0023 `squad_managers`), resolved to an
|
||||
// `owned_card_id` on the ProposedSquad — never a dangling opaque wire ref.
|
||||
/// Kicktaker role refs, opaque (see [`KicktakerRef`]).
|
||||
#[serde(default)]
|
||||
pub kicktakers: Vec<KicktakerRef>,
|
||||
@@ -132,7 +123,6 @@ impl Fifa17SquadExtensionV1 {
|
||||
custom: put.custom.clone(),
|
||||
squad_type: put.squad_type.clone(),
|
||||
kit_numbers,
|
||||
manager: put.manager.iter().map(WireItemRef::from).collect(),
|
||||
kicktakers: put
|
||||
.kicktakers
|
||||
.iter()
|
||||
@@ -256,6 +246,9 @@ mod tests {
|
||||
let ids = [
|
||||
100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009,
|
||||
100000001, 100000002, 100000025,
|
||||
// The f442 fixture's manager ref — the host resolves it like any other
|
||||
// owned instance, so the ownership-backed manager assignment is present.
|
||||
100000427,
|
||||
];
|
||||
MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect())
|
||||
}
|
||||
@@ -308,22 +301,32 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_and_kicktakers_preserved_opaquely() {
|
||||
let ext = built().extension;
|
||||
fn manager_is_canonical_and_kicktakers_stay_opaque() {
|
||||
let build = built();
|
||||
// Manager is now ownership-backed canonical state: the wire ref resolved
|
||||
// to a Core owned_card_id on the ProposedSquad, not an opaque ext blob.
|
||||
assert_eq!(
|
||||
ext.manager,
|
||||
vec![WireItemRef {
|
||||
id: 100000427,
|
||||
dream: false
|
||||
}]
|
||||
build.canonical.manager_owned_card_id.as_deref(),
|
||||
Some("oc-100000427")
|
||||
);
|
||||
// Kicktakers remain opaque in the extension.
|
||||
let ext = build.extension;
|
||||
assert_eq!(ext.kicktakers.len(), 5);
|
||||
// All five reference the same wire id in this capture; carried verbatim,
|
||||
// NEVER normalized to the captain even though they coincide here.
|
||||
assert!(ext.kicktakers.iter().all(|k| k.item.id == 100000001));
|
||||
assert_eq!(ext.kicktakers[0].index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_refuses_an_unowned_manager() {
|
||||
// Manager wire id present but NOT resolvable → refused, never assigned a
|
||||
// manager the club does not own.
|
||||
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||
let mut ids = full_resolver().0;
|
||||
ids.remove(&100000427);
|
||||
let err = build_squad_write(&put, &MapResolver(ids)).unwrap_err();
|
||||
assert_eq!(err, SquadBuildError::UnresolvedWireIds(vec![100000427]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_schema_version_is_rejected_not_coerced() {
|
||||
let payload = built().extension.to_payload();
|
||||
|
||||
@@ -77,6 +77,11 @@ pub struct SquadProjectionInput<'a> {
|
||||
/// Every owned item a slot references, keyed by `owned_card_id`. Assembled by
|
||||
/// the host in one batch — the projector only reads from it.
|
||||
pub owned: &'a HashMap<String, CoreOwnedItem>,
|
||||
/// The owned instance assigned as this squad's **manager** (Core's
|
||||
/// ownership-backed `squad_managers` assignment, migration 0023), or `None`.
|
||||
/// Projected as the FIFA `manager` wire ref resolved from ownership — never a
|
||||
/// dangling wire id, and never fabricated when absent.
|
||||
pub manager: Option<CoreOwnedItem>,
|
||||
}
|
||||
|
||||
/// Result of a projection, with the extension-freshness verdict surfaced.
|
||||
@@ -171,6 +176,17 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
||||
}
|
||||
}
|
||||
|
||||
// Manager: the ownership-backed assignment, resolved to its FIFA wire ref.
|
||||
// Emitted as the STATIC_REVERSED `[{id, dream}]` shape (the same shape the
|
||||
// client sends on save). An owned manager with no resolvable FIFA identity
|
||||
// is omitted (non-fatal, like /club dropping an unrenderable card) rather
|
||||
// than emitted with a fabricated id. NOTE: a richer manager itemData
|
||||
// (contract/leagueId/nation) is INFERRED-only from the offline-season
|
||||
// diagnosis and deliberately NOT invented here pending wire reversal.
|
||||
let manager = match input.manager.as_ref().and_then(|m| ident.resolve(m)) {
|
||||
Some(id) => json!([{ "id": id.item_id, "dream": false }]),
|
||||
None => json!([]),
|
||||
};
|
||||
let squad = json!({
|
||||
"id": input.fifa_squad_id,
|
||||
"squadName": input.name,
|
||||
@@ -180,7 +196,7 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
||||
"starRating": ext.client_reported.star_rating,
|
||||
"rating": ext.client_reported.rating,
|
||||
"captain": captain_wire,
|
||||
"manager": ext.manager,
|
||||
"manager": manager,
|
||||
"custom": ext.custom,
|
||||
"players": players,
|
||||
"kicktakers": ext.kicktakers,
|
||||
@@ -262,6 +278,7 @@ mod tests {
|
||||
}],
|
||||
ext,
|
||||
owned,
|
||||
manager: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +289,6 @@ mod tests {
|
||||
custom: Some("[1,2,3]".into()),
|
||||
squad_type: Some("REGULAR_SQUAD".into()),
|
||||
kit_numbers: kit,
|
||||
manager: vec![],
|
||||
kicktakers: vec![],
|
||||
client_reported: Default::default(),
|
||||
}
|
||||
@@ -403,6 +419,7 @@ mod tests {
|
||||
],
|
||||
ext: SquadExtInput::Fresh(ext),
|
||||
owned: owned_ref,
|
||||
manager: None,
|
||||
};
|
||||
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
|
||||
panic!();
|
||||
@@ -423,4 +440,65 @@ mod tests {
|
||||
"kit stays with the instance"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_projected_from_ownership_as_wire_ref() {
|
||||
let mut owned = HashMap::new();
|
||||
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
|
||||
let ident = TableIdentity(HashMap::from([
|
||||
(
|
||||
"oc1".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000042,
|
||||
asset_id: 20801,
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
},
|
||||
),
|
||||
(
|
||||
"oc-mgr".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000427,
|
||||
asset_id: 5001,
|
||||
resource_id: 5001,
|
||||
rareflag: 1,
|
||||
},
|
||||
),
|
||||
]));
|
||||
let mut input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
|
||||
input.manager = Some(owned_item("oc-mgr", "fifa17_mgr"));
|
||||
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
|
||||
panic!("expected Projected");
|
||||
};
|
||||
assert_eq!(
|
||||
v["manager"],
|
||||
json!([{ "id": 100000427, "dream": false }]),
|
||||
"manager is the ownership-backed wire ref, resolved from the owned item"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_manager_projects_empty_array_never_fabricated() {
|
||||
let mut owned = HashMap::new();
|
||||
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
|
||||
let ident = TableIdentity(HashMap::from([(
|
||||
"oc1".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000042,
|
||||
asset_id: 20801,
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
},
|
||||
)]));
|
||||
// one_slot_input leaves manager: None.
|
||||
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
|
||||
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
|
||||
panic!("expected Projected");
|
||||
};
|
||||
assert_eq!(
|
||||
v["manager"],
|
||||
json!([]),
|
||||
"no manager assignment => empty array, nothing fabricated"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
//! ```
|
||||
//!
|
||||
//! Fidelity is asserted by ownership class:
|
||||
//! CANONICAL player instance per index, formation, captain, bench split
|
||||
//! EXTENSION custom, kicktakers, manager, kit numbers (by player), squadType
|
||||
//! CANONICAL player instance per index, formation, captain, bench split, and
|
||||
//! the ownership-backed manager assignment (migration 0023)
|
||||
//! EXTENSION custom, kicktakers, kit numbers (by player), squadType
|
||||
//! SHADOW chemistry/rating/starRating (client-reported, round-tripped as-is)
|
||||
//! DERIVED correct FIFA 17 item identity (wire id + resourceId)
|
||||
//!
|
||||
@@ -146,6 +147,9 @@ fn project_put(
|
||||
slots,
|
||||
ext: SquadExtInput::Fresh(extension),
|
||||
owned,
|
||||
// This stand-in supplies no owned manager; the manager is projected from
|
||||
// the ownership-backed assignment, exercised in persisted_read below.
|
||||
manager: None,
|
||||
};
|
||||
match project_squad(&input, ident, &NoEntities).unwrap() {
|
||||
SquadProjection::Projected(v) => v,
|
||||
@@ -194,9 +198,11 @@ fn baseline_projects_the_known_squad_round_trip() {
|
||||
projected["captain"], 100000001,
|
||||
"captain is the player's WIRE id"
|
||||
);
|
||||
// EXTENSION: custom byte-identical, manager + squadType preserved.
|
||||
// EXTENSION: custom byte-identical, squadType preserved. The manager is now
|
||||
// an ownership-backed assignment (not projected from the PUT/ext); with none
|
||||
// supplied to this stand-in it projects empty.
|
||||
assert_eq!(projected["custom"], put_v["custom"]);
|
||||
assert_eq!(projected["manager"], put_v["manager"]);
|
||||
assert!(projected["manager"].as_array().unwrap().is_empty());
|
||||
assert_eq!(projected["squadType"], "REGULAR_SQUAD");
|
||||
// SHADOW: client-reported values carried as-is (baseline chemistry 52).
|
||||
assert_eq!(projected["chemistry"], 52);
|
||||
@@ -268,13 +274,11 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
|
||||
// evidence, then project and require the read back — the strongest fidelity
|
||||
// check across all four ownership classes.
|
||||
use openfut_adapter_fifa17::fut::squad::ClientReportedSquadEval;
|
||||
use openfut_adapter_fifa17::fut::squad_ext::{
|
||||
Fifa17SquadExtensionV1, KicktakerRef, WireItemRef,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::squad_ext::{Fifa17SquadExtensionV1, KicktakerRef};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
let oracle: Value = serde_json::from_str(READ_ORACLE).unwrap();
|
||||
let (owned, ident) = oracle_tables();
|
||||
let (owned, mut ident) = oracle_tables();
|
||||
let captain = oracle["captain"].as_i64().unwrap();
|
||||
|
||||
let mut slots = Vec::new();
|
||||
@@ -294,14 +298,35 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
|
||||
is_on_bench: index >= 11,
|
||||
});
|
||||
}
|
||||
let manager: Vec<WireItemRef> = serde_json::from_value(oracle["manager"].clone()).unwrap();
|
||||
// The manager is now ownership-backed: register its owned instance + identity
|
||||
// and pass it as the assignment, not as an opaque extension field.
|
||||
let mgr_wire = oracle["manager"][0]["id"].as_i64().unwrap();
|
||||
let mgr_oc = format!("oc-{mgr_wire}");
|
||||
ident.0.insert(
|
||||
mgr_oc.clone(),
|
||||
Fifa17Identity {
|
||||
item_id: mgr_wire as u32,
|
||||
asset_id: 5001,
|
||||
resource_id: 5001,
|
||||
rareflag: 1,
|
||||
},
|
||||
);
|
||||
let manager_item = CoreOwnedItem {
|
||||
owned_card_id: mgr_oc,
|
||||
card_id: "def-manager".to_string(),
|
||||
rating: 0,
|
||||
position: String::new(),
|
||||
nation: String::new(),
|
||||
league: String::new(),
|
||||
club: String::new(),
|
||||
attributes: [0; 6],
|
||||
};
|
||||
let kicktakers: Vec<KicktakerRef> =
|
||||
serde_json::from_value(oracle["kicktakers"].clone()).unwrap();
|
||||
let ext = Fifa17SquadExtensionV1 {
|
||||
custom: oracle["custom"].as_str().map(str::to_string),
|
||||
squad_type: oracle["squadType"].as_str().map(str::to_string),
|
||||
kit_numbers,
|
||||
manager,
|
||||
kicktakers,
|
||||
client_reported: ClientReportedSquadEval {
|
||||
chemistry: oracle["chemistry"].as_i64(),
|
||||
@@ -316,6 +341,7 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
|
||||
slots,
|
||||
ext: SquadExtInput::Fresh(ext),
|
||||
owned: &owned,
|
||||
manager: Some(manager_item),
|
||||
};
|
||||
let SquadProjection::Projected(projected) = project_squad(&input, &ident, &NoEntities).unwrap()
|
||||
else {
|
||||
|
||||
+1
-1
Submodule openfut-core updated: a034e74c16...2fb835200f
@@ -208,7 +208,15 @@ pub fn plan_apply(
|
||||
.and_then(|s| s.get(0))
|
||||
.context("report says a squad is present but profile has no squads[0]")?;
|
||||
let body = serde_json::to_vec(raw_squad).context("re-serialize source squad")?;
|
||||
let put = parse_squad_put(&body).map_err(|e| anyhow::anyhow!("parse source squad: {e}"))?;
|
||||
let mut put =
|
||||
parse_squad_put(&body).map_err(|e| anyhow::anyhow!("parse source squad: {e}"))?;
|
||||
// A HISTORICAL profile may reference a manager whose owned instance is
|
||||
// not imported (unsupported/deferred, or a dangling id with no owned
|
||||
// item at all). Drop such a manager ref here rather than failing the
|
||||
// whole import — the manager assignment is only imported when its owned
|
||||
// instance is. (A LIVE squad PUT still refuses an unresolved manager,
|
||||
// because the client is actively assigning one it must own.)
|
||||
put.manager.retain(|m| wire_to_owned.contains_key(&m.id));
|
||||
let build = build_squad_write(&put, &MapResolver(&wire_to_owned))
|
||||
.map_err(|e| anyhow::anyhow!("build squad write: {e}"))?;
|
||||
let formation = build
|
||||
|
||||
@@ -464,7 +464,10 @@ mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicI64, AtomicU32, Ordering};
|
||||
|
||||
use crate::{EconomyEntitlement, EconomyPurchase, EconomySale, EconomySaleReceipt};
|
||||
use crate::{
|
||||
CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyPurchase, EconomySale,
|
||||
EconomySaleReceipt,
|
||||
};
|
||||
|
||||
// ── Recording economy double ────────────────────────────────────────────
|
||||
|
||||
@@ -599,6 +602,13 @@ mod tests {
|
||||
// Sale settlement is not exercised by the Store/quick-sell paths.
|
||||
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 ──────────────────────────────────
|
||||
|
||||
+266
-47
@@ -55,10 +55,8 @@ 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::entities::{Fifa17Entities, ReverseEntityResolver};
|
||||
use openfut_adapter_fifa17::fut::match_wire;
|
||||
use openfut_adapter_fifa17::fut::non_economy;
|
||||
use openfut_adapter_fifa17::fut::owned_query::{
|
||||
is_special_rareflag, map_to_core, parse_club_query, MapError,
|
||||
@@ -682,6 +680,24 @@ pub trait CoreAccess: Send + Sync {
|
||||
/// Replace the active squad's canonical slots + opaque extension atomically.
|
||||
fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError>;
|
||||
|
||||
/// The owned instance id assigned as the active squad's **manager**, or
|
||||
/// `None` (`GET /club/manager`). Default: `None` — a transport without the
|
||||
/// endpoint simply projects no manager (non-fatal, like an absent
|
||||
/// assignment). The production `HttpCoreClient` overrides it.
|
||||
fn get_squad_manager(&self) -> Result<Option<String>, CoreError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Assign (`Some`) or clear (`None`) the active squad's **manager**
|
||||
/// (`PUT /club/manager`). Default: unimplemented — the production
|
||||
/// `HttpCoreClient` overrides it; a transport that cannot persist the
|
||||
/// assignment MUST fail loudly rather than silently drop it.
|
||||
fn set_squad_manager(&self, _owned_card_id: Option<&str>) -> Result<(), CoreError> {
|
||||
Err(CoreError::Parse(
|
||||
"Core squad manager write is not implemented".into(),
|
||||
))
|
||||
}
|
||||
|
||||
fn list_sbcs(&self) -> Result<Vec<CoreSbcDefinition>, CoreError> {
|
||||
Err(CoreError::Parse(
|
||||
"Core SBC access is not implemented".into(),
|
||||
@@ -821,6 +837,41 @@ impl CoreAccess for HttpCoreClient {
|
||||
})
|
||||
}
|
||||
|
||||
fn get_squad_manager(&self) -> Result<Option<String>, CoreError> {
|
||||
let url = format!("{}/club/manager", self.base_url);
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("X-OpenFUT-Game", &self.game)
|
||||
.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));
|
||||
}
|
||||
let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?;
|
||||
Ok(v.get("manager")
|
||||
.and_then(|m| m.get("owned_card_id"))
|
||||
.and_then(|x| x.as_str())
|
||||
.map(str::to_string))
|
||||
}
|
||||
|
||||
fn set_squad_manager(&self, owned_card_id: Option<&str>) -> Result<(), CoreError> {
|
||||
let url = format!("{}/club/manager", self.base_url);
|
||||
let resp = self
|
||||
.client
|
||||
.put(&url)
|
||||
.header("X-OpenFUT-Game", &self.game)
|
||||
.json(&json!({ "owned_card_id": owned_card_id }))
|
||||
.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));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_sbcs(&self) -> Result<Vec<CoreSbcDefinition>, CoreError> {
|
||||
let response = self
|
||||
.client
|
||||
@@ -1026,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
|
||||
@@ -1055,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 {
|
||||
@@ -1090,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> {
|
||||
@@ -1211,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.
|
||||
@@ -1839,6 +1958,15 @@ fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection {
|
||||
is_on_bench: s.is_on_bench,
|
||||
})
|
||||
.collect();
|
||||
// The ownership-backed manager assignment (Core `squad_managers`). A fetch
|
||||
// error or absent endpoint yields no manager — non-fatal, a squad renders
|
||||
// without one, never fabricated.
|
||||
let manager = deps
|
||||
.core
|
||||
.get_squad_manager()
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|id| owned_by_id.get(&id).cloned());
|
||||
let input = SquadProjectionInput {
|
||||
fifa_squad_id: ACTIVE_SQUAD_WIRE_ID,
|
||||
name: read.name,
|
||||
@@ -1846,6 +1974,7 @@ fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection {
|
||||
slots,
|
||||
ext: SquadExtInput::Fresh(ext),
|
||||
owned: &owned_by_id,
|
||||
manager,
|
||||
};
|
||||
match project_squad(&input, deps.resolver, deps.entities) {
|
||||
Ok(SquadProjection::Projected(v)) => HostProjection::Squad(v),
|
||||
@@ -1932,6 +2061,18 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ
|
||||
);
|
||||
}
|
||||
}
|
||||
// The manager is a resolved, owned assignment too: authorize it like a slot.
|
||||
if let Some(mgr) = &build.canonical.manager_owned_card_id {
|
||||
if !owned_set.contains(mgr) {
|
||||
return (
|
||||
error_response(403, "not_owned"),
|
||||
SquadLog {
|
||||
outcome: "unauthorized_manager",
|
||||
detail: mgr.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
// Commit canonical + extension atomically. No Python fallback on failure.
|
||||
let req = CoreReplaceRequest {
|
||||
name: build.canonical.name.clone(),
|
||||
@@ -1956,22 +2097,37 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ
|
||||
ext_schema_version: EXT_SCHEMA_VERSION,
|
||||
ext_payload: build.extension.to_payload(),
|
||||
};
|
||||
match deps.core.replace_squad(&req) {
|
||||
Ok(_) => (
|
||||
json_response(&save_ack(put.id)),
|
||||
SquadLog {
|
||||
outcome: "ok",
|
||||
detail: String::new(),
|
||||
},
|
||||
),
|
||||
Err(e) => (
|
||||
if let Err(e) = deps.core.replace_squad(&req) {
|
||||
return (
|
||||
error_response(502, "core_error"),
|
||||
SquadLog {
|
||||
outcome: "core_error",
|
||||
detail: e.to_string(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
// Persist the ownership-backed manager assignment (migration 0023). It was
|
||||
// authorized above and Core re-validates club ownership; fail loudly on a
|
||||
// transport error rather than silently dropping the manager.
|
||||
if let Err(e) = deps
|
||||
.core
|
||||
.set_squad_manager(build.canonical.manager_owned_card_id.as_deref())
|
||||
{
|
||||
return (
|
||||
error_response(502, "core_error"),
|
||||
SquadLog {
|
||||
outcome: "manager_error",
|
||||
detail: e.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
(
|
||||
json_response(&save_ack(put.id)),
|
||||
SquadLog {
|
||||
outcome: "ok",
|
||||
detail: String::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// `GET …/squad/list` — served from Core + the ONE projector. Stale/Missing are
|
||||
@@ -2275,35 +2431,46 @@ 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"),
|
||||
}
|
||||
}
|
||||
@@ -3059,7 +3226,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(),
|
||||
@@ -4232,6 +4399,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,
|
||||
@@ -4410,26 +4597,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 });
|
||||
|
||||
@@ -805,7 +805,8 @@ pub async fn handle_move_items(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt,
|
||||
CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyGrantItem,
|
||||
EconomyPurchase, EconomySale, EconomySaleReceipt,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering};
|
||||
@@ -959,6 +960,13 @@ mod tests {
|
||||
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 -----------------------------------------
|
||||
|
||||
@@ -436,8 +436,10 @@ fn case_d_two_market_buyers(h: &Harness) -> String {
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
&[],
|
||||
format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
|
||||
.as_bytes(),
|
||||
format!(
|
||||
r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#
|
||||
)
|
||||
.as_bytes(),
|
||||
None,
|
||||
)
|
||||
.expect("list routed");
|
||||
@@ -484,20 +486,39 @@ fn case_d_two_market_buyers(h: &Harness) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// E: match REWARD + Store BUY concurrently → final balance is one legal
|
||||
/// serialization (no lost update). Reward (+400) and buy (−400) commute, so the
|
||||
/// final balance must equal the start exactly.
|
||||
/// E: match REWARD + Store BUY concurrently → one legal serialization (no lost
|
||||
/// update). The two commute, so the final balance must equal exactly one serial
|
||||
/// 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 {
|
||||
// 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;
|
||||
for _ in 0..ITERS {
|
||||
for i in 0..ITERS {
|
||||
set_balance(&h.client, start);
|
||||
// A DISTINCT match per iteration (unique matchReportId) so the
|
||||
// exactly-once reward applies every time.
|
||||
let rs = fire(
|
||||
&h.server,
|
||||
vec![
|
||||
(
|
||||
"POST",
|
||||
"/ut/delete/game/fifa17/match".into(),
|
||||
b"{\"endReason\":\"WIN\"}".to_vec(),
|
||||
format!("{{\"matchReportId\":{i},\"endReason\":\"WIN\"}}").into_bytes(),
|
||||
),
|
||||
(
|
||||
"PUT",
|
||||
@@ -507,13 +528,15 @@ fn case_e_reward_and_buy(h: &Harness) -> String {
|
||||
],
|
||||
);
|
||||
assert!(rs.iter().all(|r| r.status == 200), "both ops succeed");
|
||||
assert_eq!(
|
||||
h.client.balance().unwrap(),
|
||||
start,
|
||||
"reward(+400) and buy(-400) both applied: no lost update"
|
||||
let all = bj(&rs[0])["allCoins"].as_i64().expect("allCoins");
|
||||
let after = h.client.balance().unwrap();
|
||||
// Both writers serialized: `after` is one of the two legal orderings.
|
||||
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
|
||||
|
||||
@@ -30,9 +30,9 @@ use openfut_utas_host::async_bridge::AsyncBridge;
|
||||
use openfut_utas_host::market_store::MarketStore;
|
||||
use openfut_utas_host::pile_store::PileStore;
|
||||
use openfut_utas_host::{
|
||||
build_content_pool, CoreAccess, CoreEconomy, CoreError, EconomyEntitlement, EconomyGrantItem,
|
||||
EconomyPurchase, EconomySale, EconomySaleReceipt, EconomyServices, Fifa17IdentityResolver,
|
||||
HttpCoreClient, PassClient, Server, WireResponse,
|
||||
build_content_pool, CoreAccess, CoreEconomy, CoreError, CoreMatchCompletion, CoreMatchReceipt,
|
||||
EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt,
|
||||
EconomyServices, Fifa17IdentityResolver, HttpCoreClient, PassClient, Server, WireResponse,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::Value;
|
||||
@@ -140,6 +140,12 @@ impl CoreEconomy for FaultEconomy {
|
||||
}
|
||||
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
|
||||
|
||||
@@ -140,8 +140,8 @@ fn pack_ids(pg: &Value) -> Vec<u64> {
|
||||
}
|
||||
|
||||
/// Seed via the real Core HTTP API, then exercise the host handlers + transport.
|
||||
/// Returns nothing; panics on any mismatch.
|
||||
fn seed_and_exercise(base: &str) {
|
||||
/// Returns the final Core balance so the restart phase can assert persistence.
|
||||
fn seed_and_exercise(base: &str) -> i64 {
|
||||
wait_ready(base);
|
||||
let http = reqwest::blocking::Client::new();
|
||||
|
||||
@@ -155,12 +155,33 @@ fn seed_and_exercise(base: &str) {
|
||||
let credits: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap();
|
||||
assert_eq!(credits["currencies"][0]["funds"], 5000, "seeded balance");
|
||||
|
||||
// Match-reward WRITER: win credits +400 via Core grant_reward, end to end.
|
||||
let m = handle_match_end(&client, br#"{"endReason":"WIN"}"#);
|
||||
// Match-reward WRITER: a WIN applies its reward through Core's authoritative,
|
||||
// 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);
|
||||
let mb: Value = serde_json::from_slice(&m.body).unwrap();
|
||||
assert_eq!(mb["allCoins"], 5400, "match reward credited in Core");
|
||||
assert_eq!(client.balance().unwrap(), 5400);
|
||||
assert_eq!(mb["matchCoins"], 400, "flat match coins");
|
||||
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).
|
||||
post(
|
||||
@@ -169,11 +190,16 @@ fn seed_and_exercise(base: &str) {
|
||||
"/economy/purchase-entitlement",
|
||||
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.
|
||||
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.
|
||||
let pg: Value =
|
||||
@@ -194,25 +220,27 @@ fn seed_and_exercise(base: &str) {
|
||||
client.balance().unwrap(),
|
||||
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.
|
||||
assert_eq!(
|
||||
credits2["currencies"][0]["funds"],
|
||||
mass["userInfo"]["currencies"][0]["funds"]
|
||||
);
|
||||
|
||||
after_buy
|
||||
}
|
||||
|
||||
/// 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);
|
||||
let client = HttpCoreClient::new(base, "fifa17");
|
||||
assert_eq!(
|
||||
client.balance().unwrap(),
|
||||
4800,
|
||||
expected_balance,
|
||||
"coins persisted across restart"
|
||||
);
|
||||
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 =
|
||||
serde_json::from_slice(&handle_purchasegroup(&client, StoreMode::Sentinel).body).unwrap();
|
||||
assert!(
|
||||
@@ -239,12 +267,12 @@ async fn economy_end_to_end_and_restart_persistence() {
|
||||
let b1 = base1.clone();
|
||||
let r = tokio::task::spawn_blocking(move || seed_and_exercise(&b1)).await;
|
||||
h1.abort();
|
||||
r.expect("exercise phase");
|
||||
let expected_balance = r.expect("exercise phase");
|
||||
|
||||
// --- Core instance #2: same on-disk DB, prove persistence ---
|
||||
let (h2, base2) = start_core(&db_url).await;
|
||||
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();
|
||||
r2.expect("restart phase");
|
||||
|
||||
@@ -422,7 +450,8 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
||||
"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 mm = server
|
||||
.try_handle_economy(
|
||||
@@ -434,10 +463,11 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
||||
)
|
||||
.expect("match routed");
|
||||
assert_eq!(mm.status, 200);
|
||||
assert_eq!(
|
||||
client.balance().unwrap(),
|
||||
before_match + 400,
|
||||
"WIN credited +400 via Core"
|
||||
let mmb: Value = serde_json::from_slice(&mm.body).unwrap();
|
||||
assert_eq!(mmb["matchCoins"], 400, "flat match coins");
|
||||
assert!(
|
||||
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 ->
|
||||
|
||||
Reference in New Issue
Block a user