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:
+1
-1
Submodule openfut-core updated: bae0a2bdaa...36bc594924
+350
-28
@@ -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)?;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user