From 3ef3bc32eca38ed49ebd8890e755461ccea5d884 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 11 Aug 2026 22:33:40 +0000 Subject: [PATCH] feat(utas-host): real Fifa17IdentityResolver (catalog + store + policy), drop placeholders --- Cargo.lock | 1 + openfut-utas-host/Cargo.toml | 1 + openfut-utas-host/README.md | 39 ++-- openfut-utas-host/src/config.rs | 16 +- openfut-utas-host/src/lib.rs | 254 ++++++++++++++++++++------- openfut-utas-host/src/main.rs | 4 +- openfut-utas-host/tests/host_test.rs | 37 +++- 7 files changed, 253 insertions(+), 99 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cd4090e..b2355cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3269,6 +3269,7 @@ version = "0.1.0" dependencies = [ "openfut-adapter-fifa17", "openfut-http", + "openfut-identity", "parking_lot", "reqwest", "serde_json", diff --git a/openfut-utas-host/Cargo.toml b/openfut-utas-host/Cargo.toml index e972627..11216ff 100644 --- a/openfut-utas-host/Cargo.toml +++ b/openfut-utas-host/Cargo.toml @@ -9,6 +9,7 @@ publish = false [dependencies] openfut-adapter-fifa17 = { path = "../openfut-adapter-fifa17" } openfut-http = { path = "../openfut-http" } +openfut-identity = { path = "../openfut-identity" } 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. diff --git a/openfut-utas-host/README.md b/openfut-utas-host/README.md index 96ae760..f015323 100644 --- a/openfut-utas-host/README.md +++ b/openfut-utas-host/README.md @@ -34,28 +34,37 @@ the host holds the [`CoreAccess`] boundary (`GET {core_url}/collection?…` toda |---|---|---|---| | `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_FIFA17_CATALOG` | yes | — | FIFA 17 card-definition identity catalog (`Fifa17CardCatalog` JSON: card id → asset id) | +| `OPENFUT_IDENTITY_STORE` | yes | — | persistent external-identity store file (owned instance → stable wire id) | | `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 `{ "": }` (see the blocker) | -## KNOWN BLOCKER — retail rendering of Core inventory +Startup **fails clearly** if the catalog or identity store cannot be loaded — +there is no placeholder fallback (exactly one production identity path). + +## Identity model (resolved) 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: +card** (proven live — `fut_cards.py:11-21`). Two distinct identities, never +conflated, are resolved by [`Fifa17IdentityResolver`] (the single production path): -- 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. +- **Definition identity** (`resourceId`/`assetId`) — the card's real FIFA asset + id, from the versioned `OPENFUT_FIFA17_CATALOG`. An unmapped definition is + **dropped and counted**, never faked. +- **Instance identity** (`id`) — a stable, persistent, reversible wire integer + from the generic `openfut-identity` store under the FIFA 17 wire-id policy + (monotonic from `100_000_001`). The same owned instance keeps its id across + restart and reverses exactly; two copies of one definition share a + `resourceId` but get distinct `id`s. The namespace is globally monotonic + within `(fifa17, owned-item)` — no per-account column is needed because Core + owned-instance ids are globally-unique UUIDs. -`rare=SP` ("Special") stays UNSUPPORTED (semantics unproven; parsed, reported, never -guessed). +**Remaining prerequisite for a *rendering* retail `/club`:** Core inventory must +reference cards that exist in the catalog. The catalog + store + resolver are +built and tested; wiring a controlled real FIFA 17 dev-content inventory (the +curated per-game dev pack) is the next slice. `rare=SP` ("Special") stays +UNSUPPORTED (semantics unproven; parsed, reported, never guessed). ## Retail A/B runbook (first `/club` gate) @@ -70,7 +79,7 @@ Preconditions (mirror the proven blaze/roster switch discipline): 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=:8099 OPENFUT_UTAS_PYTHON_URL=http://127.0.0.1:8199 OPENFUT_CORE_URL=http://127.0.0.1:8080 OPENFUT_FIFA17_ASSET_MAP= openfut-utas-host` + `OPENFUT_UTAS_HOST_ADDR=:8099 OPENFUT_UTAS_PYTHON_URL=http://127.0.0.1:8199 OPENFUT_CORE_URL=http://127.0.0.1:8080 OPENFUT_FIFA17_CATALOG= OPENFUT_IDENTITY_STORE= 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): diff --git a/openfut-utas-host/src/config.rs b/openfut-utas-host/src/config.rs index 880e505..1696076 100644 --- a/openfut-utas-host/src/config.rs +++ b/openfut-utas-host/src/config.rs @@ -15,9 +15,14 @@ pub struct HostConfig { 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, + /// FIFA 17 card-definition **identity catalog** (card id → FIFA asset id). + /// Required production identity source: a `/club` item's `resourceId` comes + /// from here. Startup fails if it cannot be loaded — never a placeholder. + pub catalog_path: String, + /// Persistent external-identity **store** file (owned-instance → stable wire + /// id). Required: the wire `id` of every owned item is allocated/resolved + /// here so it survives restart and reverses exactly. + pub identity_store_path: String, } #[derive(Debug)] @@ -46,9 +51,8 @@ impl HostConfig { .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()), + catalog_path: required("OPENFUT_FIFA17_CATALOG")?, + identity_store_path: required("OPENFUT_IDENTITY_STORE")?, }) } } diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index b650099..258510e 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -36,16 +36,17 @@ 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::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy}; 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 openfut_identity::ExternalIdentityStore; use serde_json::{json, Value}; use config::HostConfig; @@ -197,64 +198,77 @@ fn core_item_from_json(e: &Value) -> Option { }) } -// ───────────────────────────── Asset resolvers ────────────────────────────── +// ───────────────────────────── Item identity resolver ─────────────────────── -/// 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; +/// 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 ItemIdentityResolver for EmptyAssetResolver { - fn resolve(&self, _item: &CoreOwnedItem) -> Option { - None +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) } } -/// 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, -} - -impl MapAssetResolver { - pub fn from_map(map: HashMap) -> Self { - MapAssetResolver { map } - } - - /// Load `{ "card_id": assetId, … }` from a JSON file. - pub fn from_json_file(path: &str) -> std::io::Result { - 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 { +impl ItemIdentityResolver for Fifa17IdentityResolver { fn resolve(&self, item: &CoreOwnedItem) -> Option { - let asset = *self.map.get(&item.card_id)?; + // 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 { - item_id: stable_item_id(&item.owned_card_id), - asset_id: asset, + // Wire ids live in 1e8..9e8 (policy) — well within u32. + item_id: wire as u32, + asset_id: ident.asset_id, }) } } @@ -479,18 +493,19 @@ impl Server { } } - /// Build from config: load entity tables, pick the asset resolver, wire the - /// Core client and Python passthrough. + /// 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 assets: Arc = 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), - }; + 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 assets: Arc = + Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store))); Ok(Server { core: Arc::new(HttpCoreClient::new(cfg.core_url.clone())), entities: Arc::new(entities), @@ -740,12 +755,117 @@ mod tests { 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 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")); + 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); } } diff --git a/openfut-utas-host/src/main.rs b/openfut-utas-host/src/main.rs index 9e6bdbe..354d997 100644 --- a/openfut-utas-host/src/main.rs +++ b/openfut-utas-host/src/main.rs @@ -15,8 +15,8 @@ fn main() { } }; 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 + "utas-host starting: listen={} python_upstream={} core_url={} tables_dir={} catalog={} identity_store={}", + cfg.listen_addr, cfg.python_upstream, cfg.core_url, cfg.tables_dir, cfg.catalog_path, cfg.identity_store_path ); let server = match Server::from_config(&cfg) { Ok(s) => s, diff --git a/openfut-utas-host/tests/host_test.rs b/openfut-utas-host/tests/host_test.rs index 15cc4c1..1634bfa 100644 --- a/openfut-utas-host/tests/host_test.rs +++ b/openfut-utas-host/tests/host_test.rs @@ -8,11 +8,13 @@ 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::catalog::Fifa17CardCatalog; +use openfut_adapter_fifa17::fut::club_response::{CoreOwnedItem, ItemIdentityResolver}; use openfut_adapter_fifa17::fut::entities::Fifa17Entities; +use openfut_identity::JsonIdentityStore; use openfut_utas_host::{ - classify, read_request, CoreAccess, CoreError, CorePage, EmptyAssetResolver, MapAssetResolver, - PassClient, Route, Server, + classify, read_request, CoreAccess, CoreError, CorePage, Fifa17IdentityResolver, PassClient, + Route, Server, }; use parking_lot::Mutex; use serde_json::Value; @@ -149,17 +151,34 @@ fn spawn_mock_python() -> (String, Recorded) { (format!("http://{addr}"), rec) } +/// A unique temp path for a per-server identity store (integration tests run in +/// the same process; each server gets its own store file). +fn unique_store_path() -> std::path::PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let n = N.fetch_add(1, Ordering::SeqCst); + std::env::temp_dir().join(format!("ofut-host-it-{}-{n}.json", std::process::id())) +} + fn build_server( core: Arc, upstream: &str, assets_map: Option>, ) -> 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), - }; + // The map (card id → asset id) becomes a real identity catalog; an absent + // map is an empty catalog (every item dropped — the honest no-mapping case). + let cards = assets_map.unwrap_or_default(); + 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(",") + ); + let catalog = Fifa17CardCatalog::from_json_str(&doc).unwrap(); + let store = JsonIdentityStore::open(unique_store_path()).unwrap(); + let assets: Arc = + Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store))); Server::new( core, Arc::new(entities()),