diff --git a/openfut-import-fifa17/src/lib.rs b/openfut-import-fifa17/src/lib.rs index 4858a50..f4ec8e1 100644 --- a/openfut-import-fifa17/src/lib.rs +++ b/openfut-import-fifa17/src/lib.rs @@ -160,19 +160,60 @@ pub fn load_profile(path: impl AsRef) -> Result { // ------------------------------------------------------------- classification +/// A club item's family, settled from the CardsDLL club-item resolver +/// (`FUN_180119bd0`, `plan-2026-08-06-card-subsystem.md`) and cross-checked +/// against the shipped definition tables. Each family's `cardassetid` is +/// CONSTANT across every row it ships, which is what makes it a usable gate: +/// kit 35 (1482 rows), stadium 36 (78), badge 39 (656), ball 37 (42), +/// league logo 40 (44). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClubFamily { + pub kind: ContentKind, + pub label: &'static str, + /// The `cardassetid` every definition in this family carries. + pub card_asset_id: i64, +} + +/// Club-item `cardsubtypeid` -> family. These subtypes cannot collide with the +/// other classes: consumables occupy 51..=341 and staff 4/6/8. +pub fn club_family(subtype: i64) -> Option { + let f = |kind, label, card_asset_id| { + Some(ClubFamily { + kind, + label, + card_asset_id, + }) + }; + match subtype { + 9 => f(ContentKind::Kit, "Kit", 35), + 10 => f(ContentKind::Stadium, "Stadium", 36), + 11 => f(ContentKind::Badge, "Club Badge", 39), + 30 => f(ContentKind::Ball, "Ball", 37), + // League logos have no equipped slot of their own, so Core holds them + // as generic owned content rather than inventing a designation. + 31 => f(ContentKind::Misc, "League Logo", 40), + _ => None, + } +} + /// The disjoint source item classes. Every source item is exactly one. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ItemClass { PlayerCard, Consumable, Staff, - Kit, + /// A club item (kit / stadium / badge / ball / league logo). + Club(ClubFamily), Other, } pub fn classify(item: &Item) -> ItemClass { - if item.cardsubtypeid == Some(9) && (6_300_000..=6_400_654).contains(&item.resource_id) { - return ItemClass::Kit; + // Subtype is the settled discriminator, so a club item is recognised by what + // it IS rather than by the id range it happens to occupy — kits used to be + // matched on 6_300_000..=6_400_654, which classified every other club family + // as `Other` and silently dropped it from the import. + if let Some(family) = item.cardsubtypeid.and_then(club_family) { + return ItemClass::Club(family); } match item.item_type.as_str() { "staff" => ItemClass::Staff, @@ -193,13 +234,15 @@ pub struct ItemCounts { pub player_cards: usize, pub consumables: usize, pub staff: usize, - pub kits: usize, + /// Club items: kits, stadiums, badges, balls, league logos. + pub club_items: usize, pub other: usize, } impl ItemCounts { pub fn balances(&self) -> bool { - self.player_cards + self.consumables + self.staff + self.kits + self.other == self.total + self.player_cards + self.consumables + self.staff + self.club_items + self.other + == self.total } } @@ -213,7 +256,7 @@ pub fn count_items(profile: &Profile) -> ItemCounts { ItemClass::PlayerCard => c.player_cards += 1, ItemClass::Consumable => c.consumables += 1, ItemClass::Staff => c.staff += 1, - ItemClass::Kit => c.kits += 1, + ItemClass::Club(_) => c.club_items += 1, ItemClass::Other => c.other += 1, } } @@ -605,7 +648,7 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan { for it in &profile.items { if matches!( classify(it), - ItemClass::Consumable | ItemClass::Staff | ItemClass::Kit + ItemClass::Consumable | ItemClass::Staff | ItemClass::Club(_) ) { groups.entry(it.resource_id).or_default().push(it); } @@ -698,8 +741,24 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan { continue; } }, - ItemClass::Kit if subtype == 9 => { - if card_asset_id != Some(35) || team_id.is_none() { + ItemClass::Club(family) => { + // Every definition in a club family ships the SAME cardassetid, + // so a copy carrying anything else is not the item it claims to + // be and is deferred rather than rendered as the wrong art. + if card_asset_id != Some(family.card_asset_id) { + plan.deferred.push(DeferredNonPlayer { + resource_id, + subtype: Some(subtype), + wire_ids, + reason: "missing_club_item_render_metadata".to_string(), + }); + continue; + } + // A kit additionally needs its team: the kit identity resolver + // keys on it, and this requirement is observed on real owned + // kits. The other families ship no teamid at all, so demanding + // one would defer every legitimate badge, ball and stadium. + if family.kind == ContentKind::Kit && team_id.is_none() { plan.deferred.push(DeferredNonPlayer { resource_id, subtype: Some(subtype), @@ -708,15 +767,17 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan { }); continue; } - (ContentKind::Kit, "Kit") + (family.kind, family.label) } - _ => unreachable!("only Consumable/Staff/Kit were grouped"), + _ => unreachable!("only Consumable/Staff/Club were grouped"), }; plan.supported.push(NonPlayerDefinition { card_id: format!("fifa17_{resource_id}"), resource_id, - asset_id: if class == ItemClass::Kit { + // A club item's carddbid IS its asset id; the source may not repeat + // it in an `assetId` field. + asset_id: if matches!(class, ItemClass::Club(_)) { Some(resource_id) } else { items[0].asset_id @@ -896,7 +957,7 @@ impl Report { self.counts.player_cards, self.counts.consumables, self.counts.staff, - self.counts.kits, + self.counts.club_items, self.counts.other, self.counts.total )); @@ -989,12 +1050,12 @@ impl std::fmt::Display for Report { )?; writeln!( f, - "\nSOURCE ITEMS total={} player_cards={} consumables={} staff={} kits={} other={} balances={}", + "\nSOURCE ITEMS total={} player_cards={} consumables={} staff={} club_items={} other={} balances={}", self.counts.total, self.counts.player_cards, self.counts.consumables, self.counts.staff, - self.counts.kits, + self.counts.club_items, self.counts.other, self.counts.balances() )?; diff --git a/openfut-import-fifa17/src/tests.rs b/openfut-import-fifa17/src/tests.rs index c45493d..7b2e6e5 100644 --- a/openfut-import-fifa17/src/tests.rs +++ b/openfut-import-fifa17/src/tests.rs @@ -83,7 +83,7 @@ fn classification_balances_across_disjoint_classes() { c.player_cards, c.consumables, c.staff, - c.kits, + c.club_items, c.other ), (5, 2, 1, 1, 1, 0) @@ -767,14 +767,86 @@ fn disagreeing_render_metadata_defers_rather_than_guessing() { } #[test] -fn kit_missing_render_metadata_defers() { +fn club_item_missing_render_metadata_defers() { + // No cardassetid at all: it cannot be drawn, whatever family it claims. let item = r#"{"id":100000501,"resourceId":6300006,"assetId":6300006, "cardsubtypeid":9,"itemState":"activeHomeKit"}"# .to_string(); let plan = plan_non_player_definitions(&profile(&[item], "[]", 100000600)); assert!(plan.supported.is_empty()); assert_eq!(plan.deferred.len(), 1); + assert_eq!( + plan.deferred[0].reason, + "missing_club_item_render_metadata" + ); + + // Correct kit art, but no team: the kit identity resolver keys on teamid. + let item = r#"{"id":100000502,"resourceId":6300007,"assetId":6300007, + "cardsubtypeid":9,"cardassetid":35,"itemState":"activeHomeKit"}"# + .to_string(); + let plan = plan_non_player_definitions(&profile(&[item], "[]", 100000600)); + assert!(plan.supported.is_empty()); assert_eq!(plan.deferred[0].reason, "missing_kit_render_metadata"); + + // A kit carrying another family's art is not the item it claims to be. + let item = r#"{"id":100000503,"resourceId":6300008,"assetId":6300008, + "cardsubtypeid":9,"cardassetid":39,"teamid":21}"# + .to_string(); + let plan = plan_non_player_definitions(&profile(&[item], "[]", 100000600)); + assert!(plan.supported.is_empty()); + assert_eq!( + plan.deferred[0].reason, + "missing_club_item_render_metadata" + ); +} + +/// Club items are settled by `cardsubtypeid`, not by the id range they occupy. +/// Keying kits off 6_300_000..=6_400_654 classified every OTHER club family as +/// `Other`, so a badge, ball, stadium or league logo was silently dropped from +/// the import even though its definition table ships with the game. +#[test] +fn every_club_family_classifies_and_imports() { + let club = |id: i64, resource: i64, subtype: i64, art: i64| { + format!( + r#"{{"id":{id},"resourceId":{resource},"assetId":{resource}, + "cardsubtypeid":{subtype},"cardassetid":{art},"teamid":21}}"# + ) + }; + let items = vec![ + club(100000501, 6_300_006, 9, 35), // kit + club(100000502, 6_200_001, 10, 36), // stadium + club(100000503, 6_000_012, 11, 39), // badge + club(100000504, 8_120_194, 30, 37), // ball + club(100000505, 8_010_001, 31, 40), // league logo + ]; + let counts = count_items(&profile(&items, "[]", 100000600)); + assert_eq!(counts.club_items, 5, "no club family falls through to Other"); + assert_eq!(counts.other, 0); + + let plan = plan_non_player_definitions(&profile(&items, "[]", 100000600)); + assert!(plan.deferred.is_empty(), "{:?}", plan.deferred); + let kind = |cid: &str| { + plan.supported + .iter() + .find(|d| d.card_id == cid) + .unwrap_or_else(|| panic!("{cid} not imported")) + .kind + }; + assert_eq!(kind("fifa17_6300006"), ContentKind::Kit); + assert_eq!(kind("fifa17_6200001"), ContentKind::Stadium); + assert_eq!(kind("fifa17_6000012"), ContentKind::Badge); + assert_eq!(kind("fifa17_8120194"), ContentKind::Ball); + // A league logo has no equipped slot, so it is generic owned content. + assert_eq!(kind("fifa17_8010001"), ContentKind::Misc); + // A club item's carddbid IS its asset id. + assert_eq!( + plan.supported + .iter() + .find(|d| d.card_id == "fifa17_6000012") + .unwrap() + .asset_id, + Some(6_000_012) + ); } #[test]