diff --git a/openfut-core b/openfut-core index bae0a2b..36bc594 160000 --- a/openfut-core +++ b/openfut-core @@ -1 +1 @@ -Subproject commit bae0a2bdaafd9fed847a22a079315dcd0fa012d1 +Subproject commit 36bc594924aae333bd7956f4b2825b900f424f27 diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index ed1c86e..e26ccbd 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -44,20 +44,24 @@ pub mod market_store; pub mod pile_store; pub mod sold_experiment; +use parking_lot::Mutex as PlMutex; use std::io::{BufRead, BufReader, Write}; use std::net::{TcpListener, TcpStream}; -use parking_lot::Mutex as PlMutex; use std::sync::{Arc, Mutex}; use std::time::{Instant, SystemTime, UNIX_EPOCH}; use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy}; use openfut_adapter_fifa17::fut::club_response::{ - shape_club_response_with_kits, ActiveKitAssignments, CoreOwnedItem, Fifa17Identity, - Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver, + shape_club_response_with_kits, ActiveKitAssignments, CoreOwnedItem, Fifa17ConsumableIdentity, + Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver, }; use openfut_adapter_fifa17::fut::club_stats::{club_stats_body, ClubStatInput, ContextField}; -use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind; +use openfut_adapter_fifa17::fut::consumables::consumables_response; +use openfut_adapter_fifa17::fut::content_taxonomy::{ + consumable_families_for_category, consumable_family, position_group, ContentKind, PositionGroup, +}; use openfut_adapter_fifa17::fut::entities::{Fifa17Entities, ReverseEntityResolver}; +use openfut_adapter_fifa17::fut::item::CONSUMABLE_UNTRADEABLE; use openfut_adapter_fifa17::fut::match_wire; use openfut_adapter_fifa17::fut::non_economy; use openfut_adapter_fifa17::fut::owned_query::{ @@ -144,6 +148,11 @@ pub enum Route { /// Core-accurately in Rust (player tiers, staff/consumable families, nation /// buckets). club/stats/staff stays a separate empty-set route. ClubStats, + /// `GET …/club/consumables/` — the consumables ITEM screen, served + /// from Core as the STACK-wrapper envelope this response class actually reads. + /// A `/club` PREFIX, so it MUST be classified before the generic club arms or + /// the screen is answered with the club's player list. + ClubConsumables, /// `GET …/store` (eligibility gate), `…/match/keepalive`, `…/captcha`, /// `…/tfa`, `…/livemessage`, `…/activeMessage` — Rust-owned UNCONDITIONAL /// static acks, byte-identical to the Python oracle's constant responses @@ -222,10 +231,14 @@ pub fn classify(method: &str, path: &str) -> Route { Some("user") if get => Route::User, Some("settings") if get => Route::Settings, Some("leaderboards/options") if get => Route::LeaderboardOptions, - Some("match/reset") if put => Route::MatchReset, - Some(tail) if tail.starts_with("phishing/") => Route::SecurityQuestion, Some("club/stats/staff") if get => Route::ClubStatsStaff, Some(t) if get && t.starts_with("club/stats/") => Route::ClubStats, + // Before any other `club/` arm: this is a /club PREFIX, and letting it + // fall through is what once answered the consumables screen with the + // club's 194-card player list. + Some(t) if get && t.starts_with("club/consumables") => Route::ClubConsumables, + Some("match/reset") if put => Route::MatchReset, + Some(tail) if tail.starts_with("phishing/") => Route::SecurityQuestion, Some("hub") if get => Route::Hub, Some("store") => Route::StaticAck, Some("match/keepalive") => Route::StaticAck, @@ -749,8 +762,9 @@ pub trait CoreAccess: Send + Sync { )) } - /// Ownership-backed active home/away kit ids (`GET /club/kits`). A Core - /// without the endpoint projects no active kits rather than fabricating one. + /// Ownership-backed active club designations (`GET /club/active-items`). A + /// Core without the endpoint projects no active items rather than + /// fabricating one. fn get_active_kits(&self) -> Result { Ok(CoreKitAssignments::default()) } @@ -942,9 +956,13 @@ impl CoreAccess for HttpCoreClient { } fn get_active_kits(&self) -> Result { + // Core generalised the two-slot kit table into slot-keyed active club + // designations, so the kit ids now arrive under `home_kit`/`away_kit` + // inside an `active_items` object. Every slot key is always present and + // an empty slot is JSON null. let response = self .client - .get(format!("{}/club/kits", self.base_url)) + .get(format!("{}/club/active-items", self.base_url)) .header("X-OpenFUT-Game", &self.game) .send() .map_err(|error| CoreError::Http(error.to_string()))?; @@ -955,15 +973,17 @@ impl CoreAccess for HttpCoreClient { let body: Value = response .json() .map_err(|error| CoreError::Parse(error.to_string()))?; + let slots = body.get("active_items").unwrap_or(&body); let owned_id = |slot: &str| { - body.get(slot) + slots + .get(slot) .and_then(|item| item.get("id")) .and_then(Value::as_str) .map(str::to_string) }; Ok(CoreKitAssignments { - home_owned_card_id: owned_id("home"), - away_owned_card_id: owned_id("away"), + home_owned_card_id: owned_id("home_kit"), + away_owned_card_id: owned_id("away_kit"), }) } @@ -1797,7 +1817,9 @@ impl ItemIdentityResolver for Fifa17IdentityResolver { fn resolve_staff(&self, item: &CoreOwnedItem) -> Option { let ident = self.catalog.lookup(&item.card_id)?; - if ident.kind != ContentKind::Staff { + // The whole staff FAMILY: a catalog may classify a manager as either + // `manager` or `staff` + subtype 4, and one record shape serves both. + if !ident.kind.is_staff_family() { return None; } Some(Fifa17StaffIdentity { @@ -1812,6 +1834,39 @@ impl ItemIdentityResolver for Fifa17IdentityResolver { }) } + /// Compose an owned consumable's wire identity from the catalog. + /// + /// `rating`, `amount` and `contract` are EA's authored definition data, which + /// generic Core does not model (an imported consumable's Core `overall` is + /// 0), so they come from the FIFA catalog; `rating` falls back to Core's value + /// rather than being invented, and the two mandatory keys are simply absent + /// when the catalog has none, which + /// [`Fifa17ConsumableIdentity::is_renderable`] then refuses. + fn resolve_consumable(&self, item: &CoreOwnedItem) -> Option { + let ident = self.catalog.lookup(&item.card_id)?; + if ident.kind != ContentKind::Consumable { + return None; + } + Some(Fifa17ConsumableIdentity { + item_id: self.wire_for(item)?, + resource_id: ident.resource_id, + asset_id: ident.asset_id, + card_asset_id: ident.card_asset_id, + subtype: ident.subtype, + rareflag: ident.rareflag, + rating: ident.rating.unwrap_or(item.rating), + amount: ident.amount, + contract: ident.contract, + untradeable: CONSUMABLE_UNTRADEABLE, + }) + } + + /// The catalog `cardsubtypeid`, NON-MINTING (see the trait's contract): the + /// `/club` per-family filters call this for every owned row on every request. + fn subtype_of(&self, item: &CoreOwnedItem) -> i64 { + self.catalog.subtype_of(&item.card_id) + } + /// Delegate content classification to the catalog. Unknown definitions /// retain the backward-compatible Player default but fail identity resolution. fn kind_of(&self, item: &CoreOwnedItem) -> ContentKind { @@ -1840,6 +1895,10 @@ pub struct ClubLog { pub total: i64, pub emitted: usize, pub dropped_no_asset: usize, + /// Rows whose definition resolved but is incomplete, so the card would draw + /// a wrong value. Logged separately from `dropped_no_asset` because the fix + /// is a CATALOG re-emit, not an identity mapping. + pub dropped_incomplete: usize, pub offset: Option, pub limit: Option, } @@ -1856,6 +1915,168 @@ pub struct ClubDeps<'a> { pub active_kits: &'a CoreKitAssignments, } +/// Which owned rows one `?type=` arm of the club query selects. +/// +/// The client's taxonomy is `FUN_18012ec50`: 30 arms plus a default that returns +/// `any`. Every arm is named here, because the alternative — a narrow allow-list +/// with an "unsupported" catch-all — is how an owned manager became unreachable +/// once already. An arm either selects a real set, or is DELIBERATELY empty with +/// its reason recorded ([`ClubSelector::Withheld`]); nothing silently falls +/// through, and no arm ever answers with a family it was not asked for (that +/// mirror filter is what stops footballers appearing in the coaching staff). +enum ClubSelector { + /// Every owned row of this kind (staff arms take the whole staff family). + Kind(ContentKind), + /// One staff family, by `cardsubtypeid` (5 headcoach, 6 gkcoach, 7 physio, + /// 8 fitnesscoach). + StaffRole(i64), + /// Players in one MY CLUB position tab. + PlayerPositions(PositionGroup), + /// Answered empty on purpose; the string is why. + Withheld(&'static str), +} + +/// One resolved `?type=` arm: what it selects, plus the canonical label logged +/// for it. +struct ClubTypeFilter { + label: &'static str, + selector: ClubSelector, +} + +impl ClubTypeFilter { + fn kind(label: &'static str, kind: ContentKind) -> Self { + ClubTypeFilter { + label, + selector: ClubSelector::Kind(kind), + } + } + + /// Whether one owned row belongs in this arm's answer. + fn matches(&self, kind: ContentKind, subtype: i64, position: &str) -> bool { + match self.selector { + ClubSelector::Kind(want) if want.is_staff_family() => kind.is_staff_family(), + ClubSelector::Kind(want) => kind == want, + ClubSelector::StaffRole(role) => kind.is_staff_family() && subtype == role, + ClubSelector::PlayerPositions(group) => { + kind == ContentKind::Player && position_group(position) == Some(group) + } + ClubSelector::Withheld(_) => false, + } + } +} + +/// Resolve a `?type=` token (or its absence) to an arm, or `None` for a token +/// outside the client's own 30-arm vocabulary. +fn club_type_filter(token: Option<&str>) -> Option { + let filter = match token { + // An untyped fetch is the main club screen and is live-proven to be the + // player set. `any` is the taxonomy's own arm 0 (and its default): it is + // answered with the SAME set, because a genuinely mixed multi-family + // response is exactly what crashed the client on 2026-08-05, and `custom` + // is the observed companion of the league/team drill-downs. + None | Some("player") | Some("any") | Some("custom") => { + ClubTypeFilter::kind("player", ContentKind::Player) + } + // The three MY CLUB position tabs (`FUN_18012ddf0` suppresses `position=` + // and sends these tokens instead). + Some("playerdefender") => ClubTypeFilter { + label: "playerdefender", + selector: ClubSelector::PlayerPositions(PositionGroup::Defender), + }, + Some("playermidfielder") => ClubTypeFilter { + label: "playermidfielder", + selector: ClubSelector::PlayerPositions(PositionGroup::Midfielder), + }, + Some("playerforward") => ClubTypeFilter { + label: "playerforward", + selector: ClubSelector::PlayerPositions(PositionGroup::Forward), + }, + // The STAFF tab is the only staff request ever observed on the wire, and + // it asked with `type=manager` (count=200) for the WHOLE family — the + // client's own club-stats model likewise counts a manager inside its + // `staff` total with `staffManager` as a bucket within it. Narrowing this + // arm to subtype 4 would empty the staff tab of a club that owns coaches. + Some("staff") | Some("manager") => ClubTypeFilter::kind("staff", ContentKind::Staff), + // The four per-family coach arms, from the same taxonomy (and the + // oracle's own `CLUB_TYPES`). Each answers ONE `cardsubtypeid`. + Some("headcoach") => ClubTypeFilter { + label: "headcoach", + selector: ClubSelector::StaffRole(5), + }, + Some("gkcoach") => ClubTypeFilter { + label: "gkcoach", + selector: ClubSelector::StaffRole(6), + }, + Some("physio") => ClubTypeFilter { + label: "physio", + selector: ClubSelector::StaffRole(7), + }, + Some("fitnesscoach") => ClubTypeFilter { + label: "fitnesscoach", + selector: ClubSelector::StaffRole(8), + }, + // Club customisation, singular names, all observed live. The kind mapping + // is settled (kit 9, stadium 10, badge 11, ball 30), so each arm asks Core + // for the right rows; the item record for the three non-kit families is + // still withheld inside the shaper, which counts them. + Some("kit") => ClubTypeFilter::kind("kit", ContentKind::Kit), + Some("badge") => ClubTypeFilter::kind("badge", ContentKind::Badge), + Some("stadium") => ClubTypeFilter::kind("stadium", ContentKind::Stadium), + Some("ball") => ClubTypeFilter::kind("ball", ContentKind::Ball), + Some("misc") => ClubTypeFilter::kind("misc", ContentKind::Misc), + // WITHHELD, each for a recorded reason. + Some("equippables") => ClubTypeFilter { + label: "equippables", + // The combined customisation view, and the one response that has ever + // crashed this client: 30 items across five families at once + // (2026-08-05). A single-family answer here is not available either — + // the view is by definition multi-family — and the kit swap it feeds + // needs `item+0x60 == 4`, which a server can never produce (it can + // only ever produce 1 = club and 6 = purchased). So this stays empty + // until that is a client-side change, not a wire one. + selector: ClubSelector::Withheld("multi_family_crash_2026_08_05"), + }, + Some("leaguelogos") => ClubTypeFilter { + label: "leaguelogos", + // Subtype 31 is by elimination and unprobed, there is no + // `FUT_UC_LEAGUELOGO` caption anywhere in the DLL, and the family's + // only display name would be `localizedName` — "the parser reads it" + // is not "sending it is safe". Also not an ownable Core content kind. + selector: ClubSelector::Withheld("subtype_by_elimination_unprobed"), + }, + Some("healing") | Some("contract") | Some("training") | Some("development") => { + ClubTypeFilter { + label: "consumable_arm", + // Consumables are NOT served through `club?type=`. A previous + // round shipped four `?type=` arms for exactly these tokens and + // the screen stayed empty: the client asks + // `GET club/consumables/`, whose element is a STACK + // wrapper, and a bare item in this envelope is accepted and + // silently discarded. + selector: ClubSelector::Withheld("served_by_club_consumables_route"), + } + } + Some("unlocks") => ClubTypeFilter { + label: "unlocks", + selector: ClubSelector::Withheld("not_owned_inventory"), + }, + Some("offlinetrophy") + | Some("onlinetrophy") + | Some("featuredofflinetrophy") + | Some("featuredonlinetrophy") + | Some("allofflinetrophy") + | Some("allonlinetrophy") => ClubTypeFilter { + label: "trophy", + // Trophies are the `0x91..=0x96` tournament/season records, not owned + // club items, and Core models no trophy ownership. The club/stats + // trophy rows stay honest zeros for the same reason. + selector: ClubSelector::Withheld("no_trophy_ownership_in_core"), + }, + Some(_) => return None, + }; + Some(filter) +} + /// Filter already-shaped `/club` items to SPECIALS (`rareflag > 1`) and paginate /// the filtered set locally. Returns `(page, total_specials)`. Pure — the whole /// point is that "special" pagination is over the filtered set, never Core's @@ -1912,17 +2133,13 @@ fn paginate_items(items: &[Value], offset: Option, limit: Option) -> ( /// JSON body (UTAS must never 401/403; an empty result is the safe degrade). pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) { let raw = parse_club_query(query); - let requested_kind = match raw.item_type.as_deref() { - None | Some("player") => ContentKind::Player, - Some("kit") => ContentKind::Kit, - // The STAFF tab is the only staff request ever observed on the wire, and - // it asked with `type=manager`. `type=staff` is accepted as the obvious - // sibling token rather than betting the tab never sends it: both mean the - // same owned set here, because managers and coaches are one content kind - // (the client's own club-stats model likewise counts a manager inside its - // `staff` total, with `staffManager` as a bucket within it). - Some("staff") | Some("manager") => ContentKind::Staff, - Some(other) => { + let filter_arm = match club_type_filter(raw.item_type.as_deref()) { + Some(f) => f, + // A token outside the client's own 30-arm taxonomy. Empty is the honest + // answer AND the loud one: the log names the token so a new wire fact is + // actionable instead of silently mapped onto the player set. + None => { + let other = raw.item_type.as_deref().unwrap_or(""); return ( json_response(&json!({ "itemData": [] })), ClubLog { @@ -1931,12 +2148,29 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) total: 0, emitted: 0, dropped_no_asset: 0, + dropped_incomplete: 0, offset: raw.start.map(|value| value as i64), limit: raw.count.map(|value| value as i64), }, ); } }; + // A withheld arm never reaches Core: the reason, not a query, is the answer. + if let ClubSelector::Withheld(reason) = filter_arm.selector { + return ( + json_response(&json!({ "itemData": [] })), + ClubLog { + outcome: "withheld", + filter: format!("type={},reason={reason}", filter_arm.label), + total: 0, + emitted: 0, + dropped_no_asset: 0, + dropped_incomplete: 0, + offset: raw.start.map(|value| value as i64), + limit: raw.count.map(|value| value as i64), + }, + ); + } let core_q = match map_to_core(&raw, deps.entities) { Ok(c) => c, Err(e) => { @@ -1948,6 +2182,7 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) total: 0, emitted: 0, dropped_no_asset: 0, + dropped_incomplete: 0, offset: raw.start.map(|value| value as i64), limit: raw.count.map(|value| value as i64), }, @@ -1962,7 +2197,7 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) if !filter.is_empty() { filter.push(','); } - filter.push_str(&format!("type={}", requested_kind.as_str())); + filter.push_str(&format!("type={}", filter_arm.label)); if core_q.special { filter.push_str(",rare=SP"); } @@ -1978,7 +2213,13 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) .items .into_iter() .filter(|item| !deps.hidden.contains(&item.owned_card_id)) - .filter(|item| deps.assets.kind_of(item) == requested_kind) + .filter(|item| { + filter_arm.matches( + deps.assets.kind_of(item), + deps.assets.subtype_of(item), + &item.position, + ) + }) .collect(); let active = ActiveKitAssignments { home: deps.active_kits.home_owned_card_id.as_deref(), @@ -2005,6 +2246,7 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) total, emitted, dropped_no_asset: stats.dropped_no_asset, + dropped_incomplete: stats.dropped_incomplete, offset, limit, }, @@ -2020,6 +2262,7 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) total: 0, emitted: 0, dropped_no_asset: 0, + dropped_incomplete: 0, offset, limit, }, @@ -3627,8 +3870,16 @@ impl Server { }; let (resp, log) = handle_club(query, &deps); eprintln!( - "utas-host owner=RUST route=club status={} outcome={} filter=[{}] total={} emitted={} dropped_no_asset={} offset={:?} limit={:?}", - resp.status, log.outcome, log.filter, log.total, log.emitted, log.dropped_no_asset, log.offset, log.limit + "utas-host owner=RUST route=club status={} outcome={} filter=[{}] total={} emitted={} dropped_no_asset={} dropped_incomplete={} offset={:?} limit={:?}", + resp.status, + log.outcome, + log.filter, + log.total, + log.emitted, + log.dropped_no_asset, + log.dropped_incomplete, + log.offset, + log.limit ); resp } @@ -3690,6 +3941,7 @@ impl Server { } Route::Hub => self.handle_hub(), Route::ClubStats => self.handle_club_stats(path), + Route::ClubConsumables => self.handle_club_consumables(path), Route::StaticAck => self.handle_static_ack(path), Route::WatchList => self.handle_watchlist(method), Route::User => self.handle_user(), @@ -4375,6 +4627,76 @@ impl Server { json_status(200, &club_stats_body(&items, ctx)) } + /// `GET …/club/consumables/` — the consumables ITEM screen, served + /// from Core's authoritative inventory. + /// + /// The category segment is the client's own consumable UI group name + /// (`training`, `contracts`, `fitness`, `healing`, `position`, `playStyle`, + /// `managerLeagueModifier`), matched lower-cased. An UNKNOWN segment is + /// answered EMPTY and logged loudly: serving the whole shelf instead would + /// put the wrong families in a named tab, which is the same class of bug as + /// answering a drill-down with the entire club. + /// + /// Fail-closed 503 on a Core error, like club/stats: an empty list here is a + /// meaningful answer ("the club owns none of these"), so it must never double + /// as "Core is down". + /// + /// Cards with an ACTIVE market listing are excluded, exactly as on `/club`: a + /// listed card has LEFT the club and must not appear in both places. + fn handle_club_consumables(&self, path: &str) -> WireResponse { + let segment = ut_tail(path) + .and_then(|t| t.strip_prefix("club/consumables")) + .map(|rest| rest.trim_matches('/').to_ascii_lowercase()) + .unwrap_or_default(); + let families = match consumable_families_for_category(&segment) { + Some(f) => f, + None => { + eprintln!( + "utas-host owner=RUST route=club-consumables status=200 \ + category={segment:?} outcome=unknown_category emitted=0 \ + (add it to consumable_families_for_category if the client really asks)" + ); + return json_status(200, &json!({ "itemData": [] })); + } + }; + let owned = match self.core.all_owned() { + Ok(o) => o, + Err(e) => { + eprintln!("utas-host owner=RUST route=club-consumables status=503 error=core:{e}"); + return error_response(503, "core_unavailable"); + } + }; + let hidden = self.club_hidden_ids(); + let mut resolved: Vec = Vec::new(); + let mut unresolved = 0usize; + for item in owned + .iter() + .filter(|it| !hidden.contains(&it.owned_card_id)) + .filter(|it| self.resolver.kind_of(it) == ContentKind::Consumable) + .filter(|it| { + consumable_family(self.resolver.subtype_of(it)) + .map(|(family, _)| families.contains(&family)) + .unwrap_or(false) + }) + { + match self.resolver.resolve_consumable(item) { + Some(id) => resolved.push(id), + None => unresolved += 1, + } + } + let (body, stats) = consumables_response(&resolved); + eprintln!( + "utas-host owner=RUST route=club-consumables status=200 category={} \ + copies={} stacks={} dropped_no_asset={} dropped_incomplete={}", + segment, + stats.emitted, + body["itemData"].as_array().map(Vec::len).unwrap_or(0), + unresolved, + stats.dropped_incomplete + ); + json_status(200, &body) + } + /// Serve forever on `addr` (thread-per-connection, HTTP/1.1 keep-alive). pub fn serve(&self, addr: &str) -> std::io::Result<()> { let listener = TcpListener::bind(addr)?; diff --git a/openfut-utas-host/src/market.rs b/openfut-utas-host/src/market.rs index b09ffcd..431f3c1 100644 --- a/openfut-utas-host/src/market.rs +++ b/openfut-utas-host/src/market.rs @@ -28,6 +28,7 @@ use serde_json::{json, Value}; use openfut_adapter_fifa17::fut::entities::ReverseEntityResolver; use openfut_adapter_fifa17::fut::item::{shape_item, ItemIdentityResolver}; +use openfut_adapter_fifa17::fut::item_state; use openfut_adapter_fifa17::fut::non_economy; use openfut_adapter_fifa17::fut::squad::SquadWireResolver; @@ -81,7 +82,7 @@ fn trade_id_from_path(path: &str) -> Option { /// snapshot persisted at listing time; a row written before snapshots existed /// degrades to the stub (honest, not fabricated). /// -/// `item_state` overrides the card's `itemState`. FIFA 17's vocabulary is the +/// `state` overrides the card's `itemState`. FIFA 17's vocabulary is the /// 12-row `{const char*, int}` table at `0x180229cc0`, and `forSale` (5) is its /// value for an item offered for sale. The Python oracle stamps `listFS` on the /// seller's own pile instead — a token that does NOT EXIST in FIFA 17 (zero @@ -89,8 +90,8 @@ fn trade_id_from_path(path: &str) -> Option { /// memory) and therefore decodes to `-1` through `FUN_180166660`, i.e. the client /// is handed an unrecognised `CARD_OFFERSTATE`. Where the binary contradicts the /// oracle, the binary wins. -fn auction_record_as(l: &Listing, item_state: &str) -> Value { - auction_record_tuned(l, item_state, None, 0) +fn auction_record_as(l: &Listing, state: &str) -> Value { + auction_record_tuned(l, state, None, 0) } /// [`auction_record_as`] with the two fields the staging sold experiment varies. @@ -105,7 +106,7 @@ fn auction_record_as(l: &Listing, item_state: &str) -> Value { /// what makes the client's reaction attributable to the token. fn auction_record_tuned( l: &Listing, - item_state: &str, + state: &str, sold_bid_state: Option<&str>, coins_processed: i64, ) -> Value { @@ -152,7 +153,7 @@ fn auction_record_tuned( .map(|mut card| { // Keep the wire identity and presentation state authoritative here. card["id"] = json!(item_id); - card["itemState"] = json!(item_state); + card["itemState"] = json!(state); card["untradeable"] = json!(false); card }) @@ -160,7 +161,7 @@ fn auction_record_tuned( json!({ "id": item_id, "resourceId": resource, - "itemState": item_state, + "itemState": state, "untradeable": false, }) }); @@ -204,9 +205,9 @@ fn auction_record_tuned( /// closed/sold echoes the buy path returns. fn auction_record(l: &Listing) -> Value { let state = if l.state == "active" { - "forSale" + item_state::FOR_SALE } else { - "free" + item_state::FREE }; auction_record_as(l, state) } @@ -444,7 +445,7 @@ pub async fn handle_market_query( }; let mut auctions: Vec = listings .iter() - .map(|l| auction_record_as(l, "forSale")) + .map(|l| auction_record_as(l, item_state::FOR_SALE)) .collect(); // STAGING ONLY. FIFA 17's bulk `DELETE …/trade/sold` verb only makes sense if // sold rows persist in the seller's pile until acknowledged, so the experiment @@ -456,7 +457,7 @@ pub async fn handle_market_query( for l in &sold { auctions.push(auction_record_tuned( l, - "forSale", + item_state::FOR_SALE, exp.bid_state, exp.coins_processed, )); @@ -607,7 +608,7 @@ pub async fn handle_market_status( }; let auctions: Vec = listings .iter() - .map(|l| auction_record_tuned(l, "forSale", exp.bid_state, exp.coins_processed)) + .map(|l| auction_record_tuned(l, item_state::FOR_SALE, exp.bid_state, exp.coins_processed)) .collect(); eprintln!( "utas-host owner=RUST route=market-status requested={} returned={} query={}", @@ -723,7 +724,7 @@ pub async fn handle_market_buy( rec["tradeState"] = json!("closed"); rec["bidState"] = json!("highest"); rec["currentBid"] = json!(price); - rec["itemData"]["itemState"] = json!("free"); + rec["itemData"]["itemState"] = json!(item_state::FREE); ok_json(&json!({ "auctionInfo": [rec], "credits": new_balance })) } // Insufficient funds surfaced by Core (concurrent debit) -> 461. @@ -1155,6 +1156,10 @@ mod tests { let pile = handle_market_query("active", &econ, &store, SoldExperiment::OFF).await; let rec = parse(&pile)["auctionInfo"][0].clone(); assert_eq!(rec["itemData"]["itemState"], "forSale"); + assert!( + item_state::is_recovered(rec["itemData"]["itemState"].as_str().unwrap()), + "every emitted itemState must be in FIFA 17's own 12-row table" + ); assert_eq!(rec["itemData"]["rating"], 84); assert_eq!(rec["itemData"]["id"], 100004617i64); assert_eq!(rec["itemData"]["resourceId"], 169193); diff --git a/openfut-utas-host/tests/host_test.rs b/openfut-utas-host/tests/host_test.rs index f0a852f..4d8ccb9 100644 --- a/openfut-utas-host/tests/host_test.rs +++ b/openfut-utas-host/tests/host_test.rs @@ -2028,3 +2028,416 @@ fn watchlist_is_rust_owned_and_never_passed_through() { ); } } + +// ── /club ?type= completeness, and the consumables route ──────────────────── + +/// A club holding one of everything the taxonomy can ask about: two players (a +/// forward and a defender), a manager, a GK coach, a fitness coach, two kits and +/// one consumable — the same mix the real profile has, at fixture scale. +fn mixed_club_catalog() -> &'static str { + "\"fifa17_20801\":{\"asset_id\":20801},\ + \"fifa17_158023\":{\"asset_id\":158023},\ + \"fifa17_1000509\":{\"asset_id\":1000509,\"kind\":\"staff\",\"subtype\":4,\ + \"nation\":45,\"league_id\":53,\"team_id\":241},\ + \"fifa17_9000081\":{\"asset_id\":9000081,\"kind\":\"staff\",\"subtype\":6},\ + \"fifa17_3000083\":{\"asset_id\":3000083,\"kind\":\"staff\",\"subtype\":8},\ + \"fifa17_6300006\":{\"asset_id\":6300006,\"kind\":\"kit\",\"subtype\":9,\ + \"card_asset_id\":35,\"team_id\":21},\ + \"fifa17_6400003\":{\"asset_id\":6400003,\"kind\":\"kit\",\"subtype\":9,\ + \"card_asset_id\":35,\"team_id\":21},\ + \"fifa17_5003012\":{\"asset_id\":5003012,\"kind\":\"consumable\",\"subtype\":54,\ + \"card_asset_id\":3,\"rareflag\":0,\"rating\":85,\"amount\":15}" +} + +fn mixed_club_items() -> Vec { + vec![ + item( + "oc-st", + "fifa17_20801", + 94, + "ST", + "Argentina", + "Premier League", + "Chelsea", + ), + item( + "oc-cb", + "fifa17_158023", + 88, + "CB", + "Argentina", + "Premier League", + "Chelsea", + ), + item("oc-mgr", "fifa17_1000509", 0, "", "", "", ""), + item("oc-gk-coach", "fifa17_9000081", 0, "", "", "", ""), + item("oc-fit-coach", "fifa17_3000083", 0, "", "", "", ""), + item("oc-kit-home", "fifa17_6300006", 0, "", "", "", ""), + item("oc-kit-away", "fifa17_6400003", 0, "", "", "", ""), + item("oc-consumable", "fifa17_5003012", 0, "", "", "", ""), + ] +} + +/// Run one `?type=` query against the mixed club. +fn club_query(query: &str) -> (Vec, String, &'static str) { + let core = Arc::new(FakeCore::new(mixed_club_items(), 8)); + let resolver = resolver_for_catalog(mixed_club_catalog()); + let ents = entities(); + let hidden = std::collections::HashSet::new(); + let kits = CoreKitAssignments { + home_owned_card_id: Some("oc-kit-home".into()), + away_owned_card_id: Some("oc-kit-away".into()), + }; + let deps = ClubDeps { + core: core.as_ref(), + entities: &ents, + assets: resolver.as_ref(), + hidden: &hidden, + active_kits: &kits, + }; + let (resp, log) = handle_club(query, &deps); + assert_eq!(resp.status, 200, "UTAS must never fail a club query"); + let body: Value = serde_json::from_slice(&resp.body).unwrap(); + let items = body["itemData"].as_array().cloned().unwrap_or_default(); + (items, log.filter, log.outcome) +} + +/// Every `?type=` arm that claims a family must serve THAT family and nothing +/// else. The mirror filter is the point: a manager carries nation/leagueId/teamid, +/// so leaking one into a player query would put a coach in the by-league and +/// by-team drill-downs — the exact regression the filter exists to prevent. +#[test] +fn club_type_arms_serve_their_own_family_and_never_leak_another() { + // Players: the untyped fetch, `player`, and the taxonomy's `any`/`custom` + // arms all mean the club's footballers. + for query in ["", "type=player", "type=any", "type=custom"] { + let (items, _, outcome) = club_query(query); + assert_eq!(outcome, "ok", "{query}"); + assert_eq!(items.len(), 2, "{query}: two footballers"); + for it in &items { + assert_eq!(it["itemType"], "player", "{query}"); + assert_eq!(it["cardsubtypeid"], 0, "{query}: no staff/kit subtype"); + assert!(it["attributeList"].is_array(), "{query}"); + } + } + + // The whole staff family, under either observed token. + for query in ["type=staff", "type=manager"] { + let (items, _, outcome) = club_query(query); + assert_eq!(outcome, "ok", "{query}"); + let mut subtypes: Vec = items + .iter() + .map(|i| i["cardsubtypeid"].as_i64().unwrap()) + .collect(); + subtypes.sort_unstable(); + assert_eq!( + subtypes, + vec![4, 6, 8], + "{query}: manager + both coaches, no footballer and no kit" + ); + for it in &items { + assert_eq!(it["itemType"], "staff", "{query}"); + assert!(it.get("attributeList").is_none(), "{query}"); + } + } + + // One arm per coach family, by cardsubtypeid. + for (query, subtype) in [ + ("type=gkcoach", 6), + ("type=fitnesscoach", 8), + ("type=headcoach", 5), + ("type=physio", 7), + ] { + let (items, _, outcome) = club_query(query); + assert_eq!(outcome, "ok", "{query}"); + let owned: Vec = items + .iter() + .map(|i| i["cardsubtypeid"].as_i64().unwrap()) + .collect(); + // The club owns a GK coach and a fitness coach only, so the other two + // arms are legitimately empty — never "everything" to fill the tab. + let expected: Vec = if [6, 8].contains(&subtype) { + vec![subtype] + } else { + vec![] + }; + assert_eq!(owned, expected, "{query}"); + assert!( + !owned.contains(&4), + "{query}: the MANAGER is not a coach family" + ); + } + + // Kits, with their ownership-backed active designations. + let (kits, _, outcome) = club_query("type=kit"); + assert_eq!(outcome, "ok"); + assert_eq!(kits.len(), 2); + assert_eq!(kits[0]["itemState"], "activeHomeKit"); + assert_eq!(kits[1]["itemState"], "activeAwayKit"); + for kit in &kits { + assert_eq!(kit["cardsubtypeid"], 9); + assert!(kit.get("attributeList").is_none()); + } + + // The consumable is in NEITHER: it has its own route and its own envelope. + for query in ["", "type=player", "type=staff", "type=kit"] { + let (items, _, _) = club_query(query); + assert!( + !items + .iter() + .any(|i| i["cardsubtypeid"].as_i64() == Some(54)), + "{query}: a consumable must never appear in a /club item list" + ); + } +} + +/// The three MY CLUB position tabs answer their own group, from the client's own +/// position ladder — never the whole club. +#[test] +fn position_tabs_serve_only_their_own_position_group() { + let (forwards, _, outcome) = club_query("type=playerforward"); + assert_eq!(outcome, "ok"); + assert_eq!(forwards.len(), 1); + assert_eq!(forwards[0]["preferredPosition"], "ST"); + + let (defenders, _, _) = club_query("type=playerdefender"); + assert_eq!(defenders.len(), 1); + assert_eq!(defenders[0]["preferredPosition"], "CB"); + + // No midfielder is owned → an empty tab, not the other five cards. + let (mids, _, outcome) = club_query("type=playermidfielder"); + assert_eq!(outcome, "ok"); + assert!(mids.is_empty()); + + // And no position tab ever contains staff or a kit. + for query in [ + "type=playerforward", + "type=playerdefender", + "type=playermidfielder", + ] { + let (items, _, _) = club_query(query); + for it in &items { + assert_eq!(it["itemType"], "player", "{query}"); + } + } +} + +/// The arms that are DELIBERATELY empty answer 200 with an empty list and record +/// WHY — distinguishable in the log from a token we do not know. +#[test] +fn withheld_club_type_arms_are_empty_with_a_recorded_reason() { + for (query, reason) in [ + ("type=equippables", "multi_family_crash_2026_08_05"), + ("type=leaguelogos", "subtype_by_elimination_unprobed"), + ("type=healing", "served_by_club_consumables_route"), + ("type=contract", "served_by_club_consumables_route"), + ("type=training", "served_by_club_consumables_route"), + ("type=development", "served_by_club_consumables_route"), + ("type=unlocks", "not_owned_inventory"), + ("type=offlinetrophy", "no_trophy_ownership_in_core"), + ("type=onlinetrophy", "no_trophy_ownership_in_core"), + ("type=featuredofflinetrophy", "no_trophy_ownership_in_core"), + ("type=featuredonlinetrophy", "no_trophy_ownership_in_core"), + ("type=allofflinetrophy", "no_trophy_ownership_in_core"), + ("type=allonlinetrophy", "no_trophy_ownership_in_core"), + ] { + let (items, filter, outcome) = club_query(query); + assert_eq!(outcome, "withheld", "{query}"); + assert!(items.is_empty(), "{query} must serve nothing"); + assert!( + filter.contains(reason), + "{query}: log must carry the reason, got [{filter}]" + ); + } +} + +/// The three club-customisation families Core can own are MAPPED (so the arm asks +/// Core for the right rows) but their item record is still withheld, so the answer +/// is an empty list rather than a guessed record — and never another family's. +#[test] +fn club_item_arms_are_mapped_but_withhold_the_unverified_record() { + for query in ["type=badge", "type=stadium", "type=ball", "type=misc"] { + let (items, filter, outcome) = club_query(query); + assert_eq!(outcome, "ok", "{query}"); + assert!( + items.is_empty(), + "{query}: the club owns none, and no other family may fill the tab" + ); + let token = query.trim_start_matches("type="); + assert!(filter.contains(&format!("type={token}")), "{filter}"); + } +} + +/// A token outside the client's 30-arm taxonomy stays `unsupported_type`: empty, +/// and loud in the log, never silently mapped onto the player set. +#[test] +fn unknown_club_type_is_unsupported_not_silently_mapped() { + for query in ["type=nonsense", "type=PLAYER", "type=kits"] { + let (items, filter, outcome) = club_query(query); + assert_eq!(outcome, "unsupported_type", "{query}"); + assert!(items.is_empty(), "{query}"); + assert!(filter.starts_with("type="), "{filter}"); + } +} + +fn build_server_with_catalog(core: Arc, upstream: &str, cards_json: &str) -> Server { + let doc = format!("{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{cards_json}}}}}"); + let catalog = Fifa17CardCatalog::from_json_str(&doc).unwrap(); + let store = JsonIdentityStore::open(unique_store_path()).unwrap(); + let resolver = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store))); + Server::new( + core, + Arc::new(entities()), + resolver, + Arc::new(PassClient::new(upstream)), + 33_068_179, + ) +} + +/// `GET club/consumables/` is Rust-owned, served from Core, and answers +/// with the STACK wrapper this response class reads — not bare items, which the +/// client accepts and silently discards. +#[test] +fn consumables_route_serves_core_owned_stacks_per_category() { + // Two copies of one training card + one contract card + a footballer. + let items = vec![ + item("oc-c1", "fifa17_5003012", 0, "", "", "", ""), + item("oc-c2", "fifa17_5003012", 0, "", "", "", ""), + item("oc-c3", "fifa17_5001004", 0, "", "", "", ""), + item( + "oc-p", + "fifa17_20801", + 94, + "ST", + "Argentina", + "Premier League", + "Chelsea", + ), + ]; + let core = Arc::new(FakeCore::new(items, 4)); + let (py_url, rec) = spawn_mock_python(); + let server = build_server_with_catalog( + core, + &py_url, + "\"fifa17_20801\":{\"asset_id\":20801},\ + \"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}", + ); + + assert_eq!( + classify("GET", "/ut/game/fifa17/club/consumables/training"), + Route::ClubConsumables, + "a /club PREFIX must not fall through to the generic club route" + ); + + let resp = server.handle("GET", "/ut/game/fifa17/club/consumables/training", &[], b""); + assert_eq!(resp.status, 200); + let body: Value = serde_json::from_slice(&resp.body).unwrap(); + let stacks = body["itemData"].as_array().unwrap(); + assert_eq!(stacks.len(), 1, "one stack for the two identical copies"); + assert_eq!(stacks[0]["count"], 2); + assert_eq!(stacks[0]["resourceId"], 5_003_012); + assert_eq!(stacks[0]["item"]["cardsubtypeid"], 54); + assert_eq!( + stacks[0]["item"]["cardassetid"], 3, + "the ART id, not the id" + ); + assert_eq!(stacks[0]["item"]["amount"], 15, "mandatory for category 0"); + assert_eq!(stacks[0]["item"]["rating"], 85, "EA's definition rating"); + assert!( + stacks[0]["item"].get("attributeList").is_none(), + "a consumable has no attributes — that is what makes it not a player" + ); + + // The contracts category serves the OTHER card, and never the training one. + let resp = server.handle( + "GET", + "/ut/game/fifa17/club/consumables/contracts", + &[], + b"", + ); + let body: Value = serde_json::from_slice(&resp.body).unwrap(); + let stacks = body["itemData"].as_array().unwrap(); + assert_eq!(stacks.len(), 1); + assert_eq!(stacks[0]["resourceId"], 5_001_004); + assert_eq!(stacks[0]["item"]["contract"], 7); + assert!( + stacks[0]["item"].get("amount").is_none(), + "categories 2 and 3 ignore `amount`" + ); + + // A category the club owns nothing in is empty — and the footballer never + // appears in any of them. + for category in ["healing", "fitness", "position", "playstyle"] { + let path = format!("/ut/game/fifa17/club/consumables/{category}"); + let resp = server.handle("GET", &path, &[], b""); + let body: Value = serde_json::from_slice(&resp.body).unwrap(); + assert!( + body["itemData"].as_array().unwrap().is_empty(), + "{category} must not be filled with another family" + ); + } + assert_eq!(rec.lock().len(), 0, "consumables never reach Python"); +} + +/// An UNKNOWN category segment is empty, not the whole shelf: filling a named tab +/// with every family is the same bug class as answering a drill-down with the +/// entire club. +#[test] +fn consumables_route_unknown_category_serves_nothing() { + let core = Arc::new(FakeCore::new( + vec![item("oc-c1", "fifa17_5003012", 0, "", "", "", "")], + 1, + )); + let (py_url, rec) = spawn_mock_python(); + let server = build_server_with_catalog( + core, + &py_url, + "\"fifa17_5003012\":{\"asset_id\":5003012,\"kind\":\"consumable\",\"subtype\":54,\ + \"card_asset_id\":3,\"rareflag\":0,\"rating\":85,\"amount\":15}", + ); + for path in [ + "/ut/game/fifa17/club/consumables/somethingelse", + "/ut/game/fifa17/club/consumables", + ] { + let resp = server.handle("GET", path, &[], b""); + assert_eq!(resp.status, 200, "{path}"); + let body: Value = serde_json::from_slice(&resp.body).unwrap(); + assert!(body["itemData"].as_array().unwrap().is_empty(), "{path}"); + } + assert_eq!(rec.lock().len(), 0, "still never Python"); +} + +/// A consumable whose catalog entry is INCOMPLETE is dropped, not drawn wrong: +/// without `amount` the client renders "-1" (its parser initialises the temp to +/// -1 and sign-extends), and without a real `cardassetid` it draws the +/// `notfound.swf` green box. +#[test] +fn incomplete_consumable_definitions_are_dropped_not_drawn_wrong() { + let core = Arc::new(FakeCore::new( + vec![ + item("oc-no-amount", "fifa17_5003011", 0, "", "", "", ""), + item("oc-no-art", "fifa17_5003013", 0, "", "", "", ""), + ], + 2, + )); + let (py_url, _rec) = spawn_mock_python(); + let server = build_server_with_catalog( + core, + &py_url, + // (a) art id present, `amount` missing; (b) `amount` present, art missing. + "\"fifa17_5003011\":{\"asset_id\":5003011,\"kind\":\"consumable\",\"subtype\":54,\ + \"card_asset_id\":3,\"rareflag\":0,\"rating\":65},\ + \"fifa17_5003013\":{\"asset_id\":5003013,\"kind\":\"consumable\",\"subtype\":54,\ + \"rareflag\":0,\"rating\":85,\"amount\":15}", + ); + let resp = server.handle("GET", "/ut/game/fifa17/club/consumables/training", &[], b""); + assert_eq!(resp.status, 200); + let body: Value = serde_json::from_slice(&resp.body).unwrap(); + assert!( + body["itemData"].as_array().unwrap().is_empty(), + "neither definition can be drawn honestly" + ); +}