feat(fifa17): project every owned content kind, from one recovered vocabulary

Extends the FIFA17 adapter past players so the wire can carry the rest of a
real club's inventory.

itemState: the recovered 12-row table at 0x180229cc0 becomes the single source
(`fut::item_state`), replacing scattered literals. Every shaper draws from it
and the tests assert no shaper can emit a state the client does not know.
CARD_SYSTEM.md's 0x180229d20 is the middle of that table, not its start.

ContentKind covers all nine tokens. Managers stay inside the staff family for
counting, because the client's own club-stats model puts a manager INSIDE the
staff total with staffManager as a sub-bucket — a parallel Manager kind would
silently under-count.

Consumables get their own route (`club/consumables/<category>`) and a
stack-wrapper envelope, classified BEFORE the other club/ arms; they are not a
`?type=` family. This path previously fell through to Python, so owned
inventory was being served by the oracle.

The shaper refuses to emit a card it cannot render: no known art id, or a
missing `amount`/`contract` for the families that read them, or the subtype-219
rareflag trap that silently turns Player Fitness into Squad Fitness. A dropped
card is counted and logged, never faked.
This commit is contained in:
funman300
2026-08-21 19:49:52 +00:00
parent dcd470cddc
commit 6c7d0856b6
8 changed files with 1215 additions and 30 deletions
@@ -12,11 +12,12 @@ 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_state;
// Re-exported so existing `club_response::{…}` callers keep working; the types
// are now defined once in `fut::item`.
pub use crate::fut::item::{
CoreOwnedItem, Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver,
ShapeStats,
CoreOwnedItem, Fifa17ConsumableIdentity, Fifa17Identity, Fifa17KitIdentity,
Fifa17StaffIdentity, ItemIdentityResolver, ShapeStats,
};
/// Active club-level kit roles, keyed by Core owned-instance id.
@@ -36,7 +37,11 @@ pub fn shape_club_response<I: ItemIdentityResolver + ?Sized>(
}
/// Shape `/club` items, including ownership-backed active kit designations.
/// Consumables/staff remain excluded because they use separate wire envelopes.
///
/// This envelope carries the two families whose record shape it can carry:
/// players and kits, plus the staff family (manager + the four coach families).
/// Consumables have their own route and their own STACK envelope, and the
/// club-customisation families are counted and withheld — see each arm.
pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
items: &[CoreOwnedItem],
ent: &impl ReverseEntityResolver,
@@ -56,28 +61,50 @@ pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
},
ContentKind::Kit => match ident.resolve_kit(item) {
Some(id) => {
let item_state = if active_kits.home == Some(item.owned_card_id.as_str()) {
"activeHomeKit"
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()) {
"activeAwayKit"
item_state::ACTIVE_AWAY_KIT
} else {
"free"
item_state::FREE
};
out.push(shape_kit_item(id, item_state));
out.push(shape_kit_item(id, state));
stats.emitted += 1;
}
None => stats.dropped_no_asset += 1,
},
ContentKind::Staff => match ident.resolve_staff(item) {
// 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
// because a guard arm would not prove exhaustiveness).
ContentKind::Manager | ContentKind::Staff => match ident.resolve_staff(item) {
Some(id) => {
out.push(shape_staff_item(id, STAFF_CONTRACT));
stats.emitted += 1;
}
None => stats.dropped_no_asset += 1,
},
// Consumables have their OWN route and their own envelope:
// `GET club/consumables/<category>`, whose element is a stack
// wrapper, not an item (see [`crate::fut::consumables`]). A bare
// consumable item in THIS envelope is accepted by the client and
// silently discarded, so emitting one here would be a 200 that does
// nothing — the worst failure shape in this project. Counted.
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 => {
stats.excluded_non_player += 1;
}
}
}
(json!({ "itemData": out }), stats)