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:
@@ -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()
|
||||
)?;
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user