fix(fifa17): consumable stacks carry their real quick-sell value

A production club displayed "Quick sell for 0 coins" for contract cards that
Core would have paid 3/13/32 for. The consumables stack wrapper hard-coded
discardValue (atom 0xd7) to 0.

The old rationale was that the client prices the card itself, the way it does
when we omit discardValue from an item. That is true of the ITEM record and not
of the STACK, and the evidence separates them cleanly:

  * item+0x38 non-zero makes the client SKIP its local computation and show our
    number -- re-proven on the live production client, 16/16 resident cards
    "SERVER-SHOWN (local calc skipped)", including the acceptance card
    235066 -> 40.
  * We send no discardValue inside a consumable's item, so +0x38 is 0 and the
    local computation DOES run and fills +0x3c correctly -- Milestone 1 measured
    3/3/32/38 there, matching this table.
  * The screen still 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 number belongs on the stack, and it is the SAME
discard::value_for_definition that computes the 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. An
unpriceable definition stays 0 rather than inventing a number.

Verified on staging across every populated family, 16/16 stacks shown ==
recovered, none zero:
  contracts 32/3/13 · healing 32/3 · training 3/13/34 · playstyle 38/38/38
  · position 36/38/38/38/38

Two tests lock it: the payout equality (with the exact 3/13/32 the production
club would have been shortchanged on) and per-card-not-per-stack pricing for a
collapsed count=3 stack.

No payout logic changed, no taxonomy change, no ownership change, no Python.
250 adapter tests, 122 host, clippy -D warnings clean, fmt clean.
This commit is contained in:
funman300
2026-08-22 02:00:40 +00:00
parent 88b4cad780
commit ba19954ffb
+111 -5
View File
@@ -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.