Files
OpenFUT/openfut-adapter-fifa17/src/fut/catalog.rs
T
funman300 e18304d365 fix(fifa17): serve the full kit identity triple so the selector can match
Operator report: selecting a kit in the pre-match selector gives "This kit is
currently locked. To unlock and use it, please go to the football club
catalogue."

Root cause, from static RE of the UNPACKED CardsDLL. CardsDLL registers a kit
provider into the FIFA engine (singleton FUN_1800338f0, vtable 0x1801f1d68):

  slot +0x08 FUN_180033770  enumerate kits for a team
  slot +0x10 sub_180033430  describe one kit   <- the lock gate

The describe function decodes a packed kit id into (teamid, year, slot) and
compares it against the club's ACTIVE HOME and ACTIVE AWAY triples. On a match
it writes NAME/TYPE (and, for historical kits, LOCKED). On no match it writes
NOTHING AT ALL and the descriptor falls through to the engine's own default,
which is where the locked-catalogue message comes from.

The active triple is 100% server-driven. FUN_1801c26d0 -> FUN_1800d73d0 scans
club items for cardtype 7 + cardsubtypeid 9 + itemState 101/102, then reads:

  teamid    <- item+0x94   (atom 0x306)   we were sending this
  year      <- item+0xba   (atom 0x389)   WE WERE NOT SENDING THIS
  slot      <- item+0xb8   (category)     WE WERE NOT SENDING THIS
               category 2 -> slot 0 home, 3 -> slot 1 away, 5 -> slot 3 third

So we shipped kits carrying only teamid, and the triple could never match.

fifa17-recon/data/club_items.json already has category and year per kit
resourceId (1482 kits; category {2:740, 3:654, 5:88}; 85 historical years), so
this is carried through the catalog rather than invented: Fifa17CardIdentity
gains category/year, Fifa17KitIdentity carries them, and shape_club_item emits
them for KIT_SUBTYPE only. Badges and stadiums deliberately do not gain the
fields - the slot mapping is kit-specific, and sending a family a field its
resolver does not read is how this project previously froze the client.

Also corrects the Vault note: item+0xba is `year`, not `kittype`. The side comes
from itemState 101/102 and the slot from category.

Two things this does NOT fix, both client-owned and recorded rather than
guessed:
  - the runtime teamkits clone into FUT club 130000 (the kit ART) is gated on
    item+0x60 == 4, and +0x60 has no wire atom at all: the deserialiser
    unconditionally zeroes it, the only CMP against 4 in the whole DLL is
    0x1801c34f1, and a live probe measured it as 1 for players / 0 for staff,
    never 4. The existing lib.rs claim that a server can never produce 4 is
    CONFIRMED, though its stated reason was a live observation rather than the
    real one (no wire atom exists).
  - whether a year==0 kit needs an explicit LOCKED write is UNKNOWN: CardsDLL
    only writes LOCKED for year != 0, and the engine's default for an untouched
    descriptor is in Denuvo-packed FIFA17.exe.
2026-08-23 04:30:19 +00:00

573 lines
23 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,
/// Kit slot family (`club_items.json → kits[].category`): `2` home, `3`
/// away, `5` third. Zero for definitions that do not use it.
///
/// Load-bearing for the pre-match kit selector, not cosmetic. The client's
/// active-kit resolver (`FUN_1800d73d0`) reads it at record `+0xb8` and maps
/// it to the engine's kit SLOT — 2→0, 3→1, 5→3 — which then forms part of
/// the `(teamid, year, slot)` triple the kit descriptor
/// (`sub_180033430`) must match. Omit it and the triple cannot match, so the
/// engine falls through to its own catalogue kit and reports the kit as
/// locked.
pub category: i64,
/// Kit season (`club_items.json → kits[].year`), `0` for a current-season
/// kit and e.g. `2002` for a historical one.
///
/// Record `+0xba`, atom `0x389`. The third member of the identity triple
/// above, and the key the runtime `teamkits` clone queries on.
pub year: 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>,
/// Kit slot family (2 home / 3 away / 5 third); absent → `0`.
#[serde(default)]
category: Option<i64>,
/// Kit season; absent → `0` (current season).
#[serde(default)]
year: 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),
category: rc.category.unwrap_or(0),
year: rc.year.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);
}
}