//! The FIFA 17 **consumables screen** response — `GET …/club/consumables/`. //! //! 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 = Vec::new(); let mut stacks: Vec = 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"); } }