feat(fifa17): card-definition identity catalog + owned-item wire-id policy
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.
This commit is contained in:
Generated
+1
@@ -3113,6 +3113,7 @@ name = "openfut-adapter-fifa17"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openfut-protocol-blaze",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user