//! 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; use crate::fut::content_taxonomy::ContentKind; /// 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, /// FIFA wire `rareflag` (rare/special card TYPE). Carried so specials render /// as specials; observed metadata, not a guessed label. pub rareflag: i64, /// Content class of this definition. A catalog authored before this field /// existed defaults to [`ContentKind::Player`] (backward compatible). pub kind: ContentKind, /// FIFA `cardsubtypeid` for a non-player definition (consumable family / /// staff role), `0` for a player or when absent. pub subtype: i64, /// FIFA card-art class. Players default to `asset_id`; kit definitions carry /// the verified `fcc_kitcards.cardassetid` value (`35`). pub card_asset_id: u32, /// Source team id for a club kit, or a manager's real club. Zero for content /// kinds that do not use it. pub team_id: i64, /// Manager chemistry nation (`managercards.nation`), zero when unused. /// /// The client NEVER supplies this: the managercards merge (`FUN_1801356c0`) /// leaves the manager-only record slot `rec+0xde` untouched, so the server is /// its only source. See `fifa17-recon/tools/fut_staff.py`. pub nation: i64, /// Manager chemistry league, zero when unused. Derived upstream through /// `manager.teamid` → `leagueteamlinks.leagueid`, because `managercards` has /// no league column. Lands in the equally untouched slot `rec+0xe0`. pub league_id: i64, /// EA's authored `rating` for a NON-PLAYER definition (`fcc_*.rating`), which /// Core does not model: an imported consumable's Core `overall` is 0, while /// the client's own copies carry 55..95 and the value drives the card level /// (`rec+0x54`) and therefore its quick-sell price. `None` → the caller falls /// back to Core's rating, which stays authoritative for players. pub rating: Option, /// `amount` (atom 0x1b) for a consumable definition — the bonus magnitude EA /// authored in the `fcc_*` row (+5 / +10 / +15 …). MANDATORY for the /// training, healing, fitness, play-style and manager-league families: /// omitting the key draws "-1" on the card, not "0". pub amount: Option, /// `contract` (atom 0xb8) for a contract-card definition (`cardsubtypeid` /// 201/202) — the number of matches the card grants. `fcc_contractcards` has /// no amount column, so this value comes from observed data; it is never /// defaulted here. pub contract: Option, } /// 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 } /// Identity scope for MATCH session ids. /// /// A match id is deliberately NOT drawn from the owned-item scope. The /// oracle mints both from one counter, which is why an observed match id /// looks like an item id — but that is an artifact of a single-counter save /// file, not a client requirement. Here the identity store keeps a real /// reverse map, so an item-scoped match id would make /// `owned_id_for_wire` resolve a match to a bogus owned card and corrupt /// quick-sell and move. The store is generic over `(game, kind)`, so a /// separate scope costs one constant and cannot collide with, or advance, /// the owned-item watermark. pub const MATCH_KIND: &'static str = "match"; /// Base for match session ids. Clear of the owned-item range /// (`100_000_000+`) and of every synthetic overlay range the responder /// reserves (`≥ 9e8`). The client only requires a non-zero int. pub const MATCH_BASE: i64 = 200_000_000; /// First match wire id (`200_000_001`). pub fn match_base_floor() -> i64 { Self::MATCH_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, /// Absent in a base-only catalog → default 1 (rare), preserving prior wire /// behaviour; the production catalog carries the observed value. #[serde(default = "default_rareflag")] rareflag: i64, /// Content class token ("player"|"consumable"|"staff"). Absent → default /// (empty) → [`ContentKind::Player`], so existing player-only catalogs load /// unchanged. #[serde(default)] kind: String, /// FIFA `cardsubtypeid` for a non-player entry; absent → `0`. #[serde(default)] subtype: i64, /// Separate card-art id for non-player definitions; absent → `asset_id`. #[serde(default)] card_asset_id: Option, /// Source team id for a kit or manager definition; absent → `0`. #[serde(default)] team_id: Option, /// Manager chemistry nation; absent → `0`. #[serde(default)] nation: Option, /// Manager chemistry league; absent → `0`. #[serde(default)] league_id: Option, /// EA-authored rating for a non-player definition; absent → Core's rating. #[serde(default)] rating: Option, /// Consumable bonus magnitude (atom 0x1b); absent → key omitted. #[serde(default)] amount: Option, /// Contract-card grant (atom 0xb8); absent → key omitted. #[serde(default)] contract: Option, } fn default_rareflag() -> i64 { 1 } /// A loaded, validated FIFA 17 card-definition identity catalog. #[derive(Debug, Default, Clone)] pub struct Fifa17CardCatalog { by_card: HashMap, /// Reverse index: full versioned `resource_id` → the card definition id. The /// wire carries a `resourceId`; the synthetic market must mint the /// authoritative Core `card_id`, never the raw FIFA number. by_resource: 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, rareflag: rc.rareflag, kind: ContentKind::from_str(&rc.kind), subtype: rc.subtype, card_asset_id: rc.card_asset_id.unwrap_or(rc.asset_id), team_id: rc.team_id.unwrap_or(0), nation: rc.nation.unwrap_or(0), league_id: rc.league_id.unwrap_or(0), rating: rc.rating, amount: rc.amount, contract: rc.contract, }, ); } Ok(Fifa17CardCatalog { by_card, by_resource, }) } /// 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() } /// Reverse a FIFA wire `resource_id` (full versioned id) to its authoritative /// Core `card_id`, or `None` (never a fabricated/heuristic id). Used by the /// synthetic transfer market so a purchase mints real Core content. pub fn card_id_for_resource(&self, resource_id: u32) -> Option<&str> { self.by_resource.get(&resource_id).map(String::as_str) } /// Classify a `card_id` as player/consumable/staff. An unknown definition is /// [`ContentKind::Player`] — the neutral, backward-compatible default (an /// un-catalogued id was always treated as a player-shaped card). pub fn kind_of(&self, card_id: &str) -> ContentKind { self.by_card .get(card_id) .map(|c| c.kind) .unwrap_or(ContentKind::Player) } /// The FIFA `cardsubtypeid` for a definition, or `0` if unknown / a player. pub fn subtype_of(&self, card_id: &str) -> i64 { self.by_card.get(card_id).map(|c| c.subtype).unwrap_or(0) } 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); } /// The non-player definition fields a consumable needs, and the ABSENCE that /// must stay an absence: a defaulted `amount` would draw "-1" on the card and /// a defaulted `contract` would invent the number of matches a card grants. #[test] fn consumable_definition_fields_are_carried_and_never_defaulted() { let cat = Fifa17CardCatalog::from_json_str( r#"{"schema_version":1,"game":"fifa17","cards":{ "fifa17_5003012":{"asset_id":5003012,"kind":"consumable","subtype":54, "card_asset_id":3,"rareflag":0,"rating":85,"amount":15}, "fifa17_5001004":{"asset_id":5001004,"kind":"consumable","subtype":201, "card_asset_id":7,"rareflag":0,"rating":60,"contract":7}, "fifa17_5003059":{"asset_id":5003059,"kind":"consumable","subtype":91, "card_asset_id":34,"rareflag":0,"rating":95}, "fifa17_20801":{"asset_id":20801} }}"#, ) .unwrap(); // A training card: art id 3 (NOT the carddbid), EA's rating, amount 15. let training = cat.lookup("fifa17_5003012").unwrap(); assert_eq!(training.kind, ContentKind::Consumable); assert_eq!(training.subtype, 54); assert_eq!(training.card_asset_id, 3); assert_eq!(training.rating, Some(85)); assert_eq!(training.amount, Some(15)); assert_eq!(training.contract, None); // A contract card takes its number from `contract`, not `amount`. let contract = cat.lookup("fifa17_5001004").unwrap(); assert_eq!(contract.contract, Some(7)); assert_eq!(contract.amount, None); // A position modifier needs neither. let position = cat.lookup("fifa17_5003059").unwrap(); assert_eq!(position.amount, None); assert_eq!(position.contract, None); assert_eq!(position.card_asset_id, 34); // A player carries none of them and keeps Core's authoritative rating. let player = cat.lookup("fifa17_20801").unwrap(); assert_eq!(player.kind, ContentKind::Player); assert_eq!(player.rating, None); assert_eq!(player.amount, None); assert_eq!(player.contract, None); assert_eq!( player.card_asset_id, player.asset_id, "a player's card art IS its asset id" ); } #[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); } #[test] fn legacy_catalog_without_kind_loads_as_player() { // A pre-taxonomy catalog (no `kind`/`subtype`) must load unchanged and // classify every entry as a player, with subtype 0. let cat = Fifa17CardCatalog::from_json_str( r#"{"schema_version":1,"game":"fifa17","cards":{ "fifa17_20801":{"asset_id":20801}, "fifa17_176580":{"asset_id":176580,"version":5,"rareflag":3} }}"#, ) .unwrap(); let base = cat.lookup("fifa17_20801").unwrap(); assert_eq!(base.kind, ContentKind::Player); assert_eq!(base.subtype, 0); assert_eq!(base.rareflag, 1, "absent rareflag still defaults to 1"); assert_eq!(cat.kind_of("fifa17_20801"), ContentKind::Player); assert_eq!(cat.kind_of("fifa17_176580"), ContentKind::Player); // Unknown id -> neutral Player default. assert_eq!(cat.kind_of("fifa17_missing"), ContentKind::Player); assert_eq!(cat.subtype_of("fifa17_missing"), 0); } #[test] fn kind_and_subtype_are_parsed_for_non_player_entries() { let cat = Fifa17CardCatalog::from_json_str( r#"{"schema_version":1,"game":"fifa17","cards":{ "fifa17_20801":{"asset_id":20801,"kind":"player","subtype":0}, "fifa17_5003012":{"asset_id":5003012,"kind":"consumable","subtype":54,"rareflag":0}, "fifa17_3000083":{"asset_id":3000083,"kind":"staff","subtype":8,"rareflag":0}, "fifa17_6300006":{"asset_id":6300006,"kind":"kit","subtype":9, "card_asset_id":35,"team_id":21,"rareflag":0} }}"#, ) .unwrap(); assert_eq!(cat.kind_of("fifa17_20801"), ContentKind::Player); assert_eq!(cat.kind_of("fifa17_5003012"), ContentKind::Consumable); assert_eq!(cat.subtype_of("fifa17_5003012"), 54); assert_eq!(cat.kind_of("fifa17_3000083"), ContentKind::Staff); assert_eq!(cat.subtype_of("fifa17_3000083"), 8); assert_eq!(cat.lookup("fifa17_5003012").unwrap().rareflag, 0); let kit = cat.lookup("fifa17_6300006").unwrap(); assert_eq!(kit.kind, ContentKind::Kit); assert_eq!(kit.subtype, 9); assert_eq!(kit.card_asset_id, 35); assert_eq!(kit.team_id, 21); } }