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
+74 -9
View File
@@ -65,6 +65,8 @@ const S_KITS: i64 = 0x28;
const S_KITS_HOME: i64 = 0x29;
const S_KITS_AWAY: i64 = 0x2A;
const S_BADGES: i64 = 0x2D;
const S_STADIA: i64 = 0x14;
const S_BALLS: i64 = 0x1E;
/// First `carddbid` of the AWAY kit family. `fcc_kitcards` is split into a
/// `63xxxxx` home family and a `64xxxxx` away family, and the table's own
@@ -206,10 +208,10 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
g.insert(sid, 0);
}
let mut staff_total = 0i64;
for it in items
.iter()
.filter(|i| matches!(i.kind, ContentKind::Staff))
{
// A manager counts INSIDE the staff total (`staffManager` is a bucket within
// it), so this selects the whole staff FAMILY, not `ContentKind::Staff`
// alone — a `manager`-classified row would otherwise vanish from the panel.
for it in items.iter().filter(|i| i.kind.is_staff_family()) {
if let Some(sid) = staff_stat(it.subtype) {
*g.get_mut(&sid).unwrap() += 1;
staff_total += 1;
@@ -237,13 +239,23 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
}
g.insert(S_CONSUMABLES, cons_total);
// Club items: kits are Core-owned and counted (total plus the home/away
// family split); unimplemented families stay honest zeros.
for sid in [
0x14, 0x1E, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
] {
// Club items. THESE COUNTS ARE THE GATE: the client does not ask for a
// family's items until club/stats reports a non-zero count for it (proven by
// the consumables round, where two rounds of item work sat unrequested
// because this panel answered zero). They are plain ints read by the same
// getter/publisher shape as the live-proven PLAYERS_EMPLOYED rows, so every
// family Core can own is counted here — including the ones whose ITEM record
// shape is still withheld, because a count cannot desync a parser and a zero
// guarantees the family is never even asked about. Unowned families stay
// honest zeros.
for sid in [0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38] {
g.entry(sid).or_insert(0);
}
let count_kind =
|want: ContentKind| items.iter().filter(|item| item.kind == want).count() as i64;
g.insert(S_STADIA, count_kind(ContentKind::Stadium));
g.insert(S_BALLS, count_kind(ContentKind::Ball));
g.insert(S_BADGES, count_kind(ContentKind::Badge));
let kits: Vec<&ClubStatInput> = items
.iter()
.filter(|item| matches!(item.kind, ContentKind::Kit))
@@ -414,6 +426,59 @@ mod tests {
assert_eq!(g["staffManager"], 0);
}
/// A row Core classifies as `manager` must still land in the STAFF bucket and
/// in `staffManager`: the client's own model counts a manager inside its staff
/// total, and the two encodings (`manager`, or `staff` + subtype 4) are the
/// same card.
#[test]
fn a_manager_counts_inside_staff_under_either_kind_token() {
for kind in [ContentKind::Manager, ContentKind::Staff] {
let mut manager = staff(4);
manager.kind = kind;
let g = global(&club_stats_body(&[manager, staff(8)], ContextField::Nation));
assert_eq!(g["staffManager"], 1, "kind={}", kind.as_str());
assert_eq!(
g["staff"],
2,
"the manager is INSIDE the staff total (kind={})",
kind.as_str()
);
assert_eq!(g["staffFitnessCoach"], 1);
assert_eq!(g["players"], 0, "a manager is not a player");
}
}
/// The count is the GATE: the client will not ask for a family's items until
/// this panel reports a non-zero count for it, so an owned badge/ball/stadium
/// must be counted even while its item record is withheld.
#[test]
fn owned_club_items_are_counted_per_family() {
let club_item = |kind: ContentKind, subtype: i64| ClubStatInput {
kind,
subtype,
rating: 0,
rare: false,
asset_id: 0,
nation_id: None,
league_id: None,
team_id: None,
};
let items = vec![
club_item(ContentKind::Badge, 11),
club_item(ContentKind::Badge, 11),
club_item(ContentKind::Ball, 30),
club_item(ContentKind::Stadium, 10),
kit(),
];
let g = global(&club_stats_body(&items, ContextField::Nation));
assert_eq!(g["badges"], 2);
assert_eq!(g["balls"], 1);
assert_eq!(g["stadia"], 1);
assert_eq!(g["kits"], 1);
assert_eq!(g["players"], 0, "no club item is ever a player");
assert_eq!(g["leagueLogos"], 0, "not an ownable Core kind: honest zero");
}
#[test]
fn consumables_by_family() {
// 54 gk_training, 201 player_contract, 217 healing, 258 player_playstyle