//! # openfut-utas-host //! //! The first live FIFA 17 **UTAS migration host**. It fronts the client-visible //! UTAS port and does route-level migration: //! //! ```text //! FIFA 17 ──HTTP──▶ openfut-utas-host //! ├── GET …/club ──▶ FIFA17 adapter ──▶ OpenFUT Core //! └── everything else ──▶ Python UTAS oracle (verbatim) //! ``` //! //! ## Safety rules (see the mission brief) //! //! * **Classification happens once, before any execution** ([`classify`]). A //! request is either handled in Rust or proxied to Python — never both, and //! there is NO "try Rust then retry on Python", which could double-apply a //! mutation. `/club` is read-only, but the rule holds regardless. //! * The Rust `/club` path NEVER contacts Python; the passthrough path NEVER //! runs Core logic. //! * A Core failure on `/club` returns an empty (but valid) `{"itemData":[]}` //! and logs an error — it does NOT fall back to Python. //! //! ## Transport (worker D) //! //! UTAS is plaintext HTTP/1.1 keep-alive, no TLS. Body is read by `Content-Length` //! before responding; responses carry `Content-Length` and `Content-Type: //! application/json` only when a body is present. //! //! ## The asset-id boundary //! //! FIFA renders an owned card from a real FIFA asset id (`resourceId & 0xffffff` //! resolved against the client's local DB). Core's synthetic catalogue has none, //! so [`ItemIdentityResolver`] is injected and unresolved items are dropped, not //! faked (see `club_response`). With today's empty mapping, `/club` returns //! `{"itemData":[]}` — the honest state until Core inventory is asset-backed. pub mod account_store; pub mod async_bridge; pub mod clientdata_store; pub mod config; pub mod economy_store; pub mod market; pub mod market_store; pub mod pile_store; pub mod sold_experiment; use std::io::{BufRead, BufReader, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::{Arc, Mutex}; use std::time::{Instant, SystemTime, UNIX_EPOCH}; use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy}; use openfut_adapter_fifa17::fut::club_response::{ shape_club_response, CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats, }; 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::non_economy; use openfut_adapter_fifa17::fut::owned_query::{ is_special_rareflag, map_to_core, parse_club_query, MapError, }; use openfut_adapter_fifa17::fut::pack_content::GeneratedCandidate; use openfut_adapter_fifa17::fut::sbc as fifa17_sbc; use openfut_adapter_fifa17::fut::squad::{parse_squad_put, save_ack, SquadWireResolver}; use openfut_adapter_fifa17::fut::squad_ext::{ build_squad_write, Fifa17SquadExtensionV1, SquadBuildError, EXT_NAMESPACE, EXT_SCHEMA_VERSION, }; use openfut_adapter_fifa17::fut::squad_projection::{ project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput, SquadProjection, SquadProjectionInput, }; use openfut_adapter_fifa17::fut::store_catalog::build_purchasegroup; use openfut_adapter_fifa17::fut::store_session::{ validate_capability, SessionStore, StoreMode, SENTINEL_PACK_ID, }; use openfut_identity::ExternalIdentityStore; use rand::{Rng, SeedableRng}; use serde_json::{json, Value}; use account_store::{AccountStore, RenameOutcome}; use clientdata_store::ClientDataStore; use config::HostConfig; // ───────────────────────────── Route classification ───────────────────────── /// The route decision, taken once, before execution. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Route { /// `GET …/club` — the owned-player search, served from Core. Club, /// `PUT …/club` or `PUT/POST …/user/club` — validates and persists the /// FIFA club name/abbreviation, then returns the zero-atom `{}` ack. ClubRename, /// `PUT …/squad/` — full squad replacement, committed to Core. SquadReplace, /// `GET …/squad/list` — the squad summary, projected from Core. SquadList, /// `GET …/squad/active` — the active squad object, projected from Core. SquadActive, /// `GET …/userMassInfo` — served FULLY from Rust: the Core squad projection /// plus the Rust/Core economy (coins + unopened packs). No Python. UserMassInfo, /// `POST /ut/auth` — Rust mints the `X-UT-SID`, adopts the persona from the /// body (or the configured default), and opens a Rust session. Never proxied. Auth, /// `POST /openfut/account/sync` — launcher control-plane account summary, /// served from Rust with authoritative Core coins/entitlements. Never Python. AccountSync, /// `GET/PUT/POST …/clientdata/` — opaque per-user client blob store /// (`userHubData` etc.), round-tripped through the Rust client-data store. ClientData, /// `POST /openfut/fifa17/capability` — launcher capability registration, /// owned entirely in Rust (no economy, no proxy). Capability, /// `GET …/store/purchasegroup…` — proxied to Python for the authoritative /// economy body, with the empty-My-Packs topology overlaid from the Rust /// session mode (the 65534 sentinel is stripped for a verified clean-v1 SID). StorePurchaseGroup, /// `GET …/user/accountinfo` — Rust-owned static `{}` (production oracle body). AccountInfo, /// `GET …/settings` — Rust-owned static `{"configs":[]}`. Settings, /// `GET …/leaderboards/options` — Rust-owned static `{}`. LeaderboardOptions, /// `PUT …/match/reset` — Rust-owned no-op ack `{}`. MatchReset, /// `GET/POST/PUT …/phishing/{trusteddevice,question,validate}` — the retired /// FUT security-question service, owned in Rust as a stateless ack. SecurityQuestion, /// `GET …/club/stats/staff` — Rust-owned static `{}` (production oracle body; /// FIFA's staff-bonus stat set is deliberately empty). ClubStatsStaff, /// `GET …/hub` — the FUT hub tile counts (club players + auction/tradePile), /// derived from Core inventory + the durable market store (no Python). Hub, /// `GET …/club/stats/{year,consumables}` — the MY CLUB stat set, computed /// Core-accurately in Rust (player tiers, staff/consumable families, nation /// buckets). club/stats/staff stays a separate empty-set route. ClubStats, /// `GET …/store` (eligibility gate), `…/match/keepalive`, `…/captcha`, /// `…/tfa`, `…/livemessage`, `…/activeMessage` — Rust-owned UNCONDITIONAL /// static acks, byte-identical to the Python oracle's constant responses /// (these are not flag-gated in the oracle, so a constant is exact parity). StaticAck, /// `GET …/watchList` — the transfer watch list, served empty from Rust with /// authoritative Core credits (the oracle persists no watches; add/remove is a /// no-op ack). Body: `{auctionInfo:[], credits, total:0}`. WatchList, /// `GET …/user` — the FUT user profile `{"userInfo": …}`, the same userInfo /// object `userMassInfo` embeds (shared builder). POST /user (create) stays Python. User, /// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and /// the club-identity read service are disabled in this emulator, so these /// reads return `{}`, byte-identical to the Python oracle with `FUT_MODES` / /// `FUT_CLUB_IDENTITY` off. Club rename is a separate Rust-owned route. FeatureOffEmpty, /// `GET …/item/resource`, `…/defid` — FUT item-definition lookups. Rust builds /// `{itemData:[…]}` (one placeholder-or-Ronaldo def per queried id), mirroring /// the oracle's `defs_route`/`item_def`. The client renders the real card from /// its LOCAL DB, so a valid-shaped placeholder is exact parity. ItemDefs, /// `GET …/marketdata` and `…/marketdata/pricelimits` — suggested pricing. /// `/pricelimits` MUST be a bare ARRAY (one `{defId,minPrice,maxPrice}` per /// queried defId); plain `/marketdata` MUST be an OBJECT `{minPrice,maxPrice}`. /// Container type is load-bearing (object-where-array froze a live client); /// the handler picks it from the path. Constant band 150..15000. MarketData, /// Anything else — proxied verbatim to the Python oracle. Passthrough, } /// Classify a request ONCE, before execution. Rust owns complete route families; /// there is no "try Rust then Python", so a mutation can never be double-applied. /// Numeric `GET …/squad/` follows the oracle's single-current-squad behavior: /// every numeric id returns the one Core-backed active squad. pub fn classify(method: &str, path: &str) -> Route { let get = method.eq_ignore_ascii_case("GET"); let put = method.eq_ignore_ascii_case("PUT"); let post = method.eq_ignore_ascii_case("POST"); // Session/capability vertical (Rust session authority). if post && path.starts_with("/ut/auth") { return Route::Auth; } // Rust owns the /ut/delete/auth logout ack (any method). if path.starts_with("/ut/delete/auth") { return Route::Auth; } // Launcher control-plane account summary (not under /ut/game). if post && path == "/openfut/account/sync" { return Route::AccountSync; } if post && path == "/openfut/fifa17/capability" { return Route::Capability; } if get && is_exact_club_path(path) { return Route::Club; } if put && is_exact_club_path(path) { return Route::ClubRename; } match ut_tail(path) { Some("squad/list") if get => Route::SquadList, Some("squad/active") if get => Route::SquadActive, Some(tail) if get && is_numeric_squad_tail(tail) => Route::SquadActive, Some("userMassInfo") if get => Route::UserMassInfo, Some("user/club") if put || post => Route::ClubRename, Some(tail) if tail.starts_with("clientdata/") => Route::ClientData, Some(tail) if get && tail.starts_with("store/purchasegroup") => Route::StorePurchaseGroup, Some(tail) if put && is_numeric_squad_tail(tail) => Route::SquadReplace, Some("user/accountinfo") if get => Route::AccountInfo, Some("user") if get => Route::User, Some("settings") if get => Route::Settings, Some("leaderboards/options") if get => Route::LeaderboardOptions, Some("match/reset") if put => Route::MatchReset, Some(tail) if tail.starts_with("phishing/") => Route::SecurityQuestion, Some("club/stats/staff") if get => Route::ClubStatsStaff, Some(t) if get && t.starts_with("club/stats/") => Route::ClubStats, Some("hub") if get => Route::Hub, Some("store") => Route::StaticAck, Some("match/keepalive") => Route::StaticAck, Some("captcha") if get => Route::StaticAck, Some("tfa") => Route::StaticAck, Some("livemessage") => Route::StaticAck, Some("activeMessage") => Route::StaticAck, Some("watchList") => Route::WatchList, Some("season") if get => Route::FeatureOffEmpty, Some("tournament") if get => Route::FeatureOffEmpty, Some("champion") if get => Route::FeatureOffEmpty, Some("clubUser") if get => Route::FeatureOffEmpty, Some("user/list") if get => Route::FeatureOffEmpty, Some("item/resource") if get => Route::ItemDefs, Some("defid") if get => Route::ItemDefs, Some(t) if get && (t == "marketdata" || t.starts_with("marketdata/")) => Route::MarketData, _ => Route::Passthrough, } } /// The tail after `/ut/game//` or `/ut/v2/game//` (non-empty sku), or /// `None`. Retail FIFA 17 issues the Store family (`store/*`, `purchased`) under /// the `/ut/v2/game//` prefix while other routes use `/ut/game//`; both /// normalize to the same tail so economy classification is prefix-agnostic. The /// `sku` segment is generic (never hard-coded to `fifa17`). fn ut_tail(path: &str) -> Option<&str> { let rest = path .strip_prefix("/ut/game/") .or_else(|| path.strip_prefix("/ut/v2/game/"))?; let (sku, tail) = rest.split_once('/')?; if sku.is_empty() { None } else { Some(tail) } } /// Every run of ≥ 3 ASCII digits in `s`, parsed as `i64` — the allocation-light /// equivalent of the oracle's `re.findall(r"\d{3,}", query)` used by `defs_route` /// to pull ids out of `resourceId=`/`definitionId=`/`idList=a,b,c` queries. fn extract_long_ints(s: &str) -> Vec { let bytes = s.as_bytes(); let mut out = Vec::new(); let mut i = 0; while i < bytes.len() { if bytes[i].is_ascii_digit() { let start = i; while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; } if i - start >= 3 { if let Ok(v) = s[start..i].parse::() { out.push(v); } } } else { i += 1; } } out } /// The comma-separated all-digit values of the `defId=` query parameter (the /// oracle's `parse_qs(...).get("defId")` + `int(x) for x if x.isdigit()`), used /// by `/marketdata/pricelimits`. Non-digit tokens are skipped, never faked. fn extract_defid_param(query: &str) -> Vec { for pair in query.split('&') { if let Some(v) = pair.strip_prefix("defId=") { return v .split(',') .filter_map(|t| { let t = t.trim(); if !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit()) { t.parse::().ok() } else { None } }) .collect(); } } Vec::new() } fn is_exact_club_path(path: &str) -> bool { ut_tail(path) == Some("club") } /// `squad/` — the numeric full-squad target used by PUT and GET. Core /// stores one current squad, matching the oracle: every numeric GET returns that /// same squad regardless of the requested id. fn is_numeric_squad_tail(tail: &str) -> bool { match tail.strip_prefix("squad/") { Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()), None => false, } } /// A FIFA17 economy route, classified separately from [`classify`]. This is the /// *target* ownership map for the economy cutover. It is deliberately NOT wired /// into [`Server::handle_with_ip`] yet: handler wiring and authority cutover are /// distinct steps. Until the single barrier commit flips the whole cluster, /// production classification ([`classify`]) still sends every one of these to /// Python; only integration tests drive them through [`Server::try_handle_economy`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EconomyRoute { /// `GET …/user/credits` — coins + unopened-pack count. Credits, /// `GET …/store/purchasegroup` — catalogue + owned packs, full-generated. PurchaseGroup, /// `PUT …/store/transaction` — Store BUY (open-on-buy). StoreBuy, /// `POST …/purchased` — open a pack / redeem an owned entitlement. PackOpen, /// `GET …/purchased` — the pack-reveal screen (items in the "purchased" pile). PackReveal, /// `DELETE …/item/` — single-card quick-sell. QuickSellPath, /// `POST /ut/delete/game//item` — bulk quick-sell. QuickSellBody, /// `PUT …/item` — FutMoveCard pile move. MoveItems, /// `POST /ut/delete/game//match` — match END (the coin-crediting call). MatchEnd, /// `…/auctionhouse` | `…/transfermarket` — list-for-sale / browse. MarketList, /// `GET …/tradePile` — the user's own active listings. MarketQuery, /// `GET …/tradePile/counts` — FutGetAuctionCount, the auction TALLY. A /// DISTINCT deserializer from `/tradePile`: it reads five scalar ints and /// skips everything else, so answering it with the `auctionInfo` listing body /// leaves every count at its constructor default (0) and the Transfer List /// screen shows no active sale even while the hub tile shows one. MarketCounts, /// `GET …/trade/status` — live auction-state refresh, polled continuously by /// the Transfer List. MUST be classified before [`Self::MarketBuy`]: `status` /// is not a numeric trade id, so the buy/view arm answers every poll with an /// empty `auctionInfo` and the screen never learns its own auctions' state. MarketStatus, /// `…/trade/` — view / buy-now. MarketBuy, /// Cancel a listing. Both `DELETE /ut/delete/game//trade/` (the /// oracle's spelling) and plain `DELETE /ut/game//trade/` (what /// contemporaneous FIFA 17 clients use) map here — a DELETE on the plain /// spelling otherwise fell into the buy/view arm and silently did nothing. MarketCancel, /// Bulk clear-sold: `DELETE /ut/delete/game//trade/sold`, the client's /// `RemoveAllSoldFromTradePile`. Carries no trade id, so it MUST NOT reach /// [`EconomyRoute::MarketCancel`], which would parse no id and ack while /// clearing nothing. MarketClearSold, /// `GET …/sbs/sets` — SBC category/set list from Core definitions. SbcSets, /// `POST/PUT …/sbs/sets/tag` — stateless tag acknowledgement. SbcTag, /// `GET …/sbs/setId//challenges` — challenges in one set. SbcChallenges, /// `GET/PUT …/sbs/challenge//squad` — durable working squad. SbcChallengeSquad, /// `POST/PUT …/sbs/challenge/` — start or atomic submission. SbcChallenge, } /// `item/` — the single-card quick-sell tail (DELETE). fn is_item_id_tail(tail: &str) -> bool { match tail.strip_prefix("item/") { Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()), None => false, } } /// `store/transaction` or `store/transaction/` — the Store BUY create /// step. Retail sends a trailing numeric transaction id (observed live: /// `store/transaction/0`). Mirrors the Python oracle's bare `/store/transaction` /// route, but bounded to a single all-digit id segment so it never absorbs /// `store/transactions`, `store/transactionfoo`, or `store/transaction/0/extra`. fn is_store_transaction_tail(tail: &str) -> bool { match tail.strip_prefix("store/transaction") { Some("") => true, Some(rest) => match rest.strip_prefix('/') { Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()), None => false, }, None => false, } } /// `purchased` or `purchased/items` — pack OPEN (POST) / reveal (GET). Retail /// sends the `/items` sub-path (FutPurchaseItemsServerResponse); the Python /// oracle's bare `/purchased` regex matches both. Bounded to exactly these two /// tails (rejects `purchasedfoo`, `purchased/items/extra`). fn is_purchased_tail(tail: &str) -> bool { tail == "purchased" || tail == "purchased/items" } /// `tradePile` or `tradePile/counts` — the user's own listings (query) + the /// listing-count tile. CASE-INSENSITIVE: the FUT hub tile polls lowercase /// `tradepile`/`tradepile/counts` while the screen uses camelCase `tradePile` /// (the oracle routes both via `re.I`). Bounded to the `tradepile` family /// (base tail or a `tradepile/` path); allocation-free. fn is_tradepile_tail(tail: &str) -> bool { const BASE: &str = "tradePile"; match tail.len() { 9 => tail.eq_ignore_ascii_case(BASE), n if n > 9 => tail.as_bytes()[9] == b'/' && tail[..9].eq_ignore_ascii_case(BASE), _ => false, } } /// `tradePile/counts` exactly (case-insensitive) — the auction-tally sub-path, /// which MUST be classified before [`is_tradepile_tail`] because that matcher /// also accepts it (the two endpoints have different response shapes). fn is_tradepile_counts_tail(tail: &str) -> bool { const COUNTS: &str = "tradePile/counts"; tail.eq_ignore_ascii_case(COUNTS) } /// `trade/status` exactly (case-insensitive) — the live auction-state poll. MUST /// be classified before the generic `trade…` buy/view arm: `status` is not a /// numeric trade id, so that arm degrades every poll to an empty `auctionInfo`. fn is_trade_status_tail(tail: &str) -> bool { const STATUS: &str = "trade/status"; tail.eq_ignore_ascii_case(STATUS) } /// `trade/sold` — the BULK clear-sold tail, which carries no trade id. /// /// PE-proven shape: request builder `0x1801647c0` writes the literal `/sold` when /// its tradeId field is zero and `/%lld` when it is not, onto route base /// `ut/delete/%s/trade`. The client names the operation /// `RemoveAllSoldFromTradePile`. fn is_trade_sold_tail(tail: &str) -> bool { tail.eq_ignore_ascii_case("trade/sold") } fn bounded_numeric_segment<'a>(tail: &'a str, prefix: &str, suffix: &str) -> Option<&'a str> { let value = tail.strip_prefix(prefix)?.strip_suffix(suffix)?; if !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) { Some(value) } else { None } } fn sbc_challenge_id(tail: &str) -> Option { bounded_numeric_segment(tail, "sbs/challenge/", "") .or_else(|| bounded_numeric_segment(tail, "sbs/challenge/", "/squad"))? .parse() .ok() } fn sbc_set_id(tail: &str) -> Option { bounded_numeric_segment(tail, "sbs/setId/", "/challenges")? .parse() .ok() } /// Classify a FIFA17 economy route from method + path, mirroring the Python /// oracle's route table (`utas_server.py` §1418-1553). Returns `None` for any /// non-economy path. Path is already query-stripped by the caller. pub fn classify_economy(method: &str, path: &str) -> Option { let get = method.eq_ignore_ascii_case("GET"); let put = method.eq_ignore_ascii_case("PUT"); let post = method.eq_ignore_ascii_case("POST"); let delete = method.eq_ignore_ascii_case("DELETE"); // The `/ut/delete/game//…` family is NOT `/ut/game/…`-prefixed. if let Some(rest) = path .strip_prefix("/ut/delete/game/") .or_else(|| path.strip_prefix("/ut/v2/delete/game/")) { if let Some((_sku, tail)) = rest.split_once('/') { if tail == "item" && post { return Some(EconomyRoute::QuickSellBody); } // MUST precede the generic `trade…` cancel arm. FIFA 17's request // builder 0x1801647c0 emits the literal `/sold` (no numeric id) for the // bulk "RemoveAllSoldFromTradePile" verb, and `/%lld` for one trade. A // `sold` tail carries no id, so the cancel handler would parse nothing // and silently ack while clearing nothing. if delete && is_trade_sold_tail(tail) { return Some(EconomyRoute::MarketClearSold); } if tail.starts_with("trade") && delete { return Some(EconomyRoute::MarketCancel); } if tail == "match" && post { return Some(EconomyRoute::MatchEnd); } } return None; } match ut_tail(path) { Some("user/credits") if get => Some(EconomyRoute::Credits), Some(t) if get && t.starts_with("store/purchasegroup") => Some(EconomyRoute::PurchaseGroup), Some(t) if put && is_store_transaction_tail(t) => Some(EconomyRoute::StoreBuy), Some(t) if post && is_purchased_tail(t) => Some(EconomyRoute::PackOpen), Some(t) if get && is_purchased_tail(t) => Some(EconomyRoute::PackReveal), Some(t) if delete && is_item_id_tail(t) => Some(EconomyRoute::QuickSellPath), Some("item") if put => Some(EconomyRoute::MoveItems), Some(t) if (t == "auctionhouse" || t == "transfermarket") => Some(EconomyRoute::MarketList), // MUST precede the base tradePile arm: that matcher also accepts // `tradePile/counts`, but the tally is a different deserializer. Some(t) if get && is_tradepile_counts_tail(t) => Some(EconomyRoute::MarketCounts), Some(t) if get && is_tradepile_tail(t) => Some(EconomyRoute::MarketQuery), // BOTH must precede the buy/view arm, which swallows any `trade…` tail: // `trade/status` has no numeric id (so it answered polls with an empty // auctionInfo), and a plain DELETE fell in as a no-op "view". Some(t) if get && is_trade_status_tail(t) => Some(EconomyRoute::MarketStatus), Some(t) if delete && t.starts_with("trade") => Some(EconomyRoute::MarketCancel), Some("sbs/sets") if get => Some(EconomyRoute::SbcSets), Some("sbs/sets/tag") if post || put => Some(EconomyRoute::SbcTag), Some(t) if get && sbc_set_id(t).is_some() => Some(EconomyRoute::SbcChallenges), Some(t) if (get || put) && bounded_numeric_segment(t, "sbs/challenge/", "/squad").is_some() => { Some(EconomyRoute::SbcChallengeSquad) } Some(t) if (post || put) && bounded_numeric_segment(t, "sbs/challenge/", "").is_some() => { Some(EconomyRoute::SbcChallenge) } Some(t) if t.starts_with("trade") => Some(EconomyRoute::MarketBuy), _ => None, } } // ───────────────────────────── Core access boundary ───────────────────────── /// Failure reaching or reading OpenFUT Core. #[derive(Debug)] pub enum CoreError { Http(String), Status(u16), Parse(String), } impl std::fmt::Display for CoreError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { CoreError::Http(e) => write!(f, "core http error: {e}"), CoreError::Status(s) => write!(f, "core returned status {s}"), CoreError::Parse(e) => write!(f, "core response parse error: {e}"), } } } /// One page of owned items plus the filtered total, as returned by Core. pub struct CorePage { pub items: Vec, pub total: i64, } /// One canonical squad slot as read back from Core (game-independent). #[derive(Debug, Clone)] pub struct CoreSquadSlot { pub owned_card_id: String, pub index: i64, pub is_captain: bool, pub is_on_bench: bool, } /// Freshness of the stored opaque extension vs the current canonical squad, as /// Core reports it. Carries the stored payload for Fresh/Stale (never applied /// when Stale — the host decides policy). #[derive(Debug, Clone)] pub enum CoreExtState { Fresh { schema_version: i64, payload: String, }, Stale { schema_version: i64, payload: String, }, Missing, } /// The active squad, its canonical slots, and its opaque extension state — the /// result of Core's `GET /squad/ext`. #[derive(Debug, Clone)] pub struct CoreSquadRead { pub name: String, /// FIFA formation token, verbatim (Core stores it opaquely). pub formation: String, pub slots: Vec, pub ext: CoreExtState, } /// A canonical + extension squad replacement the host asks Core to commit /// atomically (`PUT /squad/replace`). pub struct CoreReplaceRequest { pub name: Option, pub formation: Option, pub slots: Vec, pub client_reported: CoreClientEval, pub ext_namespace: String, pub ext_schema_version: i64, pub ext_payload: String, } /// Client-reported shadow evaluation carried through to Core (never Core's /// authoritative evaluation). #[derive(Debug, Clone, Default)] pub struct CoreClientEval { pub chemistry: Option, pub rating: Option, pub star_rating: Option, } /// Outcome of a committed replacement. #[derive(Debug, Clone)] pub struct CoreReplaceResult { pub squad_id: String, pub canonical_fingerprint: String, pub slots_written: usize, } /// How the host reaches Core. The adapter never sees this — the host owns the /// transport, mirroring the architecture rule. Tests inject a fake. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CoreSbcDefinition { pub id: String, pub name: String, pub description: String, pub repeatable: bool, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct CoreSbcResult { pub passed: bool, pub failures: Vec, } pub trait CoreAccess: Send + Sync { /// Query the owned inventory with semantic `/collection` query params. fn query_owned(&self, params: &[(&str, String)]) -> Result; /// Every owned item for the active club, in one call (no pagination) — used /// to assemble a whole squad projection and to authorize squad writes. The /// default delegates to an unfiltered `query_owned`. fn all_owned(&self) -> Result, CoreError> { Ok(self.query_owned(&[])?.items) } /// Read the active squad + its opaque extension for `namespace`. fn read_squad_ext(&self, namespace: &str) -> Result; /// Replace the active squad's canonical slots + opaque extension atomically. fn replace_squad(&self, req: &CoreReplaceRequest) -> Result; fn list_sbcs(&self) -> Result, CoreError> { Err(CoreError::Parse( "Core SBC access is not implemented".into(), )) } fn sbc_completion_counts(&self) -> Result, CoreError> { Err(CoreError::Parse( "Core SBC access is not implemented".into(), )) } fn load_sbc_squad(&self, _sbc_id: &str) -> Result, CoreError> { Err(CoreError::Parse( "Core SBC access is not implemented".into(), )) } fn save_sbc_squad( &self, _sbc_id: &str, _owned_card_ids: &[String], ) -> Result, CoreError> { Err(CoreError::Parse( "Core SBC access is not implemented".into(), )) } fn submit_sbc( &self, _sbc_id: &str, _owned_card_ids: &[String], ) -> Result { Err(CoreError::Parse( "Core SBC access is not implemented".into(), )) } } /// Default HTTP implementation: `GET {core_url}/collection?…` (plain HTTP JSON, /// the same boundary Bridge uses to reach Core). Every request carries /// `X-OpenFUT-Game: ` so Core resolves the game-scoped active profile — for /// FIFA 17 that is the profile whose inventory is fully asset-mapped, so `/club` /// filters/paginates a wholly renderable set (no post-pagination drops). pub struct HttpCoreClient { base_url: String, game: String, client: reqwest::blocking::Client, } impl HttpCoreClient { pub fn new(base_url: impl Into, game: impl Into) -> Self { HttpCoreClient { base_url: base_url.into().trim_end_matches('/').to_string(), game: game.into(), client: reqwest::blocking::Client::new(), } } } impl CoreAccess for HttpCoreClient { fn query_owned(&self, params: &[(&str, String)]) -> Result { let url = format!("{}/collection", self.base_url); let resp = self .client .get(&url) .header("X-OpenFUT-Game", &self.game) .query(params) .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()))?; parse_core_page(&v) } fn read_squad_ext(&self, namespace: &str) -> Result { let url = format!("{}/squad/ext", self.base_url); let resp = self .client .get(&url) .header("X-OpenFUT-Game", &self.game) .query(&[("namespace", namespace)]) .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()))?; parse_core_squad_read(&v) } fn replace_squad(&self, req: &CoreReplaceRequest) -> Result { let url = format!("{}/squad/replace", self.base_url); let resp = self .client .put(&url) .header("X-OpenFUT-Game", &self.game) .json(&replace_request_body(req)) .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(CoreReplaceResult { squad_id: v .get("squad_id") .and_then(|x| x.as_str()) .unwrap_or("") .to_string(), canonical_fingerprint: v .get("canonical_fingerprint") .and_then(|x| x.as_str()) .unwrap_or("") .to_string(), slots_written: v.get("slots_written").and_then(|x| x.as_u64()).unwrap_or(0) as usize, }) } fn list_sbcs(&self) -> Result, CoreError> { let response = self .client .get(format!("{}/sbc", self.base_url)) .header("X-OpenFUT-Game", &self.game) .send() .map_err(|error| CoreError::Http(error.to_string()))?; let status = response.status().as_u16(); if !(200..300).contains(&status) { return Err(CoreError::Status(status)); } let body: Value = response .json() .map_err(|error| CoreError::Parse(error.to_string()))?; body.get("sbcs") .and_then(Value::as_array) .ok_or_else(|| CoreError::Parse("missing `sbcs` array".into()))? .iter() .map(|definition| { Ok(CoreSbcDefinition { id: json_str(definition, "id")?, name: json_str(definition, "name")?, description: json_str(definition, "description")?, repeatable: definition .get("repeatable") .and_then(Value::as_bool) .unwrap_or(false), }) }) .collect() } fn sbc_completion_counts(&self) -> Result, CoreError> { let response = self .client .get(format!("{}/sbc/status", self.base_url)) .header("X-OpenFUT-Game", &self.game) .send() .map_err(|error| CoreError::Http(error.to_string()))?; let status = response.status().as_u16(); if !(200..300).contains(&status) { return Err(CoreError::Status(status)); } let body: Value = response .json() .map_err(|error| CoreError::Parse(error.to_string()))?; let completions = body .get("completions") .and_then(Value::as_object) .ok_or_else(|| CoreError::Parse("missing `completions` object".into()))?; completions .iter() .map(|(id, value)| { value .as_i64() .map(|count| (id.clone(), count)) .ok_or_else(|| CoreError::Parse(format!("invalid completion count for {id}"))) }) .collect() } fn load_sbc_squad(&self, sbc_id: &str) -> Result, CoreError> { let response = self .client .get(format!("{}/sbc/{sbc_id}/squad", self.base_url)) .header("X-OpenFUT-Game", &self.game) .send() .map_err(|error| CoreError::Http(error.to_string()))?; parse_core_sbc_squad_response(response) } fn save_sbc_squad( &self, sbc_id: &str, owned_card_ids: &[String], ) -> Result, CoreError> { let response = self .client .put(format!("{}/sbc/{sbc_id}/squad", self.base_url)) .header("X-OpenFUT-Game", &self.game) .json(&json!({ "owned_card_ids": owned_card_ids })) .send() .map_err(|error| CoreError::Http(error.to_string()))?; parse_core_sbc_squad_response(response) } fn submit_sbc( &self, sbc_id: &str, owned_card_ids: &[String], ) -> Result { let response = self .client .post(format!("{}/sbc/submit", self.base_url)) .header("X-OpenFUT-Game", &self.game) .json(&json!({ "sbc_id": sbc_id, "owned_card_ids": owned_card_ids })) .send() .map_err(|error| CoreError::Http(error.to_string()))?; let status = response.status().as_u16(); if !(200..300).contains(&status) { return Err(CoreError::Status(status)); } let body: Value = response .json() .map_err(|error| CoreError::Parse(error.to_string()))?; Ok(CoreSbcResult { passed: body .get("passed") .and_then(Value::as_bool) .ok_or_else(|| CoreError::Parse("missing SBC `passed` bool".into()))?, failures: body .get("failures") .and_then(Value::as_array) .ok_or_else(|| CoreError::Parse("missing SBC `failures` array".into()))? .iter() .filter_map(Value::as_str) .map(str::to_owned) .collect(), }) } } fn parse_core_sbc_squad_response( response: reqwest::blocking::Response, ) -> Result, CoreError> { let status = response.status().as_u16(); if !(200..300).contains(&status) { return Err(CoreError::Status(status)); } let body: Value = response .json() .map_err(|error| CoreError::Parse(error.to_string()))?; body.get("squad") .and_then(|squad| squad.get("owned_card_ids")) .and_then(Value::as_array) .ok_or_else(|| CoreError::Parse("missing SBC squad `owned_card_ids` array".into()))? .iter() .map(|id| { id.as_str() .map(str::to_owned) .ok_or_else(|| CoreError::Parse("SBC owned-card id is not a string".into())) }) .collect() } // ───────────────────────────── Core economy boundary ──────────────────────── /// One unconsumed entitlement Core reports for the active club. #[derive(Debug, Clone, PartialEq, Eq)] pub struct EconomyEntitlement { pub id: String, pub definition_id: String, } /// Outcome of a purchase: post-debit balance + the new entitlement id. #[derive(Debug, Clone, PartialEq, Eq)] pub struct EconomyPurchase { pub balance: i64, pub entitlement_id: String, } /// An item to place into inventory on entitlement redemption. `item_id` is the /// caller-minted opaque Core instance id (the adapter maps it to/from the FIFA /// numeric wire id via the identity store); `card_id` is the definition ref. #[derive(Debug, Clone, PartialEq, Eq)] pub struct EconomyGrantItem { pub item_id: String, pub card_id: String, } /// Terms of a completed market sale, for [`CoreEconomy::settle_sale`]. `gross` /// is what the buyer pays; `fee` is the market cut destroyed on settlement, so /// the seller nets `gross - fee`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct EconomySale<'a> { pub item_id: &'a str, /// Club the caller believes owns the item; `None` -> Core's game-scoped /// active club. Core predicates the ownership move on this club, so a /// wrong (or already-settled) seller is rejected rather than silently /// re-run — this doubles as the replay guard. pub seller_club_id: Option<&'a str>, /// Acquiring club; `None` -> a counterparty outside the modelled economy: /// nobody is debited and the item is destroyed. pub buyer_club_id: Option<&'a str>, pub gross: i64, pub fee: i64, } /// What Core did when settling a sale: the post-settlement balances of both /// sides (`buyer_balance` is `None` for an outside buyer) and how many squad /// slots the sold item vacated. #[derive(Debug, Clone, PartialEq, Eq)] pub struct EconomySaleReceipt { pub item_id: String, pub card_id: String, pub seller_club_id: String, pub buyer_club_id: Option, pub gross: i64, pub fee: i64, pub proceeds: i64, pub seller_balance: i64, pub buyer_balance: Option, pub squad_slots_freed: u64, } /// 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 /// Python — a Python fallback would reintroduce a second writer. pub trait CoreEconomy: Send + Sync { fn balance(&self) -> Result; fn entitlements(&self) -> Result, CoreError>; fn purchase_entitlement( &self, cost: i64, definition_id: &str, ) -> Result; fn redeem_entitlement( &self, entitlement_id: &str, items: &[EconomyGrantItem], ) -> Result; fn sell_item(&self, item_id: &str, price: i64) -> Result; fn grant_reward(&self, amount: i64) -> Result; fn purchase_item(&self, cost: i64, item_id: &str, card_id: &str) -> Result; /// Debit `cost` and mint several items atomically (open-on-buy Store packs). fn purchase_items(&self, cost: i64, items: &[EconomyGrantItem]) -> Result; /// Settle a completed market sale in ONE Core transaction: evict the item /// from every squad, move ownership from the seller to `buyer_club_id` /// (or destroy it for an outside buyer), debit a club buyer `gross` and /// credit the seller `gross - fee`. Core rejects a sale whose named seller /// does not own the item, so a replayed settlement is refused, never /// double-paid. fn settle_sale(&self, sale: &EconomySale<'_>) -> Result; } impl HttpCoreClient { fn economy_url(&self, tail: &str) -> String { format!("{}/economy/{}", self.base_url, tail) } fn economy_get(&self, tail: &str) -> Result { let resp = self .client .get(self.economy_url(tail)) .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)); } resp.json().map_err(|e| CoreError::Parse(e.to_string())) } fn economy_post(&self, tail: &str, body: &Value) -> Result { let resp = self .client .post(self.economy_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 { v.get(key) .and_then(Value::as_i64) .ok_or_else(|| CoreError::Parse(format!("missing i64 field `{key}`"))) } fn json_str(v: &Value, key: &str) -> Result { v.get(key) .and_then(Value::as_str) .map(str::to_string) .ok_or_else(|| CoreError::Parse(format!("missing string field `{key}`"))) } fn json_u64(v: &Value, key: &str) -> Result { v.get(key) .and_then(Value::as_u64) .ok_or_else(|| CoreError::Parse(format!("missing u64 field `{key}`"))) } impl CoreEconomy for HttpCoreClient { fn balance(&self) -> Result { json_i64(&self.economy_get("balance")?, "balance") } fn entitlements(&self) -> Result, CoreError> { let v = self.economy_get("entitlements")?; let arr = v .as_array() .ok_or_else(|| CoreError::Parse("entitlements not an array".into()))?; arr.iter() .map(|e| { Ok(EconomyEntitlement { id: json_str(e, "id")?, definition_id: json_str(e, "definition_id")?, }) }) .collect() } fn purchase_entitlement( &self, cost: i64, definition_id: &str, ) -> Result { let v = self.economy_post( "purchase-entitlement", &json!({ "cost": cost, "definition_id": definition_id }), )?; Ok(EconomyPurchase { balance: json_i64(&v, "balance")?, entitlement_id: json_str(&v, "entitlement_id")?, }) } fn redeem_entitlement( &self, entitlement_id: &str, items: &[EconomyGrantItem], ) -> Result { let items_json: Vec = items .iter() .map(|i| json!({ "item_id": i.item_id, "card_id": i.card_id })) .collect(); let v = self.economy_post( "redeem-entitlement", &json!({ "entitlement_id": entitlement_id, "items": items_json }), )?; json_str(&v, "definition_id") } fn sell_item(&self, item_id: &str, price: i64) -> Result { let v = self.economy_post("sell-item", &json!({ "item_id": item_id, "price": price }))?; json_i64(&v, "balance") } fn grant_reward(&self, amount: i64) -> Result { let v = self.economy_post("grant-reward", &json!({ "amount": amount }))?; json_i64(&v, "balance") } fn purchase_item(&self, cost: i64, item_id: &str, card_id: &str) -> Result { let v = self.economy_post( "purchase-item", &json!({ "cost": cost, "item_id": item_id, "card_id": card_id }), )?; json_i64(&v, "balance") } fn purchase_items(&self, cost: i64, items: &[EconomyGrantItem]) -> Result { let items_json: Vec = items .iter() .map(|i| json!({ "item_id": i.item_id, "card_id": i.card_id })) .collect(); let v = self.economy_post( "purchase-items", &json!({ "cost": cost, "items": items_json }), )?; json_i64(&v, "balance") } fn settle_sale(&self, sale: &EconomySale<'_>) -> Result { let v = self.economy_post("settle-sale", &sale_request_body(sale))?; Ok(EconomySaleReceipt { item_id: json_str(&v, "item_id")?, card_id: json_str(&v, "card_id")?, seller_club_id: json_str(&v, "seller_club_id")?, // Absent or null both mean "no club buyer": the outside-sale case. buyer_club_id: v .get("buyer_club_id") .and_then(Value::as_str) .map(str::to_string), gross: json_i64(&v, "gross")?, fee: json_i64(&v, "fee")?, proceeds: json_i64(&v, "proceeds")?, seller_balance: json_i64(&v, "seller_balance")?, buyer_balance: v.get("buyer_balance").and_then(Value::as_i64), squad_slots_freed: json_u64(&v, "squad_slots_freed")?, }) } } /// Serialize an [`EconomySale`] into the `POST /economy/settle-sale` JSON body. /// The two club fields are OMITTED when `None` — their absence is what selects /// Core's defaults (the game-scoped active club as seller, a counterparty /// outside the modelled economy as buyer). pub fn sale_request_body(sale: &EconomySale<'_>) -> Value { let mut body = json!({ "item_id": sale.item_id, "gross": sale.gross, "fee": sale.fee, }); let obj = body .as_object_mut() .expect("the literal above is a JSON object"); if let Some(seller) = sale.seller_club_id { obj.insert("seller_club_id".into(), Value::from(seller)); } if let Some(buyer) = sale.buyer_club_id { obj.insert("buyer_club_id".into(), Value::from(buyer)); } body } /// Serialize a [`CoreReplaceRequest`] into the `PUT /squad/replace` JSON body. pub fn replace_request_body(req: &CoreReplaceRequest) -> Value { let slots: Vec = req .slots .iter() .map(|s| { json!({ "owned_card_id": s.owned_card_id, "slot": s.index, "is_captain": s.is_captain, "is_on_bench": s.is_on_bench, }) }) .collect(); json!({ "name": req.name, "formation": req.formation, "slots": slots, "client_reported": { "client_reported_chemistry": req.client_reported.chemistry, "client_reported_rating": req.client_reported.rating, "client_reported_star_rating": req.client_reported.star_rating, }, "extension": { "namespace": req.ext_namespace, "schema_version": req.ext_schema_version, "payload": req.ext_payload, }, }) } /// Parse Core's `GET /squad/ext` response into a [`CoreSquadRead`]. pub fn parse_core_squad_read(v: &Value) -> Result { let squad = v .get("squad") .ok_or_else(|| CoreError::Parse("missing `squad`".into()))?; let name = squad .get("name") .and_then(|x| x.as_str()) .unwrap_or("") .to_string(); let formation = squad .get("formation") .and_then(|x| x.as_str()) .ok_or_else(|| CoreError::Parse("missing squad.formation".into()))? .to_string(); let players = v .get("players") .and_then(|p| p.as_array()) .ok_or_else(|| CoreError::Parse("missing `players`".into()))?; let slots = players .iter() .filter_map(|p| { Some(CoreSquadSlot { owned_card_id: p.get("owned_card_id")?.as_str()?.to_string(), index: p.get("position_index")?.as_i64()?, is_captain: p .get("is_captain") .and_then(|x| x.as_bool()) .unwrap_or(false), is_on_bench: p .get("is_on_bench") .and_then(|x| x.as_bool()) .unwrap_or(false), }) }) .collect(); let ext_v = v .get("extension") .ok_or_else(|| CoreError::Parse("missing `extension`".into()))?; let ext = match ext_v.get("state").and_then(|x| x.as_str()) { Some("fresh") => CoreExtState::Fresh { schema_version: ext_v .get("schema_version") .and_then(|x| x.as_i64()) .unwrap_or(0), payload: ext_v .get("payload") .and_then(|x| x.as_str()) .unwrap_or("") .to_string(), }, Some("stale") => CoreExtState::Stale { schema_version: ext_v .get("schema_version") .and_then(|x| x.as_i64()) .unwrap_or(0), payload: ext_v .get("payload") .and_then(|x| x.as_str()) .unwrap_or("") .to_string(), }, Some("missing") => CoreExtState::Missing, other => { return Err(CoreError::Parse(format!( "unknown extension state {other:?}" ))) } }; Ok(CoreSquadRead { name, formation, slots, ext, }) } /// Parse Core's `/collection` response `{ "collection": [...], "total": n }` into /// semantic owned items. pub fn parse_core_page(v: &Value) -> Result { let arr = v .get("collection") .and_then(|c| c.as_array()) .ok_or_else(|| CoreError::Parse("missing `collection` array".into()))?; let total = v .get("total") .and_then(|t| t.as_i64()) .unwrap_or(arr.len() as i64); let items = arr.iter().filter_map(core_item_from_json).collect(); Ok(CorePage { items, total }) } fn core_item_from_json(e: &Value) -> Option { let card = e.get("card")?; let attr = |k: &str| card.get(k).and_then(|v| v.as_i64()).unwrap_or(0) as u8; let position = e .get("effective_position") .and_then(|v| v.as_str()) .or_else(|| card.get("position").and_then(|v| v.as_str()))? .to_string(); let rating = e .get("effective_overall") .and_then(|v| v.as_i64()) .or_else(|| card.get("overall").and_then(|v| v.as_i64())) .unwrap_or(0) as u8; Some(CoreOwnedItem { owned_card_id: e.get("owned_card_id")?.as_str()?.to_string(), card_id: card.get("id")?.as_str()?.to_string(), rating, position, nation: card.get("nation")?.as_str()?.to_string(), league: card.get("league")?.as_str()?.to_string(), club: card.get("club")?.as_str()?.to_string(), attributes: [ attr("pace"), attr("shooting"), attr("passing"), attr("dribbling"), attr("defending"), attr("physical"), ], }) } // ───────────────────────────── Item identity resolver ─────────────────────── /// The single production [`ItemIdentityResolver`]: it composes the two distinct /// FIFA 17 identities from real, persistent sources — no placeholder, no hash, /// no fabricated id. /// /// * **Definition identity** (`resourceId`/`assetId`) comes from the /// [`Fifa17CardCatalog`]: `card_id` → real FIFA asset id. An unmapped /// definition resolves to `None` → the item is dropped and counted, never /// faked. /// * **Instance identity** (`item_id`) comes from the generic /// [`ExternalIdentityStore`] under the FIFA 17 wire-id policy: the same owned /// instance always resolves to the same monotonic wire id, it survives /// restart, and it reverses exactly. Two copies of the same definition share a /// `resourceId` but get distinct `item_id`s. /// /// The wire-id namespace is **globally monotonic within `(game, "owned-item")`**, /// not per-account. Python restarts numbering per save file; Core owned-instance /// ids are globally-unique UUIDs, so a single monotonic sequence keeps every /// wire id unique and its reverse lookup unambiguous across all accounts — /// satisfying the client's only requirement (per-session unique/stable/ /// reversible ids). An account column is therefore unnecessary. pub struct Fifa17IdentityResolver { catalog: Fifa17CardCatalog, store: Arc, } impl Fifa17IdentityResolver { pub fn new(catalog: Fifa17CardCatalog, store: Arc) -> Self { Fifa17IdentityResolver { catalog, store } } /// Reverse an owned-item wire id back to its Core owned-instance id (used by /// later item-operation slices). `None` = unknown wire id, never a guess. pub fn owned_id_for_wire(&self, wire: i64) -> Option { self.store .core_for( Fifa17WireItemIdPolicy::GAME, Fifa17WireItemIdPolicy::OWNED_ITEM_KIND, wire, ) .unwrap_or(None) } /// The FIFA `cardsubtypeid` for an owned item's definition (0 if unknown / /// a player), from the catalog — used by club-stats family aggregation. pub fn subtype_of(&self, item: &CoreOwnedItem) -> i64 { self.catalog.subtype_of(&item.card_id) } /// The observed FIFA `rareflag` for an owned item's definition (0 if unknown), /// from the catalog — used by club-stats rare-player counting. pub fn rareflag_of(&self, item: &CoreOwnedItem) -> i64 { self.catalog .lookup(&item.card_id) .map(|c| c.rareflag) .unwrap_or(0) } } impl ItemIdentityResolver for Fifa17IdentityResolver { fn resolve(&self, item: &CoreOwnedItem) -> Option { // Definition identity first: an unmapped card is dropped (never faked). let ident = self.catalog.lookup(&item.card_id)?; // Instance identity: stable, persistent, reversible wire id. let wire = match self.store.resolve_or_allocate( Fifa17WireItemIdPolicy::GAME, Fifa17WireItemIdPolicy::OWNED_ITEM_KIND, &item.owned_card_id, Fifa17WireItemIdPolicy::owned_item_base_floor(), ) { Ok(w) => w, Err(e) => { // Infrastructure failure allocating a wire id: drop this item // (freeze-safe) and log — never emit an unstable/fake id. eprintln!( "utas-host ERROR identity store alloc failed for {}: {e}", item.owned_card_id ); return None; } }; Some(Fifa17Identity { // Wire ids live in 1e8..9e8 (policy) — well within u32. item_id: wire as u32, asset_id: ident.asset_id, resource_id: ident.resource_id, rareflag: ident.rareflag, }) } /// Delegate content classification to the catalog so `/club` excludes /// consumable/staff cards (they must never render as 0-rated players). An /// unmapped card_id resolves to `Player` (the catalog default) but is already /// dropped by `resolve` returning `None`, so it is never emitted anyway. fn kind_of(&self, item: &CoreOwnedItem) -> ContentKind { self.catalog.kind_of(&item.card_id) } } /// The same production resolver reverses a wire id to a Core owned-instance id /// for the squad PUT path — reusing the identity store, so `/club`, `/squad`, /// and PUT all agree on wire↔owned. Identity ONLY; ownership is authorized /// separately (a resolvable id is not proof of ownership). impl SquadWireResolver for Fifa17IdentityResolver { fn owned_id_for_wire(&self, wire: i64) -> Option { Fifa17IdentityResolver::owned_id_for_wire(self, wire) } } // ───────────────────────────── /club handler ──────────────────────────────── /// Safe, structured summary of a handled `/club` request (no auth/session/device /// material — the club query carries none; auth is a header we never log). #[derive(Debug, Clone)] pub struct ClubLog { pub outcome: &'static str, pub filter: String, pub total: i64, pub emitted: usize, pub dropped_no_asset: usize, pub offset: Option, pub limit: Option, } /// Dependencies for the Rust `/club` path. pub struct ClubDeps<'a> { pub core: &'a dyn CoreAccess, pub entities: &'a Fifa17Entities, pub assets: &'a (dyn ItemIdentityResolver + Send + Sync), /// Core owned-instance ids that are NOT in the club view — the cards with an /// ACTIVE transfer-market listing. In FIFA a listed card has LEFT the club, so /// it must not also appear here. Empty = show everything Core owns. pub hidden: &'a std::collections::HashSet, } /// Filter already-shaped `/club` items to SPECIALS (`rareflag > 1`) and paginate /// the filtered set locally. Returns `(page, total_specials)`. Pure — the whole /// point is that "special" pagination is over the filtered set, never Core's /// unfiltered page (which would drop specials or leak base cards). fn special_filter_page( items: &[Value], offset: Option, limit: Option, ) -> (Vec, i64) { let specials: Vec<&Value> = items .iter() .filter(|it| { it.get("rareflag") .and_then(|v| v.as_i64()) .map(is_special_rareflag) .unwrap_or(false) }) .collect(); let total = specials.len() as i64; let off = offset.unwrap_or(0).max(0) as usize; let paged: Vec = match limit { Some(l) => specials .into_iter() .skip(off) .take(l.max(0) as usize) .cloned() .collect(), None => specials.into_iter().skip(off).cloned().collect(), }; (paged, total) } /// Paginate an already-shaped, already-filtered `/club` item list locally, /// returning `(page, total_after_filtering)`. Used when the host filters on /// something Core cannot express (pile membership, rareflag), where Core's own /// offset/limit would paginate the WRONG set and yield short pages. fn paginate_items(items: &[Value], offset: Option, limit: Option) -> (Vec, i64) { let total = items.len() as i64; let off = offset.unwrap_or(0).max(0) as usize; let paged: Vec = match limit { Some(l) => items .iter() .skip(off) .take(l.max(0) as usize) .cloned() .collect(), None => items.iter().skip(off).cloned().collect(), }; (paged, total) } /// Handle `GET …/club?…` end to end: parse → map ids to names → Core query → /// shape to the FIFA `{itemData:[…]}` envelope. Always returns HTTP 200 with a /// JSON body (UTAS must never 401/403; an empty result is the safe degrade). pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) { let raw = parse_club_query(query); let core_q = match map_to_core(&raw, deps.entities) { Ok(c) => c, Err(e) => { // Unknown FIFA id — never a raw-id passthrough, never a guess. return ( json_response(&json!({ "itemData": [] })), ClubLog { outcome: "unknown_id", filter: describe_map_error(&e), total: 0, emitted: 0, dropped_no_asset: 0, offset: raw.start.map(|s| s as i64), limit: raw.count.map(|c| c as i64), }, ); } }; let pairs = core_q.to_query_pairs(); let filter = summarize(&pairs); let (offset, limit) = (core_q.offset, core_q.limit); // Host-side filters Core cannot express: // * "Special" (rare=SP): rareflag lives in the FIFA catalog, not Core. // * listed-card exclusion: transfer-market listings are host-owned state. // Either way Core must NOT paginate — it would paginate the unfiltered set // and return short pages. So fetch everything matching the OTHER filters, // exclude/shape locally, then paginate the filtered set here. When neither // applies (the common case) the fast Core-paginated path below is unchanged. if core_q.special || !deps.hidden.is_empty() { // Log which host-side filter forced local pagination. let local_desc = match (core_q.special, deps.hidden.len()) { (true, 0) => format!("{filter},rare=SP"), (true, n) => format!("{filter},rare=SP,hidden={n}"), (false, n) => format!("{filter},hidden={n}"), }; let mut base = core_q.clone(); base.offset = None; base.limit = None; return match deps.core.query_owned(&base.to_query_pairs()) { Ok(page) => { // Exclude hidden instances BEFORE shaping: a card on the transfer // list is not in the club, so it must not consume a page slot. let visible: Vec = page .items .into_iter() .filter(|it| !deps.hidden.contains(&it.owned_card_id)) .collect(); let (body, stats) = shape_club_response(&visible, deps.entities, deps.assets); let all = body .get("itemData") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); let (paged, total) = if core_q.special { special_filter_page(&all, offset, limit) } else { paginate_items(&all, offset, limit) }; let emitted = paged.len(); ( json_response(&json!({ "itemData": paged })), ClubLog { outcome: "ok", filter: local_desc, total, emitted, dropped_no_asset: stats.dropped_no_asset, offset, limit, }, ) } Err(e) => { eprintln!("utas-host ERROR /club (local-filter) core query failed: {e}"); ( json_response(&json!({ "itemData": [] })), ClubLog { outcome: "core_error", filter: local_desc, total: 0, emitted: 0, dropped_no_asset: 0, offset, limit, }, ) } }; } match deps.core.query_owned(&pairs) { Ok(page) => { let (body, stats): (Value, ShapeStats) = shape_club_response(&page.items, deps.entities, deps.assets); ( json_response(&body), ClubLog { outcome: "ok", filter, total: page.total, emitted: stats.emitted, dropped_no_asset: stats.dropped_no_asset, offset, limit, }, ) } Err(e) => { // Degrade to a valid empty page; DO NOT fall back to Python. eprintln!("utas-host ERROR /club core query failed: {e}"); ( json_response(&json!({ "itemData": [] })), ClubLog { outcome: "core_error", filter, total: 0, emitted: 0, dropped_no_asset: 0, offset, limit, }, ) } } } fn describe_map_error(e: &MapError) -> String { match e { MapError::UnknownLeague(id) => format!("unknown_league={id}"), MapError::UnknownNation(id) => format!("unknown_nation={id}"), MapError::UnknownTeam(id) => format!("unknown_team={id}"), } } fn summarize(pairs: &[(&str, String)]) -> String { pairs .iter() .map(|(k, v)| format!("{k}={v}")) .collect::>() .join(",") } // ───────────────────────────── Squad handlers ─────────────────────────────── /// FIFA wire id of the single active squad (`…/squad/0`). pub const ACTIVE_SQUAD_WIRE_ID: i64 = 0; /// Dependencies for the Rust squad paths. `resolver` is the SAME production /// identity resolver `/club` uses (forward shape + reverse wire→owned), so every /// route agrees on wire↔owned identity. pub struct SquadDeps<'a> { pub core: &'a dyn CoreAccess, pub resolver: &'a Fifa17IdentityResolver, pub entities: &'a Fifa17Entities, } /// Secret-free structured log line for a handled squad request. #[derive(Debug, Clone)] pub struct SquadLog { pub outcome: &'static str, pub detail: String, } /// The active squad projected from Core, with the freshness policy applied. enum HostProjection { /// Fresh: the projected FIFA squad object (before any endpoint envelope). Squad(Value), /// Stored extension is stale vs the canonical squad — NEVER applied. Stale, /// No extension stored — nothing fabricated. Missing, /// Core unreachable / response unreadable / projection failed. Error(String), } /// Assemble the projection input from Core in a BOUNDED number of calls — one /// `read_squad_ext` + one batch `all_owned`, never per slot — then project. The /// Fresh/Stale/Missing policy is decided HERE, not buried in a default. fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection { let read = match deps.core.read_squad_ext(EXT_NAMESPACE) { Ok(r) => r, Err(e) => return HostProjection::Error(e.to_string()), }; let ext = match &read.ext { CoreExtState::Fresh { schema_version, payload, } => { match Fifa17SquadExtensionV1::from_payload(*schema_version, payload) { Ok(e) => e, // Fresh but the payload does not parse as our schema: corruption, // never coerced into a fabricated squad. Err(e) => return HostProjection::Error(format!("fresh extension unreadable: {e}")), } } CoreExtState::Stale { .. } => return HostProjection::Stale, CoreExtState::Missing => return HostProjection::Missing, }; let owned = match deps.core.all_owned() { Ok(v) => v, Err(e) => return HostProjection::Error(e.to_string()), }; let owned_by_id: std::collections::HashMap = owned .into_iter() .map(|i| (i.owned_card_id.clone(), i)) .collect(); let slots: Vec = read .slots .iter() .map(|s| ProjectionSlot { owned_card_id: s.owned_card_id.clone(), index: s.index, is_captain: s.is_captain, is_on_bench: s.is_on_bench, }) .collect(); let input = SquadProjectionInput { fifa_squad_id: ACTIVE_SQUAD_WIRE_ID, name: read.name, formation: read.formation, slots, ext: SquadExtInput::Fresh(ext), owned: &owned_by_id, }; match project_squad(&input, deps.resolver, deps.entities) { Ok(SquadProjection::Projected(v)) => HostProjection::Squad(v), Ok(SquadProjection::Stale) => HostProjection::Stale, Ok(SquadProjection::Missing) => HostProjection::Missing, Err(e) => HostProjection::Error(e.to_string()), } } /// A small JSON error body (UTAS mutations that cannot be honoured fail loudly — /// they are NEVER retried against Python, which would risk a double mutation). fn error_response(status: u16, code: &str) -> WireResponse { WireResponse { status, headers: vec![("Content-Type".to_string(), "application/json".to_string())], body: format!("{{\"error\":\"{code}\"}}").into_bytes(), } } /// `PUT …/squad/` — parse the full replacement, reverse-resolve every wire id, /// AUTHORIZE every resolved item against the active club, then commit the /// canonical squad + FIFA extension to Core in one transaction. On any failure /// it returns an error and NEVER falls back to Python (no double mutation). pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) { let put = match parse_squad_put(body) { Ok(p) => p, Err(e) => { return ( error_response(400, "parse_error"), SquadLog { outcome: "parse_error", detail: e.to_string(), }, ) } }; // Reverse-resolve wire→owned and shape canonical + extension. Refuses on an // unresolved wire id or the same owned item placed twice. let build = match build_squad_write(&put, deps.resolver) { Ok(b) => b, Err(SquadBuildError::UnresolvedWireIds(ids)) => { return ( error_response(400, "unresolved_wire_ids"), SquadLog { outcome: "unresolved_wire_ids", detail: format!("{ids:?}"), }, ) } Err(SquadBuildError::DuplicateOwnedItem(id)) => { return ( error_response(400, "duplicate_owned_item"), SquadLog { outcome: "duplicate_owned_item", detail: id, }, ) } }; // AUTHORIZATION — identity resolution is NOT authorization. Every resolved // owned item MUST belong to the active club; a globally-valid wire id that // belongs to another profile is rejected BEFORE any canonical mutation. let owned_set: std::collections::HashSet = match deps.core.all_owned() { Ok(v) => v.into_iter().map(|i| i.owned_card_id).collect(), Err(e) => { return ( error_response(502, "core_error"), SquadLog { outcome: "core_error", detail: e.to_string(), }, ) } }; for slot in &build.canonical.slots { if !owned_set.contains(&slot.owned_card_id) { return ( error_response(403, "not_owned"), SquadLog { outcome: "unauthorized_item", detail: slot.owned_card_id.clone(), }, ); } } // Commit canonical + extension atomically. No Python fallback on failure. let req = CoreReplaceRequest { name: build.canonical.name.clone(), formation: build.canonical.formation.clone(), slots: build .canonical .slots .iter() .map(|s| CoreSquadSlot { owned_card_id: s.owned_card_id.clone(), index: s.index, is_captain: s.is_captain, is_on_bench: s.is_on_bench, }) .collect(), client_reported: CoreClientEval { chemistry: build.extension.client_reported.chemistry, rating: build.extension.client_reported.rating, star_rating: build.extension.client_reported.star_rating, }, ext_namespace: EXT_NAMESPACE.to_string(), 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) => ( error_response(502, "core_error"), SquadLog { outcome: "core_error", detail: e.to_string(), }, ), } } /// `GET …/squad/list` — served from Core + the ONE projector. Stale/Missing are /// integrity failures for the migrated dev profile: logged prominently, degraded /// to an empty list, NEVER served from Python and NEVER projected from stale ext. pub fn handle_squad_list(deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) { match project_active_squad(deps) { HostProjection::Squad(v) => ( json_response(&squad_list(&v)), SquadLog { outcome: "ok", detail: String::new(), }, ), HostProjection::Stale => ( json_response(&json!({ "squad": [] })), SquadLog { outcome: "stale_integrity", detail: "stale extension not applied".into(), }, ), HostProjection::Missing => ( json_response(&json!({ "squad": [] })), SquadLog { outcome: "missing_integrity", detail: "no extension stored".into(), }, ), HostProjection::Error(e) => ( json_response(&json!({ "squad": [] })), SquadLog { outcome: "core_error", detail: e, }, ), } } /// `GET …/squad/active` — the active squad projected from Core, returned as the /// top-level squad object (byte-identical to what `userMassInfo.squad` embeds). /// Never served from Python and NEVER projected from a stale extension; on a /// stale/missing extension or a Core error it degrades to an honest empty squad /// (never 401/403, never a Python fallback that could mask split authority). pub fn handle_squad_active(deps: &SquadDeps<'_>, persona_id: i64) -> (WireResponse, SquadLog) { match project_active_squad(deps) { HostProjection::Squad(v) => ( json_response(&user_mass_info_squad(v, persona_id)), SquadLog { outcome: "ok", detail: String::new(), }, ), HostProjection::Stale => ( json_response(&empty_squad_overlay(persona_id)), SquadLog { outcome: "stale_integrity", detail: "stale extension not applied".into(), }, ), HostProjection::Missing => ( json_response(&empty_squad_overlay(persona_id)), SquadLog { outcome: "missing_integrity", detail: "no extension stored".into(), }, ), HostProjection::Error(e) => ( json_response(&empty_squad_overlay(persona_id)), SquadLog { outcome: "core_error", detail: e, }, ), } } /// An explicit empty active squad used only when Rust squad authority cannot /// produce a Fresh projection during a userMassInfo overlay. It is NOT Python's /// squad (that would reintroduce split authority) and NOT fabricated extension /// state — it is an honest "no squad available", surfaced with an ERROR log. fn empty_squad_overlay(persona_id: i64) -> Value { json!({ "id": ACTIVE_SQUAD_WIRE_ID, "personaId": persona_id, "changed": 0, "actives": [], "players": [], }) } /// Replace `resp`'s body with `body`, fixing framing headers (Content-Length, /// Content-Type; drops any stale length/transfer-encoding). fn set_json_body(resp: &mut WireResponse, body: Vec) { resp.headers.retain(|(k, _)| { !k.eq_ignore_ascii_case("content-length") && !k.eq_ignore_ascii_case("content-type") && !k.eq_ignore_ascii_case("transfer-encoding") }); resp.headers .push(("Content-Type".to_string(), "application/json".to_string())); resp.headers .push(("Content-Length".to_string(), body.len().to_string())); resp.body = body; } /// `GET …/userMassInfo` — proxy the request to Python verbatim, then overlay ONLY /// `.squad` with the Rust/Core projection. Every unrelated field (`userInfo`, /// `settings`, `userData`, `pileSizeClientData`, …) is preserved exactly. pub fn handle_user_mass_info( method: &str, target: &str, headers: &[(String, String)], body: &[u8], deps: &SquadDeps<'_>, pass: &PassClient, ) -> (WireResponse, SquadLog) { let mut resp = match pass.forward(method, target, headers, body) { Ok(r) => r, Err(e) => { return ( error_response(502, "upstream_unavailable"), SquadLog { outcome: "python_unreachable", detail: e.to_string(), }, ) } }; // Only a successful JSON object carrying `.squad` is overlaid; anything else // is returned verbatim (we never invent a squad into an unrelated response). if !(200..300).contains(&resp.status) { return ( resp, SquadLog { outcome: "python_non_2xx_passthrough", detail: String::new(), }, ); } let mut root: Value = match serde_json::from_slice::(&resp.body) { Ok(v) if v.is_object() => v, _ => { return ( resp, SquadLog { outcome: "python_body_unusable_passthrough", detail: String::new(), }, ) } }; if root.get("squad").is_none() { return ( resp, SquadLog { outcome: "python_no_squad_passthrough", detail: String::new(), }, ); } // Preserve the client's persona from Python's own response. let persona = root["squad"] .get("personaId") .and_then(|x| x.as_i64()) .or_else(|| { root.get("userInfo") .and_then(|u| u.get("personaId")) .and_then(|x| x.as_i64()) }) .unwrap_or(0); let (squad_val, log) = match project_active_squad(deps) { HostProjection::Squad(v) => ( user_mass_info_squad(v, persona), SquadLog { outcome: "ok", detail: String::new(), }, ), HostProjection::Stale => ( empty_squad_overlay(persona), SquadLog { outcome: "stale_integrity", detail: "stale extension not applied".into(), }, ), HostProjection::Missing => ( empty_squad_overlay(persona), SquadLog { outcome: "missing_integrity", detail: "no extension stored".into(), }, ), HostProjection::Error(e) => ( empty_squad_overlay(persona), SquadLog { outcome: "core_error", detail: e, }, ), }; root["squad"] = squad_val; let new_body = serde_json::to_vec(&root).unwrap_or_else(|_| resp.body.clone()); set_json_body(&mut resp, new_body); (resp, log) } // ─────────────── Rust economy handlers (Core-backed authority) ─────────────── // // These implement the FIFA17 economy routes against Core economy authority. // They are Core-backed and fail-closed: a Core error yields a controlled FIFA- // compatible response and NEVER a Python fallback (which would be a second // writer). They are wired into `classify` only as one coherent barrier once the // whole coins cluster (writers + readers) flips together and Core is seeded from // the profile — a partial flip would desync the client's coin counter. /// Build the `GET /user/credits` body — byte-shape-identical to the Python /// oracle (`credits` + `currencies[].funds/finalFunds`, optional /// `unopenedPacks.recoveredPacks`). The hub coin counter binds to /// `currencies[0].funds`, not `credits`. pub fn build_credits_body(coins: i64, unopened_count: usize) -> Value { let mut body = json!({ "credits": coins, "currencies": [ {"name": "coins", "funds": coins, "finalFunds": coins}, {"name": "points", "funds": 0, "finalFunds": 0}, ], }); if unopened_count > 0 { body.as_object_mut().unwrap().insert( "unopenedPacks".into(), json!({ "preOrderPacks": 0, "recoveredPacks": unopened_count }), ); } body } /// `GET /user/credits` from Core authority: coins = Core balance, recoveredPacks /// = Core unconsumed entitlement count. Fail-closed on any Core error (503, no /// Python fallback, no fabricated balance). pub fn handle_credits(econ: &dyn CoreEconomy) -> WireResponse { match (econ.balance(), econ.entitlements()) { (Ok(coins), Ok(ents)) => json_response(&build_credits_body(coins, ents.len())), _ => error_response(503, "core_unavailable"), } } /// Map Core entitlements to FIFA unopened pack ids (definition_id parsed as the /// numeric pack id; unparseable entries are skipped, never faked). fn entitlement_pack_ids(ents: &[EconomyEntitlement]) -> Vec { ents.iter() .filter_map(|e| e.definition_id.parse::().ok()) .collect() } /// `GET /store/purchasegroup` fully generated in Rust: normal catalogue packs + /// Core-owned unopened packs + the empty-My-Packs shim per `StoreMode`. No /// Python body dependency. Fail-closed on Core error (503, never Python). pub fn handle_purchasegroup(econ: &dyn CoreEconomy, mode: StoreMode) -> WireResponse { match econ.entitlements() { Ok(ents) => { let ids = entitlement_pack_ids(&ents); json_response(&build_purchasegroup(&ids, mode)) } Err(_) => error_response(503, "core_unavailable"), } } /// Overlay the authoritative Core economy onto a `userMassInfo` body in place: /// set `userInfo.currencies[coins].funds/finalFunds` and /// `userInfo.unopenedPacks.recoveredPacks`. Pure; every other field is /// preserved. Mirrors the oracle shape (coins element by `name == "coins"`; /// `unopenedPacks` only present when count > 0). Returns true if applied. pub fn overlay_massinfo_economy(root: &mut Value, coins: i64, unopened_count: usize) -> bool { let Some(user_info) = root.get_mut("userInfo").and_then(Value::as_object_mut) else { return false; }; if let Some(currencies) = user_info .get_mut("currencies") .and_then(Value::as_array_mut) { for cur in currencies.iter_mut() { if cur.get("name").and_then(Value::as_str) == Some("coins") { if let Some(obj) = cur.as_object_mut() { obj.insert("funds".into(), json!(coins)); obj.insert("finalFunds".into(), json!(coins)); } } } } if unopened_count > 0 { user_info.insert( "unopenedPacks".into(), json!({ "preOrderPacks": 0, "recoveredPacks": unopened_count }), ); } else { user_info.remove("unopenedPacks"); } 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 }, }) } /// 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::(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)), Err(_) => error_response(503, "core_unavailable"), } } // ───────────────────────────── HTTP wire types ────────────────────────────── /// A response ready to write: status, headers, body. #[derive(Debug, Clone)] pub struct WireResponse { pub status: u16, pub headers: Vec<(String, String)>, pub body: Vec, } fn json_response(body: &Value) -> WireResponse { let bytes = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec()); WireResponse { status: 200, headers: vec![("Content-Type".to_string(), "application/json".to_string())], body: bytes, } } fn is_hop_by_hop(name: &str) -> bool { matches!( name.to_ascii_lowercase().as_str(), "connection" | "keep-alive" | "transfer-encoding" | "content-length" | "host" | "proxy-connection" | "te" | "trailer" | "upgrade" ) } // ───────────────────────────── Python passthrough ─────────────────────────── /// Verbatim reverse proxy to the Python UTAS oracle. Preserves method, full /// target (path + query), end-to-end headers, and body; returns the upstream's /// status/headers/body faithfully. pub struct PassClient { client: reqwest::blocking::Client, upstream: String, } impl PassClient { pub fn new(upstream: impl Into) -> Self { PassClient { client: reqwest::blocking::Client::new(), upstream: upstream.into().trim_end_matches('/').to_string(), } } pub fn forward( &self, method: &str, target: &str, headers: &[(String, String)], body: &[u8], ) -> Result { let url = format!("{}{}", self.upstream, target); let m = reqwest::Method::from_bytes(method.as_bytes()) .map_err(|e| CoreError::Http(format!("bad method: {e}")))?; let mut req = self.client.request(m, &url); for (k, v) in headers { if !is_hop_by_hop(k) { req = req.header(k, v); } } if !body.is_empty() { req = req.body(body.to_vec()); } let resp = req.send().map_err(|e| CoreError::Http(e.to_string()))?; let status = resp.status().as_u16(); let mut out = Vec::new(); for (k, v) in resp.headers() { if !is_hop_by_hop(k.as_str()) { if let Ok(s) = v.to_str() { out.push((k.to_string(), s.to_string())); } } } let bytes = resp .bytes() .map_err(|e| CoreError::Http(e.to_string()))? .to_vec(); Ok(WireResponse { status, headers: out, body: bytes, }) } } // ───────────────────────────── Server ─────────────────────────────────────── /// The durable services the FIFA17 economy handlers need, wired into [`Server`] /// ONCE at construction (never per request). All are process-lifetime `Arc`s: /// the Core economy transport, the two host-owned durable SQLite stores (listing /// and pile), the shared Tokio runtime bridge, and the pack-content candidate /// pool (the resolvable FIFA∩Core card universe). #[derive(Clone)] pub struct EconomyServices { pub econ: Arc, pub market: Arc, pub piles: Arc, pub bridge: Arc, /// The resolvable FIFA∩Core card universe a pack can award (empty → the Store /// fail-closes: it draws nothing and debits nothing). pub pool: Arc>, /// STAGING-ONLY sold-row experiment. `SoldExperiment::OFF` in production, where /// it changes nothing. pub sold_experiment: crate::sold_experiment::SoldExperiment, } /// Build the pack-content candidate pool from Core's current content, evidenced /// by the owned inventory: every distinct owned card definition that resolves to /// a real FIFA asset id is a candidate (`gold` = rating ≥ 75; `special` from the /// catalog `rareflag > 1`). This is the resolvable FIFA∩Core card universe — the /// cards a pack can award and the shared shaper can render. An empty pool (no /// content, or Core unreachable) is fail-closed by construction: the generator /// returns no cards, so the Store neither mints nor debits. pub fn build_content_pool( core: &dyn CoreAccess, resolver: &Fifa17IdentityResolver, ) -> Vec { let owned = match core.all_owned() { Ok(v) => v, Err(_) => return Vec::new(), }; let mut seen = std::collections::HashSet::new(); let mut pool = Vec::new(); for item in &owned { if !seen.insert(item.card_id.clone()) { continue; } let Some(id) = resolver.resolve(item) else { continue; }; pool.push(GeneratedCandidate { card_id: item.card_id.clone(), rating: item.rating, position: item.position.clone(), nation: item.nation.clone(), league: item.league.clone(), club: item.club.clone(), attributes: item.attributes, gold: item.rating >= 75, special: id.rareflag > 1, }); } pool } /// Production [`crate::economy_store::PurchasedPileSink`]: records a minted item /// into the durable pile store's "purchased" pile via the runtime bridge, from /// the synchronous dispatch thread. A pile-write failure is logged, never fatal /// to the mint (Core already committed the item; the reveal is presentation /// only, and a missing pile row just omits it from the reveal screen). struct BridgedPurchasedSink { bridge: Arc, piles: Arc, } impl crate::economy_store::PurchasedPileSink for BridgedPurchasedSink { fn record_purchased(&self, core_id: &str) { let piles = self.piles.clone(); let id = core_id.to_string(); if let Err(e) = self .bridge .block_on(async move { piles.set(&id, "purchased").await }) { eprintln!("utas-host WARN purchased-pile record {core_id} failed: {e}"); } } } /// The migration host. Cheap to clone (all shared state is `Arc`). #[derive(Clone)] pub struct Server { core: Arc, entities: Arc, /// The single production identity resolver, shared by `/club`, `/squad/*` /// and the userMassInfo overlay — one wire↔owned identity everywhere. resolver: Arc, pass: Arc, /// The launcher-selected FIFA persona id (injected via `OPENFUT_PERSONA_ID`), /// used to stamp `personaId` on the Core-backed `GET /squad/active` object. /// Never baked in — it must match the persona LSX/Blaze/POW/UTAS agree on. persona_id: i64, /// Shared FIFA17 account identity. Rust owns club rename and every Rust /// user/account response reads the same durable name/abbreviation. account: Arc, /// Per-login FIFA session/capability authority (empty-My-Packs topology). /// The economy (coins, unopened packs, BUY) stays Python's; this owns only /// session/capability state — Rust reads Python's live body, never writes it. sessions: Arc>, /// Monotonic clock origin for the session/pending TTLs. start: Instant, /// FIFA17 economy authority services (Core transport + durable listing/pile /// stores + runtime bridge + content pool). `None` until wired via /// [`Server::with_economy`]; the economy dispatch is inert without it, and /// `handle_with_ip` does not consult it until the classifier barrier. economy: Option>, /// Durable per-user client-data blob store (`clientdata`/`userHubData`). /// [`Server::new`] gives each instance an ephemeral temp-file store; /// [`Server::from_config`] wires the configured durable path. clientdata: Arc, /// Serializes host-owned pile/listing transitions with SBC eligibility checks. /// Core supplies the database transaction; this gate closes the cross-store race /// within one host process. economy_gate: Arc>, } impl Server { /// Assemble from injected parts (used by `from_config` and tests). pub fn new( core: Arc, entities: Arc, resolver: Arc, pass: Arc, persona_id: i64, ) -> Self { Server { core, entities, resolver, pass, persona_id, account: Arc::new(AccountStore::open(ephemeral_account_path())), sessions: Arc::new(Mutex::new(SessionStore::new())), start: Instant::now(), economy: None, clientdata: Arc::new(ClientDataStore::open(ephemeral_clientdata_path())), economy_gate: Arc::new(Mutex::new(())), } } /// Build from config: load entity tables + the FIFA 17 identity catalog, open /// the persistent identity store, and wire the Core client + Python /// passthrough. Fails clearly if a required production identity source cannot /// be loaded — there is no placeholder fallback. pub fn from_config(cfg: &HostConfig) -> Result { let entities = Fifa17Entities::from_tables_dir(std::path::Path::new(&cfg.tables_dir)) .map_err(|e| format!("loading entity tables from {}: {e}", cfg.tables_dir))?; let catalog = Fifa17CardCatalog::from_file(std::path::Path::new(&cfg.catalog_path)) .map_err(|e| format!("loading card-definition catalog {}: {e}", cfg.catalog_path))?; let store = openfut_identity::JsonIdentityStore::open(&cfg.identity_store_path) .map_err(|e| format!("opening identity store {}: {e}", cfg.identity_store_path))?; let resolver = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store))); // One HTTP client, shared as both the read (`CoreAccess`) and the economy // (`CoreEconomy`) transport — the same Core, one connection policy. let client = Arc::new(HttpCoreClient::new( cfg.core_url.clone(), Fifa17WireItemIdPolicy::GAME, )); let core: Arc = client.clone(); let econ: Arc = client; let entities = Arc::new(entities); // Economy authority services: one runtime bridge + the two durable // host-owned SQLite stores (opened here, at host lifetime, NEVER per // request) + the content pool. A store that cannot open is a hard startup // failure — NEVER a silent omission or a Python economy fallback. let bridge = Arc::new( crate::async_bridge::AsyncBridge::new() .map_err(|e| format!("building economy runtime bridge: {e}"))?, ); let market_path = cfg.market_db_path.clone(); let market = Arc::new( bridge .block_on(async move { crate::market_store::MarketStore::open(&market_path).await }) .map_err(|e| format!("opening market store {}: {e}", cfg.market_db_path))?, ); let pile_path = cfg.pile_db_path.clone(); let piles = Arc::new( bridge .block_on(async move { crate::pile_store::PileStore::open(&pile_path).await }) .map_err(|e| format!("opening pile store {}: {e}", cfg.pile_db_path))?, ); // Content pool from Core's current inventory (empty ⇒ Store fails closed, // never mints/debits — an honest degrade if Core is not yet seeded). let pool = Arc::new(build_content_pool(core.as_ref(), resolver.as_ref())); // Read once at startup and logged, so a staging capture can never be // mistaken for production output. let sold_experiment = crate::sold_experiment::SoldExperiment::from_env(); eprintln!("utas-host {}", sold_experiment.banner()); let economy = Arc::new(EconomyServices { econ, market, piles, bridge, pool, sold_experiment, }); let clientdata = Arc::new(ClientDataStore::open(cfg.clientdata_path.clone())); let account = Arc::new(AccountStore::open(cfg.account_path.clone())); Ok(Server::new( core, entities, resolver, Arc::new(PassClient::new(cfg.python_upstream.clone())), cfg.persona_id, ) .with_economy(economy) .with_clientdata(clientdata) .with_account(account)) } /// Assemble the shared squad dependencies (Core access + the one production /// resolver + entity tables). fn squad_deps(&self) -> SquadDeps<'_> { SquadDeps { core: self.core.as_ref(), resolver: self.resolver.as_ref(), entities: self.entities.as_ref(), } } /// Attach the FIFA17 economy authority services. Kept separate from /// construction so the (many) squad/club tests build a `Server` without a /// database, while the economy integration path wires real durable stores + /// the runtime bridge once. pub fn with_economy(mut self, economy: Arc) -> Self { self.economy = Some(economy); self } /// Attach the durable client-data blob store (configured path). Kept separate /// from construction so tests keep the ephemeral temp-file store. pub fn with_clientdata(mut self, clientdata: Arc) -> Self { self.clientdata = clientdata; self } /// Attach the shared durable FIFA17 account store. pub fn with_account(mut self, account: Arc) -> Self { self.account = account; self } fn sbc_views(&self) -> Result, CoreError> { let definitions = self.core.list_sbcs()?; let completions = self.core.sbc_completion_counts()?; Ok(definitions .into_iter() .filter_map(|definition| { let identity = fifa17_sbc::identity_for_core(&definition.id)?; Some(fifa17_sbc::ChallengeView { identity, name: definition.name, description: definition.description, repeatable: definition.repeatable, times_completed: completions.get(&definition.id).copied().unwrap_or(0), }) }) .collect()) } fn sbc_core_ids_from_wire( &self, service: &EconomyServices, wire_ids: &[i64], ) -> Result, WireResponse> { let owned: std::collections::HashSet = self .core .all_owned() .map_err(core_sbc_error_response)? .into_iter() .map(|item| item.owned_card_id) .collect(); let mut seen = std::collections::HashSet::with_capacity(wire_ids.len()); let mut core_ids = Vec::with_capacity(wire_ids.len()); for wire_id in wire_ids { if !seen.insert(*wire_id) { return Err(json_status( 400, &json!({ "error": format!("duplicate SBC item id {wire_id}") }), )); } let core_id = self.resolver.owned_id_for_wire(*wire_id).ok_or_else(|| { json_status( 404, &json!({ "error": format!("unknown SBC item id {wire_id}") }), ) })?; if !owned.contains(&core_id) { return Err(json_status( 404, &json!({ "error": format!("SBC item {wire_id} is not owned") }), )); } self.ensure_sbc_host_eligible(service, &core_id)?; core_ids.push(core_id); } Ok(core_ids) } fn ensure_sbc_host_eligible( &self, service: &EconomyServices, core_id: &str, ) -> Result<(), WireResponse> { let piles = service.piles.clone(); let id = core_id.to_owned(); let pile = service .bridge .block_on(async move { piles.get(&id).await }) .map_err(|error| { json_status( 503, &json!({ "error": format!("SBC pile eligibility unavailable: {error}") }), ) })?; if matches!(pile.as_deref(), Some("purchased" | "unassigned")) { return Err(json_status( 409, &json!({ "error": "purchased/unassigned items are not SBC-eligible" }), )); } let market = service.market.clone(); let id = core_id.to_owned(); let listed = service .bridge .block_on(async move { market.has_active_for_core_item(&id).await }) .map_err(|error| { json_status( 503, &json!({ "error": format!("SBC listing eligibility unavailable: {error}") }), ) })?; if listed { return Err(json_status( 409, &json!({ "error": "actively listed items are not SBC-eligible" }), )); } Ok(()) } fn sbc_wire_ids_from_core(&self, core_ids: &[String]) -> Result, WireResponse> { let owned: std::collections::HashMap = self .core .all_owned() .map_err(core_sbc_error_response)? .into_iter() .map(|item| (item.owned_card_id.clone(), item)) .collect(); core_ids .iter() .map(|core_id| { let item = owned.get(core_id).ok_or_else(|| { json_status(409, &json!({ "error": "saved SBC squad is stale" })) })?; self.resolver .resolve(item) .map(|identity| i64::from(identity.item_id)) .ok_or_else(|| { json_status( 409, &json!({ "error": "saved SBC item has no FIFA identity" }), ) }) }) .collect() } fn handle_sbc_route( &self, service: &EconomyServices, route: EconomyRoute, method: &str, path: &str, body: &[u8], ) -> WireResponse { match route { EconomyRoute::SbcSets => match self.sbc_views() { Ok(views) => json_status(200, &fifa17_sbc::sets_body(&views)), Err(error) => core_sbc_error_response(error), }, EconomyRoute::SbcTag => json_status(200, &json!({})), EconomyRoute::SbcChallenges => { let Some(set_id) = ut_tail(path).and_then(sbc_set_id) else { return json_status(400, &json!({ "error": "invalid SBC set id" })); }; match self.sbc_views() { Ok(views) => json_status(200, &fifa17_sbc::challenges_body(set_id, &views)), Err(error) => core_sbc_error_response(error), } } EconomyRoute::SbcChallengeSquad => { let Some(challenge_id) = ut_tail(path).and_then(sbc_challenge_id) else { return json_status(400, &json!({ "error": "invalid SBC challenge id" })); }; let Some(identity) = fifa17_sbc::identity_for_challenge(challenge_id) else { return json_status(404, &json!({ "error": "unknown SBC challenge" })); }; if method.eq_ignore_ascii_case("GET") { let core_ids = match self.core.load_sbc_squad(identity.core_id) { Ok(ids) => ids, Err(error) => return core_sbc_error_response(error), }; let wire_ids = match self.sbc_wire_ids_from_core(&core_ids) { Ok(ids) => ids, Err(response) => return response, }; return json_status(200, &fifa17_sbc::squad_body(challenge_id, &wire_ids)); } let wire_ids = match fifa17_sbc::parse_wire_item_ids(body) { Ok(ids) => ids, Err(error) => return json_status(400, &json!({ "error": error.to_string() })), }; let core_ids = match self.sbc_core_ids_from_wire(service, &wire_ids) { Ok(ids) => ids, Err(response) => return response, }; match self.core.save_sbc_squad(identity.core_id, &core_ids) { Ok(_) => json_status(200, &fifa17_sbc::save_body(challenge_id)), Err(error) => core_sbc_error_response(error), } } EconomyRoute::SbcChallenge => { let Some(challenge_id) = ut_tail(path).and_then(sbc_challenge_id) else { return json_status(400, &json!({ "error": "invalid SBC challenge id" })); }; let Some(identity) = fifa17_sbc::identity_for_challenge(challenge_id) else { return json_status(404, &json!({ "error": "unknown SBC challenge" })); }; if method.eq_ignore_ascii_case("POST") && body.is_empty() { return json_status(200, &fifa17_sbc::start_body(challenge_id)); } let core_ids = match fifa17_sbc::parse_wire_item_ids(body) { Ok(wire_ids) => match self.sbc_core_ids_from_wire(service, &wire_ids) { Ok(ids) => ids, Err(response) => return response, }, Err(fifa17_sbc::SbcWireError::MissingSquad) => { let ids = match self.core.load_sbc_squad(identity.core_id) { Ok(ids) => ids, Err(error) => return core_sbc_error_response(error), }; let wire_ids = match self.sbc_wire_ids_from_core(&ids) { Ok(ids) => ids, Err(response) => return response, }; match self.sbc_core_ids_from_wire(service, &wire_ids) { Ok(ids) => ids, Err(response) => return response, } } Err(error) => return json_status(400, &json!({ "error": error.to_string() })), }; let result = match self.core.submit_sbc(identity.core_id, &core_ids) { Ok(result) => result, Err(error) => return core_sbc_error_response(error), }; if !result.passed { return json_status( 400, &json!({ "error": "SBC requirements not met", "failures": result.failures }), ); } for core_id in &core_ids { let piles = service.piles.clone(); let id = core_id.clone(); if let Err(error) = service .bridge .block_on(async move { piles.remove(&id).await }) { eprintln!( "utas-host WARN SBC stale pile cleanup core_id={core_id} error={error}" ); } } let credits = match service.econ.balance() { Ok(balance) => balance, Err(error) => return core_sbc_error_response(error), }; let unopened_packs = match service.econ.entitlements() { Ok(packs) => packs.len() as i64, Err(error) => return core_sbc_error_response(error), }; json_status( 200, &fifa17_sbc::submit_body( challenge_id, identity.set_id, credits, unopened_packs, ), ) } _ => json_status(500, &json!({ "error": "invalid SBC route dispatch" })), } } /// Dispatch a FIFA17 economy route to its Rust handler, or `None` if the path /// is not an economy route (or no economy services are wired). `handle_with_ip` /// calls this before generic route classification, so matched routes never fall /// through to Python. Sync handlers run inline; async store/market handlers use /// the shared runtime bridge. pub fn try_handle_economy( &self, method: &str, target: &str, headers: &[(String, String)], body: &[u8], client_ip: Option<&str>, ) -> Option { let svc = self.economy.as_ref()?; let _economy_guard = self.economy_gate.lock().unwrap(); let path = target.split('?').next().unwrap_or(target); let route = classify_economy(method, path)?; use crate::economy_store::{ handle_pack_open, handle_quick_sell_body, handle_quick_sell_path, handle_store_buy, CoreItemLookup, QuickSellDeps, StoreDeps, }; let resp = match route { EconomyRoute::Credits => handle_credits(svc.econ.as_ref()), EconomyRoute::PurchaseGroup => { let sid = header(headers, "x-ut-sid").unwrap_or(""); let mode = self.sessions .lock() .unwrap() .empty_mypacks_mode(sid, client_ip, self.now()); handle_purchasegroup(svc.econ.as_ref(), mode) } EconomyRoute::StoreBuy => { let mut rng = rand::rngs::StdRng::from_entropy(); let sink = BridgedPurchasedSink { bridge: svc.bridge.clone(), piles: svc.piles.clone(), }; let deps = StoreDeps { econ: svc.econ.as_ref(), assets: self.resolver.as_ref(), entities: self.entities.as_ref(), pool: svc.pool.as_ref(), purchased: Some(&sink), }; handle_store_buy(body, &deps, &mut rng) } EconomyRoute::PackOpen => { let mut rng = rand::rngs::StdRng::from_entropy(); let sink = BridgedPurchasedSink { bridge: svc.bridge.clone(), piles: svc.piles.clone(), }; let deps = StoreDeps { econ: svc.econ.as_ref(), assets: self.resolver.as_ref(), entities: self.entities.as_ref(), pool: svc.pool.as_ref(), purchased: Some(&sink), }; handle_pack_open(body, &deps, &mut rng) } EconomyRoute::PackReveal => { // Async pile membership via the bridge; Core inventory read // synchronously on this (non-runtime) dispatch thread; pure shape. let (bridge, piles) = (svc.bridge.clone(), svc.piles.clone()); let purchased_ids: std::collections::HashSet = bridge .block_on(async move { piles.list_by_pile("purchased").await }) .unwrap_or_default() .into_iter() .collect(); let owned = self.core.all_owned().unwrap_or_default(); crate::economy_store::shape_purchased_reveal( &owned, &purchased_ids, self.entities.as_ref(), self.resolver.as_ref(), ) } EconomyRoute::QuickSellPath => { let id = ut_tail(path) .and_then(|t| t.strip_prefix("item/")) .and_then(|d| d.parse::().ok())?; let lookup = CoreItemLookup { core: self.core.as_ref(), }; let deps = QuickSellDeps { econ: svc.econ.as_ref(), reverse: self.resolver.as_ref(), items: &lookup, }; handle_quick_sell_path(id, &deps) } EconomyRoute::QuickSellBody => { let lookup = CoreItemLookup { core: self.core.as_ref(), }; let deps = QuickSellDeps { econ: svc.econ.as_ref(), reverse: self.resolver.as_ref(), items: &lookup, }; handle_quick_sell_body(body, &deps) } EconomyRoute::MatchEnd => handle_match_end(svc.econ.as_ref(), body), EconomyRoute::MoveItems => { let (bridge, piles, resolver, market) = ( svc.bridge.clone(), svc.piles.clone(), self.resolver.clone(), svc.market.clone(), ); let body = body.to_vec(); bridge.block_on(async move { crate::market::handle_move_items( &body, resolver.as_ref(), piles.as_ref(), market.as_ref(), ) .await }) } EconomyRoute::MarketList => { // Resolve the listed item synchronously (identity + one Core read) // on the dispatch thread, so the async persist future holds only // `Send` data (the trait-object resolvers are not `Send`). let resolved = { let lookup = CoreItemLookup { core: self.core.as_ref(), }; crate::market::resolve_market_list( body, self.resolver.as_ref(), self.resolver.as_ref(), &lookup, self.entities.as_ref(), ) }; let (bridge, market, econ) = (svc.bridge.clone(), svc.market.clone(), svc.econ.clone()); let m = method.to_string(); bridge.block_on(async move { crate::market::handle_market_list(&m, resolved, econ.as_ref(), market.as_ref()) .await }) } EconomyRoute::MarketQuery => { let (bridge, market, econ) = (svc.bridge.clone(), svc.market.clone(), svc.econ.clone()); let exp = svc.sold_experiment; bridge.block_on(async move { crate::market::handle_market_query( "active", econ.as_ref(), market.as_ref(), exp, ) .await }) } EconomyRoute::MarketCounts => { let (bridge, market) = (svc.bridge.clone(), svc.market.clone()); let exp = svc.sold_experiment; bridge.block_on(async move { crate::market::handle_market_counts(market.as_ref(), exp).await }) } EconomyRoute::MarketStatus => { let (bridge, market, econ) = (svc.bridge.clone(), svc.market.clone(), svc.econ.clone()); let exp = svc.sold_experiment; // The raw query carries `tradeIds`; `path` is already stripped. let q = target.split_once('?').map(|(_, q)| q.to_string()); bridge.block_on(async move { crate::market::handle_market_status( q.as_deref(), econ.as_ref(), market.as_ref(), exp, ) .await }) } EconomyRoute::MarketBuy => { let (bridge, market, econ) = (svc.bridge.clone(), svc.market.clone(), svc.econ.clone()); let (m, p, body) = (method.to_string(), path.to_string(), body.to_vec()); bridge.block_on(async move { crate::market::handle_market_buy(&m, &p, &body, econ.as_ref(), market.as_ref()) .await }) } EconomyRoute::MarketCancel => { let (bridge, market) = (svc.bridge.clone(), svc.market.clone()); let (p, owner) = (path.to_string(), client_ip.map(|s| s.to_string())); bridge.block_on(async move { crate::market::handle_market_cancel(&p, owner.as_deref(), market.as_ref()).await }) } route @ (EconomyRoute::SbcSets | EconomyRoute::SbcTag | EconomyRoute::SbcChallenges | EconomyRoute::SbcChallengeSquad | EconomyRoute::SbcChallenge) => { self.handle_sbc_route(svc.as_ref(), route, method, path, body) } EconomyRoute::MarketClearSold => { let (bridge, market) = (svc.bridge.clone(), svc.market.clone()); bridge.block_on(async move { crate::market::handle_market_clear_sold(market.as_ref()).await }) } }; Some(resp) } /// 4-arg entrypoint (tests + callers without a peer address). Session-bound /// routes fall back to a `None` client IP. pub fn handle( &self, method: &str, target: &str, headers: &[(String, String)], body: &[u8], ) -> WireResponse { self.handle_with_ip(method, target, headers, body, None) } /// Route one request to a response. Classification happens here, once, before /// either branch runs. `client_ip` is the peer address used to bind FIFA /// session capability (auxiliary to the authoritative X-UT-SID). pub fn handle_with_ip( &self, method: &str, target: &str, headers: &[(String, String)], body: &[u8], client_ip: Option<&str>, ) -> WireResponse { let path = target.split('?').next().unwrap_or(target); // ─────────────────────── Economy authority barrier ─────────────────── // Every economy-touching route is owned by Rust/Core. Classified and // dispatched HERE, before `classify()`, so a migrated route can NEVER also // reach the Python passthrough (NEVER BOTH). When the economy services are // wired (production `from_config`), an economy route ALWAYS returns `Some` // — fail-closed (503) on any Core error — so there is no Python economy // fallback. `None` means "not an economy route" (or no economy wired, i.e. // a bare test server), which falls through to the classifier below. if let Some(resp) = self.try_handle_economy(method, target, headers, body, client_ip) { eprintln!( "utas-host owner=RUST route=economy method={} path={} status={}", method, path, resp.status ); return resp; } match classify(method, path) { Route::Club => { let query = target.split_once('?').map(|(_, q)| q).unwrap_or(""); let hidden = self.club_hidden_ids(); let deps = ClubDeps { core: self.core.as_ref(), entities: self.entities.as_ref(), assets: self.resolver.as_ref(), hidden: &hidden, }; let (resp, log) = handle_club(query, &deps); eprintln!( "utas-host owner=RUST route=club status={} outcome={} filter=[{}] total={} emitted={} dropped_no_asset={} offset={:?} limit={:?}", resp.status, log.outcome, log.filter, log.total, log.emitted, log.dropped_no_asset, log.offset, log.limit ); resp } Route::ClubRename => self.handle_club_rename(body), Route::SquadReplace => { let deps = self.squad_deps(); let (resp, log) = handle_put_squad(body, &deps); eprintln!( "utas-host owner=RUST route=squad-replace status={} outcome={} detail=[{}]", resp.status, log.outcome, log.detail ); resp } Route::SquadList => { let deps = self.squad_deps(); let (resp, log) = handle_squad_list(&deps); eprintln!( "utas-host owner=RUST route=squad-list status={} outcome={} detail=[{}]", resp.status, log.outcome, log.detail ); resp } Route::SquadActive => { let deps = self.squad_deps(); let (resp, log) = handle_squad_active(&deps, self.persona_id); eprintln!( "utas-host owner=RUST route=squad-active status={} outcome={} detail=[{}]", resp.status, log.outcome, log.detail ); resp } Route::UserMassInfo => self.handle_user_mass_info_full(), Route::Auth => self.handle_auth(method, target, headers, body, client_ip), Route::AccountSync => self.handle_account_sync(body), Route::ClientData => self.handle_client_data(method, path, body), Route::Capability => self.handle_capability(body, client_ip), Route::StorePurchaseGroup => { self.handle_store_purchasegroup(method, target, headers, body, client_ip) } Route::AccountInfo => { eprintln!("utas-host owner=RUST route=accountinfo status=200"); json_status(200, &non_economy::accountinfo_body()) } Route::Settings => { eprintln!("utas-host owner=RUST route=settings status=200"); json_status(200, &non_economy::settings_body()) } Route::LeaderboardOptions => { eprintln!("utas-host owner=RUST route=leaderboards-options status=200"); json_status(200, &non_economy::leaderboard_options_body()) } Route::MatchReset => { eprintln!("utas-host owner=RUST route=match-reset status=200"); json_status(200, &non_economy::match_reset_body()) } Route::ClubStatsStaff => { eprintln!("utas-host owner=RUST route=club-stats-staff status=200"); json_status(200, &non_economy::club_stats_staff_body()) } Route::Hub => self.handle_hub(), Route::ClubStats => self.handle_club_stats(path), Route::StaticAck => self.handle_static_ack(path), Route::WatchList => self.handle_watchlist(method), Route::User => self.handle_user(), Route::FeatureOffEmpty => self.handle_feature_off_empty(path), Route::ItemDefs => self.handle_item_defs(target), Route::MarketData => self.handle_marketdata(path, target), Route::SecurityQuestion => self.handle_security_question(method, target, headers), Route::Passthrough => { let resp = match self.pass.forward(method, target, headers, body) { Ok(r) => r, Err(e) => { eprintln!("utas-host ERROR passthrough to Python failed: {e}"); WireResponse { status: 502, headers: vec![( "Content-Type".to_string(), "application/json".to_string(), )], body: br#"{"error":"upstream unavailable"}"#.to_vec(), } } }; eprintln!( "utas-host owner=PYTHON_FALLBACK method={} path={} status={}", method, path, resp.status ); resp } } } /// Monotonic seconds since server start — the clock for session/pending TTLs. fn now(&self) -> f64 { self.start.elapsed().as_secs_f64() } /// `POST /ut/auth` — Rust mints the `X-UT-SID`, adopts the persona from the /// request body (`nucleusPersonaId`/`nuc`, else the configured persona) and /// opens a Rust session bound to the peer IP. Never proxied to Python. The /// SID is not an auth gate — only the Rust `SessionStore` consults it — so /// minting it in Rust is complete. `/ut/delete/auth` is a `{}` logout ack. fn handle_auth( &self, method: &str, target: &str, _headers: &[(String, String)], body: &[u8], client_ip: Option<&str>, ) -> WireResponse { let path = target.split('?').next().unwrap_or(target); if path.starts_with("/ut/delete/auth") { eprintln!("utas-host owner=RUST route=auth-delete status=200"); return json_status(200, &json!({})); } let persona = non_economy::parse_auth_persona(body).unwrap_or(self.persona_id); let sid = format!( "OPENFUT-SID-{:016X}", rand::rngs::StdRng::from_entropy().gen::() ); self.sessions.lock().unwrap().open_session( &sid, client_ip.map(|s| s.to_string()), persona, self.now(), ); let epoch_secs = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0); let server_time = non_economy::format_utc_datetime(epoch_secs); eprintln!( "utas-host owner=RUST route=auth status=200 method={} ip={:?} persona={} sid_opened=true", method, client_ip, persona ); json_status(200, &non_economy::auth_body(&sid, &server_time)) } /// `PUT …/club` / `PUT|POST …/user/club` — persist the validated club /// identity in the shared account file. The response class has zero atoms, /// and the client disconnects on a 4xx, so every input returns `200 {}` just /// like the oracle; rejected/persistence outcomes remain visible in logs. fn handle_club_rename(&self, body: &[u8]) -> WireResponse { let outcome = self.account.rename_from_body(body); match &outcome { RenameOutcome::Updated => { eprintln!("utas-host owner=RUST route=club-rename status=200 outcome=updated") } RenameOutcome::Unchanged => { eprintln!("utas-host owner=RUST route=club-rename status=200 outcome=unchanged") } RenameOutcome::Rejected(reason) => eprintln!( "utas-host owner=RUST route=club-rename status=200 outcome=rejected detail={reason}" ), RenameOutcome::PersistFailed(error) => eprintln!( "utas-host ERROR owner=RUST route=club-rename status=200 outcome=persist-failed detail=[{error}]" ), } json_status(200, &json!({})) } /// `POST /openfut/account/sync` — launcher control-plane account summary. The /// coins/unopened-pack counts are the AUTHORITATIVE Core economy (balance + /// entitlements), NEVER Python's stale profile funds. Fail-closed 503 on any /// Core error — never a Python fallback. fn handle_account_sync(&self, body: &[u8]) -> WireResponse { let svc = match &self.economy { Some(s) => s, None => { eprintln!("utas-host owner=RUST route=account-sync status=503 error=no_economy"); return error_response(503, "core_unavailable"); } }; let (coins, ents) = match (svc.econ.balance(), svc.econ.entitlements()) { (Ok(c), Ok(e)) => (c, e), _ => { eprintln!("utas-host owner=RUST route=account-sync status=503 error=core"); return error_response(503, "core_unavailable"); } }; let req = non_economy::parse_account_sync(body, self.persona_id); eprintln!( "utas-host owner=RUST route=account-sync status=200 persona={} coins={} packs={}", req.persona_id, coins, ents.len() ); let club = self.account.club(); json_status( 200, &non_economy::account_sync_body(&req, coins, ents.len(), &club.name, &club.abbr), ) } /// `GET/PUT/POST …/clientdata/` — opaque per-user client blob store. GET /// returns the stored blob for `:` (or `{}` if never written); /// PUT/POST parse and store the body under the key and ALWAYS ack `{}`. fn handle_client_data(&self, method: &str, path: &str, body: &[u8]) -> WireResponse { let key = ut_tail(path) .and_then(|t| t.strip_prefix("clientdata/")) .unwrap_or(""); if method.eq_ignore_ascii_case("GET") { let blob = self .clientdata .get(self.persona_id, key) .unwrap_or_else(|| json!({})); eprintln!("utas-host owner=RUST route=clientdata method=GET key={key} status=200"); json_status(200, &blob) } else { let val: Value = serde_json::from_slice(body).unwrap_or(Value::Null); self.clientdata.put(self.persona_id, key, val); eprintln!( "utas-host owner=RUST route=clientdata method={method} key={key} status=200 stored=true" ); json_status(200, &json!({})) } } /// Build the full Rust userMassInfo Value (userInfo + squad + settings + /// pileSizeClientData) from Core, or a 503 response on Core error. Shared by /// `GET …/userMassInfo` and `GET …/user` so both agree byte-for-byte. fn build_user_mass_info(&self) -> Result<(Value, &'static str), WireResponse> { let svc = match &self.economy { Some(s) => s, None => return Err(error_response(503, "core_unavailable")), }; let (coins, ents) = match (svc.econ.balance(), svc.econ.entitlements()) { (Ok(c), Ok(e)) => (c, e), _ => return Err(error_response(503, "core_unavailable")), }; let deps = self.squad_deps(); let (squad, squad_outcome) = match project_active_squad(&deps) { HostProjection::Squad(v) => (user_mass_info_squad(v, self.persona_id), "ok"), HostProjection::Stale => (empty_squad_overlay(self.persona_id), "stale_integrity"), HostProjection::Missing => (empty_squad_overlay(self.persona_id), "missing_integrity"), HostProjection::Error(_) => (empty_squad_overlay(self.persona_id), "core_error"), }; let club = self.account.club(); Ok(( non_economy::user_mass_info_body( squad, coins, ents.len(), self.persona_id, &club.name, &club.abbr, &club.established, ), squad_outcome, )) } /// `GET …/userMassInfo` — served FULLY from Rust (no Python): the Core squad /// projection (byte-identical to `GET …/squad/active`) plus the authoritative /// Core economy (coins + unopened packs). Fail-closed 503 on Core error. fn handle_user_mass_info_full(&self) -> WireResponse { match self.build_user_mass_info() { Ok((body, squad_outcome)) => { eprintln!( "utas-host owner=RUST route=userMassInfo status=200 squad_outcome={squad_outcome}" ); json_status(200, &body) } Err(e) => { eprintln!( "utas-host owner=RUST route=userMassInfo status={} error=core", e.status ); e } } } /// `GET …/user` — the FUT user profile: `{"userInfo": …}`, the same userInfo /// object `userMassInfo` embeds (shared builder). Fail-closed 503 on Core error. fn handle_user(&self) -> WireResponse { match self.build_user_mass_info() { Ok((body, _)) => { let user_info = body.get("userInfo").cloned().unwrap_or_else(|| json!({})); eprintln!("utas-host owner=RUST route=user status=200"); json_status(200, &json!({ "userInfo": user_info })) } Err(e) => { eprintln!( "utas-host owner=RUST route=user status={} error=core", e.status ); e } } } /// `POST /openfut/fifa17/capability` — Rust-owned launcher registration (no /// economy, no proxy). Fail-closed: an unsupported name/version is a 400 that /// records nothing, so the session stays on the sentinel fallback. fn handle_capability(&self, body: &[u8], client_ip: Option<&str>) -> WireResponse { let req = match parse_capability_request(body) { Ok(r) => r, Err(()) => { eprintln!("utas-host owner=RUST route=capability status=400 outcome=unsupported"); return json_status(400, &json!({"error": "unsupported capability"})); } }; let persona = req.persona_id.unwrap_or(self.persona_id); let outcome = self.sessions.lock().unwrap().register_capability( client_ip.map(|s| s.to_string()), persona, req.version, self.now(), ); eprintln!( "utas-host owner=RUST route=capability status=200 ip={:?} persona={} -> {:?}", client_ip, persona, outcome ); json_status(200, &json!({"status": "OK"})) } /// `GET …/store/purchasegroup…` — proxy to Python for the authoritative economy /// body (catalogue + owned packs + coins), then overlay ONLY the empty-My-Packs /// topology from the Rust session mode. Python (which does not know the Rust /// capability) always emits the 65534 sentinel when My Packs is empty; for a /// verified clean-v1 SID we strip it. Rust never writes economy state. fn handle_store_purchasegroup( &self, method: &str, target: &str, headers: &[(String, String)], body: &[u8], client_ip: Option<&str>, ) -> WireResponse { let mut resp = match self.pass.forward(method, target, headers, body) { Ok(r) => r, Err(e) => { eprintln!("utas-host ERROR purchasegroup proxy to Python failed: {e}"); return error_response(502, "upstream_unavailable"); } }; let sid = header(headers, "x-ut-sid").unwrap_or(""); // Freeze the session's empty-My-Packs mode at this first store request. let mode = self .sessions .lock() .unwrap() .empty_mypacks_mode(sid, client_ip, self.now()); let mut stripped = 0usize; if (200..300).contains(&resp.status) { if let Ok(mut root) = serde_json::from_slice::(&resp.body) { stripped = overlay_empty_mypacks(&mut root, mode); if stripped > 0 { if let Ok(new_body) = serde_json::to_vec(&root) { set_json_body(&mut resp, new_body); } } } } eprintln!( "utas-host owner=RUST_OVERLAY route=purchasegroup status={} sid={} mode={} sentinel_stripped={}", resp.status, fifa17_sidlog(sid), mode.as_str(), stripped ); resp } /// `GET/POST/PUT …/phishing/{trusteddevice,question,validate}` — the retired /// FUT security-question service, owned entirely in Rust (no proxy, no Core). /// Stateless: the client-transformed answer is never stored or compared, and /// the trusted-device response is an invariant verified/trusted constant. The /// `X-UT-SID` session gate mirrors the oracle — an unknown session is a 400 /// `invalid_session`; a malformed 32-hex device id/answer is `malformed_request`. fn handle_security_question( &self, method: &str, target: &str, headers: &[(String, String)], ) -> WireResponse { fn query_param(query: &str, key: &str) -> Option { query.split('&').find_map(|kv| { let (k, v) = kv.split_once('=')?; (k == key).then(|| v.to_string()) }) } let path = target.split('?').next().unwrap_or(target); let query = target.split_once('?').map(|(_, q)| q).unwrap_or(""); let tail = ut_tail(path).unwrap_or(""); let action = non_economy::parse_security_action(tail); let sid = header(headers, "x-ut-sid").unwrap_or(""); let known = self.sessions.lock().unwrap().session_known(sid); let device_id = query_param(query, "deviceId").unwrap_or_default(); let question = query_param(query, "question"); let answer = query_param(query, "answer"); let (status, body) = non_economy::security_question_response( method, action, known, &device_id, question.as_deref(), answer.as_deref(), ); eprintln!( "utas-host owner=RUST route=security-question status={} action={:?} sid={} known={}", status, action, fifa17_sidlog(sid), known ); json_status(status, &body) } /// Core owned-instance ids that are NOT part of the CLUB view: the cards with /// an ACTIVE transfer-market listing. In FIFA a listed card has left the club, /// so it must not appear in `/club` or the hub's `clubPlayers` tally while it /// is on sale. /// /// Keyed on the ACTIVE LISTING, deliberately NOT on the `trade` pile. The pile /// can hold cards with no listing (a bare "Place on Transfer Market" move, or a /// listing that was cancelled/sold), and `/tradePile` renders ONLY active /// listings — so hiding the whole pile would make those cards invisible in BOTH /// views. Keying on the listing makes visibility self-healing: the moment a /// listing stops being active the card is back in the club, with no extra /// transition to maintain and no need to invent an "unlisted transfer-list" /// wire shape (`tradeState` has no verified spelling for that state). /// /// Empty when no economy services are wired (bare test server); a market-store /// read failure degrades to showing everything (never hides inventory silently). fn club_hidden_ids(&self) -> std::collections::HashSet { let Some(svc) = self.economy.as_ref() else { return std::collections::HashSet::new(); }; let (bridge, market) = (svc.bridge.clone(), svc.market.clone()); match bridge.block_on(async move { market.query_listings("active").await }) { Ok(listings) => listings .into_iter() .filter_map(|l| l.core_item_id) .collect(), Err(e) => { eprintln!("utas-host WARN market read failed (club shows all): {e}"); std::collections::HashSet::new() } } } /// `GET …/hub` — the FUT hub tile counts, owned in Rust (no Python). Derived /// from authoritative state: `clubPlayers` is the count of owned PLAYER cards /// in Core that are in the club (actively-listed cards excluded — they have /// left the club), and the auction / tradePile counts are the user's active /// listings in the durable market store. `clubPlayers` may be lower than the Python /// oracle's profile count by exactly the deferred (unnameable Legend) /// instances — DIFFERENT-BY-DESIGN, since deferred cards are not owned in /// Core. Fail-closed on Core error (503); a market-store read failure degrades /// the cosmetic listing counts to 0. fn handle_hub(&self) -> WireResponse { let hidden = self.club_hidden_ids(); let club_players = match self.core.all_owned() { Ok(items) => items .iter() .filter(|it| !hidden.contains(&it.owned_card_id)) .filter(|it| self.resolver.kind_of(it) == ContentKind::Player) .count(), Err(e) => { eprintln!("utas-host owner=RUST route=hub status=503 error=core:{e}"); return error_response(503, "core_unavailable"); } }; let active = match &self.economy { Some(svc) => { let market = svc.market.clone(); match svc .bridge .block_on(async move { market.query_listings("active").await }) { Ok(l) => l.len(), Err(e) => { eprintln!("utas-host WARN hub market read failed (counts=0): {e}"); 0 } } } None => 0, }; let body = json!({ "clubPlayers": club_players, "auctionCount": active, "tradePile": { "count": active, "selling": active, "sold": 0 }, }); eprintln!( "utas-host owner=RUST route=hub status=200 clubPlayers={club_players} auctionCount={active}" ); json_status(200, &body) } /// Rust-owned UNCONDITIONAL static acks — `store` eligibility gate, match /// keepalive, captcha, tfa, livemessage, activeMessage. Byte-identical to the /// Python oracle's constant responses (no flag gating), so no Python. fn handle_static_ack(&self, path: &str) -> WireResponse { let tail = ut_tail(path).unwrap_or(""); let resp = match tail { "store" => json_status(200, &json!({ "result": "SUCCESS" })), "match/keepalive" => WireResponse { status: 204, headers: Vec::new(), body: Vec::new(), }, "captcha" => json_status( 200, &json!({ "encodedImg": "", "sequence": 0, "sizeBeforeEncode": 0 }), ), // tfa, livemessage, activeMessage _ => json_status(200, &json!({})), }; eprintln!( "utas-host owner=RUST route=static-ack tail={} status={}", tail, resp.status ); resp } /// `…/watchList` — transfer watch list. The oracle persists no watches, so /// add/remove (PUT/POST/DELETE) is a bare `{}` ack; GET returns an empty watch /// list with the authoritative Core credits. Best-effort credits (0 on Core /// error) — a cosmetic balance echo, not the authoritative wallet. fn handle_watchlist(&self, method: &str) -> WireResponse { if !method.eq_ignore_ascii_case("GET") { eprintln!("utas-host owner=RUST route=watchlist method={method} status=200"); return json_status(200, &json!({})); } let credits = self .economy .as_ref() .and_then(|s| s.econ.balance().ok()) .unwrap_or(0); eprintln!("utas-host owner=RUST route=watchlist method=GET status=200 credits={credits}"); json_status( 200, &json!({ "auctionInfo": [], "credits": credits, "total": 0 }), ) } /// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and the /// club-identity read service are off, so return `{}` (parity with the /// flag-off Python oracle). Club rename is handled separately by /// [`Server::handle_club_rename`]. fn handle_feature_off_empty(&self, path: &str) -> WireResponse { let tail = ut_tail(path).unwrap_or(""); eprintln!("utas-host owner=RUST route=feature-off-empty tail={tail} status=200"); json_status(200, &non_economy::feature_off_body()) } /// `GET …/item/resource`, `…/defid` — FUT item-definition lookup. Builds /// `{itemData:[…]}` for every integer id (≥ 3 digits) in the query, mirroring /// the oracle's `defs_route` (`re.findall(r"\d{3,}")` over the raw query). fn handle_item_defs(&self, target: &str) -> WireResponse { let query = target.split_once('?').map(|(_, q)| q).unwrap_or(""); let ids = extract_long_ints(query); eprintln!( "utas-host owner=RUST route=item-defs count={} status=200", ids.len() ); json_status(200, &non_economy::item_defs_body(&ids)) } /// `GET …/marketdata[/pricelimits]` — suggested pricing. `/pricelimits` returns /// the bare ARRAY (one band per queried defId); any other `/marketdata` returns /// the OBJECT band. Container type is chosen from the path (load-bearing: the /// wrong one froze a live client). fn handle_marketdata(&self, path: &str, target: &str) -> WireResponse { if path.ends_with("/pricelimits") { let query = target.split_once('?').map(|(_, q)| q).unwrap_or(""); let ids = extract_defid_param(query); eprintln!( "utas-host owner=RUST route=marketdata kind=pricelimits count={} status=200", ids.len() ); json_status(200, &non_economy::marketdata_pricelimits_body(&ids)) } else { eprintln!("utas-host owner=RUST route=marketdata kind=object status=200"); json_status(200, &non_economy::marketdata_object_body()) } } /// `GET …/club/stats/` — the MY CLUB stat set, computed Core-accurately /// in Rust (no Python). Player tiers + rare from the Core collection, /// staff/consumable families from the catalog kind+subtype, and per-context /// buckets keyed by the screen's field: nation (year/consumables/club/…), /// league for `country/`, team for `league/`. Fail-closed 503 on Core. fn handle_club_stats(&self, path: &str) -> WireResponse { let mode = ut_tail(path) .and_then(|t| t.strip_prefix("club/stats/")) .and_then(|rest| rest.split('/').next()) .unwrap_or(""); // The URL says which SCREEN: `country/` lists LEAGUES (leagueId // buckets), `league/` lists TEAMS (teamid buckets); all others render // the default screen (nation buckets). Mirrors fut_club_stats.stats_body. let ctx = match mode { "country" => ContextField::League, "league" => ContextField::Team, _ => ContextField::Nation, }; let owned = match self.core.all_owned() { Ok(o) => o, Err(e) => { eprintln!("utas-host owner=RUST route=club-stats status=503 error=core:{e}"); return error_response(503, "core_unavailable"); } }; let items: Vec = owned .iter() .map(|it| ClubStatInput { kind: self.resolver.kind_of(it), subtype: self.resolver.subtype_of(it), rating: it.rating as i64, rare: self.resolver.rareflag_of(it) != 0, nation_id: self.entities.nation_id(&it.nation).map(|n| n as i64), league_id: self.entities.league_id(&it.league).map(|n| n as i64), team_id: self.entities.team_id(&it.club).map(|n| n as i64), }) .collect(); let players = items .iter() .filter(|i| matches!(i.kind, ContentKind::Player)) .count(); eprintln!( "utas-host owner=RUST route=club-stats mode={} ctx={:?} status=200 owned={} players={}", mode, ctx, items.len(), players ); json_status(200, &club_stats_body(&items, ctx)) } /// Serve forever on `addr` (thread-per-connection, HTTP/1.1 keep-alive). pub fn serve(&self, addr: &str) -> std::io::Result<()> { let listener = TcpListener::bind(addr)?; eprintln!("utas-host listening on {addr}"); self.serve_listener(listener); Ok(()) } /// Accept loop on an already-bound listener (lets tests bind an ephemeral /// port and learn it before serving). pub fn serve_listener(&self, listener: TcpListener) { for stream in listener.incoming() { let stream = match stream { Ok(s) => s, Err(_) => continue, }; let server = self.clone(); std::thread::spawn(move || server.handle_conn(stream)); } } fn handle_conn(&self, stream: TcpStream) { let mut reader = BufReader::new(match stream.try_clone() { Ok(s) => s, Err(_) => return, }); let mut writer = stream; let peer_ip = writer.peer_addr().ok().map(|a| a.ip().to_string()); loop { match read_request(&mut reader) { Ok(Some(req)) => { let resp = self.handle_with_ip( &req.method, &req.target, &req.headers, &req.body, peer_ip.as_deref(), ); if write_response(&mut writer, &resp).is_err() { return; } if req.close { return; } } Ok(None) => return, // clean EOF Err(_) => return, } } } } // ─────────────────────── Session/capability route helpers ─────────────────── /// Case-insensitive header lookup. fn header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { headers .iter() .find(|(k, _)| k.eq_ignore_ascii_case(name)) .map(|(_, v)| v.as_str()) } /// A short, non-secret tag for correlating a session in logs (last 6 chars). fn fifa17_sidlog(sid: &str) -> String { if sid.is_empty() { "-".to_string() } else { format!("\u{2026}{}", &sid[sid.len().saturating_sub(6)..]) } } /// A JSON response with an explicit status. fn json_status(status: u16, v: &Value) -> WireResponse { let body = serde_json::to_vec(v).unwrap_or_default(); WireResponse { status, headers: vec![ ("Content-Type".to_string(), "application/json".to_string()), ("Content-Length".to_string(), body.len().to_string()), ], body, } } fn core_sbc_error_response(error: CoreError) -> WireResponse { let status = match &error { CoreError::Status(400) => 400, CoreError::Status(404) => 404, CoreError::Status(409) => 409, _ => 503, }; json_status( status, &json!({ "error": format!("Core SBC operation failed: {error}") }), ) } /// A unique ephemeral JSON-state path used by [`Server::new`] tests. /// Production replaces both stores with configured durable paths. fn ephemeral_state_path(kind: &str) -> std::path::PathBuf { use std::sync::atomic::{AtomicU64, Ordering}; static CTR: AtomicU64 = AtomicU64::new(0); let n = CTR.fetch_add(1, Ordering::Relaxed); let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); std::env::temp_dir().join(format!( "openfut-utas-{kind}-{}-{nanos}-{n}.json", std::process::id() )) } fn ephemeral_clientdata_path() -> std::path::PathBuf { ephemeral_state_path("clientdata") } fn ephemeral_account_path() -> std::path::PathBuf { ephemeral_state_path("account") } /// A validated capability registration request. struct CapabilityRequest { persona_id: Option, version: u32, } /// Parse + validate a capability POST body. Mirrors the Python route: anything but /// `capability == "empty_mypacks_resolver"` at the supported version is rejected. fn parse_capability_request(body: &[u8]) -> Result { let v: Value = serde_json::from_slice(body).map_err(|_| ())?; let obj = v.as_object().ok_or(())?; let name = obj.get("capability").and_then(|x| x.as_str()).ok_or(())?; let version = obj .get("version") .and_then(|x| x.as_u64()) .and_then(|n| u32::try_from(n).ok()) .ok_or(())?; validate_capability(name, version).map_err(|_| ())?; let persona_id = obj.get("personaId").and_then(|x| x.as_i64()); Ok(CapabilityRequest { persona_id, version, }) } /// Overlay the empty-My-Packs topology on Python's `purchasegroup` body. Python /// always emits the synthetic 65534 sentinel when My Packs is empty (it does not /// know the Rust capability); for a verified clean-v1 session we remove it so the /// client's resolver guard routes to Browse. Sentinel mode leaves it in place, and /// real owned packs (no 65534) are untouched in either mode. Returns the count /// removed. Pure — the topology decision is unit-testable. fn overlay_empty_mypacks(root: &mut Value, mode: StoreMode) -> usize { if mode != StoreMode::CleanV1 { return 0; } let Some(arr) = root.get_mut("purchase").and_then(|p| p.as_array_mut()) else { return 0; }; let before = arr.len(); arr.retain(|entry| { entry .get("id") .and_then(|x| x.as_u64()) .map(|id| id != SENTINEL_PACK_ID) .unwrap_or(true) }); before - arr.len() } // ───────────────────────────── HTTP/1.1 request reader ────────────────────── /// A parsed request. `target` is the raw request target (path + optional query). pub struct ParsedRequest { pub method: String, pub target: String, pub headers: Vec<(String, String)>, pub body: Vec, pub close: bool, } /// Read one HTTP/1.1 request. `Ok(None)` = clean connection close before a /// request line. Body is read exactly per `Content-Length` (chunked is not used /// by this client population — worker D). pub fn read_request(reader: &mut R) -> std::io::Result> { let mut line = String::new(); let n = reader.read_line(&mut line)?; if n == 0 { return Ok(None); } let request_line = line.trim_end(); if request_line.is_empty() { // Tolerate a stray blank line before the request line. return read_request(reader); } let mut parts = request_line.split_whitespace(); let method = parts.next().unwrap_or("").to_string(); let target = parts.next().unwrap_or("").to_string(); let mut headers = Vec::new(); let mut content_length = 0usize; let mut close = false; loop { let mut h = String::new(); if reader.read_line(&mut h)? == 0 { break; } let h = h.trim_end(); if h.is_empty() { break; } if let Some((k, v)) = h.split_once(':') { let k = k.trim().to_string(); let v = v.trim().to_string(); if k.eq_ignore_ascii_case("content-length") { content_length = v.parse().unwrap_or(0); } else if k.eq_ignore_ascii_case("connection") && v.eq_ignore_ascii_case("close") { close = true; } headers.push((k, v)); } } let mut body = vec![0u8; content_length]; if content_length > 0 { reader.read_exact(&mut body)?; } Ok(Some(ParsedRequest { method, target, headers, body, close, })) } fn reason(status: u16) -> &'static str { match status { 200 => "OK", 204 => "No Content", 400 => "Bad Request", 404 => "Not Found", 500 => "Internal Server Error", 502 => "Bad Gateway", _ => "OK", } } fn write_response(w: &mut W, resp: &WireResponse) -> std::io::Result<()> { let mut head = format!("HTTP/1.1 {} {}\r\n", resp.status, reason(resp.status)); for (k, v) in &resp.headers { if is_hop_by_hop(k) { continue; } head.push_str(&format!("{k}: {v}\r\n")); } head.push_str(&format!("Content-Length: {}\r\n", resp.body.len())); head.push_str("\r\n"); w.write_all(head.as_bytes())?; w.write_all(&resp.body)?; w.flush() } #[cfg(test)] mod tests { use super::*; use crate::async_bridge::AsyncBridge; /// A configurable in-memory economy double: real balance/entitlements, or a /// forced error to prove fail-closed behavior. struct FakeEconomy { balance: i64, entitlements: Vec, fail: bool, } impl FakeEconomy { fn ok(balance: i64, ents: usize) -> Self { FakeEconomy { balance, entitlements: (0..ents) .map(|i| EconomyEntitlement { id: format!("e{i}"), definition_id: "pack".into(), }) .collect(), fail: false, } } fn failing() -> Self { FakeEconomy { balance: 0, entitlements: vec![], fail: true, } } fn with_entitlements(balance: i64, defs: &[&str]) -> Self { FakeEconomy { balance, entitlements: defs .iter() .enumerate() .map(|(i, d)| EconomyEntitlement { id: format!("e{i}"), definition_id: (*d).into(), }) .collect(), fail: false, } } } impl CoreEconomy for FakeEconomy { fn balance(&self) -> Result { if self.fail { Err(CoreError::Status(500)) } else { Ok(self.balance) } } fn entitlements(&self) -> Result, CoreError> { if self.fail { Err(CoreError::Status(500)) } else { Ok(self.entitlements.clone()) } } fn purchase_entitlement( &self, _cost: i64, definition_id: &str, ) -> Result { if self.fail { return Err(CoreError::Status(500)); } Ok(EconomyPurchase { balance: self.balance, entitlement_id: format!("bought:{definition_id}"), }) } fn redeem_entitlement( &self, _entitlement_id: &str, _items: &[EconomyGrantItem], ) -> Result { if self.fail { return Err(CoreError::Status(500)); } Ok("pack".into()) } fn sell_item(&self, _item_id: &str, _price: i64) -> Result { if self.fail { return Err(CoreError::Status(500)); } Ok(self.balance) } fn grant_reward(&self, _amount: i64) -> Result { if self.fail { return Err(CoreError::Status(500)); } Ok(self.balance) } fn purchase_item( &self, _cost: i64, _item_id: &str, _card_id: &str, ) -> Result { if self.fail { return Err(CoreError::Status(500)); } Ok(self.balance) } fn purchase_items( &self, _cost: i64, _items: &[EconomyGrantItem], ) -> Result { if self.fail { return Err(CoreError::Status(500)); } Ok(self.balance) } fn settle_sale(&self, _sale: &EconomySale<'_>) -> Result { // Sale settlement is not exercised through this double. Err(CoreError::Status(501)) } } #[test] fn settle_sale_body_omits_absent_club_ids() { // Absent club ids are the wire signal for Core's defaults (active club // as seller, outside counterparty as buyer), so they must not appear. let outside = EconomySale { item_id: "item-x", seller_club_id: None, buyer_club_id: None, gross: 15_000, fee: 750, }; let b = sale_request_body(&outside); assert_eq!(b["item_id"], "item-x"); assert_eq!(b["gross"], 15_000); assert_eq!(b["fee"], 750); assert!(b.get("seller_club_id").is_none()); assert!(b.get("buyer_club_id").is_none()); let between_clubs = EconomySale { seller_club_id: Some("club-seller"), buyer_club_id: Some("club-buyer"), ..outside }; let b = sale_request_body(&between_clubs); assert_eq!(b["seller_club_id"], "club-seller"); assert_eq!(b["buyer_club_id"], "club-buyer"); assert_eq!(b["item_id"], "item-x"); assert_eq!(b["gross"], 15_000); assert_eq!(b["fee"], 750); } #[test] fn credits_body_matches_oracle_shape() { let b = build_credits_body(29_876_776, 0); assert_eq!(b["credits"], 29_876_776); assert_eq!(b["currencies"][0]["name"], "coins"); assert_eq!(b["currencies"][0]["funds"], 29_876_776); assert_eq!(b["currencies"][0]["finalFunds"], 29_876_776); assert_eq!(b["currencies"][1]["name"], "points"); assert_eq!(b["currencies"][1]["funds"], 0); // No packs -> no unopenedPacks key (keeps the badge off). assert!(b.get("unopenedPacks").is_none()); let b2 = build_credits_body(100, 3); assert_eq!(b2["unopenedPacks"]["recoveredPacks"], 3); assert_eq!(b2["unopenedPacks"]["preOrderPacks"], 0); } #[test] fn handle_credits_reads_core_authority() { let econ = FakeEconomy::ok(4600, 2); let resp = handle_credits(&econ); assert_eq!(resp.status, 200); let body: Value = serde_json::from_slice(&resp.body).unwrap(); assert_eq!(body["currencies"][0]["funds"], 4600); assert_eq!(body["unopenedPacks"]["recoveredPacks"], 2); } #[test] fn handle_credits_fails_closed_on_core_error() { // Core error -> controlled 503, NEVER a Python fallback or fabricated balance. let resp = handle_credits(&FakeEconomy::failing()); assert_eq!(resp.status, 503); } fn pack_ids(body: &Value) -> Vec { body["purchase"] .as_array() .unwrap() .iter() .map(|p| p["id"].as_u64().unwrap()) .collect() } #[test] fn purchasegroup_full_gen_owned_pack_no_sentinel() { let econ = FakeEconomy::with_entitlements(4600, &["70"]); let resp = handle_purchasegroup(&econ, StoreMode::Sentinel); assert_eq!(resp.status, 200); let body: Value = serde_json::from_slice(&resp.body).unwrap(); let ids = pack_ids(&body); assert!(ids.contains(&70), "owned pack 70 present"); assert!( !ids.contains(&SENTINEL_PACK_ID), "no sentinel when a pack is owned" ); } #[test] fn purchasegroup_full_gen_empty_modes() { // Sentinel mode + no packs -> 65534 shim present. let sent = handle_purchasegroup(&FakeEconomy::ok(100, 0), StoreMode::Sentinel); let sent_body: Value = serde_json::from_slice(&sent.body).unwrap(); assert!(pack_ids(&sent_body).contains(&SENTINEL_PACK_ID)); // CleanV1 + no packs -> no 65534, no My Packs group. let clean = handle_purchasegroup(&FakeEconomy::ok(100, 0), StoreMode::CleanV1); let clean_body: Value = serde_json::from_slice(&clean.body).unwrap(); assert!(!pack_ids(&clean_body).contains(&SENTINEL_PACK_ID)); } #[test] fn purchasegroup_fails_closed_on_core_error() { let resp = handle_purchasegroup(&FakeEconomy::failing(), StoreMode::Sentinel); assert_eq!(resp.status, 503); } #[test] fn massinfo_economy_overlay_sets_coins_and_packs() { let mut root = json!({ "userInfo": { "currencies": [ {"name": "coins", "funds": 1, "finalFunds": 1, "active": true}, {"name": "points", "funds": 0, "finalFunds": 0, "active": true}, ], "won": 5, }, "squad": {"keep": true}, }); assert!(overlay_massinfo_economy(&mut root, 29_876_776, 2)); assert_eq!(root["userInfo"]["currencies"][0]["funds"], 29_876_776); assert_eq!(root["userInfo"]["currencies"][0]["finalFunds"], 29_876_776); // points + other fields untouched; squad preserved. assert_eq!(root["userInfo"]["currencies"][1]["funds"], 0); assert_eq!(root["userInfo"]["won"], 5); assert_eq!(root["squad"]["keep"], true); assert_eq!(root["userInfo"]["unopenedPacks"]["recoveredPacks"], 2); // Zero packs removes the key (badge off). assert!(overlay_massinfo_economy(&mut root, 10, 0)); assert!(root["userInfo"].get("unopenedPacks").is_none()); } #[test] fn credits_massinfo_purchasegroup_agree_on_core_state() { // The invariant the cutover must preserve: all three read one Core state. let econ = FakeEconomy::with_entitlements(4600, &["70"]); let coins = econ.balance().unwrap(); let count = econ.entitlements().unwrap().len(); let credits: Value = serde_json::from_slice(&handle_credits(&econ).body).unwrap(); let mut mass = json!({"userInfo": {"currencies": [{"name":"coins","funds":0,"finalFunds":0}]}}); overlay_massinfo_economy(&mut mass, coins, count); let pg: Value = serde_json::from_slice(&handle_purchasegroup(&econ, StoreMode::Sentinel).body).unwrap(); assert_eq!(credits["currencies"][0]["funds"], coins); assert_eq!(mass["userInfo"]["currencies"][0]["funds"], coins); assert_eq!(credits["unopenedPacks"]["recoveredPacks"], count); assert_eq!(mass["userInfo"]["unopenedPacks"]["recoveredPacks"], count); assert!(pack_ids(&pg).contains(&70)); } #[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"}"#); assert_eq!(resp.status, 200); let body: Value = serde_json::from_slice(&resp.body).unwrap(); 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. let draw: Value = serde_json::from_slice(&handle_match_end(&econ, 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"}"#); assert_eq!(resp.status, 503); } #[test] fn special_filter_keeps_only_specials_and_paginates_filtered_set() { let mk = |id: i64, rf: i64| serde_json::json!({ "id": id, "rareflag": rf }); // base rare(1)/common(0) interleaved with specials(3,11,24). let items = vec![mk(1, 1), mk(2, 3), mk(3, 1), mk(4, 24), mk(5, 0), mk(6, 11)]; let (all, total) = special_filter_page(&items, None, None); assert_eq!(total, 3, "only rareflag>1 counted"); assert!(all.iter().all(|it| it["rareflag"].as_i64().unwrap() > 1)); assert_eq!( all.iter() .map(|it| it["id"].as_i64().unwrap()) .collect::>(), vec![2, 4, 6], "base rare/common excluded, order preserved" ); // pagination is over the FILTERED set, no overlap, no base leakage. let (p0, t0) = special_filter_page(&items, Some(0), Some(2)); let (p1, t1) = special_filter_page(&items, Some(2), Some(2)); assert_eq!((t0, t1), (3, 3)); assert_eq!( p0.iter() .map(|it| it["id"].as_i64().unwrap()) .collect::>(), vec![2, 4] ); assert_eq!( p1.iter() .map(|it| it["id"].as_i64().unwrap()) .collect::>(), vec![6] ); } #[test] fn classify_club_read_and_rename_methods_exactly() { assert_eq!(classify("GET", "/ut/game/fifa17/club"), Route::Club); assert_eq!(classify("get", "/ut/game/fifa18/club"), Route::Club); assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::ClubRename); // bare `club/stats` (no trailing slash) is not a migrated arm -> Python. assert_eq!( classify("GET", "/ut/game/fifa17/club/stats"), Route::Passthrough ); // club/stats/ screens are Rust-owned (nation/league/team contexts). assert_eq!( classify("GET", "/ut/game/fifa17/club/stats/country/54"), Route::ClubStats ); // clubUser is now Rust-owned (`{}` while club identity is off). assert_eq!( classify("GET", "/ut/game/fifa17/clubUser"), Route::FeatureOffEmpty ); assert_eq!( classify("GET", "/ut/game/fifa17/tradePile"), Route::Passthrough ); assert_eq!( classify("POST", "/ut/game/fifa17/purchased/items"), Route::Passthrough ); assert_eq!(classify("GET", "/ut/game//club"), Route::Passthrough); assert_eq!(classify("GET", "/club"), Route::Passthrough); } #[test] fn parse_core_page_reads_collection_and_total() { let v = json!({ "collection": [{ "owned_card_id": "oc1", "effective_overall": 86, "effective_position": "CDM", "card": {"id":"card_ch_1","overall":85,"position":"CDM","nation":"Argentina","league":"Premier League","club":"Chelsea","pace":80,"shooting":70,"passing":75,"dribbling":78,"defending":84,"physical":82} }], "total": 42 }); let page = parse_core_page(&v).unwrap(); assert_eq!(page.total, 42); assert_eq!(page.items.len(), 1); let it = &page.items[0]; assert_eq!(it.owned_card_id, "oc1"); assert_eq!(it.card_id, "card_ch_1"); assert_eq!(it.rating, 86, "effective_overall wins over base"); assert_eq!(it.position, "CDM"); assert_eq!(it.attributes, [80, 70, 75, 78, 84, 82]); } // ── Fifa17IdentityResolver: the single production identity path ────────── fn test_catalog(cards: &[(&str, u32)]) -> Fifa17CardCatalog { let entries: Vec = cards .iter() .map(|(id, asset)| format!("\"{id}\":{{\"asset_id\":{asset}}}")) .collect(); let doc = format!( "{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{}}}}}", entries.join(",") ); Fifa17CardCatalog::from_json_str(&doc).unwrap() } fn temp_store_path(tag: &str) -> std::path::PathBuf { use std::sync::atomic::{AtomicU64, Ordering}; static N: AtomicU64 = AtomicU64::new(0); let n = N.fetch_add(1, Ordering::SeqCst); std::env::temp_dir().join(format!( "ofut-resolver-{tag}-{}-{n}.json", std::process::id() )) } fn owned(owned_id: &str, card: &str) -> CoreOwnedItem { CoreOwnedItem { owned_card_id: owned_id.into(), card_id: card.into(), rating: 90, position: "ST".into(), nation: "Argentina".into(), league: "Premier League".into(), club: "Chelsea".into(), attributes: [90, 90, 80, 91, 33, 80], } } fn resolver(path: &std::path::Path, cards: &[(&str, u32)]) -> Fifa17IdentityResolver { let store = openfut_identity::JsonIdentityStore::open(path).unwrap(); Fifa17IdentityResolver::new(test_catalog(cards), Arc::new(store)) } #[test] fn resolver_maps_definition_and_allocates_wire_id() { let p = temp_store_path("map"); let r = resolver(&p, &[("card_gold_001", 20801)]); let id = r.resolve(&owned("oc1", "card_gold_001")).unwrap(); assert_eq!(id.asset_id, 20801, "real asset from the catalog"); assert_eq!( id.item_id, 100_000_001, "first wire id from the policy floor" ); let _ = std::fs::remove_file(&p); } #[test] fn resolver_drops_unmapped_definition_never_faking() { let p = temp_store_path("drop"); let r = resolver(&p, &[("card_gold_001", 20801)]); assert!( r.resolve(&owned("oc1", "card_unknown")).is_none(), "no catalog entry => dropped, never a fabricated id" ); let _ = std::fs::remove_file(&p); } #[test] fn two_copies_of_a_definition_share_resource_but_get_distinct_wire_ids() { let p = temp_store_path("copies"); let r = resolver(&p, &[("card_gold_001", 20801)]); let a = r.resolve(&owned("oc1", "card_gold_001")).unwrap(); let b = r.resolve(&owned("oc2", "card_gold_001")).unwrap(); assert_eq!(a.asset_id, b.asset_id, "same definition => same resourceId"); assert_ne!(a.item_id, b.item_id, "distinct copies => distinct wire ids"); // Idempotent: the same owned instance re-resolves to the same wire id. assert_eq!( r.resolve(&owned("oc1", "card_gold_001")).unwrap().item_id, a.item_id ); let _ = std::fs::remove_file(&p); } #[test] fn wire_id_survives_restart_and_reverses_exactly() { let p = temp_store_path("restart"); let first = { let r = resolver(&p, &[("card_gold_001", 20801)]); r.resolve(&owned("oc-stable", "card_gold_001")) .unwrap() .item_id }; // Reopen the same store file (simulating a host restart). let r2 = resolver(&p, &[("card_gold_001", 20801)]); let again = r2 .resolve(&owned("oc-stable", "card_gold_001")) .unwrap() .item_id; assert_eq!( first, again, "same owned instance keeps its wire id across restart" ); assert_eq!( r2.owned_id_for_wire(again as i64).as_deref(), Some("oc-stable"), "reverse lookup returns the exact owned instance" ); assert_eq!( r2.owned_id_for_wire(999_999_999), None, "unknown wire id => None" ); let _ = std::fs::remove_file(&p); } // ── Session/capability vertical (this slice) ──────────────────────────── #[test] fn classify_routes_session_vertical() { assert_eq!(classify("POST", "/ut/auth"), Route::Auth); assert_eq!(classify("POST", "/ut/auth/"), Route::Auth); // Only POST is auth; a GET falls through to Python. assert_eq!(classify("GET", "/ut/auth"), Route::Passthrough); assert_eq!( classify("POST", "/openfut/fifa17/capability"), Route::Capability ); assert_eq!( classify("GET", "/ut/game/fifa17/store/purchasegroup/all"), Route::StorePurchaseGroup ); assert_eq!( classify("GET", "/ut/game/fifa17/store/purchasegroup/all?ppInfo=true"), Route::StorePurchaseGroup ); // A store MUTATION stays on Python (never Rust): economy is not ours. assert_eq!( classify("PUT", "/ut/game/fifa17/store/transaction/0"), Route::Passthrough ); assert_eq!( classify("POST", "/openfut/account/sync"), Route::AccountSync ); assert_eq!(classify("POST", "/ut/delete/auth"), Route::Auth); assert_eq!( classify("PUT", "/ut/game/fifa17/clientdata/userHubData"), Route::ClientData ); assert_eq!( classify("GET", "/ut/game/fifa17/clientdata/userHubData"), Route::ClientData ); } #[test] fn parse_capability_request_validates() { let ok = br#"{"capability":"empty_mypacks_resolver","version":1,"personaId":33068179,"fifaPid":42}"#; let r = parse_capability_request(ok).expect("valid"); assert_eq!(r.version, 1); assert_eq!(r.persona_id, Some(33068179)); assert!(parse_capability_request( br#"{"capability":"empty_mypacks_resolver","version":2}"# ) .is_err()); assert!(parse_capability_request(br#"{"capability":"other","version":1}"#).is_err()); assert!(parse_capability_request(b"[]").is_err()); assert!(parse_capability_request(b"nope").is_err()); } #[test] fn overlay_strips_sentinel_only_for_clean_v1() { let base = serde_json::json!({ "purchase": [ {"id": 1, "packType": "BRONZE"}, {"id": 65534, "packType": "GOLD"}, {"id": 5, "packType": "GOLD"} ] }); // clean-v1: the 65534 sentinel is stripped; real packs remain. let mut clean = base.clone(); assert_eq!(overlay_empty_mypacks(&mut clean, StoreMode::CleanV1), 1); let ids: Vec = clean["purchase"] .as_array() .unwrap() .iter() .map(|e| e["id"].as_u64().unwrap()) .collect(); assert_eq!(ids, vec![1, 5]); // sentinel mode: unchanged (the compatibility shim is kept). let mut sent = base.clone(); assert_eq!(overlay_empty_mypacks(&mut sent, StoreMode::Sentinel), 0); assert_eq!(sent["purchase"].as_array().unwrap().len(), 3); // no purchase array -> no-op. let mut other = serde_json::json!({"other": 1}); assert_eq!(overlay_empty_mypacks(&mut other, StoreMode::CleanV1), 0); } #[test] fn ut_tail_normalizes_v1_and_v2() { for (path, want) in [ ( "/ut/game/fifa17/store/purchasegroup", Some("store/purchasegroup"), ), ( "/ut/v2/game/fifa17/store/purchasegroup", Some("store/purchasegroup"), ), ( "/ut/game/fifa17/store/transaction", Some("store/transaction"), ), ( "/ut/v2/game/fifa17/store/transaction/0", Some("store/transaction/0"), ), ("/ut/game/fifa17/purchased", Some("purchased")), ("/ut/v2/game/fifa17/purchased", Some("purchased")), ("/ut/game/fifa17/user/credits", Some("user/credits")), // generic sku — helper is not fifa17-string-specific. ( "/ut/game/fifa23/store/transaction/7", Some("store/transaction/7"), ), ( "/ut/v2/game/fifa23/store/purchasegroup", Some("store/purchasegroup"), ), // negatives. ("/ut/auth", None), ("/openfut/account/sync", None), ("/ut/game/", None), ("/ut/game/fifa17", None), ("/ut/v2/game/fifa17", None), ("/ut/v2/other/thing", None), ("/ut/delete/game/fifa17/item", None), ] { assert_eq!(ut_tail(path), want, "ut_tail({path})"); } } #[test] fn is_store_transaction_tail_is_bounded() { assert!(is_store_transaction_tail("store/transaction")); assert!(is_store_transaction_tail("store/transaction/0")); assert!(is_store_transaction_tail("store/transaction/123")); assert!(!is_store_transaction_tail("store/transactions")); assert!(!is_store_transaction_tail("store/transactionfoo")); assert!(!is_store_transaction_tail("store/transaction/0/extra")); assert!(!is_store_transaction_tail("store/transaction/")); assert!(!is_store_transaction_tail("store/transaction/abc")); assert!(!is_store_transaction_tail("store/purchasegroup")); } #[test] fn classify_economy_covers_retail_v2_store_family() { use EconomyRoute::*; // The exact live-failure shape now classifies as Rust StoreBuy. assert_eq!( classify_economy("PUT", "/ut/v2/game/fifa17/store/transaction/0"), Some(StoreBuy) ); assert_eq!( classify_economy("PUT", "/ut/game/fifa17/store/transaction"), Some(StoreBuy) ); assert_eq!( classify_economy("PUT", "/ut/v2/game/fifa17/store/transaction/123"), Some(StoreBuy) ); // purchasegroup + purchased under both prefixes. assert_eq!( classify_economy("GET", "/ut/v2/game/fifa17/store/purchasegroup"), Some(PurchaseGroup) ); assert_eq!( classify_economy("GET", "/ut/game/fifa17/store/purchasegroup/all"), Some(PurchaseGroup) ); assert_eq!( classify_economy("POST", "/ut/v2/game/fifa17/purchased"), Some(PackOpen) ); assert_eq!( classify_economy("GET", "/ut/v2/game/fifa17/purchased"), Some(PackReveal) ); // v1 non-store economy routes still classify (regression). assert_eq!( classify_economy("GET", "/ut/game/fifa17/user/credits"), Some(Credits) ); assert_eq!( classify_economy("PUT", "/ut/game/fifa17/item"), Some(MoveItems) ); assert_eq!( classify_economy("DELETE", "/ut/game/fifa17/item/100000001"), Some(QuickSellPath) ); assert_eq!( classify_economy("GET", "/ut/game/fifa17/tradePile"), Some(MarketQuery) ); assert_eq!( classify_economy("POST", "/ut/delete/game/fifa17/item"), Some(QuickSellBody) ); assert_eq!( classify_economy("POST", "/ut/delete/game/fifa17/match"), Some(MatchEnd) ); // delete family under v2 prefix too (defense-in-depth symmetry). assert_eq!( classify_economy("POST", "/ut/v2/delete/game/fifa17/item"), Some(QuickSellBody) ); // negatives: non-economy stays None (proxied to Python). assert_eq!( classify_economy("PUT", "/ut/v2/game/fifa17/store/transaction/0/extra"), None ); assert_eq!( classify_economy("PUT", "/ut/game/fifa17/store/transactions"), None ); assert_eq!(classify_economy("GET", "/ut/game/fifa17/hub"), None); assert_eq!(classify_economy("GET", "/ut/v2/game/fifa17/store"), None); assert_eq!(classify_economy("POST", "/ut/auth"), None); } /// RETAIL_ROUTE_MATRIX — the audited retail economy route contract. Every /// economy row MUST classify to its Rust route (Python proxy forbidden); /// every negative near-miss MUST stay `None` (proxied). Permanent gate against /// "Python knows route X, Rust forgot route X". #[test] fn retail_route_matrix() { use EconomyRoute::*; let matrix: &[(&str, &str, Option)] = &[ // credits ("GET", "/ut/game/fifa17/user/credits", Some(Credits)), // purchasegroup (v1 + v2 + /all) ( "GET", "/ut/game/fifa17/store/purchasegroup", Some(PurchaseGroup), ), ( "GET", "/ut/game/fifa17/store/purchasegroup/all", Some(PurchaseGroup), ), ( "GET", "/ut/v2/game/fifa17/store/purchasegroup/all", Some(PurchaseGroup), ), // store transaction (v2 + trailing id) — round-1 fix ("PUT", "/ut/game/fifa17/store/transaction", Some(StoreBuy)), ( "PUT", "/ut/v2/game/fifa17/store/transaction/0", Some(StoreBuy), ), // purchased + purchased/items — round-2 fix (POST open, GET reveal) ("POST", "/ut/game/fifa17/purchased", Some(PackOpen)), ("POST", "/ut/game/fifa17/purchased/items", Some(PackOpen)), ("POST", "/ut/v2/game/fifa17/purchased/items", Some(PackOpen)), ("GET", "/ut/game/fifa17/purchased", Some(PackReveal)), ("GET", "/ut/game/fifa17/purchased/items", Some(PackReveal)), // move ("PUT", "/ut/game/fifa17/item", Some(MoveItems)), // quick-sell (path + body) ( "DELETE", "/ut/game/fifa17/item/100000001", Some(QuickSellPath), ), ("POST", "/ut/delete/game/fifa17/item", Some(QuickSellBody)), ( "POST", "/ut/v2/delete/game/fifa17/item", Some(QuickSellBody), ), // match end ("POST", "/ut/delete/game/fifa17/match", Some(MatchEnd)), // market list / query (case-insensitive tradePile + counts) / buy / cancel ("POST", "/ut/game/fifa17/auctionhouse", Some(MarketList)), ("POST", "/ut/game/fifa17/transfermarket", Some(MarketList)), ("GET", "/ut/game/fifa17/tradePile", Some(MarketQuery)), ("GET", "/ut/game/fifa17/tradepile", Some(MarketQuery)), // The tally is a DISTINCT deserializer from the listing list. ( "GET", "/ut/game/fifa17/tradePile/counts", Some(MarketCounts), ), ( "GET", "/ut/game/fifa17/tradepile/counts", Some(MarketCounts), ), ("POST", "/ut/game/fifa17/trade/900000001", Some(MarketBuy)), // The live-state poll MUST NOT land in the buy/view arm: `status` is // not a trade id, so that arm answers every poll with an empty set. ("GET", "/ut/game/fifa17/trade/status", Some(MarketStatus)), ("GET", "/ut/game/fifa17/TRADE/STATUS", Some(MarketStatus)), // Cancel: the oracle's `/ut/delete/game/…` spelling AND the plain // `DELETE /ut/game/…/trade/` used by FIFA 17 clients. The plain // form previously fell into the buy/view arm and silently no-oped. ( "DELETE", "/ut/game/fifa17/trade/900000001", Some(MarketCancel), ), ( "DELETE", "/ut/delete/game/fifa17/trade/900000001", Some(MarketCancel), ), // ── negatives: must stay None (proxied to Python) ── ("GET", "/ut/game/fifa17/store", None), ("GET", "/ut/game/fifa17/store/", None), ("PUT", "/ut/game/fifa17/store/transactions", None), ("PUT", "/ut/game/fifa17/store/transaction/0/extra", None), ("POST", "/ut/game/fifa17/purchasedfoo", None), ("POST", "/ut/game/fifa17/purchased/items/extra", None), ("GET", "/ut/game/fifa17/hub", None), ("GET", "/ut/game/fifa17/marketdata", None), ("POST", "/ut/auth", None), ("GET", "/ut/game/fifa17/watchList", None), ]; for (m, p, want) in matrix { assert_eq!( classify_economy(m, p), *want, "RETAIL_ROUTE_MATRIX: {m} {p}" ); } } #[test] fn non_economy_route_ownership() { // The non-economy routes newly owned by Rust must classify to their arm, // and lookalikes must stay Passthrough (proxied to Python). let owned: &[(&str, &str, Route)] = &[ ( "GET", "/ut/game/fifa17/user/accountinfo", Route::AccountInfo, ), ("GET", "/ut/game/fifa17/settings", Route::Settings), ( "GET", "/ut/game/fifa17/leaderboards/options", Route::LeaderboardOptions, ), ("PUT", "/ut/game/fifa17/match/reset", Route::MatchReset), ( "GET", "/ut/game/fifa17/phishing/trusteddevice", Route::SecurityQuestion, ), ( "POST", "/ut/game/fifa17/phishing/question", Route::SecurityQuestion, ), ( "POST", "/ut/game/fifa17/phishing/validate", Route::SecurityQuestion, ), ( "GET", "/ut/game/fifa17/club/stats/staff", Route::ClubStatsStaff, ), ("GET", "/ut/game/fifa17/hub", Route::Hub), ("GET", "/ut/game/fifa17/club/stats/year", Route::ClubStats), ( "GET", "/ut/game/fifa17/club/stats/consumables", Route::ClubStats, ), ( "PUT", "/ut/game/fifa17/clientdata/userHubData", Route::ClientData, ), ( "GET", "/ut/game/fifa17/clientdata/userHubData", Route::ClientData, ), ("POST", "/openfut/account/sync", Route::AccountSync), ("GET", "/ut/game/fifa17/season", Route::FeatureOffEmpty), ("GET", "/ut/game/fifa17/tournament", Route::FeatureOffEmpty), ("GET", "/ut/game/fifa17/champion", Route::FeatureOffEmpty), ("GET", "/ut/game/fifa17/clubUser", Route::FeatureOffEmpty), ("GET", "/ut/game/fifa17/user/list", Route::FeatureOffEmpty), ("PUT", "/ut/game/fifa17/user/club", Route::ClubRename), ("POST", "/ut/game/fifa17/user/club", Route::ClubRename), ("PUT", "/ut/game/fifa17/club", Route::ClubRename), ("GET", "/ut/game/fifa17/item/resource", Route::ItemDefs), ("GET", "/ut/game/fifa17/defid", Route::ItemDefs), ( "GET", "/ut/game/fifa17/marketdata/pricelimits", Route::MarketData, ), ("GET", "/ut/game/fifa17/marketdata", Route::MarketData), ]; for (m, p, want) in owned { assert_eq!(classify(m, p), *want, "OWN: {m} {p}"); } // Still Python (not yet migrated) / lookalikes / wrong method. let proxied: &[(&str, &str)] = &[ ("GET", "/ut/game/fifa17/settingsfoo"), ("POST", "/ut/game/fifa17/match/reset"), // match/reset is PUT-only ("GET", "/ut/game/fifa17/match/reset"), ("PUT", "/ut/game/fifa17/user/accountinfo"), ("POST", "/ut/game/fifa17/season"), // FUT-mode reads are GET-only ("GET", "/ut/game/fifa17/user/club"), // unknown read, not rename ("POST", "/ut/game/fifa17/item/resource"), // item-defs are GET-only ("GET", "/ut/game/fifa17/marketdatafoo"), // not the marketdata route ]; for (m, p) in proxied { assert_eq!(classify(m, p), Route::Passthrough, "PROXY: {m} {p}"); } // These non-economy routes must NOT be economy-classified. for (m, p, _) in owned { assert_eq!(classify_economy(m, p), None, "NON-ECON: {m} {p}"); } } #[test] fn item_defs_shape_matches_oracle() { // Two ids: the one hardcoded card (asset 20801) + a placeholder. assert_eq!(extract_long_ints("resourceId=20801"), vec![20801]); assert_eq!( extract_long_ints("idList=200389,200104&x=12"), vec![200389, 200104] ); assert!(extract_long_ints("foo=ab").is_empty()); let body = non_economy::item_defs_body(&[20801, 200389]); let items = body["itemData"].as_array().unwrap(); assert_eq!(items.len(), 2); // Hardcoded Ronaldo. assert_eq!(items[0]["name"], "Ronaldo"); assert_eq!(items[0]["rating"], 94); assert_eq!(items[0]["assetId"], 20801); assert_eq!(items[0]["resourceId"], 20801); // Placeholder: assetId = resourceId & 0xffffff, rating 75, "Player". assert_eq!(items[1]["name"], "Player"); assert_eq!(items[1]["rating"], 75); assert_eq!(items[1]["assetId"], 200389); assert_eq!(items[1]["attributeList"].as_array().unwrap().len(), 6); assert!(non_economy::item_defs_body(&[])["itemData"] .as_array() .unwrap() .is_empty()); } #[test] fn marketdata_container_types_are_load_bearing() { // /pricelimits MUST be a bare ARRAY (object-where-array froze a live client). assert_eq!( extract_defid_param("defId=200389,200104"), vec![200389, 200104] ); let arr = non_economy::marketdata_pricelimits_body(&[200389, 200104]); assert!(arr.is_array(), "pricelimits must be a bare array"); let arr = arr.as_array().unwrap(); assert_eq!(arr.len(), 2); assert_eq!(arr[0]["defId"], 200389); assert_eq!(arr[0]["minPrice"], 150); assert_eq!(arr[0]["maxPrice"], 15000); // plain /marketdata MUST be an OBJECT (array-where-object is the same freeze). let obj = non_economy::marketdata_object_body(); assert!(obj.is_object(), "plain marketdata must be an object"); assert_eq!(obj["minPrice"], 150); assert_eq!(obj["maxPrice"], 15000); } #[derive(Default)] struct FakeSbcCore { owned: Mutex>, saved: Mutex>>, completions: Mutex>, } impl CoreAccess for FakeSbcCore { fn query_owned(&self, _params: &[(&str, String)]) -> Result { let items = self.owned.lock().unwrap().clone(); Ok(CorePage { total: items.len() as i64, items, }) } fn read_squad_ext(&self, _namespace: &str) -> Result { Err(CoreError::Status(501)) } fn replace_squad( &self, _request: &CoreReplaceRequest, ) -> Result { Err(CoreError::Status(501)) } fn list_sbcs(&self) -> Result, CoreError> { Ok(vec![ CoreSbcDefinition { id: "sbc_bronze_upgrade".into(), name: "Bronze Upgrade".into(), description: "Submit two test players.".into(), repeatable: true, }, CoreSbcDefinition { id: "sbc_hybrid_nations".into(), name: "Hybrid Nations".into(), description: "Submit a hybrid squad.".into(), repeatable: false, }, ]) } fn sbc_completion_counts( &self, ) -> Result, CoreError> { Ok(self.completions.lock().unwrap().clone()) } fn load_sbc_squad(&self, sbc_id: &str) -> Result, CoreError> { Ok(self .saved .lock() .unwrap() .get(sbc_id) .cloned() .unwrap_or_default()) } fn save_sbc_squad( &self, sbc_id: &str, owned_card_ids: &[String], ) -> Result, CoreError> { self.saved .lock() .unwrap() .insert(sbc_id.to_owned(), owned_card_ids.to_vec()); Ok(owned_card_ids.to_vec()) } fn submit_sbc( &self, sbc_id: &str, owned_card_ids: &[String], ) -> Result { if owned_card_ids.len() != 2 { return Ok(CoreSbcResult { passed: false, failures: vec!["need exactly 2 cards".into()], }); } let mut owned = self.owned.lock().unwrap(); if owned_card_ids .iter() .any(|id| !owned.iter().any(|item| item.owned_card_id == *id)) { return Err(CoreError::Status(409)); } owned.retain(|item| !owned_card_ids.contains(&item.owned_card_id)); self.saved.lock().unwrap().remove(sbc_id); *self .completions .lock() .unwrap() .entry(sbc_id.to_owned()) .or_default() += 1; Ok(CoreSbcResult { passed: true, failures: vec![], }) } } fn sbc_test_server() -> ( Server, Arc, Arc, Arc, [i64; 2], ) { let first = owned("sbc-owned-1", "sbc-card-1"); let second = owned("sbc-owned-2", "sbc-card-2"); let core = Arc::new(FakeSbcCore { owned: Mutex::new(vec![first.clone(), second.clone()]), ..FakeSbcCore::default() }); let identity_path = temp_store_path("sbc-identity"); let resolver = Arc::new(resolver( &identity_path, &[("sbc-card-1", 20_801), ("sbc-card-2", 20_802)], )); let wires = [ i64::from(resolver.resolve(&first).unwrap().item_id), i64::from(resolver.resolve(&second).unwrap().item_id), ]; let bridge = Arc::new(AsyncBridge::new().unwrap()); let market_path = temp_store_path("sbc-market"); let pile_path = temp_store_path("sbc-piles"); let market = Arc::new( bridge .block_on(async move { crate::market_store::MarketStore::open( market_path.to_str().expect("UTF-8 temp market path"), ) .await }) .unwrap(), ); let piles = Arc::new( bridge .block_on(async move { crate::pile_store::PileStore::open( pile_path.to_str().expect("UTF-8 temp pile path"), ) .await }) .unwrap(), ); let services = Arc::new(EconomyServices { econ: Arc::new(FakeEconomy::ok(1_100, 1)), market, piles, bridge, pool: Arc::new(vec![]), sold_experiment: crate::sold_experiment::SoldExperiment::OFF, }); let server = Server::new( core.clone(), Arc::new(Fifa17Entities::default()), resolver.clone(), Arc::new(PassClient::new("http://127.0.0.1:9")), 1, ) .with_economy(services.clone()); (server, core, services, resolver, wires) } #[test] fn fifa17_sbc_route_family_is_bounded_and_rust_owned() { let routes = [ ("GET", "/ut/game/fifa17/sbs/sets", EconomyRoute::SbcSets), ("PUT", "/ut/game/fifa17/sbs/sets/tag", EconomyRoute::SbcTag), ( "GET", "/ut/game/fifa17/sbs/setId/1/challenges", EconomyRoute::SbcChallenges, ), ( "GET", "/ut/game/fifa17/sbs/challenge/101/squad", EconomyRoute::SbcChallengeSquad, ), ( "PUT", "/ut/game/fifa17/sbs/challenge/101/squad", EconomyRoute::SbcChallengeSquad, ), ( "POST", "/ut/game/fifa17/sbs/challenge/101", EconomyRoute::SbcChallenge, ), ( "PUT", "/ut/game/fifa17/sbs/challenge/101", EconomyRoute::SbcChallenge, ), ]; for (method, path, expected) in routes { assert_eq!(classify_economy(method, path), Some(expected)); } assert_eq!( classify_economy("GET", "/ut/game/fifa17/sbs/challenge/x/squad"), None ); assert_eq!( classify_economy("DELETE", "/ut/game/fifa17/sbs/challenge/101"), None ); assert_eq!( classify_economy("GET", "/ut/game/fifa17/sbs/sets/extra"), None ); } #[test] fn fifa17_sbc_flow_enforces_host_eligibility_and_consumes_once() { let (server, core, services, resolver, wires) = sbc_test_server(); let sets = server.handle("GET", "/ut/game/fifa17/sbs/sets", &[], b""); assert_eq!(sets.status, 200); let sets_body: Value = serde_json::from_slice(&sets.body).unwrap(); assert!(sets_body["categories"].is_array()); assert_eq!( sets_body["categories"][0]["sets"].as_array().unwrap().len(), 2 ); let save_body = serde_json::to_vec(&json!({ "players": [ { "index": 0, "itemData": { "id": wires[0] } }, { "index": 1, "itemData": { "id": wires[1] } } ] })) .unwrap(); let piles = services.piles.clone(); let bridge = services.bridge.clone(); bridge .block_on(async move { piles.set("sbc-owned-1", "purchased").await }) .unwrap(); let purchased = server.handle( "PUT", "/ut/game/fifa17/sbs/challenge/101/squad", &[], &save_body, ); assert_eq!(purchased.status, 409); assert!(core.saved.lock().unwrap().is_empty()); let piles = services.piles.clone(); services .bridge .block_on(async move { piles.set("sbc-owned-1", "unassigned").await }) .unwrap(); let unassigned = server.handle( "PUT", "/ut/game/fifa17/sbs/challenge/101/squad", &[], &save_body, ); assert_eq!(unassigned.status, 409); assert!(core.saved.lock().unwrap().is_empty()); let piles = services.piles.clone(); services .bridge .block_on(async move { piles.set("sbc-owned-1", "club").await }) .unwrap(); let market = services.market.clone(); services .bridge .block_on(async move { market .create_listing( "900000001", "sbc-card-1", Some("sbc-owned-1"), Some(wires[0]), Some(20_801), 150, 200, Some("owner"), None, Some(3600), ) .await }) .unwrap(); let listed = server.handle( "PUT", "/ut/game/fifa17/sbs/challenge/101/squad", &[], &save_body, ); assert_eq!(listed.status, 409); let market = services.market.clone(); services .bridge .block_on(async move { market.cancel_active_for_core_item("sbc-owned-1").await }) .unwrap(); let saved = server.handle( "PUT", "/ut/game/fifa17/sbs/challenge/101/squad", &[], &save_body, ); assert_eq!(saved.status, 200); let loaded = server.handle("GET", "/ut/game/fifa17/sbs/challenge/101/squad", &[], b""); let loaded_body: Value = serde_json::from_slice(&loaded.body).unwrap(); assert_eq!(loaded_body["squad"][0]["itemData"]["id"], wires[0]); assert_eq!(loaded_body["squad"][1]["itemData"]["id"], wires[1]); let submitted = server.handle("PUT", "/ut/game/fifa17/sbs/challenge/101", &[], br#"{}"#); assert_eq!(submitted.status, 200); let submitted_body: Value = serde_json::from_slice(&submitted.body).unwrap(); assert_eq!(submitted_body["challengeId"], 101); assert_eq!(submitted_body["setId"], 1); assert_eq!(submitted_body["credits"], 1_100); assert_eq!(submitted_body["recoveredPacks"], 1); assert!(submitted_body["grantedChallengeAwards"].is_array()); assert!(core.owned.lock().unwrap().is_empty()); assert_eq!( core.completions .lock() .unwrap() .get("sbc_bronze_upgrade") .copied(), Some(1) ); for core_id in ["sbc-owned-1", "sbc-owned-2"] { let piles = services.piles.clone(); let id = core_id.to_owned(); assert_eq!( services .bridge .block_on(async move { piles.get(&id).await }) .unwrap(), None ); } assert_eq!( resolver.owned_id_for_wire(wires[0]).as_deref(), Some("sbc-owned-1"), "durable mapping remains, but no ownership-backed projection can emit it" ); let reveal = server.handle("GET", "/ut/v2/game/fifa17/purchased/items", &[], b""); let reveal_body: Value = serde_json::from_slice(&reveal.body).unwrap(); assert!(reveal_body["itemData"].as_array().unwrap().is_empty()); let replay = server.handle("PUT", "/ut/game/fifa17/sbs/challenge/101", &[], &save_body); assert_eq!(replay.status, 404); assert_eq!( core.completions .lock() .unwrap() .get("sbc_bronze_upgrade") .copied(), Some(1) ); } }