diff --git a/openfut-adapter-fifa17/src/fut/catalog.rs b/openfut-adapter-fifa17/src/fut/catalog.rs index fb8e706..678c20d 100644 --- a/openfut-adapter-fifa17/src/fut/catalog.rs +++ b/openfut-adapter-fifa17/src/fut/catalog.rs @@ -54,6 +54,22 @@ pub struct Fifa17CardIdentity { /// `manager.teamid` → `leagueteamlinks.leagueid`, because `managercards` has /// no league column. Lands in the equally untouched slot `rec+0xe0`. pub league_id: i64, + /// EA's authored `rating` for a NON-PLAYER definition (`fcc_*.rating`), which + /// Core does not model: an imported consumable's Core `overall` is 0, while + /// the client's own copies carry 55..95 and the value drives the card level + /// (`rec+0x54`) and therefore its quick-sell price. `None` → the caller falls + /// back to Core's rating, which stays authoritative for players. + pub rating: Option, + /// `amount` (atom 0x1b) for a consumable definition — the bonus magnitude EA + /// authored in the `fcc_*` row (+5 / +10 / +15 …). MANDATORY for the + /// training, healing, fitness, play-style and manager-league families: + /// omitting the key draws "-1" on the card, not "0". + pub amount: Option, + /// `contract` (atom 0xb8) for a contract-card definition (`cardsubtypeid` + /// 201/202) — the number of matches the card grants. `fcc_contractcards` has + /// no amount column, so this value comes from observed data; it is never + /// defaulted here. + pub contract: Option, } /// The FIFA 17 numeric namespace policy for owned-item wire ids. @@ -182,6 +198,15 @@ struct RawCard { /// Manager chemistry league; absent → `0`. #[serde(default)] league_id: Option, + /// EA-authored rating for a non-player definition; absent → Core's rating. + #[serde(default)] + rating: Option, + /// Consumable bonus magnitude (atom 0x1b); absent → key omitted. + #[serde(default)] + amount: Option, + /// Contract-card grant (atom 0xb8); absent → key omitted. + #[serde(default)] + contract: Option, } fn default_rareflag() -> i64 { @@ -242,6 +267,9 @@ impl Fifa17CardCatalog { team_id: rc.team_id.unwrap_or(0), nation: rc.nation.unwrap_or(0), league_id: rc.league_id.unwrap_or(0), + rating: rc.rating, + amount: rc.amount, + contract: rc.contract, }, ); } @@ -327,6 +355,52 @@ mod tests { assert_eq!(cat.lookup("card_missing"), None); } + /// The non-player definition fields a consumable needs, and the ABSENCE that + /// must stay an absence: a defaulted `amount` would draw "-1" on the card and + /// a defaulted `contract` would invent the number of matches a card grants. + #[test] + fn consumable_definition_fields_are_carried_and_never_defaulted() { + let cat = Fifa17CardCatalog::from_json_str( + r#"{"schema_version":1,"game":"fifa17","cards":{ + "fifa17_5003012":{"asset_id":5003012,"kind":"consumable","subtype":54, + "card_asset_id":3,"rareflag":0,"rating":85,"amount":15}, + "fifa17_5001004":{"asset_id":5001004,"kind":"consumable","subtype":201, + "card_asset_id":7,"rareflag":0,"rating":60,"contract":7}, + "fifa17_5003059":{"asset_id":5003059,"kind":"consumable","subtype":91, + "card_asset_id":34,"rareflag":0,"rating":95}, + "fifa17_20801":{"asset_id":20801} + }}"#, + ) + .unwrap(); + // A training card: art id 3 (NOT the carddbid), EA's rating, amount 15. + let training = cat.lookup("fifa17_5003012").unwrap(); + assert_eq!(training.kind, ContentKind::Consumable); + assert_eq!(training.subtype, 54); + assert_eq!(training.card_asset_id, 3); + assert_eq!(training.rating, Some(85)); + assert_eq!(training.amount, Some(15)); + assert_eq!(training.contract, None); + // A contract card takes its number from `contract`, not `amount`. + let contract = cat.lookup("fifa17_5001004").unwrap(); + assert_eq!(contract.contract, Some(7)); + assert_eq!(contract.amount, None); + // A position modifier needs neither. + let position = cat.lookup("fifa17_5003059").unwrap(); + assert_eq!(position.amount, None); + assert_eq!(position.contract, None); + assert_eq!(position.card_asset_id, 34); + // A player carries none of them and keeps Core's authoritative rating. + let player = cat.lookup("fifa17_20801").unwrap(); + assert_eq!(player.kind, ContentKind::Player); + assert_eq!(player.rating, None); + assert_eq!(player.amount, None); + assert_eq!(player.contract, None); + assert_eq!( + player.card_asset_id, player.asset_id, + "a player's card art IS its asset id" + ); + } + #[test] fn two_cards_same_resource_is_a_conflict() { let err = Fifa17CardCatalog::from_json_str( diff --git a/openfut-adapter-fifa17/src/fut/club_response.rs b/openfut-adapter-fifa17/src/fut/club_response.rs index ad8485a..94d7bee 100644 --- a/openfut-adapter-fifa17/src/fut/club_response.rs +++ b/openfut-adapter-fifa17/src/fut/club_response.rs @@ -12,11 +12,12 @@ use serde_json::{json, Value}; use crate::fut::content_taxonomy::ContentKind; use crate::fut::entities::ReverseEntityResolver; use crate::fut::item::{shape_item, shape_kit_item, shape_staff_item, STAFF_CONTRACT}; +use crate::fut::item_state; // Re-exported so existing `club_response::{…}` callers keep working; the types // are now defined once in `fut::item`. pub use crate::fut::item::{ - CoreOwnedItem, Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver, - ShapeStats, + CoreOwnedItem, Fifa17ConsumableIdentity, Fifa17Identity, Fifa17KitIdentity, + Fifa17StaffIdentity, ItemIdentityResolver, ShapeStats, }; /// Active club-level kit roles, keyed by Core owned-instance id. @@ -36,7 +37,11 @@ pub fn shape_club_response( } /// Shape `/club` items, including ownership-backed active kit designations. -/// Consumables/staff remain excluded because they use separate wire envelopes. +/// +/// This envelope carries the two families whose record shape it can carry: +/// players and kits, plus the staff family (manager + the four coach families). +/// Consumables have their own route and their own STACK envelope, and the +/// club-customisation families are counted and withheld — see each arm. pub fn shape_club_response_with_kits( items: &[CoreOwnedItem], ent: &impl ReverseEntityResolver, @@ -56,28 +61,50 @@ pub fn shape_club_response_with_kits( }, ContentKind::Kit => match ident.resolve_kit(item) { Some(id) => { - let item_state = if active_kits.home == Some(item.owned_card_id.as_str()) { - "activeHomeKit" + let state = if active_kits.home == Some(item.owned_card_id.as_str()) { + item_state::ACTIVE_HOME_KIT } else if active_kits.away == Some(item.owned_card_id.as_str()) { - "activeAwayKit" + item_state::ACTIVE_AWAY_KIT } else { - "free" + item_state::FREE }; - out.push(shape_kit_item(id, item_state)); + out.push(shape_kit_item(id, state)); stats.emitted += 1; } None => stats.dropped_no_asset += 1, }, - ContentKind::Staff => match ident.resolve_staff(item) { + // A manager is a staff card: both Core kinds resolve through the one + // staff record shape, discriminated on the wire by `cardsubtypeid` + // (the same set as `ContentKind::is_staff_family`, spelled out here + // because a guard arm would not prove exhaustiveness). + ContentKind::Manager | ContentKind::Staff => match ident.resolve_staff(item) { Some(id) => { out.push(shape_staff_item(id, STAFF_CONTRACT)); stats.emitted += 1; } None => stats.dropped_no_asset += 1, }, + // Consumables have their OWN route and their own envelope: + // `GET club/consumables/`, whose element is a stack + // wrapper, not an item (see [`crate::fut::consumables`]). A bare + // consumable item in THIS envelope is accepted by the client and + // silently discarded, so emitting one here would be a 200 that does + // nothing — the worst failure shape in this project. Counted. ContentKind::Consumable => { stats.excluded_non_player += 1; } + // Club customisation. The SUBTYPES are settled (kit 9, stadium 10, + // badge 11, ball 30, league logo 31), but the RECORD is not: a + // badge/stadium still needs the narrow `teamid`/`assetId` test that + // the 2026-08-05 crash denied us, and a ball has no display name at + // all except `localizedName`, which this project's own rule scores as + // "the parser reads it" and NOT "sending it is safe". Counted and + // withheld rather than guessed — ownership stays authoritative in + // Core either way, and club/stats still counts these families so the + // screen's own numbers are right. + ContentKind::Badge | ContentKind::Ball | ContentKind::Stadium | ContentKind::Misc => { + stats.excluded_non_player += 1; + } } } (json!({ "itemData": out }), stats) diff --git a/openfut-adapter-fifa17/src/fut/club_stats.rs b/openfut-adapter-fifa17/src/fut/club_stats.rs index 672985d..e0d1999 100644 --- a/openfut-adapter-fifa17/src/fut/club_stats.rs +++ b/openfut-adapter-fifa17/src/fut/club_stats.rs @@ -65,6 +65,8 @@ const S_KITS: i64 = 0x28; const S_KITS_HOME: i64 = 0x29; const S_KITS_AWAY: i64 = 0x2A; const S_BADGES: i64 = 0x2D; +const S_STADIA: i64 = 0x14; +const S_BALLS: i64 = 0x1E; /// First `carddbid` of the AWAY kit family. `fcc_kitcards` is split into a /// `63xxxxx` home family and a `64xxxxx` away family, and the table's own @@ -206,10 +208,10 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value { g.insert(sid, 0); } let mut staff_total = 0i64; - for it in items - .iter() - .filter(|i| matches!(i.kind, ContentKind::Staff)) - { + // A manager counts INSIDE the staff total (`staffManager` is a bucket within + // it), so this selects the whole staff FAMILY, not `ContentKind::Staff` + // alone — a `manager`-classified row would otherwise vanish from the panel. + for it in items.iter().filter(|i| i.kind.is_staff_family()) { if let Some(sid) = staff_stat(it.subtype) { *g.get_mut(&sid).unwrap() += 1; staff_total += 1; @@ -237,13 +239,23 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value { } g.insert(S_CONSUMABLES, cons_total); - // Club items: kits are Core-owned and counted (total plus the home/away - // family split); unimplemented families stay honest zeros. - for sid in [ - 0x14, 0x1E, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, - ] { + // Club items. THESE COUNTS ARE THE GATE: the client does not ask for a + // family's items until club/stats reports a non-zero count for it (proven by + // the consumables round, where two rounds of item work sat unrequested + // because this panel answered zero). They are plain ints read by the same + // getter/publisher shape as the live-proven PLAYERS_EMPLOYED rows, so every + // family Core can own is counted here — including the ones whose ITEM record + // shape is still withheld, because a count cannot desync a parser and a zero + // guarantees the family is never even asked about. Unowned families stay + // honest zeros. + for sid in [0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38] { g.entry(sid).or_insert(0); } + let count_kind = + |want: ContentKind| items.iter().filter(|item| item.kind == want).count() as i64; + g.insert(S_STADIA, count_kind(ContentKind::Stadium)); + g.insert(S_BALLS, count_kind(ContentKind::Ball)); + g.insert(S_BADGES, count_kind(ContentKind::Badge)); let kits: Vec<&ClubStatInput> = items .iter() .filter(|item| matches!(item.kind, ContentKind::Kit)) @@ -414,6 +426,59 @@ mod tests { assert_eq!(g["staffManager"], 0); } + /// A row Core classifies as `manager` must still land in the STAFF bucket and + /// in `staffManager`: the client's own model counts a manager inside its staff + /// total, and the two encodings (`manager`, or `staff` + subtype 4) are the + /// same card. + #[test] + fn a_manager_counts_inside_staff_under_either_kind_token() { + for kind in [ContentKind::Manager, ContentKind::Staff] { + let mut manager = staff(4); + manager.kind = kind; + let g = global(&club_stats_body(&[manager, staff(8)], ContextField::Nation)); + assert_eq!(g["staffManager"], 1, "kind={}", kind.as_str()); + assert_eq!( + g["staff"], + 2, + "the manager is INSIDE the staff total (kind={})", + kind.as_str() + ); + assert_eq!(g["staffFitnessCoach"], 1); + assert_eq!(g["players"], 0, "a manager is not a player"); + } + } + + /// The count is the GATE: the client will not ask for a family's items until + /// this panel reports a non-zero count for it, so an owned badge/ball/stadium + /// must be counted even while its item record is withheld. + #[test] + fn owned_club_items_are_counted_per_family() { + let club_item = |kind: ContentKind, subtype: i64| ClubStatInput { + kind, + subtype, + rating: 0, + rare: false, + asset_id: 0, + nation_id: None, + league_id: None, + team_id: None, + }; + let items = vec![ + club_item(ContentKind::Badge, 11), + club_item(ContentKind::Badge, 11), + club_item(ContentKind::Ball, 30), + club_item(ContentKind::Stadium, 10), + kit(), + ]; + let g = global(&club_stats_body(&items, ContextField::Nation)); + assert_eq!(g["badges"], 2); + assert_eq!(g["balls"], 1); + assert_eq!(g["stadia"], 1); + assert_eq!(g["kits"], 1); + assert_eq!(g["players"], 0, "no club item is ever a player"); + assert_eq!(g["leagueLogos"], 0, "not an ownable Core kind: honest zero"); + } + #[test] fn consumables_by_family() { // 54 gk_training, 201 player_contract, 217 healing, 258 player_playstyle diff --git a/openfut-adapter-fifa17/src/fut/consumables.rs b/openfut-adapter-fifa17/src/fut/consumables.rs new file mode 100644 index 0000000..bd10aaf --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/consumables.rs @@ -0,0 +1,230 @@ +//! 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::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 = 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": 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"); + } +} diff --git a/openfut-adapter-fifa17/src/fut/content_taxonomy.rs b/openfut-adapter-fifa17/src/fut/content_taxonomy.rs index 7ccd85c..ab9909b 100644 --- a/openfut-adapter-fifa17/src/fut/content_taxonomy.rs +++ b/openfut-adapter-fifa17/src/fut/content_taxonomy.rs @@ -21,13 +21,30 @@ /// The disjoint content classes a FIFA 17 owned item can belong to. Player is /// the default so a catalog authored before this taxonomy existed (no `kind` /// field) still classifies every entry as a player, unchanged. +/// +/// The token set is OpenFUT Core's game-independent content vocabulary +/// (`player | manager | staff | consumable | kit | badge | ball | stadium | +/// misc`), so a Core owned row and a FIFA 17 catalog entry name the same class +/// with the same string and the FIFA numerics (`cardsubtypeid`, resource ranges) +/// never leak out of this crate. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ContentKind { #[default] Player, - Consumable, + /// A MANAGER — its own Core kind, but on the FIFA 17 side it is a member of + /// the STAFF family, never a class of its own: see + /// [`ContentKind::is_staff_family`]. The wire discriminator is + /// [`MANAGER_SUBTYPE`], not this token, so a catalog may classify a manager + /// as either `manager` or `staff` + subtype 4 and every consumer here + /// treats the two encodings identically. + Manager, Staff, + Consumable, Kit, + Badge, + Ball, + Stadium, + Misc, } impl ContentKind { @@ -35,9 +52,14 @@ impl ContentKind { pub fn as_str(&self) -> &'static str { match self { ContentKind::Player => "player", - ContentKind::Consumable => "consumable", + ContentKind::Manager => "manager", ContentKind::Staff => "staff", + ContentKind::Consumable => "consumable", ContentKind::Kit => "kit", + ContentKind::Badge => "badge", + ContentKind::Ball => "ball", + ContentKind::Stadium => "stadium", + ContentKind::Misc => "misc", } } @@ -49,12 +71,30 @@ impl ContentKind { #[allow(clippy::should_implement_trait)] pub fn from_str(s: &str) -> ContentKind { match s { - "consumable" => ContentKind::Consumable, + "manager" => ContentKind::Manager, "staff" => ContentKind::Staff, + "consumable" => ContentKind::Consumable, "kit" => ContentKind::Kit, + "badge" => ContentKind::Badge, + "ball" => ContentKind::Ball, + "stadium" => ContentKind::Stadium, + "misc" => ContentKind::Misc, _ => ContentKind::Player, } } + + /// True for the two kinds that make up the FIFA 17 STAFF family. + /// + /// A manager IS a staff card: the client's own club-stats model counts it + /// inside the `staff` total with `staffManager` as a bucket within it, its + /// STAFF tab asks for the whole family with `type=manager`, and one record + /// shape ([`crate::fut::item::shape_staff_item`]) serves all five families. + /// Every staff consumer MUST use this predicate rather than matching + /// `Staff` alone, or a `manager`-classified row silently leaves the staff + /// bucket and the STAFF tab. + pub fn is_staff_family(&self) -> bool { + matches!(self, ContentKind::Manager | ContentKind::Staff) + } } /// The functional family + honest display label for a consumable `cardsubtypeid`, @@ -104,6 +144,172 @@ pub fn staff_role(subtype: i64) -> Option<(&'static str, &'static str)> { Some(pair) } +/// The ONE extra wire key a consumable family needs, or [`ConsumableNeeds::None`]. +/// +/// Taken verbatim from `fifa17-recon/data/consumables.json`'s per-subtype `needs` +/// (generated by `build_consumables.py` from `FUN_18013f4d0`), and independently +/// confirmed by the real profile import, where `amount` is present on exactly the +/// training/healing/fitness/play-style/league families and `contract` on exactly +/// the two contract families. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsumableNeeds { + /// `amount` (atom 0x1b → `rec+0xbf`, or `+0xbe` for a play style) is + /// MANDATORY: the parser initialises its temp to -1 and both accessors read + /// it SIGNED, so omitting the key draws "-1" on the card, not "0". + Amount, + /// `contract` (atom 0xb8 → `rec+0x8c`) carries the number the card grants; + /// the two contract families IGNORE `amount` entirely. + Contract, + /// Nothing beyond the common key set — the card's whole meaning comes from + /// `cardsubtypeid` (formation and position modifiers). + None, +} + +/// Which extra key a consumable family requires. An unknown family name is +/// [`ConsumableNeeds::None`]; callers get families from [`consumable_family`], +/// so an unknown one cannot arrive from the wire. +pub fn consumable_needs(family: &str) -> ConsumableNeeds { + match family { + "gk_training" | "player_training" | "healing" | "player_fitness" | "squad_fitness" + | "player_playstyle" | "gk_playstyle" | "manager_league" => ConsumableNeeds::Amount, + "player_contract" | "manager_contract" => ConsumableNeeds::Contract, + _ => ConsumableNeeds::None, + } +} + +/// The consumable families one `GET club/consumables/` segment asks +/// for, or `None` for a segment outside the client's own group table. +/// +/// **This route, not `club?type=`.** Consumables are NOT a `?type=` family: a +/// previous round shipped four `?type=` arms for them and the screen stayed +/// empty, because the client asks here (and only once +/// `club/stats/consumables` reports a non-zero count — the counter is the gate +/// and this route is the door). +/// +/// The segment names are the consumable UI group table at `0x180203260` (seven +/// codes: `training`, `contracts`, `fitness`, `healing`, `playStyle`, +/// `managerLeagueModifier`, `position`); `training` and `contracts` are CONFIRMED +/// on the wire and the singular `contract` is accepted because the client has +/// used both spellings. Segments are matched lower-cased. +/// +/// The family sets are the `FUN_18013f4d0` categories those codes name, and the +/// correspondence is checkable against the panel: training→42, contracts→13, +/// healing→21, fitness→6, position→20, chemistry style→24 items in the oracle's +/// own shelf. NOTE the two formation-modifier families (categories 6 and 7) have +/// NO group code, so no segment can reach them — that is the client's own gap, +/// not an omission here. +pub fn consumable_families_for_category(segment: &str) -> Option<&'static [&'static str]> { + Some(match segment { + "training" => &["gk_training", "player_training"], + "contracts" | "contract" => &["player_contract", "manager_contract"], + "fitness" => &["player_fitness", "squad_fitness"], + "healing" => &["healing"], + "position" => &["position_mod"], + "playstyle" => &["player_playstyle", "gk_playstyle"], + "managerleaguemodifier" => &["manager_league"], + _ => return None, + }) +} + +/// The club-customisation `cardsubtypeid`s, SETTLED (supersedes +/// `CARD_SYSTEM.md`'s "STILL UNKNOWN, AND NOT GUESSED" section, which is stale). +/// +/// Kit 9, stadium 10 and badge 11 are cardtype **7** and resolve through +/// `FUN_180119bd0` (the manager vtable slot `+0x498`, verified from disk and live +/// memory); ball 30 (`0x1e`) and league logo 31 (`0x1f`) are cardtype 9, the +/// latter by elimination over `FUN_1800d8330`'s cardtype-9 set. Four independent +/// lines agree on kit = 9, including the deserializer's own `cardassetid` default +/// of `0x23` = 35 for cardtype 7 / subtype 9 — exactly the `cardassetid` carried +/// by all 1482 rows of `fcc_kitcards`. +/// +/// `0x91..=0x96` are TROPHIES (tournament/season), not club items. The enum table +/// at `0x180229ab0` (`badge=0xa kit=0xb leagueLogo=0xc … stadium=0x15 ball=0x16`) +/// is the transfermarket `&cat=%s` vocabulary and NOT a subtype map: reading it as +/// one swaps badge and kit and loses stadium. +pub const KIT_SUBTYPE: i64 = 9; +pub const STADIUM_SUBTYPE: i64 = 10; +pub const BADGE_SUBTYPE: i64 = 11; +pub const BALL_SUBTYPE: i64 = 30; +pub const LEAGUE_LOGO_SUBTYPE: i64 = 31; + +/// The club-customisation [`ContentKind`] for a `cardsubtypeid`, or `None` for a +/// subtype outside the settled set above. A league logo has no Core kind of its +/// own (it is not ownable club content in Core's vocabulary), so subtype 31 +/// deliberately maps to `None` rather than being folded into `Misc`. +pub fn club_item_kind(subtype: i64) -> Option { + let kind = match subtype { + KIT_SUBTYPE => ContentKind::Kit, + STADIUM_SUBTYPE => ContentKind::Stadium, + BADGE_SUBTYPE => ContentKind::Badge, + BALL_SUBTYPE => ContentKind::Ball, + _ => return None, + }; + Some(kind) +} + +/// The three MY CLUB position tabs (`type=playerdefender|playermidfielder| +/// playerforward`). `FUN_18012ddf0` remaps request field `*(req+0x14)` values +/// `0x1c/0x1d/0x1e` onto type codes `0x1b/0x1c/0x1d` and SUPPRESSES `position=`, +/// so a position tab arrives as one of those three tokens with no other filter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PositionGroup { + Defender, + Midfielder, + Forward, +} + +/// The FIFA 17 position ID for a FUT position label, from the client's OWN `pos` +/// vocabulary — the NUL-terminated `{const char*, int}` table at `0x1802295c0` +/// that it emits as the transfer-market `&pos=%s` parameter: +/// `GK=0 RWB=2 RB=3 CB=5 LB=7 LWB=8 CDM=10 RM=12 CM=14 LM=16 CAM=18 RF=20 CF=21 +/// LF=22 RW=23 ST=25 LW=27`. +/// +/// `None` = a label outside that table (never guessed): the item then belongs to +/// no position tab rather than to an invented one. +pub fn position_id(pos: &str) -> Option { + let id = match pos { + "GK" => 0, + "RWB" => 2, + "RB" => 3, + "CB" => 5, + "LB" => 7, + "LWB" => 8, + "CDM" => 10, + "RM" => 12, + "CM" => 14, + "LM" => 16, + "CAM" => 18, + "RF" => 20, + "CF" => 21, + "LF" => 22, + "RW" => 23, + "ST" => 25, + "LW" => 27, + _ => return None, + }; + Some(id) +} + +/// Which position tab a FUT position label belongs to, or `None` for a label +/// outside the client's own `pos` table. +/// +/// The ladder is the client's, not ours: `FUN_180135890` recomputes `rec+0x14c` +/// from the position at `rec+0x146` as `0 → GK`, `1..=8 → DEF`, `9..=19 → MID`, +/// `20..=27 → ATT`. +/// +/// THE ONE GUESS, named: GK is folded into `Defender`, because the client has +/// exactly three position tabs and no fourth, so a keeper must land in one of +/// them or vanish from every drill-down. Falsifier: if the DEF tab renders +/// without goalkeepers, move GK out (the group boundary becomes `1..=8`). +pub fn position_group(pos: &str) -> Option { + match position_id(pos)? { + 0..=8 => Some(PositionGroup::Defender), + 9..=19 => Some(PositionGroup::Midfielder), + 20..=27 => Some(PositionGroup::Forward), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -111,20 +317,171 @@ mod tests { #[test] fn content_kind_round_trips_and_defaults_to_player() { assert_eq!(ContentKind::default(), ContentKind::Player); - for k in [ + // The FULL Core content vocabulary, every token round-tripping. + let all = [ ContentKind::Player, - ContentKind::Consumable, + ContentKind::Manager, ContentKind::Staff, + ContentKind::Consumable, ContentKind::Kit, - ] { + ContentKind::Badge, + ContentKind::Ball, + ContentKind::Stadium, + ContentKind::Misc, + ]; + for k in all { assert_eq!(ContentKind::from_str(k.as_str()), k); } + let tokens: Vec<&str> = all.iter().map(|k| k.as_str()).collect(); + assert_eq!( + tokens, + vec![ + "player", + "manager", + "staff", + "consumable", + "kit", + "badge", + "ball", + "stadium", + "misc" + ], + "these exact strings are the cross-crate contract with Core" + ); // Unknown / absent tokens fall back to Player (backward compatible). assert_eq!(ContentKind::from_str(""), ContentKind::Player); assert_eq!(ContentKind::from_str("nonsense"), ContentKind::Player); assert_eq!(ContentKind::from_str("player"), ContentKind::Player); } + #[test] + fn only_manager_and_staff_are_the_staff_family() { + for k in [ContentKind::Manager, ContentKind::Staff] { + assert!(k.is_staff_family(), "{} is a staff card", k.as_str()); + } + for k in [ + ContentKind::Player, + ContentKind::Consumable, + ContentKind::Kit, + ContentKind::Badge, + ContentKind::Ball, + ContentKind::Stadium, + ContentKind::Misc, + ] { + assert!(!k.is_staff_family(), "{} is not staff", k.as_str()); + } + } + + #[test] + fn every_consumable_family_needs_exactly_what_the_client_reads() { + // Grouped from data/consumables.json's per-subtype `needs`, and observed + // key-for-key in the real profile import. + for f in [ + "gk_training", + "player_training", + "healing", + "player_fitness", + "squad_fitness", + "player_playstyle", + "gk_playstyle", + "manager_league", + ] { + assert_eq!(consumable_needs(f), ConsumableNeeds::Amount, "{f}"); + } + for f in ["player_contract", "manager_contract"] { + assert_eq!(consumable_needs(f), ConsumableNeeds::Contract, "{f}"); + } + for f in ["manager_formation_mod", "formation_mod", "position_mod"] { + assert_eq!(consumable_needs(f), ConsumableNeeds::None, "{f}"); + } + } + + #[test] + fn consumable_route_categories_partition_the_reachable_families() { + // The seven group codes, plus the singular `contract` spelling. + let segments = [ + "training", + "contracts", + "fitness", + "healing", + "position", + "playstyle", + "managerleaguemodifier", + ]; + let mut seen: Vec<&str> = Vec::new(); + for seg in segments { + for f in consumable_families_for_category(seg).unwrap() { + assert!(!seen.contains(f), "{f} claimed by two categories"); + seen.push(f); + } + } + assert_eq!( + consumable_families_for_category("contract"), + consumable_families_for_category("contracts"), + "both spellings the client has used mean the same set" + ); + // Eleven of the thirteen families are reachable; the two formation + // modifiers have no group code in the client's own table. + assert_eq!(seen.len(), 11, "no duplicates: {seen:?}"); + for subtype in [51, 61, 91, 201, 202, 211, 219, 220, 250, 269, 300] { + let (family, _) = consumable_family(subtype).unwrap(); + assert!(seen.contains(&family), "no category serves {family}"); + } + for unreachable in [71, 121] { + let (family, _) = consumable_family(unreachable).unwrap(); + assert!( + !seen.contains(&family), + "{family} has no group code; claiming it would invent a segment" + ); + } + // Not a consumables segment (and NOT a `?type=` token either). + for s in ["", "player", "kit", "Training", "development"] { + assert!( + consumable_families_for_category(s).is_none(), + "{s:?} is not a consumable category" + ); + } + } + + #[test] + fn club_item_subtypes_are_the_settled_five() { + assert_eq!(club_item_kind(KIT_SUBTYPE), Some(ContentKind::Kit)); + assert_eq!(club_item_kind(STADIUM_SUBTYPE), Some(ContentKind::Stadium)); + assert_eq!(club_item_kind(BADGE_SUBTYPE), Some(ContentKind::Badge)); + assert_eq!(club_item_kind(BALL_SUBTYPE), Some(ContentKind::Ball)); + assert_eq!((KIT_SUBTYPE, STADIUM_SUBTYPE, BADGE_SUBTYPE), (9, 10, 11)); + assert_eq!((BALL_SUBTYPE, LEAGUE_LOGO_SUBTYPE), (30, 31)); + // A league logo is not ownable Core content, so it maps to no kind. + assert_eq!(club_item_kind(LEAGUE_LOGO_SUBTYPE), None); + // Trophies (0x91..0x96) are NOT club items, and staff/consumable + // subtypes must never be mistaken for one. + for s in [0, 4, 8, 0x91, 0x96, 201, 231] { + assert_eq!(club_item_kind(s), None, "subtype {s} is not a club item"); + } + } + + #[test] + fn position_groups_follow_the_clients_own_ladder() { + // Ids are the client's `pos` table; groups are its 0/1..8/9..19/20..27 + // recompute. GK folded into DEF is the one named guess. + for p in ["GK", "CB", "LB", "RB", "LWB", "RWB"] { + assert_eq!(position_group(p), Some(PositionGroup::Defender), "{p}"); + } + for p in ["CDM", "CM", "CAM", "LM", "RM"] { + assert_eq!(position_group(p), Some(PositionGroup::Midfielder), "{p}"); + } + for p in ["RF", "CF", "LF", "RW", "ST", "LW"] { + assert_eq!(position_group(p), Some(PositionGroup::Forward), "{p}"); + } + assert_eq!(position_id("ST"), Some(25)); + assert_eq!(position_id("CDM"), Some(10)); + // Not in the client's table → no tab, never an invented one. + for p in ["", "SW", "st", "MID", "SUB"] { + assert_eq!(position_group(p), None, "{p:?}"); + assert_eq!(position_id(p), None, "{p:?}"); + } + } + #[test] fn consumable_family_range_boundaries() { // Each contiguous range: lower boundary, upper boundary, family + label. diff --git a/openfut-adapter-fifa17/src/fut/item.rs b/openfut-adapter-fifa17/src/fut/item.rs index b2cc222..0d1e9b9 100644 --- a/openfut-adapter-fifa17/src/fut/item.rs +++ b/openfut-adapter-fifa17/src/fut/item.rs @@ -24,8 +24,11 @@ use serde_json::{json, Value}; -use crate::fut::content_taxonomy::{ContentKind, MANAGER_SUBTYPE}; +use crate::fut::content_taxonomy::{ + consumable_family, consumable_needs, ConsumableNeeds, ContentKind, MANAGER_SUBTYPE, +}; use crate::fut::entities::ReverseEntityResolver; +use crate::fut::item_state; /// One owned item in game-independent terms, as read from Core's inventory. #[derive(Debug, Clone)] @@ -101,6 +104,85 @@ pub struct Fifa17StaffIdentity { pub team_id: i64, } +/// FIFA-side identity + definition facts needed to render an owned consumable. +/// +/// A consumable carries NO id space to discover: `FUN_18013f4d0` never touches a +/// DB handle, and category, artwork, name and both stat bytes all derive from +/// `cardsubtypeid` alone. What it does need is the fcc_* row's ART id and the one +/// extra key its family reads — see [`Fifa17ConsumableIdentity::is_renderable`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Fifa17ConsumableIdentity { + pub item_id: u32, + /// `rec+0x18`. Bookkeeping only for a consumable (artwork is a client-side + /// constant, so this never reaches the screen), but kept as EA's own + /// `carddbid` so nothing drifts out of their space. + pub resource_id: u32, + pub asset_id: u32, + /// The fcc_* `cardassetid` — the ART id, NOT a copy of `resource_id`. + /// Observed values in the real profile: 3 (training), 7/8 (contracts), + /// 9 (healing), 34 (position), 50/51 (play style). Copying `resource_id` + /// here is right for players and wrong for every other family: the client + /// looks up art `5003001`, finds none, and draws the `notfound.swf` green + /// "NOT FOUND" box. + pub card_asset_id: u32, + /// `rec+0x50`. THE ONLY selector: category, artwork, name and both stat + /// bytes derive from it. + pub subtype: i64, + /// `rec+0x58`. Observed 0 on every owned consumable in the real profile. + pub rareflag: i64, + /// `rec+0xb4`. Drives the card level (`rec+0x54`) and therefore the + /// `fcc_discardcoins` price. Definition-level EA data (55..95 observed). + pub rating: u8, + /// `amount` (atom 0x1b) → `rec+0xbf`, or `+0xbe` for a play style. + /// `Some` exactly for the families [`ConsumableNeeds::Amount`] names. + pub amount: Option, + /// `contract` (atom 0xb8) → `rec+0x8c`. `Some` for the two contract + /// families only; they ignore `amount` entirely. + pub contract: Option, + /// `rec+0x49`. Per-INSTANCE in FIFA, unmodelled by Core, so the host passes + /// the observed constant [`CONSUMABLE_UNTRADEABLE`]. Carried per copy rather + /// than baked into the shaper because the consumables route's stack wrapper + /// reports `untradeableCount` over the copies in the stack. + pub untradeable: bool, +} + +impl Fifa17ConsumableIdentity { + /// Whether this definition can be drawn HONESTLY. Three refusals, every one a + /// silent-failure guard rather than taste: + /// + /// * the family's mandatory extra key is missing — the parser initialises + /// its `amount` temp to `-1` and both accessors read the byte SIGNED, so + /// an omission draws "-1" on the card, not "0" (and a contract card with + /// no `contract` grants nothing); + /// * `rareflag != 0` on subtype 219 — `FUN_1801bfac0` case 5 renders a RARE + /// Player Fitness card as a SQUAD Fitness card, i.e. a different item + /// entirely, with no error anywhere; + /// * `card_asset_id == asset_id` — a consumable's art id is a SMALL `fcc_` + /// art id (3, 7, 8, 9, 34, 50, 51 observed) and never its own `carddbid`, + /// so this means the catalog carried no `card_asset_id` and the client + /// would draw `notfound.swf`, the green "NOT FOUND" box. + /// + /// A subtype outside every documented range is also refused: it falls to + /// `FUN_18013f4d0`'s bottom default and renders as a perfectly ordinary + /// Squad Training (Pace) card with amount 0 — plausible and wrong. + pub fn is_renderable(&self) -> bool { + if self.subtype == SQUAD_FITNESS_TRAP_SUBTYPE && self.rareflag != 0 { + return false; + } + if self.card_asset_id == self.asset_id { + return false; + } + match consumable_family(self.subtype) { + None => false, + Some((family, _)) => match consumable_needs(family) { + ConsumableNeeds::Amount => self.amount.is_some(), + ConsumableNeeds::Contract => self.contract.is_some(), + ConsumableNeeds::None => true, + }, + } + } +} + /// Supplies the FIFA numeric identity for a Core item. Returning `None` means /// "no real FIFA asset id known" → the caller must not fabricate one. pub trait ItemIdentityResolver { @@ -119,7 +201,21 @@ pub trait ItemIdentityResolver { None } - /// Classify a Core item's definition as player/consumable/staff. Defaults to + /// Resolve one owned consumable definition. Default `None` preserves + /// existing resolvers; the catalog-backed FIFA17 resolver overrides it. + fn resolve_consumable(&self, _item: &CoreOwnedItem) -> Option { + None + } + + /// The FIFA `cardsubtypeid` of a Core item's definition, or `0` when unknown + /// or a player. NON-MINTING by contract: `/club`'s per-family filters call it + /// for every owned row, so allocating a wire id here would pollute the + /// identity store on a read. + fn subtype_of(&self, _item: &CoreOwnedItem) -> i64 { + 0 + } + + /// Classify a Core item's definition into the content vocabulary. Defaults to /// [`ContentKind::Player`] so existing resolvers keep their behaviour; a /// catalog-backed resolver overrides this to consult its `kind_of`, letting /// `/club` exclude non-player content (which must never render as a @@ -133,9 +229,18 @@ pub trait ItemIdentityResolver { #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct ShapeStats { pub emitted: usize, + /// No real FIFA asset id for this definition — dropped, never faked. pub dropped_no_asset: usize, - /// Consumable/staff items excluded from a player projection (they must never - /// render as a 0-rated player). Counted, never emitted. + /// The definition resolved but is INCOMPLETE or self-contradictory, so + /// drawing it would be a lie the client cannot detect (a consumable missing + /// the mandatory `amount`/`contract`, or the subtype-219 rareflag trap). + /// Dropped and counted separately, because the fix is a catalog re-emit, not + /// an identity mapping. + pub dropped_incomplete: usize, + /// Owned content this envelope deliberately does not carry: a CONSUMABLE + /// (its own route serves it as a stack), or a club-customisation family + /// whose record shape is not yet verified (badge, ball, stadium, misc). + /// Core owns the row; the projection is withheld, never guessed. pub excluded_non_player: usize, } @@ -192,7 +297,7 @@ pub fn shape_item( "leagueId": league_id, "playStyle": 250, "attributeList": attribute_list, - "itemState": "free", + "itemState": item_state::FREE, "owners": 1, // Owned/pack-pulled cards are TRADEABLE in FIFA 17 (untradeable is the // exception for SBC/promo rewards, which Core does not model). Emitting @@ -267,7 +372,7 @@ pub fn shape_staff_item(id: Fifa17StaffIdentity, contract: i64) -> Value { // readers use it to tell a staff card from a footballer at a glance. "itemType": "staff", "contract": contract, - "itemState": "free", + "itemState": item_state::FREE, "owners": 1, "untradeable": false, }); @@ -280,6 +385,92 @@ pub fn shape_staff_item(id: Fifa17StaffIdentity, contract: i64) -> Value { item } +/// Build one FIFA 17 consumable item. +/// +/// The key set is EXACTLY what the real profile import holds for its 17 owned +/// consumables — i.e. what the client itself stored — and every key is a key the +/// live player path already proves, so this introduces NO new wire shape: +/// +/// * `id` → `rec+0x08`, `resourceId` → `rec+0x18`, `assetId`, `cardassetid` (the +/// ART id, see [`Fifa17ConsumableIdentity::card_asset_id`]), +/// `cardsubtypeid` → `rec+0x50`, `rareflag` → `rec+0x58`, +/// `rating` → `rec+0xb4`, `itemState` → `rec+0x5c`, `owners` → `rec+0x48`, +/// `untradeable` → `rec+0x49`. +/// * `amount` → `rec+0xbf` / `+0xbe` and `contract` → `rec+0x8c`, each emitted +/// only for the families that read it (the caller has already gated on +/// [`Fifa17ConsumableIdentity::is_renderable`]). +/// +/// `itemType` is `"player"`, which is not a mislabel: it is the ONLY value this +/// client has ever been sent, it is what the real profile stores on all 17, and +/// `cardtype` is derived from `cardsubtypeid` alone (`FUN_18013fe00`), so the +/// string cannot affect the render. A consumable is discriminated by its subtype +/// plus the ABSENCE of `attributeList`; inventing `"consumable"` here would be a +/// fabricated token. +/// +/// `untradeable` is carried per copy from +/// [`Fifa17ConsumableIdentity::untradeable`] (the host supplies the observed +/// [`CONSUMABLE_UNTRADEABLE`]), because the consumables route reports +/// `untradeableCount` over a stack and the two must agree. +/// +/// DELIBERATELY ABSENT, each for a named reason: +/// * `teamid`, `leagueid` and `value` — the three "extras" copied out of an fcc +/// row that CRASHED the client on 2026-08-05. `value` is the established +/// culprit (it is an OBJECT member elsewhere, and a scalar where an object is +/// expected is the type-desync busy loop at `0x1801c7f1a`); none of the three +/// is needed to draw a card. +/// * `preferredPosition`, `nation`, `playStyle`, `attributeList`, `fitness` — +/// player-only, and `attributeList` is the very thing that distinguishes a +/// footballer from a consumable. +/// * `definitionId` — not an atom at all; the parser has always skipped it. +/// * `discardValue` — the client computes it from `fcc_discardcoins` on +/// `(cardtype 6, level, rare)`, and real rows exist for both rare values. +/// * `pile` — Core/host state (the transfer pile), not a wire atom: the +/// live-proven player path does not send it either. +pub fn shape_consumable_item(id: Fifa17ConsumableIdentity) -> Value { + let mut item = json!({ + "id": id.item_id, + "resourceId": id.resource_id, + "assetId": id.asset_id, + "cardassetid": id.card_asset_id, + "cardsubtypeid": id.subtype, + "itemType": "player", + "rareflag": id.rareflag, + "rating": id.rating, + "itemState": item_state::FREE, + "owners": 1, + "untradeable": id.untradeable, + }); + let obj = item.as_object_mut().expect("json! built an object"); + if let Some(amount) = id.amount { + obj.insert("amount".to_string(), json!(amount)); + } + if let Some(contract) = id.contract { + obj.insert("contract".to_string(), json!(contract)); + } + item +} + +/// `cardsubtypeid` of the PLAYER FITNESS card, and the one subtype where +/// `rareflag` is load-bearing rather than cosmetic: `FUN_1801bfac0` case 5 reads +/// it as the squad-fitness selector, so a rare Player Fitness card silently +/// becomes a SQUAD Fitness card — a different item, with no error anywhere. +pub const SQUAD_FITNESS_TRAP_SUBTYPE: i64 = 219; + +/// Tradeability of an owned consumable. +/// +/// FIFA models this per INSTANCE (`rec+0x49`) and Core does not model it at all, +/// so this is the observed value, not a policy: all 17 owned consumables in the +/// real profile import carry `untradeable: true`, and it is also the oracle's own +/// default for the family. When Core models per-instance tradeability, this +/// constant is what it replaces. +/// +/// Note the lever it controls on screen: the consumables deserializer sets a UI +/// flag from `untradeableCount < count`, so an all-untradeable stack draws the +/// untradeable badge. That is correct for genuinely untradeable copies; it was +/// only wrong for the oracle's SYNTHETIC shelf, where the badge was its own data +/// showing through. +pub const CONSUMABLE_UNTRADEABLE: bool = true; + #[cfg(test)] mod tests { use super::*; @@ -403,4 +594,144 @@ mod tests { "special rareflag carried, not hardcoded 1" ); } + + /// The GK-training card the real profile owns: `5003012`, art 3, subtype 54, + /// rating 85, amount 15. Its key set is the acceptance criterion. + fn training_consumable() -> Fifa17ConsumableIdentity { + Fifa17ConsumableIdentity { + item_id: 100000239, + resource_id: 5_003_012, + asset_id: 5_003_012, + card_asset_id: 3, + subtype: 54, + rareflag: 0, + rating: 85, + amount: Some(15), + contract: None, + untradeable: CONSUMABLE_UNTRADEABLE, + } + } + + #[test] + fn consumable_emits_exactly_the_keys_the_client_itself_stored() { + let it = shape_consumable_item(training_consumable()); + // Verbatim from the real profile import (persona 33068179): + // {"id":100000239,"resourceId":5003012,"assetId":5003012,"cardassetid":3, + // "cardsubtypeid":54,"itemType":"player","rareflag":0,"rating":85, + // "itemState":"free","owners":1,"untradeable":true,"amount":15} + assert_eq!( + it, + json!({ + "id": 100000239, + "resourceId": 5_003_012, + "assetId": 5_003_012, + "cardassetid": 3, + "cardsubtypeid": 54, + "itemType": "player", + "rareflag": 0, + "rating": 85, + "itemState": "free", + "owners": 1, + "untradeable": true, + "amount": 15, + }) + ); + // The three "extras" that crashed the client on 2026-08-05, and the + // player-only keys that would make a consumable look like a footballer. + for forbidden in [ + "teamid", + "leagueid", + "leagueId", + "value", + "attributeList", + "preferredPosition", + "nation", + "playStyle", + "fitness", + "definitionId", + "discardValue", + "pile", + ] { + assert!( + it.get(forbidden).is_none(), + "a consumable must not carry `{forbidden}`" + ); + } + } + + #[test] + fn consumable_art_id_is_never_the_resource_id() { + // The green "NOT FOUND" box: the client resolves artwork by cardassetid, + // which is a SMALL fcc_ art id, not the carddbid. + let it = shape_consumable_item(training_consumable()); + assert_eq!(it["cardassetid"], 3); + assert_ne!(it["cardassetid"], it["resourceId"]); + } + + /// EVERY `itemState` this crate can put on the wire must be one of the twelve + /// tokens recovered from the client's own table. An unrecovered token decodes + /// to `0xffffffff` through `FUN_180166660` and the client then acts on an + /// unrecognised state. + #[test] + fn every_emitted_item_state_is_in_the_recovered_table() { + let ent = entities(); + let mut emitted: Vec = Vec::new(); + let player = shape_item( + &item("oc1", "card_ch_1", 86, "CDM"), + Fifa17Identity { + item_id: 1, + asset_id: 20801, + resource_id: 20801, + rareflag: 1, + }, + &ent, + ); + emitted.push(player["itemState"].as_str().unwrap().to_string()); + let staff = shape_staff_item( + Fifa17StaffIdentity { + item_id: 2, + resource_id: 1_000_509, + subtype: MANAGER_SUBTYPE, + nation: 45, + league_id: 53, + team_id: 241, + }, + STAFF_CONTRACT, + ); + emitted.push(staff["itemState"].as_str().unwrap().to_string()); + emitted.push( + shape_consumable_item(training_consumable())["itemState"] + .as_str() + .unwrap() + .to_string(), + ); + // Every state `/club` can hand a kit, including both equipped roles. + let kit = Fifa17KitIdentity { + item_id: 3, + asset_id: 6_300_006, + resource_id: 6_300_006, + card_asset_id: 35, + subtype: 9, + team_id: 21, + }; + for state in [ + item_state::FREE, + item_state::ACTIVE_HOME_KIT, + item_state::ACTIVE_AWAY_KIT, + ] { + let it = shape_kit_item(kit, state); + emitted.push(it["itemState"].as_str().unwrap().to_string()); + } + for state in &emitted { + assert!( + item_state::is_recovered(state), + "{state:?} is not one of the twelve recovered itemState tokens" + ); + } + assert!( + !emitted.iter().any(|s| s == item_state::INVALID), + "omitting itemState yields `invalid` (0) and fails the squad builder; \ + no shaper may emit it deliberately either" + ); + } } diff --git a/openfut-adapter-fifa17/src/fut/item_state.rs b/openfut-adapter-fifa17/src/fut/item_state.rs new file mode 100644 index 0000000..ad1804d --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/item_state.rs @@ -0,0 +1,99 @@ +//! The FIFA 17 **`itemState` vocabulary** — the complete recovered set, and the +//! only place these strings are written down. +//! +//! Twelve entries in one NUL-terminated `{const char* name, u32 value}` table at +//! `0x180229cc0` (stride 0x10), walked in full from both disk and live memory. +//! `FUN_180166660` is a linear walk over that table and returns `0xffffffff` for +//! anything not in it, so an invented token is not a cosmetic slip: it decodes to +//! "unrecognised state" and the client acts on garbage. Every shaper in this +//! crate therefore takes its `itemState` from a constant here, and +//! [`is_recovered`] is asserted over every emitted value by the tests. +//! +//! **Omitting `itemState` is NOT the same as sending [`FREE`].** The record +//! constructor zero-initialises `+0x50..+0x5f` from `_DAT_1801f66a0`, so an +//! absent key leaves `0` = [`INVALID`], and an item left at `0` fails the squad +//! builder's `state == 1 || state == 2` acceptance test. Always send it. +//! +//! (Source: `fifa17-recon/docs/plan-2026-08-06-card-subsystem.md` §4, which also +//! corrects `CARD_SYSTEM.md`'s earlier ten-row reading — that one started at +//! `0x180229d20`, the MIDDLE of the table, and so missed `invalid`, `free`, +//! `WAITING_FOR_GAME`, `inGame`, `forSale` and `offered`.) + +/// `0` — what an item gets when `itemState` is OMITTED. No consumer found; it +/// fails the squad builder. Never emit it deliberately. +pub const INVALID: &str = "invalid"; +/// `1` — the normal owned state: accepted by the squad builder, and what the +/// unequip path writes back. +pub const FREE: &str = "free"; +/// `2` — alias of [`IN_GAME`] (both decode to 2). +pub const WAITING_FOR_GAME: &str = "WAITING_FOR_GAME"; +/// `2` — accepted by the squad builder. +pub const IN_GAME: &str = "inGame"; +/// `5` — an item offered for sale. Never TESTED anywhere in CardsDLL, but it is +/// in the table, so it decodes; the transfer market emits it. +pub const FOR_SALE: &str = "forSale"; +/// `6` — never tested anywhere in CardsDLL. +pub const OFFERED: &str = "offered"; +/// `100` — equipped badge; drives the `IS_ACTIVE` tick. +pub const ACTIVE_BADGE: &str = "activeBadge"; +/// `101` — equipped home kit. +pub const ACTIVE_HOME_KIT: &str = "activeHomeKit"; +/// `102` — equipped away kit. +pub const ACTIVE_AWAY_KIT: &str = "activeAwayKit"; +/// `103` — equipped ball; the unequip path writes [`FREE`] back over it. +pub const ACTIVE_BALL: &str = "activeBall"; +/// `104` — equipped stadium. +pub const ACTIVE_STADIUM: &str = "activeStadium"; +/// `255` — no consumer found. +pub const ACTIVE: &str = "active"; + +/// The complete recovered vocabulary, in table order. +pub const ALL: [&str; 12] = [ + INVALID, + FREE, + WAITING_FOR_GAME, + IN_GAME, + FOR_SALE, + OFFERED, + ACTIVE_BADGE, + ACTIVE_HOME_KIT, + ACTIVE_AWAY_KIT, + ACTIVE_BALL, + ACTIVE_STADIUM, + ACTIVE, +]; + +/// Whether `state` is one of the twelve recovered tokens. Case-sensitive, as the +/// client's own lookup is a `strcmp` walk. +pub fn is_recovered(state: &str) -> bool { + ALL.contains(&state) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_table_is_the_twelve_recovered_rows_and_nothing_else() { + assert_eq!(ALL.len(), 12); + for s in ALL { + assert!(is_recovered(s), "{s} must be in its own table"); + } + // Tokens this project has actually seen invented or mis-cased. `listFS` + // in particular is the Python oracle's own token and appears NOWHERE in + // the client (zero occurrences in the DLL and in 4.26 GiB of live + // process memory), so it decodes to -1. + for s in [ + "listFS", + "free ", + "Free", + "activehomekit", + "sold", + "won", + "equipped", + "", + ] { + assert!(!is_recovered(s), "{s:?} is not a FIFA 17 itemState"); + } + } +} diff --git a/openfut-adapter-fifa17/src/fut/mod.rs b/openfut-adapter-fifa17/src/fut/mod.rs index 65c42b8..3be9455 100644 --- a/openfut-adapter-fifa17/src/fut/mod.rs +++ b/openfut-adapter-fifa17/src/fut/mod.rs @@ -7,11 +7,13 @@ pub mod catalog; pub mod club_response; pub mod club_stats; +pub mod consumables; pub mod content_taxonomy; pub mod economy; pub mod economy_policy; pub mod entities; pub mod item; +pub mod item_state; pub mod match_wire; pub mod non_economy; pub mod owned_query;