feat(host): serve owned non-player content from Core's ownership truth

Follows Core's kit designations becoming generic active-item slots: the host
reads `GET /club/active-items` (five always-present slots) instead of the
removed `/club/kits`.

Adds the consumables route and widens the club families to every content kind,
all resolved from Core ownership + the FIFA catalog. An item the client sees is
now an item Core actually owns.
This commit is contained in:
funman300
2026-08-21 19:49:59 +00:00
parent 6c7d0856b6
commit 802f0f580f
4 changed files with 781 additions and 41 deletions
+350 -28
View File
@@ -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/<category>` — 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<CoreKitAssignments, CoreError> {
Ok(CoreKitAssignments::default())
}
@@ -942,9 +956,13 @@ impl CoreAccess for HttpCoreClient {
}
fn get_active_kits(&self) -> Result<CoreKitAssignments, CoreError> {
// 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<Fifa17StaffIdentity> {
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<Fifa17ConsumableIdentity> {
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<i64>,
pub limit: Option<i64>,
}
@@ -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<ClubTypeFilter> {
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/<category>`, 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<i64>, limit: Option<i64>) -> (
/// 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/<category>` — 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<Fifa17ConsumableIdentity> = 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)?;
+17 -12
View File
@@ -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<String> {
/// 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<String> {
/// 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<Value> = 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<Value> = 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);
+413
View File
@@ -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<CoreOwnedItem> {
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<Value>, 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<i64> = 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<i64> = 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<i64> = 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<FakeCore>, 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/<category>` 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"
);
}