Files
OpenFUT/openfut-adapter-fifa17/src/fut/catalog.rs
T
funman300 c7d4b9f753 style(adapter): rustfmt catalog test assertions
Trailing rustfmt reflow of two catalog unit-test assertions (no logic change); clears the adapter dirty state so verify-build-identity.sh passes for the UTAS A/B.
2026-08-12 17:15:14 +00:00

313 lines
11 KiB
Rust

//! 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<String, RawCard>,
}
#[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<String, Fifa17CardIdentity>,
}
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<Self, CatalogError> {
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<u32, String> = 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<Self, CatalogError> {
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<Fifa17CardIdentity> {
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);
}
}