diff --git a/openfut-adapter-fifa17/src/fut/consumables.rs b/openfut-adapter-fifa17/src/fut/consumables.rs index bd10aaf..e6ff8fc 100644 --- a/openfut-adapter-fifa17/src/fut/consumables.rs +++ b/openfut-adapter-fifa17/src/fut/consumables.rs @@ -33,8 +33,40 @@ 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 @@ -43,10 +75,10 @@ use crate::fut::item::{shape_consumable_item, Fifa17ConsumableIdentity, ShapeSta /// 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. +/// `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 @@ -78,7 +110,7 @@ pub fn consumables_response(items: &[Fifa17ConsumableIdentity]) -> (Value, Shape order.push(id.resource_id); stacks.push(json!({ "count": 1, - "discardValue": 0, + "discardValue": stack_discard_value(id), "item": shape_consumable_item(*id), "resourceId": id.resource_id, "untradeableCount": i64::from(id.untradeable), @@ -109,6 +141,80 @@ mod tests { } } + /// 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.