//! 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::discard; use crate::fut::item::{shape_consumable_item, Fifa17ConsumableIdentity, ShapeStats}; /// The stack's `discardValue` (atom 0xd7) — the number the consumables screen /// DISPLAYS, per card. /// /// This used to be hard-coded `0`, on the theory that the client would compute /// the price itself from `fcc_discardcoins` the way it does for a card whose /// `discardValue` we omit. That theory was wrong, and the screen showed /// "Quick sell for 0 coins" on a real production club (operator-observed, /// 2026-08-22) while Core would have paid 3/13/32 for those same contracts. /// /// Why the old reasoning failed, from evidence rather than re-derivation: /// /// * `item+0x38` (the `discardValue` we send) non-zero makes the client SKIP its /// local computation and display our number — live-proven again on the /// production client, 16/16 resident cards `SERVER-SHOWN`. /// * We send no `discardValue` inside a consumable's `item`, so `+0x38` is 0 and /// the client's local computation DOES run, filling `+0x3c` with the right /// value — Milestone 1 measured exactly that (3/3/32/38, matching this table). /// * The screen nonetheless showed 0. So the screen is not reading the item's /// computed `+0x3c`; it reads the STACK's atom 0xd7, which we were sending as /// 0. /// /// So the value belongs here, and it is the SAME number /// [`discard::value_for_definition`] gives the quick-sell payout — one source, so /// the screen and the wallet cannot disagree. Per CARD, not per stack: FUT /// prices a card, and the stack is only a quantity badge over identical copies. /// /// `None` (definition not priceable) stays `0` rather than inventing a number. fn stack_discard_value(id: &Fifa17ConsumableIdentity) -> i64 { discard::value_for_definition(id.subtype, id.rareflag, Some(id.rating), id.rating).unwrap_or(0) } /// 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` carries the card's real quick-sell price — see /// [`stack_discard_value`]. It used to be `0` on the theory that the client /// priced the card itself; the production screen showed "Quick sell for 0 coins" /// instead, so the stack atom is what the screen reads. /// /// 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": stack_discard_value(id), "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, } } /// A contract card of the given subtype/rating — the family the production /// screen showed as "0 coins". fn contract( item_id: u32, resource_id: u32, subtype: i64, rating: u8, ) -> Fifa17ConsumableIdentity { Fifa17ConsumableIdentity { item_id, resource_id, asset_id: resource_id, card_asset_id: 7, subtype, rareflag: 0, rating, amount: None, contract: Some(1), untradeable: true, } } /// The stack atom the screen reads MUST carry the same number the quick-sell /// pays. A production club displayed "Quick sell for 0 coins" for contracts /// Core would have paid 3/13/32 for; nothing may reintroduce that gap. #[test] fn stack_discard_value_is_the_payout_and_never_a_silent_zero() { // The three contracts owned by the real production club. let items = vec![ contract(1, 5_001_004, 201, 60), contract(2, 5_001_008, 202, 65), contract(3, 5_001_009, 202, 80), ]; let (body, _) = consumables_response(&items); let stacks = body["itemData"].as_array().unwrap(); assert_eq!(stacks.len(), 3); for (stack, id) in stacks.iter().zip(items.iter()) { let shown = stack["discardValue"].as_i64().unwrap(); let paid = discard::value_for_definition(id.subtype, id.rareflag, Some(id.rating), id.rating) .expect("a contract definition is priceable"); assert_eq!( shown, paid, "displayed must equal payout for {}", id.resource_id ); assert!( shown > 0, "{} priced at 0 is the bug we just fixed", id.resource_id ); } // The exact recovered values, so a table regression is visible here too. assert_eq!(stacks[0]["discardValue"], 3); assert_eq!(stacks[1]["discardValue"], 13); assert_eq!(stacks[2]["discardValue"], 32); } /// Collapsing copies must not multiply the price: FUT prices a CARD, and the /// stack is a quantity badge over identical copies. #[test] fn stack_discard_value_is_per_card_not_per_stack() { let items = vec![ contract(1, 5_001_009, 202, 80), contract(2, 5_001_009, 202, 80), contract(3, 5_001_009, 202, 80), ]; let (body, _) = consumables_response(&items); let stacks = body["itemData"].as_array().unwrap(); assert_eq!(stacks.len(), 1); assert_eq!(stacks[0]["count"], 3); assert_eq!(stacks[0]["discardValue"], 32, "per card, not 3 x 32"); } #[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"); } }