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)?;