From f55c401b6ceb09997d87da08993f4dc3b3e18f46 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 11 Aug 2026 21:59:50 +0000 Subject: [PATCH] feat(fifa17): card-definition identity catalog + owned-item wire-id policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fut::catalog — Fifa17CardCatalog maps a semantic CardDefinitionId to a FIFA 17 render identity (resource_id = (version<<24)|asset_id; version 0 => resource== asset). Versioned JSON (schema_version=1, game=fifa17); validates schema/game, rejects asset_id > 24 bits, and rejects two card ids claiming one resource_id. Unknown definitions resolve to None (callers drop, never fabricate). Fifa17WireItemIdPolicy carries the owned-item namespace (base 100_000_000, first id 100_000_001, per the oracle) supplied to the generic store. Adds serde derive to the adapter. 8 catalog tests; 3/3 mutations killed (resourceId-drops-version, conflict-detection-off, asset-range-off). Phase commit 2/5. No card->asset DATA shipped: the synthetic Core catalogue is unmappable (see seed plan); the loader + format land now, population later. --- Cargo.lock | 1 + openfut-adapter-fifa17/Cargo.toml | 1 + openfut-adapter-fifa17/src/fut/catalog.rs | 287 ++++++++++++++++++++++ openfut-adapter-fifa17/src/fut/mod.rs | 1 + 4 files changed, 290 insertions(+) create mode 100644 openfut-adapter-fifa17/src/fut/catalog.rs diff --git a/Cargo.lock b/Cargo.lock index dfff001..cd4090e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3113,6 +3113,7 @@ name = "openfut-adapter-fifa17" version = "0.1.0" dependencies = [ "openfut-protocol-blaze", + "serde", "serde_json", ] diff --git a/openfut-adapter-fifa17/Cargo.toml b/openfut-adapter-fifa17/Cargo.toml index 12a2fe6..99b420c 100644 --- a/openfut-adapter-fifa17/Cargo.toml +++ b/openfut-adapter-fifa17/Cargo.toml @@ -13,6 +13,7 @@ openfut-protocol-blaze = { path = "../openfut-protocol-blaze" } # protocol crate below it, this crate is ordinary server-side code, so a real # JSON parser is the right call — hand-rolling one to preserve a zero-dependency # streak would be reinventing a solved problem in the riskiest possible place. +serde = { version = "1", features = ["derive"] } serde_json = "1" [dev-dependencies] diff --git a/openfut-adapter-fifa17/src/fut/catalog.rs b/openfut-adapter-fifa17/src/fut/catalog.rs new file mode 100644 index 0000000..097ecfe --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/catalog.rs @@ -0,0 +1,287 @@ +//! FIFA 17 **card-definition identity catalog** and owned-item **wire-id policy**. +//! +//! Two distinct identities (never conflate them): +//! +//! * **Card definition** — *what card is this?* A semantic OpenFUT +//! `CardDefinitionId` maps to a FIFA 17 render identity here: +//! `resource_id = (version << 24) | asset_id`. The client resolves +//! `resource_id & 0xFFFFFF` (= `asset_id`) against its own local player DB; +//! an invented id renders a blank card, so this catalog is authored from +//! verified FIFA 17 data (`pool.json` player asset ids), never guessed. +//! * **Owned-item instance** — *which exact copy?* A monotonic integer wire id, +//! allocated per account by the generic external-identity store; this module +//! only holds the FIFA 17 numeric **policy** ([`Fifa17WireItemIdPolicy`]). +//! +//! The catalog is game DATA (a versioned JSON file), not deployment config, and +//! not a generic-Core concern. Unknown definitions resolve to `None` — callers +//! drop them, never fabricate an asset id. + +use std::collections::HashMap; + +use serde::Deserialize; + +/// The FIFA 17 render identity of a card definition. `version` is the high byte +/// of `resource_id`; `asset_id` (the low 24 bits) is the real FIFA player id. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Fifa17CardIdentity { + pub asset_id: u32, + pub version: u8, + pub resource_id: u32, +} + +/// The FIFA 17 numeric namespace policy for owned-item wire ids. +/// +/// Owned-item ids are monotonic from `OWNED_ITEM_BASE + 1` (= 100_000_001, +/// matching the oracle's `ITEM_ID_BASE = 100_000_000` and its first minted id), +/// staying below the synthetic-overlay ranges the responder uses (≥ 9e8). The +/// generic store enforces monotonicity/uniqueness; this type supplies the game, +/// entity-kind and base floor. +pub struct Fifa17WireItemIdPolicy; + +impl Fifa17WireItemIdPolicy { + pub const GAME: &'static str = "fifa17"; + pub const OWNED_ITEM_KIND: &'static str = "owned-item"; + pub const OWNED_ITEM_BASE: i64 = 100_000_000; + + /// First owned-item wire id (`100_000_001`). + pub fn owned_item_base_floor() -> i64 { + Self::OWNED_ITEM_BASE + 1 + } +} + +/// Highest representable asset id (24 bits); above this `version` would be +/// clobbered in `resource_id`. +const MAX_ASSET_ID: u32 = 0x00FF_FFFF; +const SCHEMA_VERSION: u32 = 1; + +/// Catalog load/validation errors — all explicit, no silent fallback. +#[derive(Debug, PartialEq, Eq)] +pub enum CatalogError { + BadSchemaVersion(u32), + WrongGame(String), + AssetTooLarge { + card_id: String, + asset_id: u32, + }, + DuplicateResource { + resource_id: u32, + first: String, + second: String, + }, + Parse(String), +} + +impl std::fmt::Display for CatalogError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CatalogError::BadSchemaVersion(v) => { + write!(f, "unsupported catalog schema_version {v}") + } + CatalogError::WrongGame(g) => write!(f, "catalog game is '{g}', expected 'fifa17'"), + CatalogError::AssetTooLarge { card_id, asset_id } => { + write!(f, "card '{card_id}' asset_id {asset_id} exceeds 24 bits") + } + CatalogError::DuplicateResource { + resource_id, + first, + second, + } => write!( + f, + "resource_id {resource_id} claimed by both '{first}' and '{second}'" + ), + CatalogError::Parse(e) => write!(f, "catalog parse error: {e}"), + } + } +} +impl std::error::Error for CatalogError {} + +#[derive(Deserialize)] +struct RawCatalog { + schema_version: u32, + game: String, + #[serde(default)] + cards: std::collections::BTreeMap, +} + +#[derive(Deserialize)] +struct RawCard { + asset_id: u32, + #[serde(default)] + version: u8, +} + +/// A loaded, validated FIFA 17 card-definition identity catalog. +#[derive(Debug, Default, Clone)] +pub struct Fifa17CardCatalog { + by_card: HashMap, +} + +impl Fifa17CardCatalog { + /// Parse + validate a catalog document. Rejects wrong schema/game, an + /// asset id that would overflow into the version byte, and two card ids + /// claiming the same `resource_id` (a semantic-vs-FIFA identity conflict). + pub fn from_json_str(s: &str) -> Result { + let raw: RawCatalog = + serde_json::from_str(s).map_err(|e| CatalogError::Parse(e.to_string()))?; + if raw.schema_version != SCHEMA_VERSION { + return Err(CatalogError::BadSchemaVersion(raw.schema_version)); + } + if raw.game != Fifa17WireItemIdPolicy::GAME { + return Err(CatalogError::WrongGame(raw.game)); + } + let mut by_card = HashMap::new(); + let mut by_resource: HashMap = HashMap::new(); + for (card_id, rc) in raw.cards { + if rc.asset_id > MAX_ASSET_ID { + return Err(CatalogError::AssetTooLarge { + card_id, + asset_id: rc.asset_id, + }); + } + let resource_id = ((rc.version as u32) << 24) | rc.asset_id; + if let Some(first) = by_resource.get(&resource_id) { + return Err(CatalogError::DuplicateResource { + resource_id, + first: first.clone(), + second: card_id, + }); + } + by_resource.insert(resource_id, card_id.clone()); + by_card.insert( + card_id, + Fifa17CardIdentity { + asset_id: rc.asset_id, + version: rc.version, + resource_id, + }, + ); + } + Ok(Fifa17CardCatalog { by_card }) + } + + /// Load a catalog from a JSON file. + pub fn from_file(path: &std::path::Path) -> Result { + let raw = std::fs::read_to_string(path) + .map_err(|e| CatalogError::Parse(format!("reading {}: {e}", path.display())))?; + Self::from_json_str(&raw) + } + + /// The FIFA 17 identity for a definition, or `None` (never a fabricated id). + pub fn lookup(&self, card_id: &str) -> Option { + self.by_card.get(card_id).copied() + } + + pub fn len(&self) -> usize { + self.by_card.len() + } + pub fn is_empty(&self) -> bool { + self.by_card.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resource_id_composition_base_and_special() { + let cat = Fifa17CardCatalog::from_json_str( + r#"{"schema_version":1,"game":"fifa17","cards":{ + "card_base":{"asset_id":20801}, + "card_totw":{"asset_id":20801,"version":3} + }}"#, + ) + .unwrap(); + // version 0 -> resource_id == asset_id + let base = cat.lookup("card_base").unwrap(); + assert_eq!(base.version, 0); + assert_eq!(base.resource_id, 20801); + assert_eq!(base.resource_id, base.asset_id); + // version 3 -> high byte set; same base player, different FIFA card + let totw = cat.lookup("card_totw").unwrap(); + assert_eq!(totw.asset_id, 20801, "asset_id (base player) unchanged"); + assert_eq!(totw.resource_id, (3u32 << 24) | 20801); + assert_ne!(base.resource_id, totw.resource_id); + } + + #[test] + fn unknown_card_is_none() { + let cat = Fifa17CardCatalog::from_json_str( + r#"{"schema_version":1,"game":"fifa17","cards":{"card_base":{"asset_id":1}}}"#, + ) + .unwrap(); + assert_eq!(cat.lookup("card_missing"), None); + } + + #[test] + fn two_cards_same_resource_is_a_conflict() { + let err = Fifa17CardCatalog::from_json_str( + r#"{"schema_version":1,"game":"fifa17","cards":{ + "card_a":{"asset_id":20801}, + "card_b":{"asset_id":20801} + }}"#, + ) + .unwrap_err(); + assert!(matches!( + err, + CatalogError::DuplicateResource { + resource_id: 20801, + .. + } + )); + } + + #[test] + fn schema_and_game_are_validated() { + assert_eq!( + Fifa17CardCatalog::from_json_str(r#"{"schema_version":2,"game":"fifa17","cards":{}}"#) + .unwrap_err(), + CatalogError::BadSchemaVersion(2) + ); + assert_eq!( + Fifa17CardCatalog::from_json_str(r#"{"schema_version":1,"game":"fifa23","cards":{}}"#) + .unwrap_err(), + CatalogError::WrongGame("fifa23".into()) + ); + } + + #[test] + fn asset_exceeding_24_bits_is_rejected() { + let err = Fifa17CardCatalog::from_json_str( + r#"{"schema_version":1,"game":"fifa17","cards":{"c":{"asset_id":16777216}}}"#, + ) + .unwrap_err(); + assert!(matches!( + err, + CatalogError::AssetTooLarge { + asset_id: 16_777_216, + .. + } + )); + } + + #[test] + fn malformed_json_is_a_parse_error() { + assert!(matches!( + Fifa17CardCatalog::from_json_str("{not json").unwrap_err(), + CatalogError::Parse(_) + )); + } + + #[test] + fn deterministic_reload() { + let doc = r#"{"schema_version":1,"game":"fifa17","cards":{"a":{"asset_id":10},"b":{"asset_id":20,"version":1}}}"#; + let c1 = Fifa17CardCatalog::from_json_str(doc).unwrap(); + let c2 = Fifa17CardCatalog::from_json_str(doc).unwrap(); + assert_eq!(c1.lookup("a"), c2.lookup("a")); + assert_eq!(c1.lookup("b"), c2.lookup("b")); + assert_eq!(c1.len(), 2); + } + + #[test] + fn wire_id_policy_constants() { + assert_eq!(Fifa17WireItemIdPolicy::GAME, "fifa17"); + assert_eq!(Fifa17WireItemIdPolicy::OWNED_ITEM_KIND, "owned-item"); + assert_eq!(Fifa17WireItemIdPolicy::owned_item_base_floor(), 100_000_001); + } +} diff --git a/openfut-adapter-fifa17/src/fut/mod.rs b/openfut-adapter-fifa17/src/fut/mod.rs index eac0fb0..20b91b0 100644 --- a/openfut-adapter-fifa17/src/fut/mod.rs +++ b/openfut-adapter-fifa17/src/fut/mod.rs @@ -4,6 +4,7 @@ //! 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 catalog; pub mod club_response; pub mod entities; pub mod owned_query;