6c7d0856b6
Extends the FIFA17 adapter past players so the wire can carry the rest of a real club's inventory. itemState: the recovered 12-row table at 0x180229cc0 becomes the single source (`fut::item_state`), replacing scattered literals. Every shaper draws from it and the tests assert no shaper can emit a state the client does not know. CARD_SYSTEM.md's 0x180229d20 is the middle of that table, not its start. ContentKind covers all nine tokens. Managers stay inside the staff family for counting, because the client's own club-stats model puts a manager INSIDE the staff total with staffManager as a sub-bucket — a parallel Manager kind would silently under-count. Consumables get their own route (`club/consumables/<category>`) and a stack-wrapper envelope, classified BEFORE the other club/ arms; they are not a `?type=` family. This path previously fell through to Python, so owned inventory was being served by the oracle. The shaper refuses to emit a card it cannot render: no known art id, or a missing `amount`/`contract` for the families that read them, or the subtype-219 rareflag trap that silently turns Player Fitness into Squad Fitness. A dropped card is counted and logged, never faked.
231 lines
9.5 KiB
Rust
231 lines
9.5 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::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<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": 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");
|
|
}
|
|
}
|