6c7d0856b6
Extends the FIFA17 adapter past players so the wire can carry the rest of a real club's inventory. itemState: the recovered 12-row table at 0x180229cc0 becomes the single source (`fut::item_state`), replacing scattered literals. Every shaper draws from it and the tests assert no shaper can emit a state the client does not know. CARD_SYSTEM.md's 0x180229d20 is the middle of that table, not its start. ContentKind covers all nine tokens. Managers stay inside the staff family for counting, because the client's own club-stats model puts a manager INSIDE the staff total with staffManager as a sub-bucket — a parallel Manager kind would silently under-count. Consumables get their own route (`club/consumables/<category>`) and a stack-wrapper envelope, classified BEFORE the other club/ arms; they are not a `?type=` family. This path previously fell through to Python, so owned inventory was being served by the oracle. The shaper refuses to emit a card it cannot render: no known art id, or a missing `amount`/`contract` for the families that read them, or the subtype-219 rareflag trap that silently turns Player Fitness into Squad Fitness. A dropped card is counted and logged, never faked.
548 lines
22 KiB
Rust
548 lines
22 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;
|
|
|
|
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<u8>,
|
|
/// `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<i64>,
|
|
/// `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<i64>,
|
|
}
|
|
|
|
/// 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<String, RawCard>,
|
|
}
|
|
|
|
#[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<u32>,
|
|
/// Source team id for a kit or manager definition; absent → `0`.
|
|
#[serde(default)]
|
|
team_id: Option<i64>,
|
|
/// Manager chemistry nation; absent → `0`.
|
|
#[serde(default)]
|
|
nation: Option<i64>,
|
|
/// Manager chemistry league; absent → `0`.
|
|
#[serde(default)]
|
|
league_id: Option<i64>,
|
|
/// EA-authored rating for a non-player definition; absent → Core's rating.
|
|
#[serde(default)]
|
|
rating: Option<u8>,
|
|
/// Consumable bonus magnitude (atom 0x1b); absent → key omitted.
|
|
#[serde(default)]
|
|
amount: Option<i64>,
|
|
/// Contract-card grant (atom 0xb8); absent → key omitted.
|
|
#[serde(default)]
|
|
contract: Option<i64>,
|
|
}
|
|
|
|
fn default_rareflag() -> i64 {
|
|
1
|
|
}
|
|
|
|
/// A loaded, validated FIFA 17 card-definition identity catalog.
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct Fifa17CardCatalog {
|
|
by_card: HashMap<String, Fifa17CardIdentity>,
|
|
/// 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<u32, String>,
|
|
}
|
|
|
|
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,
|
|
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<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()
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|