From c0a3f68ded0a2cb8dca098a0a2cb4bf72d23c572 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 11 Aug 2026 21:40:15 +0000 Subject: [PATCH] feat(utas): FIFA17 UTAS migration host + /club adapter mappings openfut-utas-host: the first live UTAS host. Serves GET /ut/game//club from OpenFUT Core via the FIFA17 adapter and reverse-proxies every other UTAS route verbatim to the Python oracle. Plaintext HTTP/1.1 keep-alive (no TLS); route classification before execution; a Core error on /club degrades to an empty page and never falls back to Python. CoreAccess is a host-owned boundary (the adapter stays transport-agnostic). openfut-adapter-fifa17::fut: owned_query (wire parse + FIFA id->name mapping, unknown id = hard error), entities (id<->name from committed tables), and club_response (FIFA _item shaping; drops items lacking a real FIFA asset id, never fabricates one). openfut-core submodule advanced to the reconciled trunk (6acae54 = 8c8a4116 multi-game + eab522a replace_squad/SquadRules + the /club semantic query). 11 host tests + adapter fut tests; 10/10 host mutations killed. rare=SP UNKNOWN. Retail rendering of Core inventory still blocked on the Core-card->asset-id identity decision (next phase). --- Cargo.lock | 11 + Cargo.toml | 1 + .../src/fut/club_response.rs | 290 +++++++ openfut-adapter-fifa17/src/fut/entities.rs | 239 ++++++ openfut-adapter-fifa17/src/fut/mod.rs | 9 + openfut-adapter-fifa17/src/fut/owned_query.rs | 492 ++++++++++++ openfut-adapter-fifa17/src/lib.rs | 4 + openfut-core | 2 +- openfut-utas-host/Cargo.toml | 18 + openfut-utas-host/README.md | 89 +++ openfut-utas-host/src/config.rs | 54 ++ openfut-utas-host/src/lib.rs | 751 ++++++++++++++++++ openfut-utas-host/src/main.rs | 32 + openfut-utas-host/tests/host_test.rs | 465 +++++++++++ 14 files changed, 2456 insertions(+), 1 deletion(-) create mode 100644 openfut-adapter-fifa17/src/fut/club_response.rs create mode 100644 openfut-adapter-fifa17/src/fut/entities.rs create mode 100644 openfut-adapter-fifa17/src/fut/mod.rs create mode 100644 openfut-adapter-fifa17/src/fut/owned_query.rs create mode 100644 openfut-utas-host/Cargo.toml create mode 100644 openfut-utas-host/README.md create mode 100644 openfut-utas-host/src/config.rs create mode 100644 openfut-utas-host/src/lib.rs create mode 100644 openfut-utas-host/src/main.rs create mode 100644 openfut-utas-host/tests/host_test.rs diff --git a/Cargo.lock b/Cargo.lock index 25c0365..eac2800 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3252,6 +3252,17 @@ dependencies = [ "openssl", ] +[[package]] +name = "openfut-utas-host" +version = "0.1.0" +dependencies = [ + "openfut-adapter-fifa17", + "openfut-http", + "parking_lot", + "reqwest", + "serde_json", +] + [[package]] name = "openssl" version = "0.10.81" diff --git a/Cargo.toml b/Cargo.toml index 5ace1df..54185ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "openfut-tls", "openfut-redirector-host", "openfut-roster-host", + "openfut-utas-host", "openfut-bridge", "openfut-launcher", "openfut-launcher/openfut-hook", diff --git a/openfut-adapter-fifa17/src/fut/club_response.rs b/openfut-adapter-fifa17/src/fut/club_response.rs new file mode 100644 index 0000000..b2a5e03 --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/club_response.rs @@ -0,0 +1,290 @@ +//! Shape OpenFUT Core's semantic owned inventory into the FIFA 17 `/club` +//! response envelope `{"itemData":[ <player item>, … ]}`. +//! +//! The per-item field set replicates `fifa17-recon/tools/fut_store.py::_item` +//! (the proven-safe player item), reversed onto Core's semantic values. +//! +//! ## The asset-id boundary (load-bearing, evidence-grounded) +//! +//! FIFA renders a card by resolving `resourceId & 0xffffff` against the client's +//! OWN local players table (`fut_cards.py:11-21`, proven live): a real id renders +//! a real footballer, an **invented id renders a blank generic card**. OpenFUT +//! Core's catalogue is synthetic string ids (`card_pl_001`) with no FIFA asset +//! id, and no committed card→asset mapping exists. So an [`ItemIdentityResolver`] +//! is injected; when it cannot supply a **real** FIFA asset id for an item, that +//! item is **dropped and counted** — never emitted with a fabricated id. This +//! keeps the response freeze-safe and honest until the Core-card→asset identity +//! decision is made (the current blocker for retail rendering of Core inventory). +//! +//! Entity ids (`leagueId`/`teamid`/`nation`) come from the reverse resolver; an +//! unresolved name yields a neutral `0` (a valid int — non-fatal; it only means +//! "no badge/flag"), because those are not the identity the renderer keys on. + +use serde_json::{json, Value}; + +use crate::fut::entities::ReverseEntityResolver; + +/// One owned item in game-independent terms, as read from Core's `/collection`. +#[derive(Debug, Clone)] +pub struct CoreOwnedItem { + /// Core owned-instance id (string). + pub owned_card_id: String, + /// Core card-definition id (string), used for asset resolution. + pub card_id: String, + /// Effective overall rating. + pub rating: u8, + /// Effective position, e.g. "ST". + pub position: String, + pub nation: String, + pub league: String, + pub club: String, + /// [pace, shooting, passing, dribbling, defending, physical]. + pub attributes: [u8; 6], +} + +/// The FIFA-side numeric identity of an owned item. `asset_id` MUST be a real +/// FIFA player asset (low 24 bits the client resolves); `item_id` is the wire +/// instance id used for later item operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Fifa17Identity { + pub item_id: u32, + pub asset_id: u32, +} + +/// Supplies the FIFA numeric identity for a Core item. Returning `None` means +/// "no real FIFA asset id known" → the item is dropped (never faked). +pub trait ItemIdentityResolver { + fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity>; +} + +/// Diagnostics from shaping (safe to log — counts only). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct ShapeStats { + pub emitted: usize, + pub dropped_no_asset: usize, +} + +/// Quick-sell / discard value by rating tier (mirrors Core's quick-sell table; +/// non-fatal display field). +fn discard_value(rating: u8) -> i64 { + match rating { + r if r >= 85 => 1500, + r if r >= 80 => 900, + r if r >= 75 => 600, + r if r >= 65 => 300, + _ => 150, + } +} + +/// Build one FIFA `_item` object (version byte 0x00 → `resourceId == assetId`). +fn shape_item(item: &CoreOwnedItem, id: Fifa17Identity, ent: &impl ReverseEntityResolver) -> Value { + let asset = id.asset_id; + let league_id = ent.league_id(&item.league).unwrap_or(0); + let team_id = ent.team_id(&item.club).unwrap_or(0); + let nation_id = ent.nation_id(&item.nation).unwrap_or(0); + let attribute_list: Vec<Value> = item + .attributes + .iter() + .enumerate() + .map(|(i, v)| json!({ "index": i, "value": v })) + .collect(); + json!({ + "id": id.item_id, + "resourceId": asset, + "assetId": asset, + "cardassetid": asset, + "definitionId": asset, + "cardsubtypeid": 0, + "itemType": "player", + "rareflag": 1, + "rating": item.rating, + "preferredPosition": item.position, + "nation": nation_id, + "teamid": team_id, + "leagueId": league_id, + "playStyle": 250, + "attributeList": attribute_list, + "itemState": "free", + "owners": 1, + "untradeable": true, + "contract": 7, + "fitness": 99, + "discardValue": discard_value(item.rating), + }) +} + +/// Shape the whole `/club` response. Items without a resolvable real asset id +/// are dropped (counted in `ShapeStats`), never emitted with a fabricated id. +pub fn shape_club_response<I: ItemIdentityResolver + ?Sized>( + items: &[CoreOwnedItem], + ent: &impl ReverseEntityResolver, + ident: &I, +) -> (Value, ShapeStats) { + let mut out = Vec::with_capacity(items.len()); + let mut stats = ShapeStats::default(); + for item in items { + match ident.resolve(item) { + Some(id) => { + out.push(shape_item(item, id, ent)); + stats.emitted += 1; + } + None => stats.dropped_no_asset += 1, + } + } + (json!({ "itemData": out }), stats) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fut::entities::Fifa17Entities; + use std::collections::HashMap; + + fn entities() -> Fifa17Entities { + Fifa17Entities::from_maps( + HashMap::from([(13, "Premier League".to_string())]), + HashMap::from([(52, "Argentina".to_string())]), + HashMap::from([(5, "Chelsea".to_string())]), + ) + } + + fn item( + owned: &str, + card: &str, + rating: u8, + pos: &str, + nation: &str, + league: &str, + club: &str, + ) -> CoreOwnedItem { + CoreOwnedItem { + owned_card_id: owned.into(), + card_id: card.into(), + rating, + position: pos.into(), + nation: nation.into(), + league: league.into(), + club: club.into(), + attributes: [90, 88, 70, 85, 40, 78], + } + } + + /// Test resolver: card_id -> real asset id, item_id from a table. Stands in + /// for the (unresolved-in-production) Core-card→asset mapping. + struct MapIdentity(HashMap<String, Fifa17Identity>); + impl ItemIdentityResolver for MapIdentity { + fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> { + self.0.get(&it.card_id).copied() + } + } + + #[test] + fn shapes_item_with_full_field_set_and_reverse_ids() { + let ent = entities(); + let ident = MapIdentity(HashMap::from([( + "card_ch_1".to_string(), + Fifa17Identity { + item_id: 100000001, + asset_id: 20801, + }, + )])); + let items = vec![item( + "oc1", + "card_ch_1", + 86, + "CDM", + "Argentina", + "Premier League", + "Chelsea", + )]; + let (body, stats) = shape_club_response(&items, &ent, &ident); + assert_eq!(stats.emitted, 1); + assert_eq!(stats.dropped_no_asset, 0); + let it = &body["itemData"][0]; + assert_eq!(it["id"], 100000001); + assert_eq!(it["resourceId"], 20801); + assert_eq!(it["assetId"], 20801); + assert_eq!( + it["definitionId"], 20801, + "version byte 0 => resourceId==assetId==definitionId" + ); + assert_eq!(it["rating"], 86); + assert_eq!(it["preferredPosition"], "CDM"); + assert_eq!(it["leagueId"], 13); + assert_eq!(it["teamid"], 5); + assert_eq!(it["nation"], 52); + assert_eq!(it["itemType"], "player"); + assert_eq!(it["rareflag"], 1); + assert_eq!(it["contract"], 7); + assert_eq!(it["fitness"], 99); + assert_eq!(it["attributeList"].as_array().unwrap().len(), 6); + assert_eq!(it["attributeList"][0], json!({"index":0,"value":90})); + } + + #[test] + fn drops_items_without_a_real_asset_id_never_faking() { + let ent = entities(); + // Empty identity map == the current synthetic-catalogue reality. + let ident = MapIdentity(HashMap::new()); + let items = vec![item( + "oc1", + "card_pl_001", + 84, + "ST", + "England", + "Premier League", + "Northgate United", + )]; + let (body, stats) = shape_club_response(&items, &ent, &ident); + assert_eq!(stats.emitted, 0); + assert_eq!(stats.dropped_no_asset, 1); + assert_eq!( + body["itemData"].as_array().unwrap().len(), + 0, + "no fabricated ids emitted" + ); + } + + #[test] + fn unresolved_entity_names_become_neutral_zero_not_dropped() { + let ent = entities(); + let ident = MapIdentity(HashMap::from([( + "card_x".to_string(), + Fifa17Identity { + item_id: 100000002, + asset_id: 158023, + }, + )])); + // Synthetic club "Northgate United" has no FIFA team id. + let items = vec![item( + "oc2", + "card_x", + 84, + "ST", + "England", + "Premier League", + "Northgate United", + )]; + let (body, _) = shape_club_response(&items, &ent, &ident); + let it = &body["itemData"][0]; + assert_eq!( + it["teamid"], 0, + "unknown club -> neutral 0, item still emitted" + ); + assert_eq!(it["leagueId"], 13); + assert_eq!(it["nation"], 0, "England not in the test nation map -> 0"); + } + + #[test] + fn envelope_is_itemdata_object() { + let ent = entities(); + let ident = MapIdentity(HashMap::new()); + let (body, _) = shape_club_response(&[], &ent, &ident); + assert!(body.get("itemData").unwrap().is_array()); + assert_eq!( + body.as_object().unwrap().len(), + 1, + "only itemData at top level" + ); + } +} diff --git a/openfut-adapter-fifa17/src/fut/entities.rs b/openfut-adapter-fifa17/src/fut/entities.rs new file mode 100644 index 0000000..94a69c9 --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/entities.rs @@ -0,0 +1,239 @@ +//! FIFA 17 entity id ⇄ name resolution, loaded from the committed game-DB +//! tables (`leagues.json`, `nations.json`, `teams.json`, dumped from +//! `FIFA17.exe`'s resident DB). +//! +//! Two directions, both FIFA-17-specific and therefore adapter-owned: +//! * **forward** id → name — resolves a wire filter (`league=13`) to the +//! semantic name Core filters on ("Premier League"). See [`EntityResolver`]. +//! * **reverse** name → id — shapes a Core item's names back into the numeric +//! ids the FIFA `/club` response carries (`leagueId`/`teamid`/`nation`). +//! +//! Grounding (worker-verified against the tables): forward is 1:1 for all three +//! (unique ids, no gaps). Reverse is clean for leagues (50 distinct names) and +//! nations (221 distinct); **team names collide** (e.g. `Arsenal` ×3, plus the +//! FUT "CHAMPIONS *" placeholder teams), so reverse team lookup is first-id-wins +//! and a collision count is exposed for diagnostics. `teamid == assetid` on +//! every row. + +use std::collections::HashMap; + +use crate::fut::owned_query::EntityResolver; + +/// Reverse (name → FIFA id) resolution, used when shaping a Core item back onto +/// the FIFA wire. Unknown names return `None`; the shaper substitutes a neutral +/// `0` (a valid, non-desyncing int) rather than dropping the item. +pub trait ReverseEntityResolver { + fn league_id(&self, name: &str) -> Option<u32>; + fn nation_id(&self, name: &str) -> Option<u32>; + fn team_id(&self, name: &str) -> Option<u32>; +} + +/// Forward + reverse FIFA 17 entity maps. +#[derive(Debug, Default, Clone)] +pub struct Fifa17Entities { + league_by_id: HashMap<u32, String>, + league_by_name: HashMap<String, u32>, + nation_by_id: HashMap<u32, String>, + nation_by_name: HashMap<String, u32>, + team_by_id: HashMap<u32, String>, + team_by_name: HashMap<String, u32>, + /// name-collisions dropped from the reverse maps (diagnostics only). + pub reverse_collisions: usize, +} + +/// Errors loading the entity tables. +#[derive(Debug)] +pub enum LoadError { + Io(std::io::Error), + Parse { file: String, detail: String }, +} + +impl std::fmt::Display for LoadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LoadError::Io(e) => write!(f, "reading entity tables: {e}"), + LoadError::Parse { file, detail } => write!(f, "parsing {file}: {detail}"), + } + } +} + +impl std::error::Error for LoadError {} + +impl Fifa17Entities { + /// Load from a directory holding `leagues.json`, `nations.json`, + /// `teams.json` in the FIFA17 `{schema, rows:[...]}` dump format. + pub fn from_tables_dir(dir: &std::path::Path) -> Result<Self, LoadError> { + let mut e = Fifa17Entities::default(); + e.load_table( + &dir.join("leagues.json"), + "leagueid", + "leaguename", + Entity::League, + )?; + e.load_table( + &dir.join("nations.json"), + "nationid", + "nationname", + Entity::Nation, + )?; + e.load_table(&dir.join("teams.json"), "teamid", "teamname", Entity::Team)?; + Ok(e) + } + + fn load_table( + &mut self, + path: &std::path::Path, + id_key: &str, + name_key: &str, + which: Entity, + ) -> Result<(), LoadError> { + let raw = std::fs::read_to_string(path).map_err(LoadError::Io)?; + let file = path.display().to_string(); + let doc: serde_json::Value = serde_json::from_str(&raw).map_err(|e| LoadError::Parse { + file: file.clone(), + detail: e.to_string(), + })?; + let rows = doc + .get("rows") + .and_then(|r| r.as_array()) + .ok_or_else(|| LoadError::Parse { + file: file.clone(), + detail: "missing `rows` array".into(), + })?; + for row in rows { + let (Some(id), Some(name)) = ( + row.get(id_key).and_then(|v| v.as_u64()), + row.get(name_key).and_then(|v| v.as_str()), + ) else { + continue; + }; + let id = id as u32; + let (by_id, by_name) = match which { + Entity::League => (&mut self.league_by_id, &mut self.league_by_name), + Entity::Nation => (&mut self.nation_by_id, &mut self.nation_by_name), + Entity::Team => (&mut self.team_by_id, &mut self.team_by_name), + }; + by_id.insert(id, name.to_string()); + // Reverse: first id wins on a name collision; count the rest. + if by_name.contains_key(name) { + self.reverse_collisions += 1; + } else { + by_name.insert(name.to_string(), id); + } + } + Ok(()) + } + + /// Build directly from maps (tests / small deployments). + pub fn from_maps( + leagues: HashMap<u32, String>, + nations: HashMap<u32, String>, + teams: HashMap<u32, String>, + ) -> Self { + let invert = |m: &HashMap<u32, String>| { + let mut out = HashMap::new(); + for (&id, name) in m { + out.entry(name.clone()).or_insert(id); + } + out + }; + Fifa17Entities { + league_by_name: invert(&leagues), + nation_by_name: invert(&nations), + team_by_name: invert(&teams), + league_by_id: leagues, + nation_by_id: nations, + team_by_id: teams, + reverse_collisions: 0, + } + } + + pub fn league_count(&self) -> usize { + self.league_by_id.len() + } + pub fn nation_count(&self) -> usize { + self.nation_by_id.len() + } + pub fn team_count(&self) -> usize { + self.team_by_id.len() + } +} + +#[derive(Clone, Copy)] +enum Entity { + League, + Nation, + Team, +} + +impl EntityResolver for Fifa17Entities { + fn league_name(&self, id: u32) -> Option<String> { + self.league_by_id.get(&id).cloned() + } + fn nation_name(&self, id: u32) -> Option<String> { + self.nation_by_id.get(&id).cloned() + } + fn team_name(&self, id: u32) -> Option<String> { + self.team_by_id.get(&id).cloned() + } +} + +impl ReverseEntityResolver for Fifa17Entities { + fn league_id(&self, name: &str) -> Option<u32> { + self.league_by_name.get(name).copied() + } + fn nation_id(&self, name: &str) -> Option<u32> { + self.nation_by_name.get(name).copied() + } + fn team_id(&self, name: &str) -> Option<u32> { + self.team_by_name.get(name).copied() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Path to the committed game-DB tables, relative to this crate. + fn tables_dir() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../fifa17-recon/data/tables") + } + + #[test] + fn loads_committed_tables_and_resolves_both_directions() { + let dir = tables_dir(); + if !dir.join("leagues.json").exists() { + eprintln!( + "skipping: committed tables not present at {}", + dir.display() + ); + return; + } + let e = Fifa17Entities::from_tables_dir(&dir).expect("load tables"); + assert_eq!(e.league_count(), 50); + assert_eq!(e.nation_count(), 221); + assert_eq!(e.team_count(), 750); + // forward id -> name (grounding pinned in owned_query too) + assert_eq!(e.league_name(13).as_deref(), Some("Premier League")); + assert_eq!(e.nation_name(52).as_deref(), Some("Argentina")); + assert_eq!(e.team_name(5).as_deref(), Some("Chelsea")); + // reverse name -> id (clean for leagues/nations) + assert_eq!(e.league_id("Premier League"), Some(13)); + assert_eq!(e.nation_id("Argentina"), Some(52)); + assert_eq!(e.team_id("Chelsea"), Some(5)); + // unknown -> None (never a raw-id fallback) + assert_eq!(e.league_name(999_999), None); + assert_eq!(e.team_id("Northgate United"), None); + } + + #[test] + fn from_maps_inverts() { + let e = Fifa17Entities::from_maps( + HashMap::from([(13, "Premier League".to_string())]), + HashMap::from([(52, "Argentina".to_string())]), + HashMap::from([(5, "Chelsea".to_string())]), + ); + assert_eq!(e.league_id("Premier League"), Some(13)); + assert_eq!(e.team_name(5).as_deref(), Some("Chelsea")); + } +} diff --git a/openfut-adapter-fifa17/src/fut/mod.rs b/openfut-adapter-fifa17/src/fut/mod.rs new file mode 100644 index 0000000..eac0fb0 --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/mod.rs @@ -0,0 +1,9 @@ +//! FIFA 17 FUT (UTAS/RS4) wire → OpenFUT Core semantic mappings. +//! +//! Unlike [`crate::blaze`] (binary Blaze RPC), this is the JSON/HTTP FUT surface. +//! It currently holds the owned-player ("My Squad") search mapping; more UTAS +//! routes join it as the UTAS→Core migration proceeds. Nothing here opens a +//! socket — a Rust UTAS host wires it to Core later. +pub mod club_response; +pub mod entities; +pub mod owned_query; diff --git a/openfut-adapter-fifa17/src/fut/owned_query.rs b/openfut-adapter-fifa17/src/fut/owned_query.rs new file mode 100644 index 0000000..0955187 --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/owned_query.rs @@ -0,0 +1,492 @@ +//! FIFA 17 "My Squad" owned-player search: parse the RS4 `club` query and map +//! FIFA 17 wire encodings to the **game-independent** semantic values OpenFUT +//! Core understands. +//! +//! ## The boundary this enforces +//! +//! The FIFA 17 client sends its owned-player search as query params on +//! `GET /ut/game/fifa17/club`, e.g. +//! `?year=2017&type=player&count=11&level=gold&position=ST&nation=52&league=13&team=5&sort=desc&start=10`. +//! Two encoding families appear: **string enums** (`level`, `rare`, `position`) +//! and **numeric FIFA entity ids** (`nation`, `league`, `team`). +//! +//! **Numeric FIFA ids must never reach Core.** Core filters on semantic names +//! ("Premier League", "Chelsea", "Argentina"), so this adapter resolves each id +//! to a name via an injected [`EntityResolver`]. An id the resolver cannot map is +//! a hard [`MapError`] — never a silent passthrough of the raw number, which is +//! exactly how a game-specific id would leak into the generic layer. +//! +//! ## Evidence-grounded semantics (see vault Protocol Findings / Endpoint Map) +//! +//! * `level=gold` → semantic quality tier. Grounded in FIFA 17's own convention +//! (`fut_cards.py::tier`, gold ≥ 75). `level=any` (the always-present default) +//! → no quality constraint. +//! * `position` / `nation` / `league` / `team` → applied. The Python oracle +//! applied only `league`+`team`; applying the rest is a deliberate correction +//! of a proven bug, not a guess (each maps to a card attribute Core already +//! stores). FIFA `team` is Core `club`. +//! * `start` / `count` → semantic `offset` / `limit`. The oracle ignored both and +//! re-served page one forever; Core paginates for real. The client's 11-count / +//! 10-step windowing is a UI convention and stays out of Core. +//! * `sort=desc` → **dropped**. No sort key was ever proven (the oracle does not +//! sort); Core imposes its own deterministic order. We do not invent a named +//! FIFA sort mode. +//! * `rare=SP` ("Special") → **UNKNOWN and unsupported.** The oracle never reads +//! it and no committed metadata grounds "SP" to a card set. It is recorded in +//! [`CoreOwnedQuery::unsupported`] and deliberately produces **no** Core filter. +//! +//! ## Decoupling +//! +//! This module does not depend on `openfut-core`. The contract between the two is +//! the set of Core `/collection` query-parameter *names* emitted by +//! [`CoreOwnedQuery::to_query_pairs`]; they mirror Core's `OwnedItemQuery` fields +//! and are pinned by a test so drift is caught. + +use std::collections::HashMap; + +/// The FIFA 17 club-search query exactly as it arrives on the wire. Numeric +/// fields are FIFA entity ids that MUST be resolved before reaching Core. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Fifa17ClubQuery { + /// Quality filter: `any` (default, always present) or `gold`. + pub level: Option<String>, + /// "Special" filter (`SP`). Semantics UNKNOWN — never applied. + pub rare: Option<String>, + /// Playing position, e.g. `ST`. + pub position: Option<String>, + /// FIFA nation id (e.g. 52 = Argentina). + pub nation: Option<u32>, + /// FIFA league id (e.g. 13 = Premier League). + pub league: Option<u32>, + /// FIFA team id (e.g. 5 = Chelsea). Core calls this "club". + pub team: Option<u32>, + /// Client sort token (`desc`). No proven key; dropped. + pub sort: Option<String>, + /// Pagination offset. + pub start: Option<u32>, + /// Pagination page size. + pub count: Option<u32>, +} + +/// Minimal percent/`+` decoding, dependency-free. FIFA sends bare tokens and +/// numeric ids, but names in general may be percent-encoded. +fn percent_decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let hex = |c: u8| (c as char).to_digit(16); + let mut i = 0; + while i < b.len() { + match b[i] { + b'+' => { + out.push(b' '); + i += 1; + } + b'%' if i + 2 < b.len() => match (hex(b[i + 1]), hex(b[i + 2])) { + (Some(hi), Some(lo)) => { + out.push((hi * 16 + lo) as u8); + i += 3; + } + _ => { + out.push(b'%'); + i += 1; + } + }, + c => { + out.push(c); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Parse the raw query string into a [`Fifa17ClubQuery`]. +/// +/// Order-independent by construction (each key sets its own field), so HTTP +/// parameter order can never change the result. Unknown keys (`year`, `type`, …) +/// are ignored. A present-but-unparseable numeric id is treated as absent (the +/// retail client never sends one; absent is the safe, non-amplifying choice). +pub fn parse_club_query(query: &str) -> Fifa17ClubQuery { + let q = query.strip_prefix('?').unwrap_or(query); + let mut out = Fifa17ClubQuery::default(); + for pair in q.split('&').filter(|p| !p.is_empty()) { + let (k, v) = match pair.split_once('=') { + Some((k, v)) => (k, percent_decode(v)), + None => (pair, String::new()), + }; + match k { + "level" => out.level = Some(v), + "rare" => out.rare = Some(v), + "position" => out.position = Some(v), + "nation" => out.nation = v.parse().ok(), + "league" => out.league = v.parse().ok(), + "team" => out.team = v.parse().ok(), + "sort" => out.sort = Some(v), + "start" => out.start = v.parse().ok(), + "count" => out.count = v.parse().ok(), + _ => {} + } + } + out +} + +/// Resolves FIFA 17 numeric entity ids to their semantic names. A real +/// implementation reads the game's `leagues`/`teams`/`nations` tables; tests use +/// [`StaticResolver`]. Returning `None` means "unknown id" and is fatal, by +/// design — the raw id must not flow onward. +pub trait EntityResolver { + fn league_name(&self, id: u32) -> Option<String>; + fn nation_name(&self, id: u32) -> Option<String>; + fn team_name(&self, id: u32) -> Option<String>; +} + +/// A map-backed [`EntityResolver`] for tests and small deployments. +#[derive(Debug, Default, Clone)] +pub struct StaticResolver { + pub leagues: HashMap<u32, String>, + pub nations: HashMap<u32, String>, + pub teams: HashMap<u32, String>, +} + +impl EntityResolver for StaticResolver { + fn league_name(&self, id: u32) -> Option<String> { + self.leagues.get(&id).cloned() + } + fn nation_name(&self, id: u32) -> Option<String> { + self.nations.get(&id).cloned() + } + fn team_name(&self, id: u32) -> Option<String> { + self.teams.get(&id).cloned() + } +} + +/// A FIFA id that no resolver could map. Fatal on purpose: never fall back to +/// the raw id. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MapError { + UnknownLeague(u32), + UnknownNation(u32), + UnknownTeam(u32), +} + +impl std::fmt::Display for MapError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MapError::UnknownLeague(id) => write!(f, "unknown FIFA league id {id}"), + MapError::UnknownNation(id) => write!(f, "unknown FIFA nation id {id}"), + MapError::UnknownTeam(id) => write!(f, "unknown FIFA team id {id}"), + } + } +} + +impl std::error::Error for MapError {} + +/// The semantic query handed to OpenFUT Core. Contains only game-independent +/// values: a quality tier string, entity **names**, and semantic offset/limit. +/// No FIFA ids. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct CoreOwnedQuery { + pub quality: Option<String>, + pub position: Option<String>, + pub nation: Option<String>, + pub league: Option<String>, + pub club: Option<String>, + pub offset: Option<i64>, + pub limit: Option<i64>, + /// Wire filters that were parsed but deliberately NOT applied because their + /// semantics are unproven (currently: `rare`/Special). Recorded, never guessed. + pub unsupported: Vec<&'static str>, +} + +impl CoreOwnedQuery { + /// Core `/collection` query parameters. Param **names mirror** + /// `openfut_core::services::inventory::OwnedItemQuery` and are the wire + /// contract between this adapter and Core (pinned by test). `unsupported` + /// filters are intentionally absent. + pub fn to_query_pairs(&self) -> Vec<(&'static str, String)> { + let mut p = Vec::new(); + if let Some(q) = &self.quality { + p.push(("quality", q.clone())); + } + if let Some(x) = &self.position { + p.push(("position", x.clone())); + } + if let Some(x) = &self.nation { + p.push(("nation", x.clone())); + } + if let Some(x) = &self.league { + p.push(("league", x.clone())); + } + if let Some(x) = &self.club { + p.push(("club", x.clone())); + } + if let Some(x) = self.offset { + p.push(("offset", x.to_string())); + } + if let Some(x) = self.limit { + p.push(("limit", x.to_string())); + } + p + } +} + +/// Map a parsed FIFA 17 query to the semantic Core query, resolving every +/// numeric id to a name. Any unknown id is a hard error — the raw id never flows +/// through. +pub fn map_to_core( + q: &Fifa17ClubQuery, + resolver: &impl EntityResolver, +) -> Result<CoreOwnedQuery, MapError> { + // level: only the proven quality tiers map; "any"/absent → no constraint. + let quality = match q.level.as_deref() { + Some("gold") => Some("gold".to_string()), + Some("silver") => Some("silver".to_string()), + Some("bronze") => Some("bronze".to_string()), + _ => None, + }; + + // rare=SP: semantics UNKNOWN. Recorded, never turned into a filter. + let mut unsupported = Vec::new(); + if q.rare.is_some() { + unsupported.push("rare"); + } + + let position = q.position.as_ref().map(|p| p.to_uppercase()); + + let nation = match q.nation { + Some(id) => Some( + resolver + .nation_name(id) + .ok_or(MapError::UnknownNation(id))?, + ), + None => None, + }; + let league = match q.league { + Some(id) => Some( + resolver + .league_name(id) + .ok_or(MapError::UnknownLeague(id))?, + ), + None => None, + }; + // FIFA "team" is Core "club". + let club = match q.team { + Some(id) => Some(resolver.team_name(id).ok_or(MapError::UnknownTeam(id))?), + None => None, + }; + + Ok(CoreOwnedQuery { + quality, + position, + nation, + league, + club, + offset: q.start.map(|s| s as i64), + limit: q.count.map(|c| c as i64), + unsupported, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn resolver() -> StaticResolver { + // Confirmed against fifa17-recon/data/tables/{leagues,teams,nations}.json. + StaticResolver { + leagues: HashMap::from([(13, "Premier League".to_string())]), + nations: HashMap::from([(52, "Argentina".to_string())]), + teams: HashMap::from([(5, "Chelsea".to_string())]), + } + } + + #[test] + fn parse_full_query() { + let q = parse_club_query( + "year=2017&type=player&count=11&level=gold&position=ST&nation=52&league=13&team=5&sort=desc&start=10", + ); + assert_eq!( + q, + Fifa17ClubQuery { + level: Some("gold".into()), + rare: None, + position: Some("ST".into()), + nation: Some(52), + league: Some(13), + team: Some(5), + sort: Some("desc".into()), + start: Some(10), + count: Some(11), + } + ); + } + + #[test] + fn parse_is_parameter_order_independent() { + let a = parse_club_query("level=gold&league=13&position=ST&start=10&count=11"); + let b = parse_club_query("count=11&start=10&position=ST&league=13&level=gold"); + assert_eq!(a, b); + } + + #[test] + fn parse_ignores_unknown_keys_and_omitted_optionals() { + let q = parse_club_query("year=2017&type=player&level=any&sort=desc"); + assert_eq!(q.level.as_deref(), Some("any")); + assert!(q.rare.is_none() && q.position.is_none() && q.nation.is_none()); + assert!(q.league.is_none() && q.team.is_none() && q.start.is_none()); + } + + #[test] + fn parse_percent_encoded_value() { + let q = parse_club_query("position=ST&rare=SP"); + assert_eq!(q.position.as_deref(), Some("ST")); + assert_eq!(q.rare.as_deref(), Some("SP")); + } + + #[test] + fn parse_malformed_numeric_is_absent() { + let q = parse_club_query("league=notanumber"); + assert!( + q.league.is_none(), + "malformed id treated as absent, not applied" + ); + } + + #[test] + fn map_level_gold_to_quality() { + let core = map_to_core(&parse_club_query("level=gold"), &resolver()).unwrap(); + assert_eq!(core.quality.as_deref(), Some("gold")); + } + + #[test] + fn map_level_any_has_no_quality_filter() { + let core = map_to_core(&parse_club_query("level=any"), &resolver()).unwrap(); + assert_eq!(core.quality, None, "'any' must NOT become a quality filter"); + } + + #[test] + fn map_rare_sp_is_unsupported_not_a_filter() { + let core = map_to_core(&parse_club_query("level=any&rare=SP"), &resolver()).unwrap(); + assert!( + core.unsupported.contains(&"rare"), + "rare must be recorded unsupported" + ); + // never guessed into a Core predicate + assert_eq!(core.quality, None); + let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect(); + assert!(!keys.contains(&"rare") && !keys.contains(&"special")); + } + + #[test] + fn map_resolves_ids_to_semantic_names() { + let core = + map_to_core(&parse_club_query("nation=52&league=13&team=5"), &resolver()).unwrap(); + assert_eq!(core.nation.as_deref(), Some("Argentina")); + assert_eq!(core.league.as_deref(), Some("Premier League")); + assert_eq!( + core.club.as_deref(), + Some("Chelsea"), + "FIFA team -> Core club" + ); + } + + #[test] + fn map_unknown_id_is_a_hard_error_not_passthrough() { + assert_eq!( + map_to_core(&parse_club_query("league=9999"), &resolver()), + Err(MapError::UnknownLeague(9999)) + ); + assert_eq!( + map_to_core(&parse_club_query("nation=9999"), &resolver()), + Err(MapError::UnknownNation(9999)) + ); + assert_eq!( + map_to_core(&parse_club_query("team=9999"), &resolver()), + Err(MapError::UnknownTeam(9999)) + ); + } + + #[test] + fn no_raw_fifa_id_ever_reaches_core() { + // Every resolvable id becomes a name; a numeric string must never appear + // as a nation/league/club value in the Core-bound pairs. + let core = + map_to_core(&parse_club_query("nation=52&league=13&team=5"), &resolver()).unwrap(); + for (k, v) in core.to_query_pairs() { + if matches!(k, "nation" | "league" | "club") { + assert!( + v.parse::<u32>().is_err(), + "{k}={v} looks like a raw FIFA id leaking into Core" + ); + } + } + } + + #[test] + fn map_start_count_to_offset_limit() { + let core = map_to_core(&parse_club_query("start=20&count=11"), &resolver()).unwrap(); + assert_eq!(core.offset, Some(20)); + assert_eq!(core.limit, Some(11)); + } + + #[test] + fn map_position_uppercased() { + let core = map_to_core(&parse_club_query("position=st"), &resolver()).unwrap(); + assert_eq!(core.position.as_deref(), Some("ST")); + } + + #[test] + fn sort_is_dropped_no_core_param() { + let core = map_to_core(&parse_club_query("sort=desc"), &resolver()).unwrap(); + let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect(); + assert!( + !keys.contains(&"sort"), + "no proven FIFA sort key; must not emit one" + ); + } + + #[test] + fn core_query_param_names_mirror_core_contract() { + // Pins the wire contract with openfut-core's OwnedItemQuery field names. + let core = CoreOwnedQuery { + quality: Some("gold".into()), + position: Some("ST".into()), + nation: Some("Argentina".into()), + league: Some("Premier League".into()), + club: Some("Chelsea".into()), + offset: Some(10), + limit: Some(11), + unsupported: vec![], + }; + let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect(); + assert_eq!( + keys, + ["quality", "position", "nation", "league", "club", "offset", "limit"] + ); + } + + #[test] + fn end_to_end_capture_shaped_query() { + // Mirrors the retail PAGINATION capture: PL + Chelsea, page 2. + let core = map_to_core( + &parse_club_query( + "year=2017&type=player&count=11&level=gold&nation=52&league=13&team=5&sort=desc&start=10", + ), + &resolver(), + ) + .unwrap(); + assert_eq!( + core, + CoreOwnedQuery { + quality: Some("gold".into()), + position: None, + nation: Some("Argentina".into()), + league: Some("Premier League".into()), + club: Some("Chelsea".into()), + offset: Some(10), + limit: Some(11), + unsupported: vec![], + } + ); + } +} diff --git a/openfut-adapter-fifa17/src/lib.rs b/openfut-adapter-fifa17/src/lib.rs index 1451826..ba6e444 100644 --- a/openfut-adapter-fifa17/src/lib.rs +++ b/openfut-adapter-fifa17/src/lib.rs @@ -25,6 +25,9 @@ //! Implemented; runtime validated against the retail client. //! * [`roster`] — the FUT roster-update response, the last gate before the hub. //! Implemented; not yet runtime validated. +//! * [`fut`] — FUT/RS4 (UTAS) wire → Core semantic mappings; currently the +//! owned-player ("My Squad") search: query parse + FIFA-id→name resolution + +//! semantic filter/pagination. Pure mapping; no Rust UTAS host yet. //! //! Still served only by the Python backend: LSX/Origin (`:4216`), UTAS/RS4 //! (`:8099`) and POW/EASFC (`:8094`). Roster XML (`:8081`) has an adapter here @@ -68,6 +71,7 @@ //! ``` pub mod blaze; +pub mod fut; pub mod redirector; pub mod roster; pub mod tls; diff --git a/openfut-core b/openfut-core index 8c8a411..6acae54 160000 --- a/openfut-core +++ b/openfut-core @@ -1 +1 @@ -Subproject commit 8c8a4116bfeff0e099cd1911cb52f39bbd12ce9f +Subproject commit 6acae54f80d61526fda2c6efaf4a9c7243659b93 diff --git a/openfut-utas-host/Cargo.toml b/openfut-utas-host/Cargo.toml new file mode 100644 index 0000000..e972627 --- /dev/null +++ b/openfut-utas-host/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "openfut-utas-host" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "FIFA 17 UTAS migration host: serves implemented routes (/club) from OpenFUT Core, transparently proxies everything else to the Python UTAS oracle" +publish = false + +[dependencies] +openfut-adapter-fifa17 = { path = "../openfut-adapter-fifa17" } +openfut-http = { path = "../openfut-http" } +serde_json = "1" +# Plain-HTTP client for Core queries and Python passthrough. UTAS is plaintext +# HTTP (worker D: no wrap_socket, no cert), so no TLS backend is linked. +reqwest = { version = "0.11", default-features = false, features = ["blocking", "json"] } + +[dev-dependencies] +parking_lot = "0.12" diff --git a/openfut-utas-host/README.md b/openfut-utas-host/README.md new file mode 100644 index 0000000..96ae760 --- /dev/null +++ b/openfut-utas-host/README.md @@ -0,0 +1,89 @@ +# openfut-utas-host + +The first live FIFA 17 **UTAS migration host**. It fronts the client-visible UTAS +port and migrates one route at a time to OpenFUT Core, proxying everything else to +the Python UTAS oracle so the rest of FUT keeps working unchanged. + +``` +FIFA 17 ──HTTP──▶ openfut-utas-host + ├── GET …/club ──▶ FIFA17 adapter ──▶ OpenFUT Core (/collection) + └── everything else ──▶ Python UTAS oracle (verbatim reverse proxy) +``` + +## What it owns / does not own + +Owns: socket + HTTP/1.1 keep-alive transport, route classification, the Core +access client, the Python passthrough, and diagnostics. It owns **no** game +domain state — filtering/pagination is Core's; wire parsing/shaping is the +adapter's. The adapter never learns how Core is reached (the architecture rule): +the host holds the [`CoreAccess`] boundary (`GET {core_url}/collection?…` today). + +## Safety model + +- **Classification happens once, before execution.** Exact `GET /ut/game/<title>/club` + → Rust; everything else → Python. No shared path, no "try Rust then Python". +- A Core failure on `/club` degrades to a valid empty `{"itemData":[]}` and logs + an error — it never falls back to Python (which could double-apply a mutation on + other routes). `/club` is read-only, but the rule is absolute. +- Mutating routes (PUT/POST, `/squad`, `/purchased`, quick-sell, market, auth, SBC, + `/club/stats/*`, `/clubUser`) all classify to passthrough and are untouched. + +## Configuration (env) + +| Var | Required | Default | Meaning | +|---|---|---|---| +| `OPENFUT_UTAS_HOST_ADDR` | yes | — | where this host listens (client-visible UTAS addr) | +| `OPENFUT_UTAS_PYTHON_URL` | yes | — | Python UTAS oracle base URL for fallback (must differ from this host) | +| `OPENFUT_CORE_URL` | no | `http://127.0.0.1:8080` | OpenFUT Core base | +| `OPENFUT_FIFA17_TABLES_DIR` | no | `fifa17-recon/data/tables` | `leagues/nations/teams.json` for id⇄name | +| `OPENFUT_FIFA17_ASSET_MAP` | no | — | JSON `{ "<core_card_id>": <fifa_asset_id> }` (see the blocker) | + +## KNOWN BLOCKER — retail rendering of Core inventory + +FIFA renders an owned card by resolving `resourceId & 0xffffff` against the +client's **own local players table**; an invented id renders a **blank generic +card** (proven live — `fut_cards.py:11-21`). OpenFUT Core's catalogue is synthetic +string-id cards (`card_pl_001`) with **no FIFA asset id**, and no committed +card→asset mapping exists. So: + +- Without `OPENFUT_FIFA17_ASSET_MAP`, `/club` returns `{"itemData":[]}` — honest, + never faked. The shaper drops any item lacking a **real** asset id. +- Making Core inventory actually render in retail requires a Core-card→FIFA-asset + identity decision (seed Core from FIFA assets, or a real mapping table). This is + the "who owns FUT state" question and is the **prerequisite** for a rendering + retail `/club`. Filtering, pagination, entity mapping, transport and fallback are + all done and tested independently of it. + +`rare=SP` ("Special") stays UNSUPPORTED (semantics unproven; parsed, reported, never +guessed). + +## Retail A/B runbook (first `/club` gate) + +Change **only** the UTAS routing layer; keep the validated Rust Redirector/Roster +and the current Blaze path. Python remains the rollback oracle — do not modify it. + +Preconditions (mirror the proven blaze/roster switch discipline): +1. `cargo test -p openfut-utas-host -p openfut-adapter-fifa17` green; `clippy -D warnings` clean; `fmt --check` clean. +2. Built binary identity == HEAD (`scripts/verify-build-identity.sh`); no dirty tree. +3. Python UTAS directly reachable; the Rust host directly probeable; no stale NAT/switch rules; FIFA fully closed. + +Bring-up: +1. Move Python UTAS to an alternate port (`FUT_PORT=8199` in the container/`openfut-fut.sh`); it keeps serving there. +2. Start this host on the client-visible UTAS addr: + `OPENFUT_UTAS_HOST_ADDR=<lan>:8099 OPENFUT_UTAS_PYTHON_URL=http://127.0.0.1:8199 OPENFUT_CORE_URL=http://127.0.0.1:8080 OPENFUT_FIFA17_ASSET_MAP=<map.json> openfut-utas-host` +3. Launch FIFA → FUT → **My Squad** player picker and exercise: no-filter, position, nation, league, league+team, Gold+position, then scroll beyond page one. + +Evidence to capture (all six): +- **Switch**: client traffic hits the Rust host. +- **Rust positive**: host log `owner=RUST route=club …` for the client IP. +- **Python negative for /club**: Python logs no `/club` request in the window. +- **Python positive for other UTAS**: unimplemented routes still reach Python. +- **Core positive**: Core logs the `/collection` query and returns the expected set. +- **Application + pagination**: the UI shows filtered results; later pages differ + from page one (no repeated-first-page amplification). + +Rollback: point the UTAS addr back at Python directly; confirm FUT still usable; +then re-enable the host and confirm `/club` again (proves reversibility). + +Logs are safe by construction: no auth/session/device/token material — only owner, +route, filter summary, counts, status. diff --git a/openfut-utas-host/src/config.rs b/openfut-utas-host/src/config.rs new file mode 100644 index 0000000..880e505 --- /dev/null +++ b/openfut-utas-host/src/config.rs @@ -0,0 +1,54 @@ +//! Environment → [`HostConfig`]. Client-visible bind and the Python upstream are +//! REQUIRED with no default (host-family discipline: a defaulted port could +//! collide with the live oracle). `core_url` defaults to Bridge's convention. + +use std::env; + +#[derive(Debug, Clone)] +pub struct HostConfig { + /// Where this host listens (the address FIFA reaches for UTAS). Required. + pub listen_addr: String, + /// Base URL of the Python UTAS oracle for fallback, e.g. + /// `http://127.0.0.1:8199`. Required — must NOT be this host's own address. + pub python_upstream: String, + /// OpenFUT Core base URL. Default `http://127.0.0.1:8080` (Bridge convention). + pub core_url: String, + /// Directory holding `leagues.json`/`nations.json`/`teams.json`. + pub tables_dir: String, + /// Optional JSON file mapping Core card id → FIFA asset id. Absent = the + /// current reality (no mapping) → Core items cannot render and are dropped. + pub asset_map_path: Option<String>, +} + +#[derive(Debug)] +pub struct ConfigError(pub String); + +impl std::fmt::Display for ConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} +impl std::error::Error for ConfigError {} + +fn required(key: &str) -> Result<String, ConfigError> { + match env::var(key) { + Ok(v) if !v.is_empty() => Ok(v), + _ => Err(ConfigError(format!("{key} is required (no default)"))), + } +} + +impl HostConfig { + pub fn from_env() -> Result<Self, ConfigError> { + Ok(HostConfig { + listen_addr: required("OPENFUT_UTAS_HOST_ADDR")?, + python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?, + core_url: env::var("OPENFUT_CORE_URL") + .unwrap_or_else(|_| "http://127.0.0.1:8080".into()), + tables_dir: env::var("OPENFUT_FIFA17_TABLES_DIR") + .unwrap_or_else(|_| "fifa17-recon/data/tables".into()), + asset_map_path: env::var("OPENFUT_FIFA17_ASSET_MAP") + .ok() + .filter(|s| !s.is_empty()), + }) + } +} diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs new file mode 100644 index 0000000..b650099 --- /dev/null +++ b/openfut-utas-host/src/lib.rs @@ -0,0 +1,751 @@ +//! # 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 config; + +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; + +use openfut_adapter_fifa17::fut::club_response::{ + shape_club_response, CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats, +}; +use openfut_adapter_fifa17::fut::entities::Fifa17Entities; +use openfut_adapter_fifa17::fut::owned_query::{map_to_core, parse_club_query, MapError}; +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 { + /// The owned-player search, served from Core. + Club, + /// Anything else — proxied verbatim to the Python oracle. + Passthrough, +} + +/// Classify a request. ONLY `GET /ut/game/<title>/club` (exact) is owned by Rust. +/// `/club/stats/*`, `/clubUser`, mutations, auth, packs, market, squad, etc. all +/// fall through to Python. +pub fn classify(method: &str, path: &str) -> Route { + if method.eq_ignore_ascii_case("GET") && is_exact_club_path(path) { + Route::Club + } else { + Route::Passthrough + } +} + +fn is_exact_club_path(path: &str) -> bool { + // /ut/game/<seg>/club with nothing after and a non-empty title segment. + match path.strip_prefix("/ut/game/") { + Some(rest) => match rest.split_once('/') { + Some((title, tail)) => !title.is_empty() && tail == "club", + None => false, + }, + None => false, + } +} + +// ───────────────────────────── 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<CoreOwnedItem>, + pub total: i64, +} + +/// 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<CorePage, CoreError>; +} + +/// Default HTTP implementation: `GET {core_url}/collection?…` (plain HTTP JSON, +/// the same boundary Bridge uses to reach Core). +pub struct HttpCoreClient { + base_url: String, + client: reqwest::blocking::Client, +} + +impl HttpCoreClient { + pub fn new(base_url: impl Into<String>) -> Self { + HttpCoreClient { + base_url: base_url.into().trim_end_matches('/').to_string(), + client: reqwest::blocking::Client::new(), + } + } +} + +impl CoreAccess for HttpCoreClient { + fn query_owned(&self, params: &[(&str, String)]) -> Result<CorePage, CoreError> { + let url = format!("{}/collection", self.base_url); + let resp = self + .client + .get(&url) + .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) + } +} + +/// Parse Core's `/collection` response `{ "collection": [...], "total": n }` into +/// semantic owned items. +pub fn parse_core_page(v: &Value) -> Result<CorePage, CoreError> { + 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<CoreOwnedItem> { + 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"), + ], + }) +} + +// ───────────────────────────── Asset resolvers ────────────────────────────── + +/// The current production reality: no Core-card→FIFA-asset mapping exists, so +/// every item is dropped (rendered response is `{"itemData":[]}`). Honest, not +/// faked. +pub struct EmptyAssetResolver; + +impl ItemIdentityResolver for EmptyAssetResolver { + fn resolve(&self, _item: &CoreOwnedItem) -> Option<Fifa17Identity> { + None + } +} + +/// Map-backed resolver (from a config file or tests): Core card id → FIFA asset +/// id. The wire item id is derived stably from the owned-card id (adequate for a +/// read-only search; item-operation identity is a later slice). +pub struct MapAssetResolver { + map: HashMap<String, u32>, +} + +impl MapAssetResolver { + pub fn from_map(map: HashMap<String, u32>) -> Self { + MapAssetResolver { map } + } + + /// Load `{ "card_id": assetId, … }` from a JSON file. + pub fn from_json_file(path: &str) -> std::io::Result<Self> { + let raw = std::fs::read_to_string(path)?; + let v: Value = serde_json::from_str(&raw) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + let mut map = HashMap::new(); + if let Some(obj) = v.as_object() { + for (k, val) in obj { + if let Some(id) = val.as_u64() { + map.insert(k.clone(), id as u32); + } + } + } + Ok(MapAssetResolver { map }) + } +} + +/// Stable wire item id in the 100_000_000+ space (FNV-1a of the owned id). +fn stable_item_id(owned_card_id: &str) -> u32 { + let mut h: u32 = 2_166_136_261; + for b in owned_card_id.bytes() { + h ^= b as u32; + h = h.wrapping_mul(16_777_619); + } + 100_000_000 + (h % 900_000_000) +} + +impl ItemIdentityResolver for MapAssetResolver { + fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity> { + let asset = *self.map.get(&item.card_id)?; + Some(Fifa17Identity { + item_id: stable_item_id(&item.owned_card_id), + asset_id: asset, + }) + } +} + +// ───────────────────────────── /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<i64>, + pub limit: Option<i64>, +} + +/// 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), +} + +/// 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); + 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::<Vec<_>>() + .join(",") +} + +// ───────────────────────────── 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<u8>, +} + +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<String>) -> 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<WireResponse, CoreError> { + 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 migration host. Cheap to clone (all shared state is `Arc`). +#[derive(Clone)] +pub struct Server { + core: Arc<dyn CoreAccess>, + entities: Arc<Fifa17Entities>, + assets: Arc<dyn ItemIdentityResolver + Send + Sync>, + pass: Arc<PassClient>, +} + +impl Server { + /// Assemble from injected parts (used by `from_config` and tests). + pub fn new( + core: Arc<dyn CoreAccess>, + entities: Arc<Fifa17Entities>, + assets: Arc<dyn ItemIdentityResolver + Send + Sync>, + pass: Arc<PassClient>, + ) -> Self { + Server { + core, + entities, + assets, + pass, + } + } + + /// Build from config: load entity tables, pick the asset resolver, wire the + /// Core client and Python passthrough. + pub fn from_config(cfg: &HostConfig) -> Result<Self, String> { + 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 assets: Arc<dyn ItemIdentityResolver + Send + Sync> = match &cfg.asset_map_path { + Some(p) => Arc::new( + MapAssetResolver::from_json_file(p) + .map_err(|e| format!("loading asset map {p}: {e}"))?, + ), + None => Arc::new(EmptyAssetResolver), + }; + Ok(Server { + core: Arc::new(HttpCoreClient::new(cfg.core_url.clone())), + entities: Arc::new(entities), + assets, + pass: Arc::new(PassClient::new(cfg.python_upstream.clone())), + }) + } + + /// Route one request to a response. Classification happens here, once, + /// before either branch runs. + pub fn handle( + &self, + method: &str, + target: &str, + headers: &[(String, String)], + body: &[u8], + ) -> WireResponse { + let path = target.split('?').next().unwrap_or(target); + 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.assets.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::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 + } + } + } + + /// 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; + loop { + match read_request(&mut reader) { + Ok(Some(req)) => { + let resp = self.handle(&req.method, &req.target, &req.headers, &req.body); + if write_response(&mut writer, &resp).is_err() { + return; + } + if req.close { + return; + } + } + Ok(None) => return, // clean EOF + Err(_) => return, + } + } + } +} + +// ───────────────────────────── 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<u8>, + 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<R: BufRead>(reader: &mut R) -> std::io::Result<Option<ParsedRequest>> { + 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: Write>(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::*; + + #[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]); + } + + #[test] + fn stable_item_id_is_deterministic_and_in_range() { + let a = stable_item_id("oc1"); + let b = stable_item_id("oc1"); + assert_eq!(a, b); + assert!((100_000_000..1_000_000_000).contains(&a)); + assert_ne!(stable_item_id("oc1"), stable_item_id("oc2")); + } +} diff --git a/openfut-utas-host/src/main.rs b/openfut-utas-host/src/main.rs new file mode 100644 index 0000000..9e6bdbe --- /dev/null +++ b/openfut-utas-host/src/main.rs @@ -0,0 +1,32 @@ +//! FIFA 17 UTAS migration host entrypoint. +//! +//! Serves `GET …/club` from OpenFUT Core and proxies every other UTAS route to +//! the Python oracle. Config is env-only (see [`openfut_utas_host::config`]); +//! bind and Python upstream are required with no default. + +use openfut_utas_host::{config::HostConfig, Server}; + +fn main() { + let cfg = match HostConfig::from_env() { + Ok(c) => c, + Err(e) => { + eprintln!("utas-host config error: {e}"); + std::process::exit(2); + } + }; + eprintln!( + "utas-host starting: listen={} python_upstream={} core_url={} tables_dir={} asset_map={:?}", + cfg.listen_addr, cfg.python_upstream, cfg.core_url, cfg.tables_dir, cfg.asset_map_path + ); + let server = match Server::from_config(&cfg) { + Ok(s) => s, + Err(e) => { + eprintln!("utas-host startup error: {e}"); + std::process::exit(1); + } + }; + if let Err(e) = server.serve(&cfg.listen_addr) { + eprintln!("utas-host serve error: {e}"); + std::process::exit(1); + } +} diff --git a/openfut-utas-host/tests/host_test.rs b/openfut-utas-host/tests/host_test.rs new file mode 100644 index 0000000..15cc4c1 --- /dev/null +++ b/openfut-utas-host/tests/host_test.rs @@ -0,0 +1,465 @@ +//! Integration tests for the FIFA 17 UTAS migration host: `/club` served from a +//! fake Core through the real adapter, faithful Python passthrough against a mock +//! upstream, route-classification safety, negatives, and an end-to-end socket run. + +use std::collections::HashMap; +use std::io::{BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use openfut_adapter_fifa17::fut::club_response::CoreOwnedItem; +use openfut_adapter_fifa17::fut::entities::Fifa17Entities; +use openfut_utas_host::{ + classify, read_request, CoreAccess, CoreError, CorePage, EmptyAssetResolver, MapAssetResolver, + PassClient, Route, Server, +}; +use parking_lot::Mutex; +use serde_json::Value; + +// ── Fakes ──────────────────────────────────────────────────────────────────── + +/// (method, target, body) recorded by the mock upstream. +type Recorded = Arc<Mutex<Vec<(String, String, Vec<u8>)>>>; + +struct FakeCore { + items: Vec<CoreOwnedItem>, + total: i64, + calls: AtomicUsize, + last_params: Mutex<Vec<(String, String)>>, + panic_if_called: bool, + return_err: bool, +} + +impl FakeCore { + fn new(items: Vec<CoreOwnedItem>, total: i64) -> Self { + FakeCore { + items, + total, + calls: AtomicUsize::new(0), + last_params: Mutex::new(vec![]), + panic_if_called: false, + return_err: false, + } + } + fn forbidden() -> Self { + FakeCore { + items: vec![], + total: 0, + calls: AtomicUsize::new(0), + last_params: Mutex::new(vec![]), + panic_if_called: true, + return_err: false, + } + } + fn erroring() -> Self { + FakeCore { + items: vec![], + total: 0, + calls: AtomicUsize::new(0), + last_params: Mutex::new(vec![]), + panic_if_called: false, + return_err: true, + } + } + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + fn last(&self) -> Vec<(String, String)> { + self.last_params.lock().clone() + } +} + +impl CoreAccess for FakeCore { + fn query_owned(&self, params: &[(&str, String)]) -> Result<CorePage, CoreError> { + assert!( + !self.panic_if_called, + "Core must NOT be called on this path" + ); + self.calls.fetch_add(1, Ordering::SeqCst); + if self.return_err { + return Err(CoreError::Status(500)); + } + *self.last_params.lock() = params + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(); + Ok(CorePage { + items: self.items.clone(), + total: self.total, + }) + } +} + +fn entities() -> Fifa17Entities { + Fifa17Entities::from_maps( + HashMap::from([(13, "Premier League".to_string())]), + HashMap::from([(52, "Argentina".to_string())]), + HashMap::from([(5, "Chelsea".to_string())]), + ) +} + +fn item( + owned: &str, + card: &str, + rating: u8, + pos: &str, + nation: &str, + league: &str, + club: &str, +) -> CoreOwnedItem { + CoreOwnedItem { + owned_card_id: owned.into(), + card_id: card.into(), + rating, + position: pos.into(), + nation: nation.into(), + league: league.into(), + club: club.into(), + attributes: [90, 88, 70, 85, 40, 78], + } +} + +/// A mock Python upstream: records each request, replies 200 + `X-From-Python`. +fn spawn_mock_python() -> (String, Recorded) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let rec = Arc::new(Mutex::new(Vec::new())); + let rec2 = rec.clone(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let mut s = match stream { + Ok(s) => s, + Err(_) => continue, + }; + let mut r = BufReader::new(s.try_clone().unwrap()); + if let Ok(Some(req)) = read_request(&mut r) { + rec2.lock() + .push((req.method.clone(), req.target.clone(), req.body.clone())); + let body = br#"{"python":true}"#; + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nX-From-Python: 1\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = s.write_all(head.as_bytes()); + let _ = s.write_all(body); + } + } + }); + (format!("http://{addr}"), rec) +} + +fn build_server( + core: Arc<FakeCore>, + upstream: &str, + assets_map: Option<HashMap<String, u32>>, +) -> Server { + let assets: Arc< + dyn openfut_adapter_fifa17::fut::club_response::ItemIdentityResolver + Send + Sync, + > = match assets_map { + Some(m) => Arc::new(MapAssetResolver::from_map(m)), + None => Arc::new(EmptyAssetResolver), + }; + Server::new( + core, + Arc::new(entities()), + assets, + Arc::new(PassClient::new(upstream)), + ) +} + +// ── /club served from Core ───────────────────────────────────────────────── + +#[test] +fn club_route_maps_query_and_shapes_core_items() { + let core = Arc::new(FakeCore::new( + vec![item( + "oc1", + "card_ch_1", + 86, + "CDM", + "Argentina", + "Premier League", + "Chelsea", + )], + 1, + )); + let server = build_server( + core.clone(), + "http://127.0.0.1:1", // passthrough must not be used + Some(HashMap::from([("card_ch_1".to_string(), 20801u32)])), + ); + + let resp = server.handle( + "GET", + "/ut/game/fifa17/club?year=2017&type=player&count=11&level=gold&nation=52&league=13&team=5&sort=desc&start=10", + &[], + b"", + ); + assert_eq!(resp.status, 200); + let body: Value = serde_json::from_slice(&resp.body).unwrap(); + let it = &body["itemData"][0]; + assert_eq!(it["resourceId"], 20801, "real asset id from the resolver"); + assert_eq!(it["rating"], 86); + assert_eq!(it["preferredPosition"], "CDM"); + assert_eq!(it["leagueId"], 13); + assert_eq!(it["teamid"], 5); + assert_eq!(it["nation"], 52); + + // Core was queried once with SEMANTIC params (ids resolved to names), and the + // FIFA UI window (start/count) became semantic offset/limit. + assert_eq!(core.calls(), 1); + let p = core.last(); + assert!( + p.contains(&("quality".into(), "gold".into())), + "level=gold -> quality {p:?}" + ); + assert!( + p.contains(&("league".into(), "Premier League".into())), + "league id 13 -> name {p:?}" + ); + assert!( + p.contains(&("club".into(), "Chelsea".into())), + "team id 5 -> club name {p:?}" + ); + assert!(p.contains(&("offset".into(), "10".into()))); + assert!(p.contains(&("limit".into(), "11".into()))); + // No raw FIFA id reached Core. + for (_, v) in &p { + if v == "13" || v == "5" || v == "52" { + panic!("raw FIFA id leaked into Core params: {p:?}"); + } + } +} + +#[test] +fn club_route_with_empty_asset_map_drops_items_not_fakes_them() { + // The current production reality: no card→asset mapping → empty itemData. + let core = Arc::new(FakeCore::new( + vec![item( + "oc1", + "card_pl_001", + 84, + "ST", + "England", + "Premier League", + "Northgate United", + )], + 1, + )); + let server = build_server(core.clone(), "http://127.0.0.1:1", None); + let resp = server.handle("GET", "/ut/game/fifa17/club?level=any", &[], b""); + assert_eq!(resp.status, 200); + let body: Value = serde_json::from_slice(&resp.body).unwrap(); + assert_eq!( + body["itemData"].as_array().unwrap().len(), + 0, + "no fabricated ids" + ); + assert_eq!( + core.calls(), + 1, + "Core still queried; drop happens at shaping" + ); +} + +#[test] +fn club_route_unknown_id_returns_empty_and_never_calls_core() { + let core = Arc::new(FakeCore::forbidden()); + let server = build_server(core.clone(), "http://127.0.0.1:1", None); + // league 9999 is not in the entity map → hard MapError → empty, no Core call. + let resp = server.handle("GET", "/ut/game/fifa17/club?league=9999", &[], b""); + assert_eq!(resp.status, 200); + let body: Value = serde_json::from_slice(&resp.body).unwrap(); + assert_eq!(body["itemData"].as_array().unwrap().len(), 0); + assert_eq!(core.calls(), 0, "unknown id must short-circuit before Core"); +} + +// ── Passthrough to Python ──────────────────────────────────────────────────── + +#[test] +fn passthrough_forwards_verbatim_and_never_calls_core() { + let (upstream, rec) = spawn_mock_python(); + let core = Arc::new(FakeCore::forbidden()); // proves /club-only for Core + let server = build_server(core, &upstream, None); + + let resp = server.handle( + "POST", + "/ut/game/fifa17/purchased/items", + &[("X-UT-SID".into(), "sess".into())], + br#"{"itemData":[{"id":1}]}"#, + ); + // upstream response returned faithfully + assert_eq!(resp.status, 200); + assert!( + resp.headers + .iter() + .any(|(k, v)| k.eq_ignore_ascii_case("x-from-python") && v == "1"), + "upstream headers preserved: {:?}", + resp.headers + ); + let body: Value = serde_json::from_slice(&resp.body).unwrap(); + assert_eq!(body["python"], true); + + // upstream received the exact method, target and body + std::thread::sleep(std::time::Duration::from_millis(50)); + let got = rec.lock().clone(); + assert_eq!(got.len(), 1); + assert_eq!(got[0].0, "POST"); + assert_eq!(got[0].1, "/ut/game/fifa17/purchased/items"); + assert_eq!(got[0].2, br#"{"itemData":[{"id":1}]}"#); +} + +#[test] +fn mutating_route_classifies_to_passthrough_not_rust() { + // A PUT to the club PATH is NOT the read route; it must go to Python, never + // execute Rust/Core (guards against double-applying a mutation). + assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::Passthrough); + assert_eq!( + classify("POST", "/ut/game/fifa17/squad/0"), + Route::Passthrough + ); + + let (upstream, _rec) = spawn_mock_python(); + let core = Arc::new(FakeCore::forbidden()); + let server = build_server(core.clone(), &upstream, None); + let resp = server.handle("PUT", "/ut/game/fifa17/club", &[], br#"{"x":1}"#); + assert_eq!(resp.status, 200); + assert_eq!(core.calls(), 0); +} + +// ── End-to-end over a socket (read_request + write_response + keep-alive) ───── + +#[test] +fn end_to_end_socket_serves_club_and_passthrough() { + let (upstream, rec) = spawn_mock_python(); + let core = Arc::new(FakeCore::new( + vec![item( + "oc1", + "card_ch_1", + 86, + "CDM", + "Argentina", + "Premier League", + "Chelsea", + )], + 1, + )); + let server = build_server( + core, + &upstream, + Some(HashMap::from([("card_ch_1".to_string(), 20801u32)])), + ); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || server.serve_listener(listener)); + + // /club → Rust/Core + let club = raw_get( + &addr.to_string(), + "/ut/game/fifa17/club?level=gold&league=13", + ); + assert!(club.contains("200 OK"), "club status: {club}"); + assert!( + club.contains("\"resourceId\":20801") || club.contains("\"resourceId\": 20801"), + "club body: {club}" + ); + + // passthrough → Python + let pt = raw_get(&addr.to_string(), "/ut/game/fifa17/tradePile?x=1"); + assert!(pt.contains("200 OK")); + assert!( + pt.to_lowercase().contains("x-from-python"), + "upstream header relayed (case-insensitive): {pt}" + ); + + std::thread::sleep(std::time::Duration::from_millis(50)); + let got = rec.lock().clone(); + assert!( + got.iter() + .any(|(m, t, _)| m == "GET" && t == "/ut/game/fifa17/tradePile?x=1"), + "python saw the passthrough with query intact: {got:?}" + ); + assert!( + !got.iter().any(|(_, t, _)| t.contains("/club")), + "python must NOT have seen the /club request: {got:?}" + ); +} + +/// Minimal raw HTTP/1.1 GET (Connection: close) returning the whole response text. +fn raw_get(addr: &str, target: &str) -> String { + let mut s = TcpStream::connect(addr).unwrap(); + let req = format!("GET {target} HTTP/1.1\r\nHost: fifa\r\nConnection: close\r\n\r\n"); + s.write_all(req.as_bytes()).unwrap(); + let mut buf = String::new(); + s.read_to_string(&mut buf).unwrap(); + buf +} + +#[test] +fn club_core_error_returns_empty_and_never_falls_back_to_python() { + // A Core failure on /club must degrade to an empty page, NOT retry on Python + // (which could double-apply a mutation on other routes; the rule is absolute). + let (upstream, rec) = spawn_mock_python(); + let core = Arc::new(FakeCore::erroring()); + let server = build_server( + core.clone(), + &upstream, + Some(HashMap::from([("c".into(), 1u32)])), + ); + let resp = server.handle("GET", "/ut/game/fifa17/club?level=any", &[], b""); + assert_eq!(resp.status, 200); + let body: Value = serde_json::from_slice(&resp.body).unwrap(); + assert_eq!(body["itemData"].as_array().unwrap().len(), 0); + assert_eq!(core.calls(), 1, "Core was attempted once"); + std::thread::sleep(std::time::Duration::from_millis(50)); + assert!( + rec.lock().is_empty(), + "Python must NOT be contacted on a /club core error" + ); +} + +#[test] +fn host_does_not_refilter_core_results() { + // Filtering is Core's job. The host must emit whatever Core returns; if it + // re-applied the filter it would drop items Core already vetted. + let core = Arc::new(FakeCore::new( + vec![ + item( + "oc1", + "card_a", + 90, + "ST", + "Argentina", + "Premier League", + "Chelsea", + ), + item( + "oc2", + "card_b", + 60, + "GK", + "England", + "Premier League", + "Chelsea", + ), + ], + 2, + )); + let server = build_server( + core, + "http://127.0.0.1:1", + Some(HashMap::from([ + ("card_a".into(), 20801u32), + ("card_b".into(), 158023u32), + ])), + ); + // Query says gold; Core (faked) returns both regardless. Host must emit BOTH. + let resp = server.handle("GET", "/ut/game/fifa17/club?level=gold", &[], b""); + let body: Value = serde_json::from_slice(&resp.body).unwrap(); + assert_eq!( + body["itemData"].as_array().unwrap().len(), + 2, + "host must not second-guess Core's filtering" + ); +}