//! 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); } #[test] fn loads_the_committed_generated_catalog() { // The generated base-card catalog (scripts/seed_fifa17_cards.py) must be // loadable by this adapter and carry real asset identities. let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("data/fifa17-card-identities.json"); if !path.exists() { eprintln!("skip: {} not generated", path.display()); return; } let cat = Fifa17CardCatalog::from_file(&path).expect("load committed catalog"); assert!( cat.len() > 17_000, "full FIFA17 base pool, got {}", cat.len() ); // Ronaldo (asset 20801), version 0 => resource_id == asset_id. let ron = cat .lookup("fifa17_20801") .expect("known base asset present"); assert_eq!(ron.asset_id, 20801); assert_eq!(ron.version, 0); assert_eq!(ron.resource_id, 20801); } }