//! # 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 async_bridge; pub mod config; pub mod economy_store; pub mod market; pub mod market_store; pub mod pile_store; use std::io::{BufRead, BufReader, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::{Arc, Mutex}; use std::time::Instant; 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::economy_policy::{ match_reward_total, result_from_end_reason, MatchResult, }; use openfut_adapter_fifa17::fut::entities::Fifa17Entities; 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::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::SeedableRng; use serde_json::{json, Value}; 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 …/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` — proxied to Python, with only `.squad` overlaid. UserMassInfo, /// `POST /ut/auth` — proxied to Python (persona adoption + SID mint); the /// returned `X-UT-SID` is observed to open a Rust session. Auth, /// `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, /// Anything else — proxied verbatim to the Python oracle. Passthrough, } /// Classify a request ONCE, before execution. Rust owns exactly: /// * `GET …/club` /// * `PUT …/squad/` (numeric id) /// * `GET …/squad/list` /// * `GET …/squad/active` (the active squad, projected from Core) /// * `GET …/userMassInfo` (proxied, `.squad` overlaid) /// /// Everything else — numeric `GET …/squad/`, `/clubUser`, auth, packs, /// market, other mutations — falls through to Python. There is no /// "try Rust then Python", so a squad mutation can never be double-applied. 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; economy stays Python). if post && path.starts_with("/ut/auth") { return Route::Auth; } if post && path == "/openfut/fifa17/capability" { return Route::Capability; } if get && is_exact_club_path(path) { return Route::Club; } match ut_tail(path) { Some("squad/list") if get => Route::SquadList, Some("squad/active") if get => Route::SquadActive, Some("userMassInfo") if get => Route::UserMassInfo, Some(tail) if get && tail.starts_with("store/purchasegroup") => Route::StorePurchaseGroup, Some(tail) if put && is_numeric_squad_tail(tail) => Route::SquadReplace, _ => 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) } } fn is_exact_club_path(path: &str) -> bool { ut_tail(path) == Some("club") } /// `squad/` — the numeric full-squad target used by `PUT`. The active /// squad READ (`GET …/squad/active`) is routed separately (Core-backed); a /// numeric `GET …/squad/` for a non-active squad stays on Python (there is no /// Core model for multiple squads yet). 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, /// `…/trade/` — view / buy-now. MarketBuy, /// `DELETE /ut/delete/game//trade/` — cancel a listing. MarketCancel, } /// `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, } } /// 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); } 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), Some(t) if get && is_tradepile_tail(t) => Some(EconomyRoute::MarketQuery), 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. 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; } /// 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, }) } } // ───────────────────────────── 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, } /// 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; } 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}`"))) } 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") } } /// 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) } } 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, }) } } /// 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) } } /// Reverse a FIFA wire `resourceId` to the authoritative Core `card_id`, via the /// same catalog `/club` shaping uses — so a synthetic market buy mints real Core /// content, never the raw FIFA number. Out-of-range or unmapped → `None` /// (fail closed; Core never sees a FIFA resource id). impl crate::market::MarketCardResolver for Fifa17IdentityResolver { fn card_id_for_resource(&self, resource_id: i64) -> Option { let rid = u32::try_from(resource_id).ok()?; self.catalog.card_id_for_resource(rid).map(str::to_string) } } // ───────────────────────────── /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), } /// 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) } /// 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); // "Special" (rare=SP): rareflag lives in the FIFA catalog, not Core, so Core // cannot filter it. Fetch everything matching the OTHER filters (no // offset/limit), shape (which resolves each item's rareflag), keep only // specials (rareflag > 1), then paginate the filtered set locally. if core_q.special { let mut base = core_q.clone(); base.offset = None; base.limit = None; return match deps.core.query_owned(&base.to_query_pairs()) { Ok(page) => { let (body, stats) = shape_club_response(&page.items, deps.entities, deps.assets); let all = body .get("itemData") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); let (paged, total) = special_filter_page(&all, offset, limit); let emitted = paged.len(); ( json_response(&json!({ "itemData": paged })), ClubLog { outcome: "ok", filter: format!("{filter},rare=SP"), total, emitted, dropped_no_asset: stats.dropped_no_asset, offset, limit, }, ) } Err(e) => { eprintln!("utas-host ERROR /club (special) core query failed: {e}"); ( json_response(&json!({ "itemData": [] })), ClubLog { outcome: "core_error", filter: format!("{filter},rare=SP"), 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>, } /// 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, /// 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>, } 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, sessions: Arc::new(Mutex::new(SessionStore::new())), start: Instant::now(), economy: None, } } /// 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())); let economy = Arc::new(EconomyServices { econ, market, piles, bridge, pool, }); Ok(Server::new( core, entities, resolver, Arc::new(PassClient::new(cfg.python_upstream.clone())), cfg.persona_id, ) .with_economy(economy)) } /// 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 } /// 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). This is the /// handler-wiring entry point exercised by the integration harness; it is /// deliberately NOT called by `handle_with_ip` yet — handler wiring and the /// authority cutover are distinct steps, and the classifier barrier is one /// later coherent flip. Sync handlers run inline; the async transfer-market / /// move handlers run on the shared runtime via the 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 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) = (svc.bridge.clone(), svc.piles.clone(), self.resolver.clone()); let body = body.to_vec(); bridge.block_on(async move { crate::market::handle_move_items(&body, resolver.as_ref(), piles.as_ref()).await }) } EconomyRoute::MarketList => { let (bridge, market, econ, resolver) = ( svc.bridge.clone(), svc.market.clone(), svc.econ.clone(), self.resolver.clone(), ); let (m, body) = (method.to_string(), body.to_vec()); bridge.block_on(async move { crate::market::handle_market_list( &m, &body, econ.as_ref(), market.as_ref(), resolver.as_ref(), ) .await }) } EconomyRoute::MarketQuery => { let (bridge, market, econ) = (svc.bridge.clone(), svc.market.clone(), svc.econ.clone()); bridge.block_on(async move { crate::market::handle_market_query("active", econ.as_ref(), market.as_ref()) .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 }) } }; 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 deps = ClubDeps { core: self.core.as_ref(), entities: self.entities.as_ref(), assets: self.resolver.as_ref(), }; 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::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 => { let deps = self.squad_deps(); let (mut resp, log) = handle_user_mass_info(method, target, headers, body, &deps, self.pass.as_ref()); // Overlay the authoritative Core economy (coins + unopened-pack // count) so NO stale Python economy value is visible post-barrier. // userMassInfo remains a hybrid by design: Python supplies the // non-economy envelope; Rust owns the squad AND the economy fields. let mut econ_overlaid = false; if let Some(svc) = &self.economy { if (200..300).contains(&resp.status) { if let (Ok(coins), Ok(ents)) = (svc.econ.balance(), svc.econ.entitlements()) { if let Ok(mut root) = serde_json::from_slice::(&resp.body) { if overlay_massinfo_economy(&mut root, coins, ents.len()) { if let Ok(nb) = serde_json::to_vec(&root) { set_json_body(&mut resp, nb); econ_overlaid = true; } } } } } } eprintln!( "utas-host owner=RUST_OVERLAY route=userMassInfo status={} squad_outcome={} econ_overlaid={} detail=[{}]", resp.status, log.outcome, econ_overlaid, log.detail ); resp } Route::Auth => self.handle_auth(method, target, headers, body, client_ip), Route::Capability => self.handle_capability(body, client_ip), Route::StorePurchaseGroup => { self.handle_store_purchasegroup(method, target, headers, body, client_ip) } 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` — proxy to Python (which mints the X-UT-SID, adopts the /// persona and refreshes its save), then OBSERVE the returned SID to open a /// Rust session bound to the peer IP + configured persona. Account/economy /// stays Python-authoritative; Rust only tracks the session. Python's response /// is returned byte-for-byte. fn handle_auth( &self, method: &str, target: &str, headers: &[(String, String)], body: &[u8], client_ip: Option<&str>, ) -> WireResponse { let resp = match self.pass.forward(method, target, headers, body) { Ok(r) => r, Err(e) => { eprintln!("utas-host ERROR auth proxy to Python failed: {e}"); return error_response(502, "upstream_unavailable"); } }; let mut outcome = "no_sid"; if (200..300).contains(&resp.status) { if let Some(sid) = observe_sid(&resp.body) { self.sessions.lock().unwrap().open_session( &sid, client_ip.map(|s| s.to_string()), self.persona_id, self.now(), ); outcome = "session_opened"; } } eprintln!( "utas-host owner=RUST_OBSERVE route=auth status={} ip={:?} outcome={}", resp.status, client_ip, outcome ); resp } /// `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 } /// 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, } } /// Observe the minted `sid` from Python's `/ut/auth` JSON response body. fn observe_sid(body: &[u8]) -> Option { serde_json::from_slice::(body) .ok()? .get("sid")? .as_str() .map(str::to_string) } /// 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::*; /// 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) } } #[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_only_on_exact_get() { assert_eq!(classify("GET", "/ut/game/fifa17/club"), Route::Club); assert_eq!(classify("get", "/ut/game/fifa18/club"), Route::Club); // method must be GET assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::Passthrough); // near-misses stay on Python assert_eq!( classify("GET", "/ut/game/fifa17/club/stats/staff"), Route::Passthrough ); assert_eq!( classify("GET", "/ut/game/fifa17/clubUser"), Route::Passthrough ); 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::Passthrough ); } #[test] fn observe_sid_extracts_minted_sid() { let body = br#"{"protocol":1,"sid":"OPENFUT-SID-42C15A6F78DC6E74","serverTime":"x"}"#; assert_eq!( observe_sid(body).as_deref(), Some("OPENFUT-SID-42C15A6F78DC6E74") ); assert_eq!(observe_sid(b"{}"), None); assert_eq!(observe_sid(b"not json"), None); } #[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)), ("GET", "/ut/game/fifa17/tradePile/counts", Some(MarketQuery)), ("GET", "/ut/game/fifa17/tradepile/counts", Some(MarketQuery)), ("POST", "/ut/game/fifa17/trade/900000001", Some(MarketBuy)), ( "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}" ); } } }