Files
OpenFUT/openfut-adapter-fifa17/src/fut/consumables.rs
T
funman300 ba19954ffb 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.
2026-08-22 02:00:40 +00:00

337 lines
14 KiB
Rust

//! 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::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<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": 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");
}
}