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
@@ -0,0 +1,230 @@
//! The FIFA 17 **consumables screen** response — `GET …/club/consumables/<category>`.
//!
//! Consumables are NOT a `club?type=` family. A previous round shipped four
//! `?type=` arms for them and the screen stayed empty, because the client asks
//! HERE — and it asks only once `club/stats/consumables` reports a non-zero count
//! for the family, so the counter in [`crate::fut::club_stats`] is the gate and
//! this route is the door. Before that was known, the path fell through the
//! generic `/club` PREFIX and the consumables screen was answered with the club's
//! player list.
//!
//! ## The element is a STACK WRAPPER, not an item
//!
//! Learned the hard way (live, 2026-08-05): bare items here were ACCEPTED and did
//! nothing — the client's card map afterwards held only the squad, and the screen
//! stayed empty with no error anywhere. `FutConsumablesSearchServerResponse`
//! (RS4 literal `0x1802222f8`, factory `0x180130a10`, vtable `0x180222200`,
//! deserializer `+0x08` = `0x180130d10`) reads `itemData` (atom 0x16b) at the root
//! like the club list, but its ELEMENT is a five-atom wrapper of which exactly one
//! atom carries the item:
//!
//! | atom | key | |
//! |---|---|---|
//! | 0xbc | `count` | copies in the stack |
//! | 0xd7 | `discardValue` | |
//! | 0x16a | `item` | → `FUN_18013fe00`, the item parser itself |
//! | 0x287 | `resourceId` | the stack's identity |
//! | 0x362 | `untradeableCount` | drives a UI flag as `untradeableCount < count` |
//!
//! Everything else falls to the value-SKIP handler, which is exactly why a bare
//! item was silently discarded. It is also why FUT draws consumables as one card
//! with a quantity badge rather than N cards: identical copies COLLAPSE by
//! `resourceId` here.
use serde_json::{json, Value};
use crate::fut::item::{shape_consumable_item, Fifa17ConsumableIdentity, ShapeStats};
/// Build the consumables-screen body from the club's owned consumable copies.
///
/// Copies are collapsed by `resourceId` into one stack each, in first-seen order
/// (deterministic: Core's own owned order), with `count` and `untradeableCount`
/// counted over the copies. A copy whose definition is incomplete is DROPPED and
/// counted — see [`Fifa17ConsumableIdentity::is_renderable`]; drawing "-1" or a
/// different item than the club owns is worse than omitting the stack.
///
/// `discardValue` is `0`: the client computes a card's own quick-sell price from
/// `fcc_discardcoins` on `(cardtype 6, level, rare)`, and `0` is the value the
/// live-proven oracle sends on this route. Inventing a price from the player
/// quick-sell table would be a fabricated number the client does not need.
///
/// The stack's `item` is the FIRST copy, so its `id` is a real owned wire id — a
/// later item operation on the stack therefore addresses a card the club really
/// owns. (Which copy a quick-sell of a whole stack should consume is a lifecycle
/// question, not a projection one, and is not decided here.)
pub fn consumables_response(items: &[Fifa17ConsumableIdentity]) -> (Value, ShapeStats) {
let mut stats = ShapeStats::default();
// (resource_id, index into `stacks`) — a Vec keeps first-seen order without a
// second sort, and a club holds tens of stacks, not thousands.
let mut order: Vec<u32> = Vec::new();
let mut stacks: Vec<Value> = Vec::new();
for id in items {
if !id.is_renderable() {
stats.dropped_incomplete += 1;
continue;
}
stats.emitted += 1;
match order.iter().position(|r| *r == id.resource_id) {
Some(i) => {
let stack = stacks[i].as_object_mut().expect("stack is an object");
let count = stack["count"].as_i64().unwrap_or(0) + 1;
stack["count"] = json!(count);
if id.untradeable {
let untradeable = stack["untradeableCount"].as_i64().unwrap_or(0) + 1;
stack["untradeableCount"] = json!(untradeable);
}
}
None => {
order.push(id.resource_id);
stacks.push(json!({
"count": 1,
"discardValue": 0,
"item": shape_consumable_item(*id),
"resourceId": id.resource_id,
"untradeableCount": i64::from(id.untradeable),
}));
}
}
}
(json!({ "itemData": stacks }), stats)
}
#[cfg(test)]
mod tests {
use super::*;
/// A play-style card (category 9): `amount` mandatory, art id 50.
fn playstyle(item_id: u32, resource_id: u32) -> Fifa17ConsumableIdentity {
Fifa17ConsumableIdentity {
item_id,
resource_id,
asset_id: resource_id,
card_asset_id: 50,
subtype: 258,
rareflag: 0,
rating: 95,
amount: Some(2),
contract: None,
untradeable: true,
}
}
#[test]
fn identical_copies_collapse_into_one_counted_stack() {
// Two copies of 5003103 plus one of 5003112 → two stacks, counts 2 and 1.
let items = vec![
playstyle(100000293, 5_003_103),
playstyle(100000326, 5_003_112),
playstyle(100000294, 5_003_103),
];
let (body, stats) = consumables_response(&items);
assert_eq!(stats.emitted, 3, "every copy is accounted for");
let stacks = body["itemData"].as_array().unwrap();
assert_eq!(stacks.len(), 2, "collapsed by resourceId");
assert_eq!(stacks[0]["resourceId"], 5_003_103);
assert_eq!(stacks[0]["count"], 2);
assert_eq!(stacks[0]["untradeableCount"], 2);
assert_eq!(stacks[1]["resourceId"], 5_003_112);
assert_eq!(stacks[1]["count"], 1);
// The five wrapper atoms and nothing else: anything extra falls to the
// value-SKIP handler and only misleads the next reader.
let mut keys: Vec<&str> = stacks[0]
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(
keys,
vec![
"count",
"discardValue",
"item",
"resourceId",
"untradeableCount"
]
);
// The item rides inside the wrapper, not beside it.
assert_eq!(stacks[0]["item"]["id"], 100000293);
assert_eq!(stacks[0]["item"]["cardsubtypeid"], 258);
assert_eq!(stacks[0]["item"]["cardassetid"], 50);
}
#[test]
fn a_tradeable_copy_lowers_untradeable_count_below_the_stack_count() {
// The client's UI flag is `untradeableCount < count`, so the two numbers
// must be counted over the same copies.
let mut tradeable = playstyle(100000295, 5_003_103);
tradeable.untradeable = false;
let items = vec![playstyle(100000293, 5_003_103), tradeable];
let (body, _) = consumables_response(&items);
let stack = &body["itemData"][0];
assert_eq!(stack["count"], 2);
assert_eq!(stack["untradeableCount"], 1);
}
#[test]
fn incomplete_definitions_are_dropped_and_counted_never_drawn_wrong() {
// (a) a play style with no `amount` would draw "-1" on the card;
let mut no_amount = playstyle(100000293, 5_003_103);
no_amount.amount = None;
// (b) rareflag on 219 turns Player Fitness into SQUAD Fitness;
let trap = Fifa17ConsumableIdentity {
item_id: 100000300,
resource_id: 5_002_030,
asset_id: 5_002_030,
card_asset_id: 9,
subtype: 219,
rareflag: 1,
rating: 70,
amount: Some(10),
contract: None,
untradeable: true,
};
// (c) a subtype in no documented range renders as a plausible Squad
// Training (Pace) card with amount 0.
let mut dead_zone = playstyle(100000301, 5_003_999);
dead_zone.subtype = 137;
// (d) no `card_asset_id` in the catalog → the resolver defaulted it to
// the asset id and the client would draw the notfound.swf green box.
let mut no_art = playstyle(100000302, 5_003_104);
no_art.card_asset_id = no_art.asset_id;
let (body, stats) = consumables_response(&[no_amount, trap, dead_zone, no_art]);
assert_eq!(stats.emitted, 0);
assert_eq!(stats.dropped_incomplete, 4);
assert_eq!(body["itemData"].as_array().unwrap().len(), 0);
}
#[test]
fn a_contract_card_carries_contract_and_no_amount() {
let contract = Fifa17ConsumableIdentity {
item_id: 100000294,
resource_id: 5_001_004,
asset_id: 5_001_004,
card_asset_id: 7,
subtype: 201,
rareflag: 0,
rating: 60,
amount: None,
contract: Some(7),
untradeable: true,
};
let (body, stats) = consumables_response(&[contract]);
assert_eq!(stats.emitted, 1);
let item = &body["itemData"][0]["item"];
assert_eq!(item["contract"], 7);
assert!(
item.get("amount").is_none(),
"categories 2 and 3 ignore `amount` entirely"
);
}
#[test]
fn an_empty_club_is_an_empty_itemdata_not_a_missing_key() {
let (body, stats) = consumables_response(&[]);
assert_eq!(stats.emitted, 0);
assert!(body["itemData"].as_array().unwrap().is_empty());
assert_eq!(body.as_object().unwrap().len(), 1, "only itemData at root");
}
}