feat(fifa17): project badges and stadiums, the other two cardtype-7 club items

Kit, badge and stadium are ONE record with ONE client-side resolver
(`FUN_180119bd0`, dispatched on `item+0x4c == 7`); they differ only in the field
their caption reads. Kits already ship and render, so the record is live-proven
— badges and stadiums were being withheld as if unreversed when the authority
(`plan-2026-08-06-card-subsystem.md`) marks both CONFIRMED, and its own rollout
order is "kits first, then badges, then stadia".

So the shaper generalises to the family, and carries exactly what each caption
resolves: `teamid` for kit and badge (`TeamName_Abbr15_<teamid>`), withheld for
stadium, whose resolver reads `StadiumName_<assetId>` and never looks at teamid.
Sending a field the resolver does not read is how this project earned a client
freeze.

Ball (30) and league logo (31) stay withheld. They are cardtype 9 with NO
database name resolver, so their name can only come from `localizedName`: the
offset is confirmed, but "the parser reads it" is not "sending it is safe".

Verified against the real club on staging: badge 6000005 emits cardsubtypeid 11
/ cardassetid 39 / teamid 21, stadium 6200000 emits cardsubtypeid 10 /
cardassetid 36 and no teamid, ball and logo emit nothing.
This commit is contained in:
funman300
2026-08-21 20:45:29 +00:00
parent 7a4dab04d2
commit 9823bdac78
5 changed files with 217 additions and 37 deletions
+102 -24
View File
@@ -11,7 +11,7 @@ use serde_json::{json, Value};
use crate::fut::content_taxonomy::ContentKind;
use crate::fut::entities::ReverseEntityResolver;
use crate::fut::item::{shape_item, shape_kit_item, shape_staff_item, STAFF_CONTRACT};
use crate::fut::item::{shape_club_item, shape_item, shape_staff_item, STAFF_CONTRACT};
use crate::fut::item_state;
// Re-exported so existing `club_response::{…}` callers keep working; the types
// are now defined once in `fut::item`.
@@ -59,20 +59,24 @@ pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
}
None => stats.dropped_no_asset += 1,
},
ContentKind::Kit => match ident.resolve_kit(item) {
Some(id) => {
let state = if active_kits.home == Some(item.owned_card_id.as_str()) {
item_state::ACTIVE_HOME_KIT
} else if active_kits.away == Some(item.owned_card_id.as_str()) {
item_state::ACTIVE_AWAY_KIT
} else {
item_state::FREE
};
out.push(shape_kit_item(id, state));
stats.emitted += 1;
// Kit, badge and stadium are ONE cardtype-7 record with one
// client-side resolver; only the equipped designation differs.
ContentKind::Kit | ContentKind::Badge | ContentKind::Stadium => {
match ident.resolve_kit(item) {
Some(id) => {
let state = if active_kits.home == Some(item.owned_card_id.as_str()) {
item_state::ACTIVE_HOME_KIT
} else if active_kits.away == Some(item.owned_card_id.as_str()) {
item_state::ACTIVE_AWAY_KIT
} else {
item_state::FREE
};
out.push(shape_club_item(id, state));
stats.emitted += 1;
}
None => stats.dropped_no_asset += 1,
}
None => stats.dropped_no_asset += 1,
},
}
// A manager is a staff card: both Core kinds resolve through the one
// staff record shape, discriminated on the wire by `cardsubtypeid`
// (the same set as `ContentKind::is_staff_family`, spelled out here
@@ -93,16 +97,15 @@ pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
ContentKind::Consumable => {
stats.excluded_non_player += 1;
}
// Club customisation. The SUBTYPES are settled (kit 9, stadium 10,
// badge 11, ball 30, league logo 31), but the RECORD is not: a
// badge/stadium still needs the narrow `teamid`/`assetId` test that
// the 2026-08-05 crash denied us, and a ball has no display name at
// all except `localizedName`, which this project's own rule scores as
// "the parser reads it" and NOT "sending it is safe". Counted and
// withheld rather than guessed — ownership stays authoritative in
// Core either way, and club/stats still counts these families so the
// screen's own numbers are right.
ContentKind::Badge | ContentKind::Ball | ContentKind::Stadium | ContentKind::Misc => {
// The cardtype-9 families. Unlike kits/badges/stadia these have NO
// database name resolver at all, so the displayed name can only come
// from `localizedName` on the wire. That offset is confirmed
// (`+0xd9`), but "the parser reads it" is NOT "sending it is safe",
// and this project pays for that distinction with a client freeze.
// Counted and withheld rather than guessed: ownership stays
// authoritative in Core either way, and club/stats still counts the
// families so the screen's own numbers are right.
ContentKind::Ball | ContentKind::Misc => {
stats.excluded_non_player += 1;
}
}
@@ -465,4 +468,79 @@ mod tests {
assert!(body["itemData"][0].get("attributeList").is_none());
assert!(body["itemData"][0].get("itemType").is_none());
}
/// Kit, badge and stadium are one cardtype-7 record and MUST all project.
/// Ball and league logo are cardtype 9, have no database name resolver, and
/// stay withheld until `localizedName` is established as safe to send.
/// Counting a family in club/stats while never shaping it is the divergence
/// this test pins: the wire set and the withheld set are both asserted.
#[test]
fn cardtype7_club_items_project_and_cardtype9_stay_withheld() {
let ent = entities();
let kit_id = |item_id, resource, subtype, art| Fifa17KitIdentity {
item_id,
asset_id: resource,
resource_id: resource,
card_asset_id: art,
subtype,
team_id: 21,
};
let ident = KindMapIdentity {
ids: HashMap::new(),
kinds: HashMap::from([
("c_kit".to_string(), ContentKind::Kit),
("c_badge".to_string(), ContentKind::Badge),
("c_stadium".to_string(), ContentKind::Stadium),
("c_ball".to_string(), ContentKind::Ball),
("c_logo".to_string(), ContentKind::Misc),
]),
kits: HashMap::from([
("c_kit".to_string(), kit_id(1, 6_300_006, 9, 35)),
("c_badge".to_string(), kit_id(2, 6_000_005, 11, 39)),
("c_stadium".to_string(), kit_id(3, 6_200_000, 10, 36)),
// Resolvable on purpose: withholding must be a decision about the
// FAMILY, not an accident of a missing identity.
("c_ball".to_string(), kit_id(4, 8_120_194, 30, 37)),
("c_logo".to_string(), kit_id(5, 8_010_015, 31, 40)),
]),
staff: HashMap::new(),
};
let items: Vec<CoreOwnedItem> = ["c_kit", "c_badge", "c_stadium", "c_ball", "c_logo"]
.iter()
.map(|c| item(&format!("oc_{c}"), c, 0, "", "", "", ""))
.collect();
let (body, stats) = shape_club_response_with_kits(
&items,
&ent,
&ident,
ActiveKitAssignments {
home: None,
away: None,
},
);
let arr = body["itemData"].as_array().unwrap();
assert_eq!(stats.emitted, 3, "kit + badge + stadium");
assert_eq!(stats.excluded_non_player, 2, "ball + league logo withheld");
assert_eq!(stats.dropped_no_asset, 0, "withholding is not a drop");
let subtypes: Vec<i64> = arr
.iter()
.map(|i| i["cardsubtypeid"].as_i64().unwrap())
.collect();
assert_eq!(subtypes, vec![9, 11, 10]);
// teamid only where the caption resolves TeamName_Abbr15_<teamid>.
assert_eq!(arr[0]["teamid"], 21, "kit");
assert_eq!(arr[1]["teamid"], 21, "badge");
assert!(
arr[2].get("teamid").is_none(),
"stadium caption reads assetId"
);
for it in arr {
assert!(
item_state::is_recovered(it["itemState"].as_str().unwrap()),
"every emitted state must be a recovered token"
);
}
}
}