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
+90 -9
View File
@@ -25,7 +25,8 @@
use serde_json::{json, Value};
use crate::fut::content_taxonomy::{
consumable_family, consumable_needs, ConsumableNeeds, ContentKind, MANAGER_SUBTYPE,
consumable_family, consumable_needs, ConsumableNeeds, ContentKind, BADGE_SUBTYPE, KIT_SUBTYPE,
MANAGER_SUBTYPE,
};
use crate::fut::entities::ReverseEntityResolver;
use crate::fut::item_state;
@@ -311,11 +312,29 @@ pub fn shape_item(
})
}
/// Build one FIFA 17 club-kit item. `item_state` is the proven wire enum token:
/// `free`, `activeHomeKit`, or `activeAwayKit`; the client deserializes the
/// latter two to runtime values 101 and 102.
pub fn shape_kit_item(id: Fifa17KitIdentity, item_state: &str) -> Value {
json!({
/// Build one FIFA 17 **cardtype-7 club item**: a kit (subtype 9), a badge (11)
/// or a stadium (10). `item_state` is the proven wire enum token — `free`, or
/// one of the `active*` designations the client deserializes to 100..104.
///
/// All three families share one record and one client-side resolver
/// (`FUN_180119bd0`, dispatched when `item+0x4c == 7`), differing only in the
/// field their caption reads:
///
/// * kit `FUT_UC_KITS` + `TeamName_Abbr15_<teamid>` — needs `teamid`
/// * badge `Badge` + `TeamName_Abbr15_<teamid>` — needs `teamid`
/// * stadium `Stadium` + `StadiumName_<assetId>` — needs `assetId`, which
/// `resourceId` already supplies
///
/// `teamid` (atom 0x306, record `+0x94`) is therefore emitted for kits and
/// badges and WITHHELD for stadiums, whose resolver never reads it. It is an
/// established scalar field, not a new shape.
///
/// The cardtype-9 families (ball 30, league logo 31) are deliberately NOT
/// shaped here: they have no DB name resolver, so their display name can only
/// come from `localizedName` on the wire, and while that offset is confirmed
/// it is NOT established that sending it is safe.
pub fn shape_club_item(id: Fifa17KitIdentity, item_state: &str) -> Value {
let mut item = json!({
"id": id.item_id,
"resourceId": id.resource_id,
"assetId": id.asset_id,
@@ -324,8 +343,11 @@ pub fn shape_kit_item(id: Fifa17KitIdentity, item_state: &str) -> Value {
"itemState": item_state,
"owners": 1,
"untradeable": false,
"teamid": id.team_id,
})
});
if matches!(id.subtype, KIT_SUBTYPE | BADGE_SUBTYPE) {
item["teamid"] = json!(id.team_id);
}
item
}
/// Contracts remaining on an owned staff card.
@@ -474,6 +496,7 @@ pub const CONSUMABLE_UNTRADEABLE: bool = true;
#[cfg(test)]
mod tests {
use super::*;
use crate::fut::content_taxonomy::STADIUM_SUBTYPE;
use crate::fut::entities::Fifa17Entities;
use std::collections::HashMap;
@@ -719,7 +742,7 @@ mod tests {
item_state::ACTIVE_HOME_KIT,
item_state::ACTIVE_AWAY_KIT,
] {
let it = shape_kit_item(kit, state);
let it = shape_club_item(kit, state);
emitted.push(it["itemState"].as_str().unwrap().to_string());
}
for state in &emitted {
@@ -734,4 +757,62 @@ mod tests {
no shaper may emit it deliberately either"
);
}
/// Kit, badge and stadium share ONE cardtype-7 record, but their captions do
/// not read the same field: kit and badge resolve
/// `TeamName_Abbr15_<teamid>`, while a stadium resolves
/// `StadiumName_<assetId>` and its resolver never reads teamid. Sending a
/// field the resolver does not read is how this project earned a client
/// freeze, so the record carries exactly what each family consumes.
#[test]
fn a_club_item_carries_only_the_field_its_caption_resolves() {
let ident = |subtype| Fifa17KitIdentity {
item_id: 100_000_500,
asset_id: 6_000_005,
resource_id: 6_000_005,
card_asset_id: 39,
subtype,
team_id: 21,
};
for subtype in [KIT_SUBTYPE, BADGE_SUBTYPE] {
let it = shape_club_item(ident(subtype), item_state::FREE);
assert_eq!(
it["teamid"], 21,
"subtype {subtype} resolves TeamName_Abbr15"
);
assert_eq!(it["cardsubtypeid"], subtype);
}
let stadium = shape_club_item(ident(STADIUM_SUBTYPE), item_state::FREE);
assert!(
stadium.get("teamid").is_none(),
"a stadium caption reads assetId, never teamid"
);
// The art id is the FAMILY's, never a copy of the resource id: a card
// whose card_asset_id equals its asset_id draws the notfound box.
assert_eq!(stadium["cardassetid"], 39);
assert_ne!(stadium["cardassetid"], stadium["resourceId"]);
// Every cardtype-7 family keeps the established minimal key set.
for key in [
"id",
"resourceId",
"assetId",
"cardassetid",
"cardsubtypeid",
"itemState",
"owners",
"untradeable",
] {
assert!(stadium.get(key).is_some(), "missing {key}");
}
// Never a player field: these have no rating, contract or attributes.
for key in [
"attributeList",
"contract",
"fitness",
"rating",
"discardValue",
] {
assert!(stadium.get(key).is_none(), "club item must not carry {key}");
}
}
}