feat(import-fifa17): classify every club family, not just kits

Kits were recognised by the id range 6_300_000..=6_400_654, so stadiums,
badges, balls and league logos all fell through to `Other` and were silently
dropped from the import — a club could own them and Core would never hear.

Club items are now settled by `cardsubtypeid` (kit 9, stadium 10, badge 11,
ball 30, league logo 31), which is the discriminator the client's own club-item
resolver uses and cannot collide with the other classes: consumables occupy
51..=341 and staff 4/6/8.

The render gate generalises with it. Each family ships a CONSTANT cardassetid
(kit 35, stadium 36, ball 37, badge 39, logo 40 — verified across all 2302
shipped rows), so a copy carrying anything else is deferred rather than drawn
as the wrong art. Only kits additionally require a teamid, because their
identity resolver keys on it and real owned kits carry it; demanding one of the
other families would defer every legitimate badge, ball and stadium.

League logos map to `misc`: they have no equipped slot, so Core holds them as
generic owned content rather than inventing a designation.

The real profile is unaffected (it owns no club items beyond its kits) and its
emitted catalog is byte-identical.
This commit is contained in:
funman300
2026-08-21 20:19:04 +00:00
parent 23f120f889
commit 7a4dab04d2
2 changed files with 150 additions and 17 deletions
+76 -15
View File
@@ -160,19 +160,60 @@ pub fn load_profile(path: impl AsRef<Path>) -> Result<Profile> {
// ------------------------------------------------------------- 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<ClubFamily> {
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()
)?;
+74 -2
View File
@@ -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]