Files
OpenFUT/openfut-utas-host/src/lib.rs
T
funman300 d4a39ba75d host: ViewCards must return OWNED INSTANCES, not definition placeholders
GET ut/%s/item is FutViewCards and its ids are OWNED INSTANCE ids - the client
builds the query as ?idList=%lld (CardsDLL .rdata 0x220080) from ids it already
holds. It was being answered with the definition body, which echoes the queried
id straight back. Asked about the active home kit, instance 100004874, the server
replied:

    resourceId 100004874, cardsubtypeid 0, itemType "player", itemState "free"

i.e. "your active home kit is a free player". No kit can ever be seen as active
through that. Real players were equally wrong: instance 100000003 came back as
resourceId 100000003 instead of the actual card 200389 rating 87.

This is on the active-kit path, and the evidence for that is the client's own UI.
KitAssignmentPopup.BIG (exported from Frosty today) decompiles to
external.ion_fut.components.KitAssignmentPopup and contains OSDKCards_ViewCards,
OSDKCards_ActivateCard, mHomeKitID/mAwayKitID/mSourceKitID, and the search states
SEARCH_STATE_ACTIVE_HOME_KIT / SEARCH_STATE_ACTIVE_AWAY_KIT. Those states can
only come from the itemState this route returns.

Route::ViewCards is now distinct from Route::ItemDefs. Owned instances are shaped
by the SAME projector /club uses, so there is one wire dialect and no drift; ids
that are not owned instances still fall back to the definition placeholder, and
the empty query still answers {"itemData": []}, preserving oracle parity for the
definition-style callers (item/resource, defid).

Verified on staging:
  ?idList=100004874,100004873 -> resourceId 6300006/6400003, cardsubtypeid 9,
        itemType kit, itemState activeHomeKit/activeAwayKit, teamid 21, cat 2/3
  ?idList=100000003           -> resourceId 200389, rating 87 (the real card)
  ?idList=999999999           -> definition fallback
  no query                    -> {"itemData": []}
  item/resource? and defid?   -> unchanged

cargo test -p openfut-utas-host: 190 passed, 0 failed. clippy -D warnings clean.
2026-08-23 21:20:07 +00:00

8013 lines
331 KiB
Rust

//! # openfut-utas-host
//!
//! The first live FIFA 17 **UTAS migration host**. It fronts the client-visible
//! UTAS port and does route-level migration:
//!
//! ```text
//! FIFA 17 ──HTTP──▶ openfut-utas-host
//! ├── GET …/club ──▶ FIFA17 adapter ──▶ OpenFUT Core
//! └── everything else ──▶ Python UTAS oracle (verbatim)
//! ```
//!
//! ## Safety rules (see the mission brief)
//!
//! * **Classification happens once, before any execution** ([`classify`]). A
//! request is either handled in Rust or proxied to Python — never both, and
//! there is NO "try Rust then retry on Python", which could double-apply a
//! mutation. `/club` is read-only, but the rule holds regardless.
//! * The Rust `/club` path NEVER contacts Python; the passthrough path NEVER
//! runs Core logic.
//! * A Core failure on `/club` returns an empty (but valid) `{"itemData":[]}`
//! and logs an error — it does NOT fall back to Python.
//!
//! ## Transport (worker D)
//!
//! UTAS is plaintext HTTP/1.1 keep-alive, no TLS. Body is read by `Content-Length`
//! before responding; responses carry `Content-Length` and `Content-Type:
//! application/json` only when a body is present.
//!
//! ## The asset-id boundary
//!
//! FIFA renders an owned card from a real FIFA asset id (`resourceId & 0xffffff`
//! resolved against the client's local DB). Core's synthetic catalogue has none,
//! so [`ItemIdentityResolver`] is injected and unresolved items are dropped, not
//! faked (see `club_response`). With today's empty mapping, `/club` returns
//! `{"itemData":[]}` — the honest state until Core inventory is asset-backed.
pub mod account_store;
pub mod async_bridge;
pub mod clientdata_store;
pub mod config;
pub mod economy_store;
pub mod market;
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 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, Fifa17ConsumableIdentity,
Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver,
};
use openfut_adapter_fifa17::fut::club_stats::{club_stats_body, ClubStatInput, ContextField};
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, MANAGER_SUBTYPE,
};
use openfut_adapter_fifa17::fut::contract_cards::{
contract_grant, staff_tier, tier_for_rating, CONTRACT_MATCH_CAP, MANAGER_CONTRACT_SUBTYPE,
PACK_FRESH_CONTRACT_MATCHES, PLAYER_CONTRACT_SUBTYPE,
};
use openfut_adapter_fifa17::fut::discard;
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::{
is_special_rareflag, map_to_core, parse_club_query, MapError,
};
use openfut_adapter_fifa17::fut::pack_content::GeneratedCandidate;
use openfut_adapter_fifa17::fut::sbc as fifa17_sbc;
use openfut_adapter_fifa17::fut::season_wire;
use openfut_adapter_fifa17::fut::squad::{parse_squad_put, save_ack, SquadWireResolver};
use openfut_adapter_fifa17::fut::squad_ext::{
build_squad_write, Fifa17SquadExtensionV1, SquadBuildError, EXT_NAMESPACE, EXT_SCHEMA_VERSION,
};
use openfut_adapter_fifa17::fut::squad_projection::{
project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
SquadProjection, SquadProjectionInput,
};
use openfut_adapter_fifa17::fut::store_catalog::{
build_purchasegroup, owned_pack_id_for_definition,
};
use openfut_adapter_fifa17::fut::store_session::{
validate_capability, SessionStore, StoreMode, SENTINEL_PACK_ID,
};
use openfut_adapter_fifa17::fut::training_cards::{
ceiling_for, class_accepts_position, training_effect,
};
use openfut_identity::ExternalIdentityStore;
use rand::{Rng, SeedableRng};
use serde_json::{json, Value};
use account_store::{AccountStore, RenameOutcome};
use clientdata_store::ClientDataStore;
use config::{HostConfig, SbcPostCommitFault};
// ───────────────────────────── Route classification ─────────────────────────
/// The route decision, taken once, before execution.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Route {
/// `GET …/club` — the owned-player search, served from Core.
Club,
/// `PUT …/club` or `PUT/POST …/user/club` — validates and persists the
/// FIFA club name/abbreviation, then returns the zero-atom `{}` ack.
ClubRename,
/// `PUT …/squad/<n>` — full squad replacement, committed to Core.
SquadReplace,
/// `GET …/squad/list` — the squad summary, projected from Core.
SquadList,
/// `GET …/squad/active` — the active squad object, projected from Core.
SquadActive,
/// `GET …/userMassInfo` — served FULLY from Rust: the Core squad projection
/// plus the Rust/Core economy (coins + unopened packs). No Python.
UserMassInfo,
/// `POST /ut/auth` — Rust mints the `X-UT-SID`, adopts the persona from the
/// body (or the configured default), and opens a Rust session. Never proxied.
Auth,
/// `POST /openfut/account/sync` — launcher control-plane account summary,
/// served from Rust with authoritative Core coins/entitlements. Never Python.
AccountSync,
/// `GET/PUT/POST …/clientdata/<key>` — opaque per-user client blob store
/// (`userHubData` etc.), round-tripped through the Rust client-data store.
ClientData,
/// `POST /openfut/fifa17/capability` — launcher capability registration,
/// owned entirely in Rust (no economy, no proxy).
Capability,
/// `GET …/store/purchasegroup…` — proxied to Python for the authoritative
/// economy body, with the empty-My-Packs topology overlaid from the Rust
/// session mode (the 65534 sentinel is stripped for a verified clean-v1 SID).
StorePurchaseGroup,
/// `GET …/user/accountinfo` — Rust-owned static `{}` (production oracle body).
AccountInfo,
/// `GET …/settings` — Rust-owned static `{"configs":[]}`.
Settings,
/// `GET …/leaderboards/options` — Rust-owned static `{}`.
LeaderboardOptions,
/// `PUT …/match/reset` — Rust-owned no-op ack `{}`.
MatchReset,
/// `GET/POST/PUT …/phishing/{trusteddevice,question,validate}` — the retired
/// FUT security-question service, owned in Rust as a stateless ack.
SecurityQuestion,
/// `GET …/club/stats/staff` — Rust-owned static `{}` (production oracle body;
/// FIFA's staff-bonus stat set is deliberately empty).
ClubStatsStaff,
/// `GET …/hub` — the FUT hub tile counts (club players + auction/tradePile),
/// derived from Core inventory + the durable market store (no Python).
Hub,
/// `GET …/club/stats/{year,consumables}` — the MY CLUB stat set, computed
/// 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
/// (these are not flag-gated in the oracle, so a constant is exact parity).
StaticAck,
/// `GET …/watchList` — the transfer watch list, served empty from Rust with
/// authoritative Core credits (the oracle persists no watches; add/remove is a
/// no-op ack). Body: `{auctionInfo:[], credits, total:0}`.
WatchList,
/// `GET …/user` — the FUT user profile `{"userInfo": …}`, the same userInfo
/// object `userMassInfo` embeds (shared builder). POST /user (create) stays Python.
User,
/// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and
/// the club-identity read service are disabled in this emulator, so these
/// reads return `{}`, byte-identical to the Python oracle with `FUT_MODES` /
/// `FUT_CLUB_IDENTITY` off. Club rename is a separate Rust-owned route.
FeatureOffEmpty,
/// `…/season…` — FIFA 17 offline Seasons. Rust-owned: the schedule the
/// client needs before it will open the mode at all, plus the user's
/// position in it. See [`openfut_adapter_fifa17::fut::season_wire`].
Season,
/// `GET …/item/resource`, `…/defid` — FUT item-definition lookups. Rust builds
/// `{itemData:[…]}` (one placeholder-or-Ronaldo def per queried id), mirroring
/// the oracle's `defs_route`/`item_def`. The client renders the real card from
/// its LOCAL DB, so a valid-shaped placeholder is exact parity.
ItemDefs,
/// `GET …/item` — FutViewCards. DISTINCT from [`Route::ItemDefs`]: the client
/// builds `?idList=%lld` from OWNED INSTANCE ids and expects the owned items
/// back, carrying their real `resourceId`, `cardsubtypeid` and `itemState`.
/// Answering it with definition placeholders tells the client its active kit
/// is a free player. See [`UtasHost::handle_view_cards`].
ViewCards,
/// `GET …/marketdata` and `…/marketdata/pricelimits` — suggested pricing.
/// `/pricelimits` MUST be a bare ARRAY (one `{defId,minPrice,maxPrice}` per
/// queried defId); plain `/marketdata` MUST be an OBJECT `{minPrice,maxPrice}`.
/// Container type is load-bearing (object-where-array froze a live client);
/// the handler picks it from the path. Constant band 150..15000.
MarketData,
/// Anything else — proxied verbatim to the Python oracle.
Passthrough,
}
/// Classify a request ONCE, before execution. Rust owns complete route families;
/// there is no "try Rust then Python", so a mutation can never be double-applied.
/// Numeric `GET …/squad/<n>` follows the oracle's single-current-squad behavior:
/// every numeric id returns the one Core-backed active squad.
pub fn classify(method: &str, path: &str) -> Route {
let get = method.eq_ignore_ascii_case("GET");
let put = method.eq_ignore_ascii_case("PUT");
let post = method.eq_ignore_ascii_case("POST");
// Session/capability vertical (Rust session authority).
if post && path.starts_with("/ut/auth") {
return Route::Auth;
}
// Rust owns the /ut/delete/auth logout ack (any method).
if path.starts_with("/ut/delete/auth") {
return Route::Auth;
}
// Launcher control-plane account summary (not under /ut/game).
if post && path == "/openfut/account/sync" {
return Route::AccountSync;
}
if post && path == "/openfut/fifa17/capability" {
return Route::Capability;
}
if get && is_exact_club_path(path) {
return Route::Club;
}
if put && is_exact_club_path(path) {
return Route::ClubRename;
}
match ut_tail(path) {
Some("squad/list") if get => Route::SquadList,
Some("squad/active") if get => Route::SquadActive,
Some(tail) if get && is_numeric_squad_tail(tail) => Route::SquadActive,
Some("userMassInfo") if get => Route::UserMassInfo,
Some("user/club") if put || post => Route::ClubRename,
Some(tail) if tail.starts_with("clientdata/") => Route::ClientData,
Some(tail) if get && tail.starts_with("store/purchasegroup") => Route::StorePurchaseGroup,
Some(tail) if put && is_numeric_squad_tail(tail) => Route::SquadReplace,
Some("user/accountinfo") if get => Route::AccountInfo,
Some("user") if get => Route::User,
Some("settings") if get => Route::Settings,
Some("leaderboards/options") if get => Route::LeaderboardOptions,
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,
// THIRD instance of the `watchList` defect below: `handle_static_ack`
// has answered these four since it was written — `captcha` with the
// oracle's exact `{encodedImg,sequence,sizeBeforeEncode}` and the other
// three with `{}` (`tools/utas_server.py:1525-1529`) — and `Route`'s own
// doc comment claims them as "Rust-owned UNCONDITIONAL". But no arm ever
// produced the route, so every one fell through to the Python upstream.
// Invisible in production, where that upstream answers; on staging, where
// it is deliberately dead, all four are a 502.
Some("captcha") => Route::StaticAck,
Some("tfa") => Route::StaticAck,
Some("livemessage") => Route::StaticAck,
Some("activeMessage") => Route::StaticAck,
// `watchList` has had a Rust handler all along, but nothing ever produced
// this route, so `Route::WatchList` was unreachable and every request fell
// through to Passthrough — the same defect class as `season/list`. The
// handler answers GET with an empty list plus Core credits, and acks the
// mutating verbs, so all four methods are claimed here.
Some("watchList") => Route::WatchList,
// `season…` is Rust-owned for the methods the client actually uses: the
// GETs that drive the mode, and the PUT that stores season state. The
// bare tail keeps the old empty body. Matching the PREFIX (not just
// `"season"`) matters — `season/list` used to fall through to
// Passthrough, i.e. the deliberately-dead Python upstream. Anything else
// still proxies rather than being claimed without evidence.
Some(t)
if (get || method.eq_ignore_ascii_case("PUT"))
&& (t == "season" || t.starts_with("season/")) =>
{
Route::Season
}
Some("tournament") if get => Route::FeatureOffEmpty,
// FOURTH instance of the same defect: the client builds
// `ut/%s/tournament/user` (literal at CardsDLL 0x18021e540) and the bare
// `tournament` arm does not match it, so it fell through to Python. The
// oracle answers it with `{}` whenever FUT_MODES is off
// (`tools/utas_server.py:1504`), which is exactly what FeatureOffEmpty
// returns — so claiming it is byte-identical parity, not new behaviour.
Some("tournament/user") if get => Route::FeatureOffEmpty,
Some("champion") if get => Route::FeatureOffEmpty,
Some("clubUser") if get => Route::FeatureOffEmpty,
Some("user/list") if get => Route::FeatureOffEmpty,
Some(t) if get && (t == "item/resource" || t.starts_with("item/resource?")) => {
Route::ItemDefs
}
Some(t) if get && (t == "defid" || t.starts_with("defid?")) => Route::ItemDefs,
// `GET ut/%s/item` is FutViewCards (deser `0x1801293d0`, top-level
// `itemData` via the shared card element `0x18013fe00`). It is NOT the
// definition lookup, and answering it as one is a real defect — see
// [`Self::handle_view_cards`]. The client builds it as `?idList=%lld`
// (CardsDLL `.rdata` 0x220080) carrying OWNED INSTANCE ids.
//
// It was unclaimed entirely until 2026-08-23, so it fell through to the
// Python upstream: invisible in production where the oracle answers, and a
// 502 on staging — the same dead-route class already found four times
// (season/list, watchList, the handle_static_ack tails, tournament/user).
//
// The `?` forms are not decoration: `ut_tail` does NOT strip the query, so
// a bare equality arm silently misses every real request while passing a
// no-query unit test. That is how this stayed unclaimed. The oracle's own
// pattern is `item(\?|$)` (`tools/utas_server.py:1419`).
//
// MUST stay below `item/resource` (matched first) and must not swallow
// `item/<id>`, which is DELETE-only Quick Sell, nor PUT `item`, which is
// FutMoveCard on the economy path.
Some(t) if get && (t == "item" || t.starts_with("item?")) => Route::ViewCards,
Some(t) if get && (t == "marketdata" || t.starts_with("marketdata/")) => Route::MarketData,
_ => Route::Passthrough,
}
}
/// The tail after `/ut/game/<sku>/` or `/ut/v2/game/<sku>/` (non-empty sku), or
/// `None`. Retail FIFA 17 issues the Store family (`store/*`, `purchased`) under
/// the `/ut/v2/game/<sku>/` prefix while other routes use `/ut/game/<sku>/`; both
/// normalize to the same tail so economy classification is prefix-agnostic. The
/// `sku` segment is generic (never hard-coded to `fifa17`).
fn ut_tail(path: &str) -> Option<&str> {
let rest = path
.strip_prefix("/ut/game/")
.or_else(|| path.strip_prefix("/ut/v2/game/"))?;
let (sku, tail) = rest.split_once('/')?;
if sku.is_empty() {
None
} else {
Some(tail)
}
}
/// Every run of ≥ 3 ASCII digits in `s`, parsed as `i64` — the allocation-light
/// equivalent of the oracle's `re.findall(r"\d{3,}", query)` used by `defs_route`
/// to pull ids out of `resourceId=`/`definitionId=`/`idList=a,b,c` queries.
fn extract_long_ints(s: &str) -> Vec<i64> {
let bytes = s.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i].is_ascii_digit() {
let start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i - start >= 3 {
if let Ok(v) = s[start..i].parse::<i64>() {
out.push(v);
}
}
} else {
i += 1;
}
}
out
}
/// The comma-separated all-digit values of the `defId=` query parameter (the
/// oracle's `parse_qs(...).get("defId")` + `int(x) for x if x.isdigit()`), used
/// by `/marketdata/pricelimits`. Non-digit tokens are skipped, never faked.
fn extract_defid_param(query: &str) -> Vec<i64> {
for pair in query.split('&') {
if let Some(v) = pair.strip_prefix("defId=") {
return v
.split(',')
.filter_map(|t| {
let t = t.trim();
if !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit()) {
t.parse::<i64>().ok()
} else {
None
}
})
.collect();
}
}
Vec::new()
}
fn is_exact_club_path(path: &str) -> bool {
ut_tail(path) == Some("club")
}
/// `squad/<digits>` — the numeric full-squad target used by PUT and GET. Core
/// stores one current squad, matching the oracle: every numeric GET returns that
/// same squad regardless of the requested id.
fn is_numeric_squad_tail(tail: &str) -> bool {
match tail.strip_prefix("squad/") {
Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()),
None => false,
}
}
/// A FIFA17 economy route, classified separately from [`classify`]. This is the
/// *target* ownership map for the economy cutover. It is deliberately NOT wired
/// into [`Server::handle_with_ip`] yet: handler wiring and authority cutover are
/// distinct steps. Until the single barrier commit flips the whole cluster,
/// production classification ([`classify`]) still sends every one of these to
/// Python; only integration tests drive them through [`Server::try_handle_economy`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EconomyRoute {
/// `GET …/user/credits` — coins + unopened-pack count.
Credits,
/// `GET …/store/purchasegroup` — catalogue + owned packs, full-generated.
PurchaseGroup,
/// `PUT …/store/transaction` — Store BUY (open-on-buy).
StoreBuy,
/// `POST …/purchased` — open a pack / redeem an owned entitlement.
PackOpen,
/// `GET …/purchased` — the pack-reveal screen (items in the "purchased" pile).
PackReveal,
/// `DELETE …/item/<id>` — single-card quick-sell.
QuickSellPath,
/// `POST /ut/delete/game/<sku>/item` — bulk quick-sell.
QuickSellBody,
/// `POST …/item/resource/<resourceId>` — apply one CONSUMABLE to one owned
/// target (`ApplyCardByRes`, task id `0x0e`), live-captured 2026-08-21 as
/// `{"apply":[{"id":<target>}]}`. The source consumable is the RESOURCE id in
/// the path; the target is an owned-item wire id in the body.
///
/// THIS PATH SERVES THREE VERBS and conflating any two of them consumes or
/// sells the wrong card: GET is the definition lookup ([`Route::ItemDefs`]),
/// POST is this apply, PUT is [`Self::QuickSellResource`]. That is not
/// hypothetical — the Python oracle maps `item/resource` method-agnostically
/// to its definition route, so an unclaimed verb there answers 200 with a
/// definition list, mutating nothing while the client reports success.
ConsumableApply,
/// `PUT …/item/resource/<resourceId>` — CONSUMABLE quick-sell, keyed by the
/// stack's resource id rather than an owned instance, with an EMPTY body.
///
/// Live-captured 2026-08-22 when a Position Modifier was quick-sold from the
/// consumables screen. The same path serves three verbs: GET is the
/// definition lookup, POST is the apply (`ApplyCardByRes`), PUT is this.
///
/// Neither stack had ever served it. The Python oracle maps `item/resource`
/// method-agnostically to its definition route, so a PUT there returns 200
/// with a definition list and sells NOTHING — the client believes it sold a
/// card that it still owns. That is why this must be Rust-owned.
QuickSellResource,
/// `PUT …/item` — FutMoveCard pile move.
MoveItems,
/// `…/match/end` (any verb) — match END, the coin-crediting call.
/// Also reachable as `POST /ut/delete/game/<sku>/match`, which the retail
/// client does not send; `/match/end` is the real one, from the RPC
/// descriptor block.
MatchEnd,
/// `…/match` (any verb) — FutCreateMatch, and FutPlayGame on the same path
/// discriminated by an integer `matchId` in the body. Zero economy: it only
/// mints the session id that `/match/end` later uses as its exactly-once key.
MatchCreate,
/// `…/match/ready` (any verb) — FutMatchReady. Zero economy.
MatchReady,
/// `…/auctionhouse` | `…/transfermarket` — list-for-sale / browse.
MarketList,
/// `GET …/tradePile` — the user's own active listings.
MarketQuery,
/// `GET …/tradePile/counts` — FutGetAuctionCount, the auction TALLY. A
/// DISTINCT deserializer from `/tradePile`: it reads five scalar ints and
/// skips everything else, so answering it with the `auctionInfo` listing body
/// leaves every count at its constructor default (0) and the Transfer List
/// screen shows no active sale even while the hub tile shows one.
MarketCounts,
/// `GET …/trade/status` — live auction-state refresh, polled continuously by
/// the Transfer List. MUST be classified before [`Self::MarketBuy`]: `status`
/// is not a numeric trade id, so the buy/view arm answers every poll with an
/// empty `auctionInfo` and the screen never learns its own auctions' state.
MarketStatus,
/// `…/trade/<id>` — view / buy-now.
MarketBuy,
/// Cancel a listing. Both `DELETE /ut/delete/game/<sku>/trade/<id>` (the
/// oracle's spelling) and plain `DELETE /ut/game/<sku>/trade/<id>` (what
/// contemporaneous FIFA 17 clients use) map here — a DELETE on the plain
/// spelling otherwise fell into the buy/view arm and silently did nothing.
MarketCancel,
/// Bulk clear-sold: `DELETE /ut/delete/game/<sku>/trade/sold`, the client's
/// `RemoveAllSoldFromTradePile`. Carries no trade id, so it MUST NOT reach
/// [`EconomyRoute::MarketCancel`], which would parse no id and ack while
/// clearing nothing.
MarketClearSold,
/// `GET …/sbs/sets` — SBC category/set list from Core definitions.
SbcSets,
/// `POST/PUT …/sbs/sets/tag` — stateless tag acknowledgement.
SbcTag,
/// `GET …/sbs/setId/<id>/challenges` — challenges in one set.
SbcChallenges,
/// `GET/PUT …/sbs/challenge/<id>/squad` — durable working squad.
SbcChallengeSquad,
/// `POST/PUT …/sbs/challenge/<id>` — start or atomic submission.
SbcChallenge,
}
/// `item/<digits>` — the single-card quick-sell tail (DELETE).
fn is_item_id_tail(tail: &str) -> bool {
match tail.strip_prefix("item/") {
Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()),
None => false,
}
}
/// `store/transaction` or `store/transaction/<digits>` — the Store BUY create
/// step. Retail sends a trailing numeric transaction id (observed live:
/// `store/transaction/0`). Mirrors the Python oracle's bare `/store/transaction`
/// route, but bounded to a single all-digit id segment so it never absorbs
/// `store/transactions`, `store/transactionfoo`, or `store/transaction/0/extra`.
fn is_store_transaction_tail(tail: &str) -> bool {
match tail.strip_prefix("store/transaction") {
Some("") => true,
Some(rest) => match rest.strip_prefix('/') {
Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()),
None => false,
},
None => false,
}
}
/// `purchased` or `purchased/items` — pack OPEN (POST) / reveal (GET). Retail
/// sends the `/items` sub-path (FutPurchaseItemsServerResponse); the Python
/// oracle's bare `/purchased` regex matches both. Bounded to exactly these two
/// tails (rejects `purchasedfoo`, `purchased/items/extra`).
fn is_purchased_tail(tail: &str) -> bool {
tail == "purchased" || tail == "purchased/items"
}
/// `tradePile` or `tradePile/counts` — the user's own listings (query) + the
/// listing-count tile. CASE-INSENSITIVE: the FUT hub tile polls lowercase
/// `tradepile`/`tradepile/counts` while the screen uses camelCase `tradePile`
/// (the oracle routes both via `re.I`). Bounded to the `tradepile` family
/// (base tail or a `tradepile/<sub>` path); allocation-free.
fn is_tradepile_tail(tail: &str) -> bool {
const BASE: &str = "tradePile";
match tail.len() {
9 => tail.eq_ignore_ascii_case(BASE),
n if n > 9 => tail.as_bytes()[9] == b'/' && tail[..9].eq_ignore_ascii_case(BASE),
_ => false,
}
}
/// `tradePile/counts` exactly (case-insensitive) — the auction-tally sub-path,
/// which MUST be classified before [`is_tradepile_tail`] because that matcher
/// also accepts it (the two endpoints have different response shapes).
fn is_tradepile_counts_tail(tail: &str) -> bool {
const COUNTS: &str = "tradePile/counts";
tail.eq_ignore_ascii_case(COUNTS)
}
/// `trade/status` exactly (case-insensitive) — the live auction-state poll. MUST
/// be classified before the generic `trade…` buy/view arm: `status` is not a
/// numeric trade id, so that arm degrades every poll to an empty `auctionInfo`.
fn is_trade_status_tail(tail: &str) -> bool {
const STATUS: &str = "trade/status";
tail.eq_ignore_ascii_case(STATUS)
}
/// `trade/sold` — the BULK clear-sold tail, which carries no trade id.
///
/// PE-proven shape: request builder `0x1801647c0` writes the literal `/sold` when
/// its tradeId field is zero and `/%lld` when it is not, onto route base
/// `ut/delete/%s/trade`. The client names the operation
/// `RemoveAllSoldFromTradePile`.
fn is_trade_sold_tail(tail: &str) -> bool {
tail.eq_ignore_ascii_case("trade/sold")
}
fn bounded_numeric_segment<'a>(tail: &'a str, prefix: &str, suffix: &str) -> Option<&'a str> {
let value = tail.strip_prefix(prefix)?.strip_suffix(suffix)?;
if !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) {
Some(value)
} else {
None
}
}
fn sbc_challenge_id(tail: &str) -> Option<i64> {
bounded_numeric_segment(tail, "sbs/challenge/", "")
.or_else(|| bounded_numeric_segment(tail, "sbs/challenge/", "/squad"))?
.parse()
.ok()
}
fn sbc_set_id(tail: &str) -> Option<i64> {
bounded_numeric_segment(tail, "sbs/setId/", "/challenges")?
.parse()
.ok()
}
/// Classify a FIFA17 economy route from method + path, mirroring the Python
/// oracle's route table (`utas_server.py` §1418-1553). Returns `None` for any
/// non-economy path. Path is already query-stripped by the caller.
pub fn classify_economy(method: &str, path: &str) -> Option<EconomyRoute> {
let get = method.eq_ignore_ascii_case("GET");
let put = method.eq_ignore_ascii_case("PUT");
let post = method.eq_ignore_ascii_case("POST");
let delete = method.eq_ignore_ascii_case("DELETE");
// The `/ut/delete/game/<sku>/…` family is NOT `/ut/game/…`-prefixed.
if let Some(rest) = path
.strip_prefix("/ut/delete/game/")
.or_else(|| path.strip_prefix("/ut/v2/delete/game/"))
{
if let Some((_sku, tail)) = rest.split_once('/') {
if tail == "item" && post {
return Some(EconomyRoute::QuickSellBody);
}
// MUST precede the generic `trade…` cancel arm. FIFA 17's request
// builder 0x1801647c0 emits the literal `/sold` (no numeric id) for the
// bulk "RemoveAllSoldFromTradePile" verb, and `/%lld` for one trade. A
// `sold` tail carries no id, so the cancel handler would parse nothing
// and silently ack while clearing nothing.
if delete && is_trade_sold_tail(tail) {
return Some(EconomyRoute::MarketClearSold);
}
if tail.starts_with("trade") && delete {
return Some(EconomyRoute::MarketCancel);
}
if tail == "match" && post {
return Some(EconomyRoute::MatchEnd);
}
}
return None;
}
match ut_tail(path) {
// The match family, kept together and deliberately VERB-AGNOSTIC. The
// strings "PUT" and "DELETE" do not appear anywhere in cardsdll.dll, so
// verb selection happens outside the DLL and cannot be pinned
// statically; the RPC descriptor block discriminates by PATH SUFFIX on
// the `ut/%s/match` template. Matching on the verb is how these came to
// be proxied to a dead upstream, which is what the client reported as
// "There was an error creating your game session".
//
// They live in the ECONOMY classifier, not the plain one, because
// `/match/end` credits coins and `try_handle_economy` is the barrier
// that guarantees a matched route can never also fall through to
// Python. Create and end must share the in-flight match id, so keeping
// the family in one classifier is what stops them diverging again.
Some("match/end") => Some(EconomyRoute::MatchEnd),
Some("match/ready") => Some(EconomyRoute::MatchReady),
Some("match") => Some(EconomyRoute::MatchCreate),
Some("user/credits") if get => Some(EconomyRoute::Credits),
Some(t) if get && t.starts_with("store/purchasegroup") => Some(EconomyRoute::PurchaseGroup),
Some(t) if put && is_store_transaction_tail(t) => Some(EconomyRoute::StoreBuy),
Some(t) if post && is_purchased_tail(t) => Some(EconomyRoute::PackOpen),
Some(t) if get && is_purchased_tail(t) => Some(EconomyRoute::PackReveal),
Some(t) if delete && is_item_id_tail(t) => Some(EconomyRoute::QuickSellPath),
// MUST stay adjacent to the PUT arm below so the `item/resource/` family
// is read as one unit: same path, three verbs (GET definition lookup,
// POST apply, PUT consumable quick-sell). It is an ECONOMY route because
// a successful apply destroys the source card, and `try_handle_economy`
// is the barrier that guarantees a matched route can never ALSO fall
// through to Python and be applied twice.
Some(t)
if post
&& t.strip_prefix("item/resource/")
.is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit())) =>
{
Some(EconomyRoute::ConsumableApply)
}
Some(t)
if put
&& t.strip_prefix("item/resource/")
.is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit())) =>
{
Some(EconomyRoute::QuickSellResource)
}
Some("item") if put => Some(EconomyRoute::MoveItems),
Some(t) if (t == "auctionhouse" || t == "transfermarket") => Some(EconomyRoute::MarketList),
// MUST precede the base tradePile arm: that matcher also accepts
// `tradePile/counts`, but the tally is a different deserializer.
Some(t) if get && is_tradepile_counts_tail(t) => Some(EconomyRoute::MarketCounts),
Some(t) if get && is_tradepile_tail(t) => Some(EconomyRoute::MarketQuery),
// BOTH must precede the buy/view arm, which swallows any `trade…` tail:
// `trade/status` has no numeric id (so it answered polls with an empty
// auctionInfo), and a plain DELETE fell in as a no-op "view".
Some(t) if get && is_trade_status_tail(t) => Some(EconomyRoute::MarketStatus),
Some(t) if delete && t.starts_with("trade") => Some(EconomyRoute::MarketCancel),
Some("sbs/sets") if get => Some(EconomyRoute::SbcSets),
Some("sbs/sets/tag") if post || put => Some(EconomyRoute::SbcTag),
Some(t) if get && sbc_set_id(t).is_some() => Some(EconomyRoute::SbcChallenges),
Some(t)
if (get || put) && bounded_numeric_segment(t, "sbs/challenge/", "/squad").is_some() =>
{
Some(EconomyRoute::SbcChallengeSquad)
}
Some(t) if (post || put) && bounded_numeric_segment(t, "sbs/challenge/", "").is_some() => {
Some(EconomyRoute::SbcChallenge)
}
Some(t) if t.starts_with("trade") => Some(EconomyRoute::MarketBuy),
_ => None,
}
}
// ───────────────────────────── Core access boundary ─────────────────────────
/// Failure reaching or reading OpenFUT Core.
#[derive(Debug)]
pub enum CoreError {
Http(String),
Status(u16),
Parse(String),
}
impl std::fmt::Display for CoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CoreError::Http(e) => write!(f, "core http error: {e}"),
CoreError::Status(s) => write!(f, "core returned status {s}"),
CoreError::Parse(e) => write!(f, "core response parse error: {e}"),
}
}
}
/// One page of owned items plus the filtered total, as returned by Core.
pub struct CorePage {
pub items: Vec<CoreOwnedItem>,
pub total: i64,
}
/// One canonical squad slot as read back from Core (game-independent).
#[derive(Debug, Clone)]
pub struct CoreSquadSlot {
pub owned_card_id: String,
pub index: i64,
pub is_captain: bool,
pub is_on_bench: bool,
}
/// Freshness of the stored opaque extension vs the current canonical squad, as
/// Core reports it. Carries the stored payload for Fresh/Stale (never applied
/// when Stale — the host decides policy).
#[derive(Debug, Clone)]
pub enum CoreExtState {
Fresh {
schema_version: i64,
payload: String,
},
Stale {
schema_version: i64,
payload: String,
},
Missing,
}
/// The active squad, its canonical slots, and its opaque extension state — the
/// result of Core's `GET /squad/ext`.
#[derive(Debug, Clone)]
pub struct CoreSquadRead {
pub name: String,
/// FIFA formation token, verbatim (Core stores it opaquely).
pub formation: String,
pub slots: Vec<CoreSquadSlot>,
pub ext: CoreExtState,
}
/// A canonical + extension squad replacement the host asks Core to commit
/// atomically (`PUT /squad/replace`).
pub struct CoreReplaceRequest {
pub name: Option<String>,
pub formation: Option<String>,
pub slots: Vec<CoreSquadSlot>,
pub client_reported: CoreClientEval,
pub ext_namespace: String,
pub ext_schema_version: i64,
pub ext_payload: String,
}
/// Client-reported shadow evaluation carried through to Core (never Core's
/// authoritative evaluation).
#[derive(Debug, Clone, Default)]
pub struct CoreClientEval {
pub chemistry: Option<i64>,
pub rating: Option<i64>,
pub star_rating: Option<i64>,
}
/// Outcome of a committed replacement.
#[derive(Debug, Clone)]
pub struct CoreReplaceResult {
pub squad_id: String,
pub canonical_fingerprint: String,
pub slots_written: usize,
}
/// Core owned-instance ids assigned to the club's two active kit roles.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CoreKitAssignments {
pub home_owned_card_id: Option<String>,
pub away_owned_card_id: Option<String>,
}
/// How the host reaches Core. The adapter never sees this — the host owns the
/// transport, mirroring the architecture rule. Tests inject a fake.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreSbcDefinition {
pub id: String,
pub name: String,
pub description: String,
pub repeatable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreSbcResult {
pub passed: bool,
pub failures: Vec<String>,
}
pub trait CoreAccess: Send + Sync {
/// Query the owned inventory with semantic `/collection` query params.
fn query_owned(&self, params: &[(&str, String)]) -> Result<CorePage, CoreError>;
/// Every owned item for the active club, in one call (no pagination) — used
/// to assemble a whole squad projection and to authorize squad writes. The
/// default delegates to an unfiltered `query_owned`.
fn all_owned(&self) -> Result<Vec<CoreOwnedItem>, CoreError> {
Ok(self.query_owned(&[])?.items)
}
/// Every card DEFINITION in Core content (`GET /cards`) — the full card
/// universe a pack can draw from, independent of ownership. Same shape as
/// owned items (rating/position/nation/league/club/attributes) so
/// [`build_content_pool`] treats definitions and owned items uniformly.
/// Default: unimplemented (callers fall back to owned inventory).
fn all_definitions(&self) -> Result<Vec<CoreOwnedItem>, CoreError> {
Err(CoreError::Parse(
"Core content enumeration is not implemented".into(),
))
}
/// Read the active squad + its opaque extension for `namespace`.
fn read_squad_ext(&self, namespace: &str) -> Result<CoreSquadRead, CoreError>;
/// Replace the active squad's canonical slots + opaque extension atomically.
fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError>;
/// The owned instance id assigned as the active squad's **manager**, or
/// `None` (`GET /club/manager`). Default: `None` — a transport without the
/// endpoint simply projects no manager (non-fatal, like an absent
/// assignment). The production `HttpCoreClient` overrides it.
fn get_squad_manager(&self) -> Result<Option<String>, CoreError> {
Ok(None)
}
/// Assign (`Some`) or clear (`None`) the active squad's **manager**
/// (`PUT /club/manager`). Default: unimplemented — the production
/// `HttpCoreClient` overrides it; a transport that cannot persist the
/// assignment MUST fail loudly rather than silently drop it.
fn set_squad_manager(&self, _owned_card_id: Option<&str>) -> Result<(), CoreError> {
Err(CoreError::Parse(
"Core squad manager write is not implemented".into(),
))
}
/// 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())
}
fn list_sbcs(&self) -> Result<Vec<CoreSbcDefinition>, CoreError> {
Err(CoreError::Parse(
"Core SBC access is not implemented".into(),
))
}
fn sbc_completion_counts(&self) -> Result<std::collections::HashMap<String, i64>, CoreError> {
Err(CoreError::Parse(
"Core SBC access is not implemented".into(),
))
}
fn load_sbc_squad(&self, _sbc_id: &str) -> Result<Vec<String>, CoreError> {
Err(CoreError::Parse(
"Core SBC access is not implemented".into(),
))
}
fn save_sbc_squad(
&self,
_sbc_id: &str,
_owned_card_ids: &[String],
) -> Result<Vec<String>, CoreError> {
Err(CoreError::Parse(
"Core SBC access is not implemented".into(),
))
}
fn submit_sbc(
&self,
_sbc_id: &str,
_owned_card_ids: &[String],
) -> Result<CoreSbcResult, CoreError> {
Err(CoreError::Parse(
"Core SBC access is not implemented".into(),
))
}
}
/// Default HTTP implementation: `GET {core_url}/collection?…` (plain HTTP JSON,
/// the same boundary Bridge uses to reach Core). Every request carries
/// `X-OpenFUT-Game: <game>` so Core resolves the game-scoped active profile — for
/// FIFA 17 that is the profile whose inventory is fully asset-mapped, so `/club`
/// filters/paginates a wholly renderable set (no post-pagination drops).
pub struct HttpCoreClient {
base_url: String,
game: String,
client: reqwest::blocking::Client,
}
impl HttpCoreClient {
pub fn new(base_url: impl Into<String>, game: impl Into<String>) -> Self {
HttpCoreClient {
base_url: base_url.into().trim_end_matches('/').to_string(),
game: game.into(),
client: reqwest::blocking::Client::new(),
}
}
}
impl CoreAccess for HttpCoreClient {
fn query_owned(&self, params: &[(&str, String)]) -> Result<CorePage, CoreError> {
let url = format!("{}/collection", self.base_url);
let resp = self
.client
.get(&url)
.header("X-OpenFUT-Game", &self.game)
.query(params)
.send()
.map_err(|e| CoreError::Http(e.to_string()))?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
return Err(CoreError::Status(status));
}
let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?;
parse_core_page(&v)
}
fn all_definitions(&self) -> Result<Vec<CoreOwnedItem>, CoreError> {
let url = format!("{}/cards", self.base_url);
let resp = self
.client
.get(&url)
.header("X-OpenFUT-Game", &self.game)
.send()
.map_err(|e| CoreError::Http(e.to_string()))?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
return Err(CoreError::Status(status));
}
let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?;
parse_core_definitions(&v)
}
fn read_squad_ext(&self, namespace: &str) -> Result<CoreSquadRead, CoreError> {
let url = format!("{}/squad/ext", self.base_url);
let resp = self
.client
.get(&url)
.header("X-OpenFUT-Game", &self.game)
.query(&[("namespace", namespace)])
.send()
.map_err(|e| CoreError::Http(e.to_string()))?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
return Err(CoreError::Status(status));
}
let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?;
parse_core_squad_read(&v)
}
fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError> {
let url = format!("{}/squad/replace", self.base_url);
let resp = self
.client
.put(&url)
.header("X-OpenFUT-Game", &self.game)
.json(&replace_request_body(req))
.send()
.map_err(|e| CoreError::Http(e.to_string()))?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
return Err(CoreError::Status(status));
}
let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?;
Ok(CoreReplaceResult {
squad_id: v
.get("squad_id")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string(),
canonical_fingerprint: v
.get("canonical_fingerprint")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string(),
slots_written: v.get("slots_written").and_then(|x| x.as_u64()).unwrap_or(0) as usize,
})
}
fn get_squad_manager(&self) -> Result<Option<String>, CoreError> {
let url = format!("{}/club/manager", self.base_url);
let resp = self
.client
.get(&url)
.header("X-OpenFUT-Game", &self.game)
.send()
.map_err(|e| CoreError::Http(e.to_string()))?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
return Err(CoreError::Status(status));
}
let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?;
// Core returns the assigned OWNED CARD (or null), so the instance id is
// the card's `id` — the PUT above takes `owned_card_id` because there it
// is a reference, not the resource. Reading the wrong key here used to
// yield None, which is indistinguishable from "no manager assigned": the
// assignment simply never arrived and the squad projected without one.
// A present-but-unreadable manager is therefore an error, never a silent
// absence.
match v.get("manager") {
None | Some(Value::Null) => Ok(None),
Some(manager) => match manager.get("id").and_then(Value::as_str) {
Some(id) => Ok(Some(id.to_string())),
None => Err(CoreError::Parse(format!(
"/club/manager returned a manager with no string `id`: {manager}"
))),
},
}
}
fn set_squad_manager(&self, owned_card_id: Option<&str>) -> Result<(), CoreError> {
let url = format!("{}/club/manager", self.base_url);
let resp = self
.client
.put(&url)
.header("X-OpenFUT-Game", &self.game)
.json(&json!({ "owned_card_id": owned_card_id }))
.send()
.map_err(|e| CoreError::Http(e.to_string()))?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
return Err(CoreError::Status(status));
}
Ok(())
}
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/active-items", self.base_url))
.header("X-OpenFUT-Game", &self.game)
.send()
.map_err(|error| CoreError::Http(error.to_string()))?;
let status = response.status().as_u16();
if !(200..300).contains(&status) {
return Err(CoreError::Status(status));
}
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| {
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_kit"),
away_owned_card_id: owned_id("away_kit"),
})
}
fn list_sbcs(&self) -> Result<Vec<CoreSbcDefinition>, CoreError> {
let response = self
.client
.get(format!("{}/sbc", self.base_url))
.header("X-OpenFUT-Game", &self.game)
.send()
.map_err(|error| CoreError::Http(error.to_string()))?;
let status = response.status().as_u16();
if !(200..300).contains(&status) {
return Err(CoreError::Status(status));
}
let body: Value = response
.json()
.map_err(|error| CoreError::Parse(error.to_string()))?;
body.get("sbcs")
.and_then(Value::as_array)
.ok_or_else(|| CoreError::Parse("missing `sbcs` array".into()))?
.iter()
.map(|definition| {
Ok(CoreSbcDefinition {
id: json_str(definition, "id")?,
name: json_str(definition, "name")?,
description: json_str(definition, "description")?,
repeatable: definition
.get("repeatable")
.and_then(Value::as_bool)
.unwrap_or(false),
})
})
.collect()
}
fn sbc_completion_counts(&self) -> Result<std::collections::HashMap<String, i64>, CoreError> {
let response = self
.client
.get(format!("{}/sbc/status", self.base_url))
.header("X-OpenFUT-Game", &self.game)
.send()
.map_err(|error| CoreError::Http(error.to_string()))?;
let status = response.status().as_u16();
if !(200..300).contains(&status) {
return Err(CoreError::Status(status));
}
let body: Value = response
.json()
.map_err(|error| CoreError::Parse(error.to_string()))?;
let completions = body
.get("completions")
.and_then(Value::as_object)
.ok_or_else(|| CoreError::Parse("missing `completions` object".into()))?;
completions
.iter()
.map(|(id, value)| {
value
.as_i64()
.map(|count| (id.clone(), count))
.ok_or_else(|| CoreError::Parse(format!("invalid completion count for {id}")))
})
.collect()
}
fn load_sbc_squad(&self, sbc_id: &str) -> Result<Vec<String>, CoreError> {
let response = self
.client
.get(format!("{}/sbc/{sbc_id}/squad", self.base_url))
.header("X-OpenFUT-Game", &self.game)
.send()
.map_err(|error| CoreError::Http(error.to_string()))?;
parse_core_sbc_squad_response(response)
}
fn save_sbc_squad(
&self,
sbc_id: &str,
owned_card_ids: &[String],
) -> Result<Vec<String>, CoreError> {
let response = self
.client
.put(format!("{}/sbc/{sbc_id}/squad", self.base_url))
.header("X-OpenFUT-Game", &self.game)
.json(&json!({ "owned_card_ids": owned_card_ids }))
.send()
.map_err(|error| CoreError::Http(error.to_string()))?;
parse_core_sbc_squad_response(response)
}
fn submit_sbc(
&self,
sbc_id: &str,
owned_card_ids: &[String],
) -> Result<CoreSbcResult, CoreError> {
let response = self
.client
.post(format!("{}/sbc/submit", self.base_url))
.header("X-OpenFUT-Game", &self.game)
.json(&json!({ "sbc_id": sbc_id, "owned_card_ids": owned_card_ids }))
.send()
.map_err(|error| CoreError::Http(error.to_string()))?;
let status = response.status().as_u16();
if !(200..300).contains(&status) {
return Err(CoreError::Status(status));
}
let body: Value = response
.json()
.map_err(|error| CoreError::Parse(error.to_string()))?;
Ok(CoreSbcResult {
passed: body
.get("passed")
.and_then(Value::as_bool)
.ok_or_else(|| CoreError::Parse("missing SBC `passed` bool".into()))?,
failures: body
.get("failures")
.and_then(Value::as_array)
.ok_or_else(|| CoreError::Parse("missing SBC `failures` array".into()))?
.iter()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect(),
})
}
}
fn parse_core_sbc_squad_response(
response: reqwest::blocking::Response,
) -> Result<Vec<String>, CoreError> {
let status = response.status().as_u16();
if !(200..300).contains(&status) {
return Err(CoreError::Status(status));
}
let body: Value = response
.json()
.map_err(|error| CoreError::Parse(error.to_string()))?;
body.get("squad")
.and_then(|squad| squad.get("owned_card_ids"))
.and_then(Value::as_array)
.ok_or_else(|| CoreError::Parse("missing SBC squad `owned_card_ids` array".into()))?
.iter()
.map(|id| {
id.as_str()
.map(str::to_owned)
.ok_or_else(|| CoreError::Parse("SBC owned-card id is not a string".into()))
})
.collect()
}
// ───────────────────────────── Core economy boundary ────────────────────────
/// One unconsumed entitlement Core reports for the active club.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EconomyEntitlement {
pub id: String,
pub definition_id: String,
}
/// Outcome of a purchase: post-debit balance + the new entitlement id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EconomyPurchase {
pub balance: i64,
pub entitlement_id: String,
}
/// An item to place into inventory on entitlement redemption. `item_id` is the
/// caller-minted opaque Core instance id (the adapter maps it to/from the FIFA
/// numeric wire id via the identity store); `card_id` is the definition ref.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EconomyGrantItem {
pub item_id: String,
pub card_id: String,
}
/// Terms of a completed market sale, for [`CoreEconomy::settle_sale`]. `gross`
/// is what the buyer pays; `fee` is the market cut destroyed on settlement, so
/// the seller nets `gross - fee`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EconomySale<'a> {
pub item_id: &'a str,
/// Club the caller believes owns the item; `None` -> Core's game-scoped
/// active club. Core predicates the ownership move on this club, so a
/// wrong (or already-settled) seller is rejected rather than silently
/// re-run — this doubles as the replay guard.
pub seller_club_id: Option<&'a str>,
/// Acquiring club; `None` -> a counterparty outside the modelled economy:
/// nobody is debited and the item is destroyed.
pub buyer_club_id: Option<&'a str>,
pub gross: i64,
pub fee: i64,
}
/// What Core did when settling a sale: the post-settlement balances of both
/// sides (`buyer_balance` is `None` for an outside buyer) and how many squad
/// slots the sold item vacated.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EconomySaleReceipt {
pub item_id: String,
pub card_id: String,
pub seller_club_id: String,
pub buyer_club_id: Option<String>,
pub gross: i64,
pub fee: i64,
pub proceeds: i64,
pub seller_balance: i64,
pub buyer_balance: Option<i64>,
pub squad_slots_freed: u64,
}
/// A finished match to apply to Core's authoritative, exactly-once
/// `complete_match` transaction. `match_identity` is the durable per-match
/// idempotency key (persona-scoped); `result` is the canonical Core token
/// (`win`/`draw`/`loss`/`dnf`/`no_contest`) the adapter derived from `endReason`.
pub struct CoreMatchCompletion<'a> {
pub match_identity: &'a str,
pub result: &'a str,
pub squad_id: &'a str,
pub opponent_name: &'a str,
pub goals_for: i64,
pub goals_against: i64,
pub mode: &'a str,
}
/// Core's authoritative answer for a match completion. `applied` is `false` on an
/// idempotent replay; the coin figures are Core's, rendered straight onto the
/// wire reward body.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreMatchReceipt {
pub applied: bool,
pub result: String,
pub coins_awarded: i64,
pub coins_balance: i64,
}
/// A consumable effect Core is asked to execute, in Core's closed vocabulary.
///
/// The magnitude is the caller's ALREADY-RESOLVED FIFA 17 number, not a hint:
/// Core owns the mutation, the caller owns the game formula (the same split as
/// quick-sell, where the host computes `discard_value` and Core performs the
/// atomic sale). The remaining fields are the client's own constants that Core
/// cannot know — a ceiling to clamp with, the pack-fresh number to seed an
/// instance it tracks no contract for, and the authored maximum a training card
/// may grant.
///
/// This is an ENUM rather than a growing struct because Core dispatches on
/// `kind`: an unproven family must be impossible to express here, not merely
/// discouraged.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApplyEffect {
/// Grant match-contracts to the target.
AddContractMatches {
amount: i64,
cap: i64,
default_when_unset: i64,
},
/// Attach an attribute training effect to the target.
///
/// `attribute_index` is a slot in CORE's six-attribute model, already mapped
/// out of `cardsubtypeid` by the adapter — Core is never told a FIFA
/// attribute name.
ApplyTraining {
/// `None` = the rare card that boosts all six attributes.
attribute_index: Option<i64>,
amount: i64,
max_amount: i64,
},
}
impl ApplyEffect {
/// The effect exactly as Core's `InstanceEffect` deserialises it. The `kind`
/// tokens are constants here, not caller-supplied strings: every unproven
/// family is refused long before it reaches this point, so there is no other
/// value either arm could legitimately take.
pub fn to_json(self) -> Value {
match self {
ApplyEffect::AddContractMatches {
amount,
cap,
default_when_unset,
} => json!({
"kind": "add_contract_matches",
"amount": amount,
"cap": cap,
"default_when_unset": default_when_unset,
}),
ApplyEffect::ApplyTraining {
attribute_index,
amount,
max_amount,
} => json!({
// `attribute_index` is deliberately null for the rare all-six
// card: Core reads absence as "every slot", so omitting the key
// or sending 0 would silently train pace only.
"kind": "apply_training",
"attribute_index": attribute_index,
"amount": amount,
"max_amount": max_amount,
}),
}
}
}
/// One consumable application to hand to Core's atomic `/consumables/apply`
/// transaction, which destroys the source instance and mutates the target in a
/// single durable step.
///
/// `action_identity` is the exactly-once key. `target_kind` is Core's own
/// lowercase `ContentKind` token for the target, so Core never has to infer what
/// it is mutating.
pub struct ConsumableApplyRequest<'a> {
pub action_identity: &'a str,
pub source_owned_card_id: &'a str,
pub target_owned_card_id: &'a str,
pub target_kind: &'a str,
pub effect: ApplyEffect,
}
/// Core's authoritative answer for a consumable application.
///
/// `applied` is `false` on an idempotent REPLAY of the same `action_identity`:
/// nothing was mutated and every field below echoes the RECORDED outcome, so a
/// replay must never be read as a fresh grant.
///
/// `source_quantity_after` is `None` whenever the source is not quantity-modelled
/// — which is always, for FIFA 17: consumables are separate owned instances and
/// a successful apply destroys exactly one of them.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsumableApplyOutcome {
pub applied: bool,
pub source_destroyed: bool,
pub source_quantity_after: Option<i64>,
pub granted: i64,
pub before: i64,
pub after: i64,
}
/// The host's authoritative economy transport to Core. Every method is a single
/// durable Core transaction. **Fail-closed:** on any transport/status/parse
/// error the caller MUST surface a controlled error and NEVER fall back to
/// Python — a Python fallback would reintroduce a second writer.
pub trait CoreEconomy: Send + Sync {
fn balance(&self) -> Result<i64, CoreError>;
fn entitlements(&self) -> Result<Vec<EconomyEntitlement>, CoreError>;
fn purchase_entitlement(
&self,
cost: i64,
definition_id: &str,
) -> Result<EconomyPurchase, CoreError>;
fn redeem_entitlement(
&self,
entitlement_id: &str,
items: &[EconomyGrantItem],
) -> Result<String, CoreError>;
fn sell_item(&self, item_id: &str, price: i64) -> Result<i64, CoreError>;
fn grant_reward(&self, amount: i64) -> Result<i64, CoreError>;
fn purchase_item(&self, cost: i64, item_id: &str, card_id: &str) -> Result<i64, CoreError>;
/// Debit `cost` and mint several items atomically (open-on-buy Store packs).
fn purchase_items(&self, cost: i64, items: &[EconomyGrantItem]) -> Result<i64, CoreError>;
/// Settle a completed market sale in ONE Core transaction: evict the item
/// from every squad, move ownership from the seller to `buyer_club_id`
/// (or destroy it for an outside buyer), debit a club buyer `gross` and
/// credit the seller `gross - fee`. Core rejects a sale whose named seller
/// does not own the item, so a replayed settlement is refused, never
/// double-paid.
fn settle_sale(&self, sale: &EconomySale<'_>) -> Result<EconomySaleReceipt, CoreError>;
/// Apply a finished match to Core's authoritative, atomic, exactly-once
/// `complete_match` transaction. Core is the sole economy writer here — a
/// replay/duplicate returns `applied = false` with the canonical result, and
/// any error is surfaced (never a Python fallback).
fn complete_match(&self, m: &CoreMatchCompletion<'_>) -> Result<CoreMatchReceipt, CoreError>;
/// Apply one consumable to one target in Core's atomic, exactly-once
/// `/consumables/apply` transaction: the source instance is destroyed and the
/// target mutated together, or neither happens. A replayed
/// `action_identity` returns `applied = false` with the recorded outcome, and
/// any error is surfaced (never a Python fallback — the oracle would answer
/// this path 200 from its definition route and consume nothing).
fn apply_consumable(
&self,
req: &ConsumableApplyRequest<'_>,
) -> Result<ConsumableApplyOutcome, CoreError>;
}
impl HttpCoreClient {
fn economy_url(&self, tail: &str) -> String {
format!("{}/economy/{}", self.base_url, tail)
}
fn economy_get(&self, tail: &str) -> Result<Value, CoreError> {
let resp = self
.client
.get(self.economy_url(tail))
.header("X-OpenFUT-Game", &self.game)
.send()
.map_err(|e| CoreError::Http(e.to_string()))?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
return Err(CoreError::Status(status));
}
resp.json().map_err(|e| CoreError::Parse(e.to_string()))
}
fn economy_post(&self, tail: &str, body: &Value) -> Result<Value, CoreError> {
let resp = self
.client
.post(self.economy_url(tail))
.header("X-OpenFUT-Game", &self.game)
.json(body)
.send()
.map_err(|e| CoreError::Http(e.to_string()))?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
return Err(CoreError::Status(status));
}
resp.json().map_err(|e| CoreError::Parse(e.to_string()))
}
/// POST to a non-`/economy/` Core endpoint (e.g. `matches/complete`), same
/// game header + status/parse handling as [`Self::economy_post`].
fn core_post(&self, tail: &str, body: &Value) -> Result<Value, CoreError> {
let resp = self
.client
.post(format!("{}/{}", self.base_url, tail))
.header("X-OpenFUT-Game", &self.game)
.json(body)
.send()
.map_err(|e| CoreError::Http(e.to_string()))?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
return Err(CoreError::Status(status));
}
resp.json().map_err(|e| CoreError::Parse(e.to_string()))
}
}
fn json_i64(v: &Value, key: &str) -> Result<i64, CoreError> {
v.get(key)
.and_then(Value::as_i64)
.ok_or_else(|| CoreError::Parse(format!("missing i64 field `{key}`")))
}
fn json_str(v: &Value, key: &str) -> Result<String, CoreError> {
v.get(key)
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| CoreError::Parse(format!("missing string field `{key}`")))
}
fn json_u64(v: &Value, key: &str) -> Result<u64, CoreError> {
v.get(key)
.and_then(Value::as_u64)
.ok_or_else(|| CoreError::Parse(format!("missing u64 field `{key}`")))
}
impl CoreEconomy for HttpCoreClient {
fn balance(&self) -> Result<i64, CoreError> {
json_i64(&self.economy_get("balance")?, "balance")
}
fn entitlements(&self) -> Result<Vec<EconomyEntitlement>, CoreError> {
let v = self.economy_get("entitlements")?;
let arr = v
.as_array()
.ok_or_else(|| CoreError::Parse("entitlements not an array".into()))?;
arr.iter()
.map(|e| {
Ok(EconomyEntitlement {
id: json_str(e, "id")?,
definition_id: json_str(e, "definition_id")?,
})
})
.collect()
}
fn purchase_entitlement(
&self,
cost: i64,
definition_id: &str,
) -> Result<EconomyPurchase, CoreError> {
let v = self.economy_post(
"purchase-entitlement",
&json!({ "cost": cost, "definition_id": definition_id }),
)?;
Ok(EconomyPurchase {
balance: json_i64(&v, "balance")?,
entitlement_id: json_str(&v, "entitlement_id")?,
})
}
fn redeem_entitlement(
&self,
entitlement_id: &str,
items: &[EconomyGrantItem],
) -> Result<String, CoreError> {
let items_json: Vec<Value> = items
.iter()
.map(|i| json!({ "item_id": i.item_id, "card_id": i.card_id }))
.collect();
let v = self.economy_post(
"redeem-entitlement",
&json!({ "entitlement_id": entitlement_id, "items": items_json }),
)?;
json_str(&v, "definition_id")
}
fn sell_item(&self, item_id: &str, price: i64) -> Result<i64, CoreError> {
let v = self.economy_post("sell-item", &json!({ "item_id": item_id, "price": price }))?;
json_i64(&v, "balance")
}
fn grant_reward(&self, amount: i64) -> Result<i64, CoreError> {
let v = self.economy_post("grant-reward", &json!({ "amount": amount }))?;
json_i64(&v, "balance")
}
fn purchase_item(&self, cost: i64, item_id: &str, card_id: &str) -> Result<i64, CoreError> {
let v = self.economy_post(
"purchase-item",
&json!({ "cost": cost, "item_id": item_id, "card_id": card_id }),
)?;
json_i64(&v, "balance")
}
fn purchase_items(&self, cost: i64, items: &[EconomyGrantItem]) -> Result<i64, CoreError> {
let items_json: Vec<Value> = items
.iter()
.map(|i| json!({ "item_id": i.item_id, "card_id": i.card_id }))
.collect();
let v = self.economy_post(
"purchase-items",
&json!({ "cost": cost, "items": items_json }),
)?;
json_i64(&v, "balance")
}
fn settle_sale(&self, sale: &EconomySale<'_>) -> Result<EconomySaleReceipt, CoreError> {
let v = self.economy_post("settle-sale", &sale_request_body(sale))?;
Ok(EconomySaleReceipt {
item_id: json_str(&v, "item_id")?,
card_id: json_str(&v, "card_id")?,
seller_club_id: json_str(&v, "seller_club_id")?,
// Absent or null both mean "no club buyer": the outside-sale case.
buyer_club_id: v
.get("buyer_club_id")
.and_then(Value::as_str)
.map(str::to_string),
gross: json_i64(&v, "gross")?,
fee: json_i64(&v, "fee")?,
proceeds: json_i64(&v, "proceeds")?,
seller_balance: json_i64(&v, "seller_balance")?,
buyer_balance: v.get("buyer_balance").and_then(Value::as_i64),
squad_slots_freed: json_u64(&v, "squad_slots_freed")?,
})
}
fn complete_match(&self, m: &CoreMatchCompletion<'_>) -> Result<CoreMatchReceipt, CoreError> {
let v = self.core_post(
"matches/complete",
&json!({
"match_identity": m.match_identity,
"result": m.result,
"squad_id": m.squad_id,
"opponent_name": m.opponent_name,
"goals_for": m.goals_for,
"goals_against": m.goals_against,
"mode": m.mode,
}),
)?;
Ok(CoreMatchReceipt {
applied: v.get("applied").and_then(Value::as_bool).unwrap_or(false),
result: json_str(&v, "result")?,
coins_awarded: json_i64(&v, "coins_awarded")?,
coins_balance: json_i64(&v, "coins_balance")?,
})
}
fn apply_consumable(
&self,
req: &ConsumableApplyRequest<'_>,
) -> Result<ConsumableApplyOutcome, CoreError> {
let v = self.core_post(
"consumables/apply",
&json!({
"action_identity": req.action_identity,
"source_owned_card_id": req.source_owned_card_id,
"target_owned_card_id": req.target_owned_card_id,
"target_kind": req.target_kind,
"effect": req.effect.to_json(),
}),
)?;
// The effect block is REQUIRED even on a replay (Core echoes what it
// recorded). Missing it means the caller cannot tell what the target now
// holds, so it is a parse error rather than a defaulted zero.
let effect = v
.get("effect")
.ok_or_else(|| CoreError::Parse("missing `effect` object".into()))?;
Ok(ConsumableApplyOutcome {
applied: v.get("applied").and_then(Value::as_bool).unwrap_or(false),
source_destroyed: v
.get("source_destroyed")
.and_then(Value::as_bool)
.unwrap_or(false),
// Absent or null both mean "not a quantity-modelled source".
source_quantity_after: v.get("source_quantity_after").and_then(Value::as_i64),
granted: json_i64(effect, "granted")?,
before: json_i64(effect, "before")?,
after: json_i64(effect, "after")?,
})
}
}
/// Serialize an [`EconomySale`] into the `POST /economy/settle-sale` JSON body.
/// The two club fields are OMITTED when `None` — their absence is what selects
/// Core's defaults (the game-scoped active club as seller, a counterparty
/// outside the modelled economy as buyer).
pub fn sale_request_body(sale: &EconomySale<'_>) -> Value {
let mut body = json!({
"item_id": sale.item_id,
"gross": sale.gross,
"fee": sale.fee,
});
let obj = body
.as_object_mut()
.expect("the literal above is a JSON object");
if let Some(seller) = sale.seller_club_id {
obj.insert("seller_club_id".into(), Value::from(seller));
}
if let Some(buyer) = sale.buyer_club_id {
obj.insert("buyer_club_id".into(), Value::from(buyer));
}
body
}
/// Serialize a [`CoreReplaceRequest`] into the `PUT /squad/replace` JSON body.
pub fn replace_request_body(req: &CoreReplaceRequest) -> Value {
let slots: Vec<Value> = req
.slots
.iter()
.map(|s| {
json!({
"owned_card_id": s.owned_card_id,
"slot": s.index,
"is_captain": s.is_captain,
"is_on_bench": s.is_on_bench,
})
})
.collect();
json!({
"name": req.name,
"formation": req.formation,
"slots": slots,
"client_reported": {
"client_reported_chemistry": req.client_reported.chemistry,
"client_reported_rating": req.client_reported.rating,
"client_reported_star_rating": req.client_reported.star_rating,
},
"extension": {
"namespace": req.ext_namespace,
"schema_version": req.ext_schema_version,
"payload": req.ext_payload,
},
})
}
/// Parse Core's `GET /squad/ext` response into a [`CoreSquadRead`].
pub fn parse_core_squad_read(v: &Value) -> Result<CoreSquadRead, CoreError> {
let squad = v
.get("squad")
.ok_or_else(|| CoreError::Parse("missing `squad`".into()))?;
let name = squad
.get("name")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
let formation = squad
.get("formation")
.and_then(|x| x.as_str())
.ok_or_else(|| CoreError::Parse("missing squad.formation".into()))?
.to_string();
let players = v
.get("players")
.and_then(|p| p.as_array())
.ok_or_else(|| CoreError::Parse("missing `players`".into()))?;
let slots = players
.iter()
.filter_map(|p| {
Some(CoreSquadSlot {
owned_card_id: p.get("owned_card_id")?.as_str()?.to_string(),
index: p.get("position_index")?.as_i64()?,
is_captain: p
.get("is_captain")
.and_then(|x| x.as_bool())
.unwrap_or(false),
is_on_bench: p
.get("is_on_bench")
.and_then(|x| x.as_bool())
.unwrap_or(false),
})
})
.collect();
let ext_v = v
.get("extension")
.ok_or_else(|| CoreError::Parse("missing `extension`".into()))?;
let ext = match ext_v.get("state").and_then(|x| x.as_str()) {
Some("fresh") => CoreExtState::Fresh {
schema_version: ext_v
.get("schema_version")
.and_then(|x| x.as_i64())
.unwrap_or(0),
payload: ext_v
.get("payload")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string(),
},
Some("stale") => CoreExtState::Stale {
schema_version: ext_v
.get("schema_version")
.and_then(|x| x.as_i64())
.unwrap_or(0),
payload: ext_v
.get("payload")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string(),
},
Some("missing") => CoreExtState::Missing,
other => {
return Err(CoreError::Parse(format!(
"unknown extension state {other:?}"
)))
}
};
Ok(CoreSquadRead {
name,
formation,
slots,
ext,
})
}
/// Parse Core's `/collection` response `{ "collection": [...], "total": n }` into
/// semantic owned items.
pub fn parse_core_page(v: &Value) -> Result<CorePage, CoreError> {
let arr = v
.get("collection")
.and_then(|c| c.as_array())
.ok_or_else(|| CoreError::Parse("missing `collection` array".into()))?;
let total = v
.get("total")
.and_then(|t| t.as_i64())
.unwrap_or(arr.len() as i64);
let items = arr.iter().filter_map(core_item_from_json).collect();
Ok(CorePage { items, total })
}
fn core_item_from_json(e: &Value) -> Option<CoreOwnedItem> {
let card = e.get("card")?;
// Attributes come from Core's per-instance `effective_attributes` when it
// sends them, and only fall back to the immutable definition when it does
// not. That fallback is what keeps an older Core serving this host, but it
// is NOT a default: a Core that knows about training always answers with the
// finished numbers, and reading the definition instead would silently drop
// every applied training off the card the client draws.
let effective = e.get("effective_attributes");
let attr = |k: &str| {
effective
.and_then(|a| a.get(k))
.or_else(|| card.get(k))
.and_then(|v| v.as_i64())
.unwrap_or(0) as u8
};
let position = e
.get("effective_position")
.and_then(|v| v.as_str())
.or_else(|| card.get("position").and_then(|v| v.as_str()))?
.to_string();
let rating = e
.get("effective_overall")
.and_then(|v| v.as_i64())
.or_else(|| card.get("overall").and_then(|v| v.as_i64()))
.unwrap_or(0) as u8;
Some(CoreOwnedItem {
owned_card_id: e.get("owned_card_id")?.as_str()?.to_string(),
card_id: card.get("id")?.as_str()?.to_string(),
rating,
position,
nation: card.get("nation")?.as_str()?.to_string(),
league: card.get("league")?.as_str()?.to_string(),
club: card.get("club")?.as_str()?.to_string(),
attributes: [
attr("pace"),
attr("shooting"),
attr("passing"),
attr("dribbling"),
attr("defending"),
attr("physical"),
],
contract_matches: e.get("contract_matches").and_then(|v| v.as_i64()),
// Core's authored definition rating for a NON-PLAYER (a staff card's EA
// `value`). It is a separate key from `overall`, which Core deliberately
// keeps at 0 for non-players because that number feeds pricing.
source_rating: card
.get("source_rating")
.and_then(|v| v.as_i64())
.map(|r| r as u8),
// Core's own kind token, verbatim: Core says `manager` where the FIFA
// catalog says `staff` + subtype 4, and Core compares against its own.
core_content_kind: e
.get("content_kind")
.and_then(|v| v.as_str())
.map(str::to_string),
})
}
/// Parse Core's `/cards` response `{ "cards": [CardDefinition...], ... }` into the
/// same [`CoreOwnedItem`] shape as owned items (`owned_card_id` empty — a
/// definition is not an instance), for full-universe pool building.
pub fn parse_core_definitions(v: &Value) -> Result<Vec<CoreOwnedItem>, CoreError> {
let arr = v
.get("cards")
.and_then(|c| c.as_array())
.ok_or_else(|| CoreError::Parse("missing `cards` array".into()))?;
Ok(arr.iter().filter_map(core_item_from_definition).collect())
}
fn core_item_from_definition(card: &Value) -> Option<CoreOwnedItem> {
let attr = |k: &str| card.get(k).and_then(|v| v.as_i64()).unwrap_or(0) as u8;
Some(CoreOwnedItem {
owned_card_id: String::new(),
card_id: card.get("id")?.as_str()?.to_string(),
rating: card.get("overall").and_then(|v| v.as_i64()).unwrap_or(0) as u8,
position: card.get("position")?.as_str()?.to_string(),
nation: card.get("nation")?.as_str()?.to_string(),
league: card.get("league")?.as_str()?.to_string(),
club: card.get("club")?.as_str()?.to_string(),
attributes: [
attr("pace"),
attr("shooting"),
attr("passing"),
attr("dribbling"),
attr("defending"),
attr("physical"),
],
// A definition is not an instance, so it holds no contracts — the same
// reasoning as the empty `owned_card_id` above. `None` makes the caller
// substitute the pack-fresh default instead of reading a fabricated 0.
contract_matches: None,
// Likewise: a definition is not an instance, so it carries neither an
// instance-scoped authored rating nor Core's per-instance kind token.
source_rating: None,
core_content_kind: None,
})
}
// ───────────────────────────── Item identity resolver ───────────────────────
/// The single production [`ItemIdentityResolver`]: it composes the two distinct
/// FIFA 17 identities from real, persistent sources — no placeholder, no hash,
/// no fabricated id.
///
/// * **Definition identity** (`resourceId`/`assetId`) comes from the
/// [`Fifa17CardCatalog`]: `card_id` → real FIFA asset id. An unmapped
/// definition resolves to `None` → the item is dropped and counted, never
/// faked.
/// * **Instance identity** (`item_id`) comes from the generic
/// [`ExternalIdentityStore`] under the FIFA 17 wire-id policy: the same owned
/// instance always resolves to the same monotonic wire id, it survives
/// restart, and it reverses exactly. Two copies of the same definition share a
/// `resourceId` but get distinct `item_id`s.
///
/// The wire-id namespace is **globally monotonic within `(game, "owned-item")`**,
/// not per-account. Python restarts numbering per save file; Core owned-instance
/// ids are globally-unique UUIDs, so a single monotonic sequence keeps every
/// wire id unique and its reverse lookup unambiguous across all accounts —
/// satisfying the client's only requirement (per-session unique/stable/
/// reversible ids). An account column is therefore unnecessary.
pub struct Fifa17IdentityResolver {
catalog: Fifa17CardCatalog,
store: Arc<dyn ExternalIdentityStore>,
}
impl Fifa17IdentityResolver {
pub fn new(catalog: Fifa17CardCatalog, store: Arc<dyn ExternalIdentityStore>) -> Self {
Fifa17IdentityResolver { catalog, store }
}
/// Reverse an owned-item wire id back to its Core owned-instance id (used by
/// later item-operation slices). `None` = unknown wire id, never a guess.
pub fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
self.store
.core_for(
Fifa17WireItemIdPolicy::GAME,
Fifa17WireItemIdPolicy::OWNED_ITEM_KIND,
wire,
)
.unwrap_or(None)
}
/// The FIFA `cardsubtypeid` for an owned item's definition (0 if unknown /
/// a player), from the catalog — used by club-stats family aggregation.
pub fn subtype_of(&self, item: &CoreOwnedItem) -> i64 {
self.catalog.subtype_of(&item.card_id)
}
/// The observed FIFA `rareflag` for an owned item's definition (0 if unknown),
/// from the catalog — used by club-stats rare-player counting.
pub fn rareflag_of(&self, item: &CoreOwnedItem) -> i64 {
self.catalog
.lookup(&item.card_id)
.map(|c| c.rareflag)
.unwrap_or(0)
}
/// The base FIFA `assetId` for an owned item's definition (0 if unknown),
/// from the catalog. Non-minting: club-stats must not allocate a wire id as a
/// side effect of counting, which `resolve`/`resolve_kit` would do.
pub fn asset_id_of(&self, item: &CoreOwnedItem) -> i64 {
self.catalog
.lookup(&item.card_id)
.map(|c| c.asset_id as i64)
.unwrap_or(0)
}
/// The FIFA `teamid` carried by a KIT definition, from the catalog. `None`
/// for every other content kind, whose team affiliation is the owning
/// player's club and comes from the entity tables instead.
pub fn kit_team_id_of(&self, item: &CoreOwnedItem) -> Option<i64> {
self.catalog
.lookup(&item.card_id)
.filter(|c| c.kind == ContentKind::Kit)
.map(|c| c.team_id)
}
/// Non-minting definition identity from the catalog: `Some((rareflag, kind))`
/// if the card resolves, else `None`. Used to build the pack pool over ALL
/// content definitions WITHOUT allocating a wire id per definition (that would
/// pollute the identity store); a real wire id is minted only when a pack draw
/// actually mints the card.
pub fn definition_identity(&self, card_id: &str) -> Option<(i64, ContentKind)> {
self.catalog.lookup(card_id).map(|c| (c.rareflag, c.kind))
}
fn wire_for(&self, item: &CoreOwnedItem) -> Option<u32> {
match self.store.resolve_or_allocate(
Fifa17WireItemIdPolicy::GAME,
Fifa17WireItemIdPolicy::OWNED_ITEM_KIND,
&item.owned_card_id,
Fifa17WireItemIdPolicy::owned_item_base_floor(),
) {
Ok(wire) => Some(wire as u32),
Err(error) => {
eprintln!(
"utas-host ERROR identity store alloc failed for {}: {error}",
item.owned_card_id
);
None
}
}
}
/// Allocate a MATCH session id in its own identity scope.
///
/// Kept here so one type still owns every wire-id allocation, but under
/// `MATCH_KIND` rather than the owned-item scope, so a match can never
/// appear in the owned-item reverse map. `core_id` must be unique per match
/// — `resolve_or_allocate` is idempotent per `core_id`, so reusing one would
/// hand back the same id and silently merge two matches into one economic
/// identity.
pub fn allocate_match_id(&self, core_id: &str) -> Option<i64> {
match self.store.resolve_or_allocate(
Fifa17WireItemIdPolicy::GAME,
Fifa17WireItemIdPolicy::MATCH_KIND,
core_id,
Fifa17WireItemIdPolicy::match_base_floor(),
) {
Ok(wire) => Some(wire),
Err(error) => {
eprintln!("utas-host ERROR match id alloc failed for {core_id}: {error}");
None
}
}
}
}
impl ItemIdentityResolver for Fifa17IdentityResolver {
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity> {
// Definition identity first: an unmapped card is dropped (never faked).
let ident = self.catalog.lookup(&item.card_id)?;
// Instance identity: stable, persistent, reversible wire id.
let wire = self.wire_for(item)?;
Some(Fifa17Identity {
item_id: wire,
asset_id: ident.asset_id,
resource_id: ident.resource_id,
rareflag: ident.rareflag,
})
}
/// Price a card from the CLIENT'S OWN `fcc_discardcoins` table when
/// `OPENFUT_FIFA17_DISCARD_TABLE=1`, else keep the legacy placeholder ladder.
///
/// Non-minting: it reads the catalog directly and never calls `wire_for`.
///
/// Falls back to the ladder — never to a fabricated or zero price — when the
/// inputs the client uses are not in hand:
/// * the definition is not in the catalog at all; or
/// * the subtype decodes to cardtype 0 (no table row); or
/// * a NON-PLAYER carries no catalog rating. Core models a non-player's
/// `overall` as 0, and 0 would price the card at 0 coins, so an absent
/// rating means "not known", not "worthless". This is currently the case
/// for staff, whose rating lives in the `value` column of
/// `managercards`/`*coachcards`/`physiocards` and is not yet imported.
fn discard_value(&self, item: &CoreOwnedItem) -> i64 {
if discard_table_enabled() {
if let Some(ident) = self.catalog.lookup(&item.card_id) {
if let Some(price) = discard::value_for_definition(
ident.subtype,
ident.rareflag,
ident.rating,
item.rating,
) {
return price;
}
}
}
openfut_adapter_fifa17::fut::item::legacy_discard_value(item.rating)
}
fn resolve_kit(&self, item: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
let ident = self.catalog.lookup(&item.card_id)?;
// The whole cardtype-7 club family shares this record: kit, stadium and
// badge differ only in which field their caption resolves.
if !ident.kind.is_cardtype7_club_item() {
return None;
}
Some(Fifa17KitIdentity {
item_id: self.wire_for(item)?,
asset_id: ident.asset_id,
resource_id: ident.resource_id,
card_asset_id: ident.card_asset_id,
subtype: ident.subtype,
team_id: ident.team_id,
category: ident.category,
year: ident.year,
})
}
fn resolve_staff(&self, item: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
let ident = self.catalog.lookup(&item.card_id)?;
// 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 {
item_id: self.wire_for(item)?,
// RAW, unmasked: the staff merge keys on this exactly, so it must be
// the table carddbid with no version byte.
resource_id: ident.resource_id,
subtype: ident.subtype,
nation: ident.nation,
league_id: ident.league_id,
team_id: ident.team_id,
})
}
/// 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 {
self.catalog.kind_of(&item.card_id)
}
}
/// The same production resolver reverses a wire id to a Core owned-instance id
/// for the squad PUT path — reusing the identity store, so `/club`, `/squad`,
/// and PUT all agree on wire↔owned. Identity ONLY; ownership is authorized
/// separately (a resolvable id is not proof of ownership).
impl SquadWireResolver for Fifa17IdentityResolver {
fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
Fifa17IdentityResolver::owned_id_for_wire(self, wire)
}
}
// ───────────────────────────── /club handler ────────────────────────────────
/// Safe, structured summary of a handled `/club` request (no auth/session/device
/// material — the club query carries none; auth is a header we never log).
#[derive(Debug, Clone)]
pub struct ClubLog {
pub outcome: &'static str,
pub filter: String,
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>,
}
/// Dependencies for the Rust `/club` path.
pub struct ClubDeps<'a> {
pub core: &'a dyn CoreAccess,
pub entities: &'a Fifa17Entities,
pub assets: &'a (dyn ItemIdentityResolver + Send + Sync),
/// Core owned-instance ids that are NOT in the club view — the cards with an
/// ACTIVE transfer-market listing. In FIFA a listed card has LEFT the club, so
/// it must not also appear here. Empty = show everything Core owns.
pub hidden: &'a std::collections::HashSet<String>,
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).
//
// 2026-08-24: this is also the prime suspect for the LOCKED kit. A
// live `kit_trace` run showed the kit clone driver only ever sees
// PLAYERS (~19 records, cardtype 1 / itemState 1 / `+0x60` 1) and
// never a kit, with no `KIT_DBCLONE` at all — so the failure is
// upstream of the `item+0x60 == 4` gate. In the same session the
// client asked for `?type=kit` (6x, which we answer and which feeds
// the items BROWSER) and `?type=equippables` (2x, which we answer
// empty). If the equippable view is what populates the collection
// `FUN_1800d73d0` scans for the active-kit triple, an empty answer
// is exactly why the triple stays zero and the engine falls back to
// its own catalogue kit.
//
// So the arm is now selectable behind `OPENFUT_FIFA17_EQUIPPABLES=1`
// and answers with KITS ONLY — two items, not the thirty across five
// families that crashed the client. Default OFF: the crash is real
// and reproducible, and this narrower body is a hypothesis under
// test, not an established safe response.
selector: if equippables_enabled() {
ClubSelector::Kind(ContentKind::Kit)
} else {
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
/// unfiltered page (which would drop specials or leak base cards).
fn special_filter_page(
items: &[Value],
offset: Option<i64>,
limit: Option<i64>,
) -> (Vec<Value>, i64) {
let specials: Vec<&Value> = items
.iter()
.filter(|it| {
it.get("rareflag")
.and_then(|v| v.as_i64())
.map(is_special_rareflag)
.unwrap_or(false)
})
.collect();
let total = specials.len() as i64;
let off = offset.unwrap_or(0).max(0) as usize;
let paged: Vec<Value> = match limit {
Some(l) => specials
.into_iter()
.skip(off)
.take(l.max(0) as usize)
.cloned()
.collect(),
None => specials.into_iter().skip(off).cloned().collect(),
};
(paged, total)
}
/// Paginate an already-shaped, already-filtered `/club` item list locally,
/// returning `(page, total_after_filtering)`. Used when the host filters on
/// something Core cannot express (pile membership, rareflag), where Core's own
/// offset/limit would paginate the WRONG set and yield short pages.
fn paginate_items(items: &[Value], offset: Option<i64>, limit: Option<i64>) -> (Vec<Value>, i64) {
let total = items.len() as i64;
let off = offset.unwrap_or(0).max(0) as usize;
let paged: Vec<Value> = match limit {
Some(l) => items
.iter()
.skip(off)
.take(l.max(0) as usize)
.cloned()
.collect(),
None => items.iter().skip(off).cloned().collect(),
};
(paged, total)
}
/// Handle `GET …/club?…` end to end: parse → map ids to names → Core query →
/// shape to the FIFA `{itemData:[…]}` envelope. Always returns HTTP 200 with a
/// 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 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 {
outcome: "unsupported_type",
filter: format!("type={other}"),
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) => {
return (
json_response(&json!({ "itemData": [] })),
ClubLog {
outcome: "unknown_id",
filter: describe_map_error(&e),
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 (offset, limit) = (core_q.offset, core_q.limit);
let mut base = core_q.clone();
base.offset = None;
base.limit = None;
let mut filter = summarize(&base.to_query_pairs());
if !filter.is_empty() {
filter.push(',');
}
filter.push_str(&format!("type={}", filter_arm.label));
if core_q.special {
filter.push_str(",rare=SP");
}
if !deps.hidden.is_empty() {
filter.push_str(&format!(",hidden={}", deps.hidden.len()));
}
// The documented club grammar allows a comma-joined `defId=` list INSTEAD of
// the filter block, and this host does not narrow on it. That grammar is
// single-source and no observed request has ever carried one, so guessing the
// semantics could turn "too many items" into "zero items". Make the first
// real occurrence impossible to miss instead of silently answering wrong.
if !raw.def_ids.is_empty() {
filter.push_str(&format!(",defId={}", raw.def_ids.len()));
eprintln!(
"utas-host owner=RUST route=club NOTICE unhandled defId list ({} id(s): {:?}) \
— the response is NOT narrowed to them. This is the first observation of a \
parameter only ever seen in a decompile; capture the full request and \
implement the filter against it.",
raw.def_ids.len(),
&raw.def_ids[..raw.def_ids.len().min(8)]
);
}
match deps.core.query_owned(&base.to_query_pairs()) {
Ok(page) => {
// Kind and transfer-pile membership live outside generic Core, so
// filtering and pagination must happen here over the final set.
let visible: Vec<CoreOwnedItem> = page
.items
.into_iter()
.filter(|item| !deps.hidden.contains(&item.owned_card_id))
.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(),
away: deps.active_kits.away_owned_card_id.as_deref(),
};
let (body, stats) =
shape_club_response_with_kits(&visible, deps.entities, deps.assets, active);
let all = body
.get("itemData")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let (paged, total) = if core_q.special {
special_filter_page(&all, offset, limit)
} else {
paginate_items(&all, offset, limit)
};
let emitted = paged.len();
(
json_response(&json!({ "itemData": paged })),
ClubLog {
outcome: "ok",
filter,
total,
emitted,
dropped_no_asset: stats.dropped_no_asset,
dropped_incomplete: stats.dropped_incomplete,
offset,
limit,
},
)
}
Err(error) => {
eprintln!("utas-host ERROR /club core query failed: {error}");
(
json_response(&json!({ "itemData": [] })),
ClubLog {
outcome: "core_error",
filter,
total: 0,
emitted: 0,
dropped_no_asset: 0,
dropped_incomplete: 0,
offset,
limit,
},
)
}
}
}
fn describe_map_error(e: &MapError) -> String {
match e {
MapError::UnknownLeague(id) => format!("unknown_league={id}"),
MapError::UnknownNation(id) => format!("unknown_nation={id}"),
MapError::UnknownTeam(id) => format!("unknown_team={id}"),
}
}
fn summarize(pairs: &[(&str, String)]) -> String {
pairs
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(",")
}
// ───────────────────────────── Squad handlers ───────────────────────────────
/// FIFA wire id of the single active squad (`…/squad/0`).
pub const ACTIVE_SQUAD_WIRE_ID: i64 = 0;
/// Dependencies for the Rust squad paths. `resolver` is the SAME production
/// identity resolver `/club` uses (forward shape + reverse wire→owned), so every
/// route agrees on wire↔owned identity.
pub struct SquadDeps<'a> {
pub core: &'a dyn CoreAccess,
pub resolver: &'a Fifa17IdentityResolver,
pub entities: &'a Fifa17Entities,
}
/// Secret-free structured log line for a handled squad request.
#[derive(Debug, Clone)]
pub struct SquadLog {
pub outcome: &'static str,
pub detail: String,
}
/// The active squad projected from Core, with the freshness policy applied.
enum HostProjection {
/// Fresh: the projected FIFA squad object (before any endpoint envelope).
Squad(Value),
/// Stored extension is stale vs the canonical squad — NEVER applied.
Stale,
/// No extension stored — nothing fabricated.
Missing,
/// Core unreachable / response unreadable / projection failed.
Error(String),
}
/// Assemble the projection input from Core in a BOUNDED number of calls — one
/// `read_squad_ext` + one batch `all_owned`, never per slot — then project. The
/// Fresh/Stale/Missing policy is decided HERE, not buried in a default.
fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection {
let read = match deps.core.read_squad_ext(EXT_NAMESPACE) {
Ok(r) => r,
Err(e) => return HostProjection::Error(e.to_string()),
};
let ext = match &read.ext {
CoreExtState::Fresh {
schema_version,
payload,
} => {
match Fifa17SquadExtensionV1::from_payload(*schema_version, payload) {
Ok(e) => e,
// Fresh but the payload does not parse as our schema: corruption,
// never coerced into a fabricated squad.
Err(e) => return HostProjection::Error(format!("fresh extension unreadable: {e}")),
}
}
CoreExtState::Stale { .. } => return HostProjection::Stale,
CoreExtState::Missing => return HostProjection::Missing,
};
let owned = match deps.core.all_owned() {
Ok(v) => v,
Err(e) => return HostProjection::Error(e.to_string()),
};
let owned_by_id: std::collections::HashMap<String, CoreOwnedItem> = owned
.into_iter()
.map(|i| (i.owned_card_id.clone(), i))
.collect();
let slots: Vec<ProjectionSlot> = read
.slots
.iter()
.map(|s| ProjectionSlot {
owned_card_id: s.owned_card_id.clone(),
index: s.index,
is_captain: s.is_captain,
is_on_bench: s.is_on_bench,
})
.collect();
// The ownership-backed manager assignment (Core `squad_managers`). An absent
// endpoint or a transport error yields no manager — non-fatal, a squad still
// renders without one and an older Core has no such route. But a manager that
// IS assigned and cannot be resolved is reported: the client refuses to start
// a match without a manager, so silence here costs an unexplained dead end.
let manager = match deps.core.get_squad_manager() {
Ok(Some(owned_card_id)) => {
let found = owned_by_id.get(&owned_card_id).cloned();
if found.is_none() {
eprintln!(
"utas-host WARN squad manager {owned_card_id} is assigned in Core \
but absent from the owned collection — its card definition is \
most likely missing from the loaded content pack, so Core drops \
it from /collection without erroring"
);
}
found
}
Ok(None) => None,
Err(error) => {
eprintln!("utas-host WARN squad manager read unavailable: {error}");
None
}
};
let input = SquadProjectionInput {
fifa_squad_id: ACTIVE_SQUAD_WIRE_ID,
name: read.name,
formation: read.formation,
slots,
ext: SquadExtInput::Fresh(ext),
owned: &owned_by_id,
manager,
};
match project_squad(&input, deps.resolver, deps.entities) {
Ok(SquadProjection::Projected(v)) => HostProjection::Squad(v),
Ok(SquadProjection::Stale) => HostProjection::Stale,
Ok(SquadProjection::Missing) => HostProjection::Missing,
Err(e) => HostProjection::Error(e.to_string()),
}
}
/// A small JSON error body (UTAS mutations that cannot be honoured fail loudly —
/// they are NEVER retried against Python, which would risk a double mutation).
fn error_response(status: u16, code: &str) -> WireResponse {
WireResponse {
status,
headers: vec![("Content-Type".to_string(), "application/json".to_string())],
body: format!("{{\"error\":\"{code}\"}}").into_bytes(),
transport: ResponseTransport::Normal,
}
}
/// `PUT …/squad/<n>` — parse the full replacement, reverse-resolve every wire id,
/// AUTHORIZE every resolved item against the active club, then commit the
/// canonical squad + FIFA extension to Core in one transaction. On any failure
/// it returns an error and NEVER falls back to Python (no double mutation).
pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
let put = match parse_squad_put(body) {
Ok(p) => p,
Err(e) => {
return (
error_response(400, "parse_error"),
SquadLog {
outcome: "parse_error",
detail: e.to_string(),
},
)
}
};
// Reverse-resolve wire→owned and shape canonical + extension. Refuses on an
// unresolved wire id or the same owned item placed twice.
let build = match build_squad_write(&put, deps.resolver) {
Ok(b) => b,
Err(SquadBuildError::UnresolvedWireIds(ids)) => {
return (
error_response(400, "unresolved_wire_ids"),
SquadLog {
outcome: "unresolved_wire_ids",
detail: format!("{ids:?}"),
},
)
}
Err(SquadBuildError::DuplicateOwnedItem(id)) => {
return (
error_response(400, "duplicate_owned_item"),
SquadLog {
outcome: "duplicate_owned_item",
detail: id,
},
)
}
};
// AUTHORIZATION — identity resolution is NOT authorization. Every resolved
// owned item MUST belong to the active club; a globally-valid wire id that
// belongs to another profile is rejected BEFORE any canonical mutation.
let owned_set: std::collections::HashSet<String> = match deps.core.all_owned() {
Ok(v) => v.into_iter().map(|i| i.owned_card_id).collect(),
Err(e) => {
return (
error_response(502, "core_error"),
SquadLog {
outcome: "core_error",
detail: e.to_string(),
},
)
}
};
for slot in &build.canonical.slots {
if !owned_set.contains(&slot.owned_card_id) {
return (
error_response(403, "not_owned"),
SquadLog {
outcome: "unauthorized_item",
detail: slot.owned_card_id.clone(),
},
);
}
}
// The manager is a resolved, owned assignment too: authorize it like a slot.
if let Some(mgr) = &build.canonical.manager_owned_card_id {
if !owned_set.contains(mgr) {
return (
error_response(403, "not_owned"),
SquadLog {
outcome: "unauthorized_manager",
detail: mgr.clone(),
},
);
}
}
// FIFA always sends a manager ref, and on a real profile it does not resolve
// to an owned instance (production's own save points at 100000427, absent
// from its /club/staff). That is not an error: the save commits with NO
// ownership-backed manager. Logged so a ref we cannot map stays visible
// instead of vanishing.
if let Some(wire) = build.canonical.unresolved_manager_wire_id {
eprintln!(
"utas-host owner=RUST route=squad-replace manager_ref_unresolved={wire} \
(saved with no manager assignment)"
);
}
// Commit canonical + extension atomically. No Python fallback on failure.
let req = CoreReplaceRequest {
name: build.canonical.name.clone(),
formation: build.canonical.formation.clone(),
slots: build
.canonical
.slots
.iter()
.map(|s| CoreSquadSlot {
owned_card_id: s.owned_card_id.clone(),
index: s.index,
is_captain: s.is_captain,
is_on_bench: s.is_on_bench,
})
.collect(),
client_reported: CoreClientEval {
chemistry: build.extension.client_reported.chemistry,
rating: build.extension.client_reported.rating,
star_rating: build.extension.client_reported.star_rating,
},
ext_namespace: EXT_NAMESPACE.to_string(),
ext_schema_version: EXT_SCHEMA_VERSION,
ext_payload: build.extension.to_payload(),
};
if let Err(e) = deps.core.replace_squad(&req) {
return (
error_response(502, "core_error"),
SquadLog {
outcome: "core_error",
detail: e.to_string(),
},
);
}
// Persist the ownership-backed manager assignment (migration 0023). It was
// authorized above and Core re-validates club ownership; fail loudly on a
// transport error rather than silently dropping the manager.
if let Err(e) = deps
.core
.set_squad_manager(build.canonical.manager_owned_card_id.as_deref())
{
return (
error_response(502, "core_error"),
SquadLog {
outcome: "manager_error",
detail: e.to_string(),
},
);
}
(
json_response(&save_ack(put.id)),
SquadLog {
outcome: "ok",
detail: String::new(),
},
)
}
/// `GET …/squad/list` — served from Core + the ONE projector. Stale/Missing are
/// integrity failures for the migrated dev profile: logged prominently, degraded
/// to an empty list, NEVER served from Python and NEVER projected from stale ext.
pub fn handle_squad_list(deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
match project_active_squad(deps) {
HostProjection::Squad(v) => (
json_response(&squad_list(&v)),
SquadLog {
outcome: "ok",
detail: String::new(),
},
),
HostProjection::Stale => (
json_response(&json!({ "squad": [] })),
SquadLog {
outcome: "stale_integrity",
detail: "stale extension not applied".into(),
},
),
HostProjection::Missing => (
json_response(&json!({ "squad": [] })),
SquadLog {
outcome: "missing_integrity",
detail: "no extension stored".into(),
},
),
HostProjection::Error(e) => (
json_response(&json!({ "squad": [] })),
SquadLog {
outcome: "core_error",
detail: e,
},
),
}
}
/// `GET …/squad/active` — the active squad projected from Core, returned as the
/// top-level squad object (byte-identical to what `userMassInfo.squad` embeds).
/// Never served from Python and NEVER projected from a stale extension; on a
/// stale/missing extension or a Core error it degrades to an honest empty squad
/// (never 401/403, never a Python fallback that could mask split authority).
pub fn handle_squad_active(deps: &SquadDeps<'_>, persona_id: i64) -> (WireResponse, SquadLog) {
match project_active_squad(deps) {
HostProjection::Squad(v) => (
json_response(&user_mass_info_squad(v, persona_id)),
SquadLog {
outcome: "ok",
detail: String::new(),
},
),
HostProjection::Stale => (
json_response(&empty_squad_overlay(persona_id)),
SquadLog {
outcome: "stale_integrity",
detail: "stale extension not applied".into(),
},
),
HostProjection::Missing => (
json_response(&empty_squad_overlay(persona_id)),
SquadLog {
outcome: "missing_integrity",
detail: "no extension stored".into(),
},
),
HostProjection::Error(e) => (
json_response(&empty_squad_overlay(persona_id)),
SquadLog {
outcome: "core_error",
detail: e,
},
),
}
}
/// An explicit empty active squad used only when Rust squad authority cannot
/// produce a Fresh projection during a userMassInfo overlay. It is NOT Python's
/// squad (that would reintroduce split authority) and NOT fabricated extension
/// state — it is an honest "no squad available", surfaced with an ERROR log.
fn empty_squad_overlay(persona_id: i64) -> Value {
json!({
"id": ACTIVE_SQUAD_WIRE_ID,
"personaId": persona_id,
"changed": 0,
"actives": [],
"players": [],
})
}
/// Replace `resp`'s body with `body`, fixing framing headers (Content-Length,
/// Content-Type; drops any stale length/transfer-encoding).
fn set_json_body(resp: &mut WireResponse, body: Vec<u8>) {
resp.headers.retain(|(k, _)| {
!k.eq_ignore_ascii_case("content-length")
&& !k.eq_ignore_ascii_case("content-type")
&& !k.eq_ignore_ascii_case("transfer-encoding")
});
resp.headers
.push(("Content-Type".to_string(), "application/json".to_string()));
resp.headers
.push(("Content-Length".to_string(), body.len().to_string()));
resp.body = body;
}
/// `GET …/userMassInfo` — proxy the request to Python verbatim, then overlay ONLY
/// `.squad` with the Rust/Core projection. Every unrelated field (`userInfo`,
/// `settings`, `userData`, `pileSizeClientData`, …) is preserved exactly.
pub fn handle_user_mass_info(
method: &str,
target: &str,
headers: &[(String, String)],
body: &[u8],
deps: &SquadDeps<'_>,
pass: &PassClient,
) -> (WireResponse, SquadLog) {
let mut resp = match pass.forward(method, target, headers, body) {
Ok(r) => r,
Err(e) => {
return (
error_response(502, "upstream_unavailable"),
SquadLog {
outcome: "python_unreachable",
detail: e.to_string(),
},
)
}
};
// Only a successful JSON object carrying `.squad` is overlaid; anything else
// is returned verbatim (we never invent a squad into an unrelated response).
if !(200..300).contains(&resp.status) {
return (
resp,
SquadLog {
outcome: "python_non_2xx_passthrough",
detail: String::new(),
},
);
}
let mut root: Value = match serde_json::from_slice::<Value>(&resp.body) {
Ok(v) if v.is_object() => v,
_ => {
return (
resp,
SquadLog {
outcome: "python_body_unusable_passthrough",
detail: String::new(),
},
)
}
};
if root.get("squad").is_none() {
return (
resp,
SquadLog {
outcome: "python_no_squad_passthrough",
detail: String::new(),
},
);
}
// Preserve the client's persona from Python's own response.
let persona = root["squad"]
.get("personaId")
.and_then(|x| x.as_i64())
.or_else(|| {
root.get("userInfo")
.and_then(|u| u.get("personaId"))
.and_then(|x| x.as_i64())
})
.unwrap_or(0);
let (squad_val, log) = match project_active_squad(deps) {
HostProjection::Squad(v) => (
user_mass_info_squad(v, persona),
SquadLog {
outcome: "ok",
detail: String::new(),
},
),
HostProjection::Stale => (
empty_squad_overlay(persona),
SquadLog {
outcome: "stale_integrity",
detail: "stale extension not applied".into(),
},
),
HostProjection::Missing => (
empty_squad_overlay(persona),
SquadLog {
outcome: "missing_integrity",
detail: "no extension stored".into(),
},
),
HostProjection::Error(e) => (
empty_squad_overlay(persona),
SquadLog {
outcome: "core_error",
detail: e,
},
),
};
root["squad"] = squad_val;
let new_body = serde_json::to_vec(&root).unwrap_or_else(|_| resp.body.clone());
set_json_body(&mut resp, new_body);
(resp, log)
}
// ─────────────── Rust economy handlers (Core-backed authority) ───────────────
//
// These implement the FIFA17 economy routes against Core economy authority.
// They are Core-backed and fail-closed: a Core error yields a controlled FIFA-
// compatible response and NEVER a Python fallback (which would be a second
// writer). They are wired into `classify` only as one coherent barrier once the
// whole coins cluster (writers + readers) flips together and Core is seeded from
// the profile — a partial flip would desync the client's coin counter.
/// Build the `GET /user/credits` body — byte-shape-identical to the Python
/// oracle (`credits` + `currencies[].funds/finalFunds`, optional
/// `unopenedPacks.recoveredPacks`). The hub coin counter binds to
/// `currencies[0].funds`, not `credits`.
pub fn build_credits_body(coins: i64, unopened_count: usize) -> Value {
let mut body = json!({
"credits": coins,
"currencies": [
{"name": "coins", "funds": coins, "finalFunds": coins},
{"name": "points", "funds": 0, "finalFunds": 0},
],
});
if unopened_count > 0 {
body.as_object_mut().unwrap().insert(
"unopenedPacks".into(),
json!({ "preOrderPacks": 0, "recoveredPacks": unopened_count }),
);
}
body
}
/// `GET /user/credits` from Core authority: coins = Core balance, recoveredPacks
/// = Core unconsumed entitlement count. Fail-closed on any Core error (503, no
/// Python fallback, no fabricated balance).
pub fn handle_credits(econ: &dyn CoreEconomy) -> WireResponse {
match (econ.balance(), econ.entitlements()) {
(Ok(coins), Ok(ents)) => json_response(&build_credits_body(coins, ents.len())),
_ => error_response(503, "core_unavailable"),
}
}
/// Map Core entitlements to FIFA 17 unopened pack ids. `definition_id` is either
/// a numeric owned-only pack id (imported entitlements) or a symbolic reward-pack
/// name granted by Core's reward services; both resolve via
/// [`owned_pack_id_for_definition`]. Unresolvable entitlements are skipped, never
/// faked.
fn entitlement_pack_ids(ents: &[EconomyEntitlement]) -> Vec<u64> {
ents.iter()
.filter_map(|e| owned_pack_id_for_definition(&e.definition_id))
.collect()
}
/// `GET /store/purchasegroup` fully generated in Rust: normal catalogue packs +
/// Core-owned unopened packs + the empty-My-Packs shim per `StoreMode`. No
/// Python body dependency. Fail-closed on Core error (503, never Python).
pub fn handle_purchasegroup(econ: &dyn CoreEconomy, mode: StoreMode) -> WireResponse {
match econ.entitlements() {
Ok(ents) => {
let ids = entitlement_pack_ids(&ents);
json_response(&build_purchasegroup(&ids, mode))
}
Err(_) => error_response(503, "core_unavailable"),
}
}
/// Overlay the authoritative Core economy onto a `userMassInfo` body in place:
/// set `userInfo.currencies[coins].funds/finalFunds` and
/// `userInfo.unopenedPacks.recoveredPacks`. Pure; every other field is
/// preserved. Mirrors the oracle shape (coins element by `name == "coins"`;
/// `unopenedPacks` only present when count > 0). Returns true if applied.
pub fn overlay_massinfo_economy(root: &mut Value, coins: i64, unopened_count: usize) -> bool {
let Some(user_info) = root.get_mut("userInfo").and_then(Value::as_object_mut) else {
return false;
};
if let Some(currencies) = user_info
.get_mut("currencies")
.and_then(Value::as_array_mut)
{
for cur in currencies.iter_mut() {
if cur.get("name").and_then(Value::as_str) == Some("coins") {
if let Some(obj) = cur.as_object_mut() {
obj.insert("funds".into(), json!(coins));
obj.insert("finalFunds".into(), json!(coins));
}
}
}
}
if unopened_count > 0 {
user_info.insert(
"unopenedPacks".into(),
json!({ "preOrderPacks": 0, "recoveredPacks": unopened_count }),
);
} else {
user_info.remove("unopenedPacks");
}
true
}
/// Derive the per-match economic identity from a persona and the match session.
/// This is the key for Core's durable exactly-once guard — NOT a FIFA HTTP
/// receipt id.
///
/// Precedence, strongest first:
///
/// 1. `created` — the id minted by `POST …/match` for THIS session. This is the
/// only source that is unique per match by construction, and it is what makes
/// a second abandoned match credit at all.
/// 2. A non-zero `matchReportId` from the body.
/// 3. A fingerprint of the body, for an end with no preceding create (a hand
/// probe). Retained only as a floor.
///
/// The fingerprint MUST NOT be the primary key: the live DNF body is
/// byte-identical for every abandoned match (`matchReportId:0`, empty items,
/// empty matchData, empty telemetry, flags 0), so it collapses every DNF onto
/// one identity. Core's `UNIQUE(profile_id, match_identity)` would then refuse
/// each one after the first, returning `applied=false` and awarding nothing —
/// a silent, permanent under-credit. It is also `DefaultHasher`, whose output
/// has no cross-version stability guarantee yet would be persisted durably.
fn match_identity(
persona: i64,
end: &match_wire::MatchEnd,
body: &[u8],
created: Option<i64>,
) -> String {
if let Some(id) = created {
return format!("{persona}:match:{id}");
}
if end.match_report_id != 0 {
return format!("{persona}:report:{}", end.match_report_id);
}
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
body.hash(&mut h);
format!("{persona}:fp:{:016x}", h.finish())
}
/// Handle the coin-crediting match-end call: parse the FIFA wire, then apply the
/// match to OpenFUT Core's authoritative, atomic, exactly-once `complete_match`
/// transaction and render Core's authoritative coin numbers back onto the wire.
/// FAIL-CLOSED: a malformed body is a 400 and ANY Core error is a 503 — the
/// economy is never applied by Python (a second writer would break exactly-once).
pub fn handle_match_end(
econ: &dyn CoreEconomy,
persona: i64,
created: Option<i64>,
body: &[u8],
) -> WireResponse {
let Some(end) = match_wire::parse_match_end(body) else {
return error_response(400, "bad_match_end");
};
let identity = match_identity(persona, &end, body, created);
let completion = CoreMatchCompletion {
match_identity: &identity,
result: end.result.core_token(),
squad_id: "",
opponent_name: "",
goals_for: end.goals_for,
goals_against: end.goals_against,
mode: "seasons",
};
match econ.complete_match(&completion) {
Ok(receipt) => json_response(&match_wire::reward_response(
receipt.coins_balance,
receipt.coins_awarded,
)),
Err(_) => error_response(503, "core_unavailable"),
}
}
// ───────────────────────────── HTTP wire types ──────────────────────────────
/// A response ready to write: status, headers, body, and an internal
/// staging-only transport directive.
#[derive(Debug, Clone)]
pub struct WireResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
transport: ResponseTransport,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ResponseTransport {
Normal,
Drop,
Malformed,
Delay { millis: u64 },
}
impl From<SbcPostCommitFault> for ResponseTransport {
fn from(value: SbcPostCommitFault) -> Self {
match value {
SbcPostCommitFault::Off => Self::Normal,
SbcPostCommitFault::Drop => Self::Drop,
SbcPostCommitFault::Malformed => Self::Malformed,
SbcPostCommitFault::Delay { millis } => Self::Delay { millis },
}
}
}
fn json_response(body: &Value) -> WireResponse {
let bytes = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
WireResponse {
status: 200,
headers: vec![("Content-Type".to_string(), "application/json".to_string())],
body: bytes,
transport: ResponseTransport::Normal,
}
}
fn is_hop_by_hop(name: &str) -> bool {
matches!(
name.to_ascii_lowercase().as_str(),
"connection"
| "keep-alive"
| "transfer-encoding"
| "content-length"
| "host"
| "proxy-connection"
| "te"
| "trailer"
| "upgrade"
)
}
// ───────────────────────────── Python passthrough ───────────────────────────
/// Verbatim reverse proxy to the Python UTAS oracle. Preserves method, full
/// target (path + query), end-to-end headers, and body; returns the upstream's
/// status/headers/body faithfully.
pub struct PassClient {
client: reqwest::blocking::Client,
upstream: String,
}
impl PassClient {
pub fn new(upstream: impl Into<String>) -> Self {
PassClient {
client: reqwest::blocking::Client::new(),
upstream: upstream.into().trim_end_matches('/').to_string(),
}
}
pub fn forward(
&self,
method: &str,
target: &str,
headers: &[(String, String)],
body: &[u8],
) -> Result<WireResponse, CoreError> {
let url = format!("{}{}", self.upstream, target);
let m = reqwest::Method::from_bytes(method.as_bytes())
.map_err(|e| CoreError::Http(format!("bad method: {e}")))?;
let mut req = self.client.request(m, &url);
for (k, v) in headers {
if !is_hop_by_hop(k) {
req = req.header(k, v);
}
}
if !body.is_empty() {
req = req.body(body.to_vec());
}
let resp = req.send().map_err(|e| CoreError::Http(e.to_string()))?;
let status = resp.status().as_u16();
let mut out = Vec::new();
for (k, v) in resp.headers() {
if !is_hop_by_hop(k.as_str()) {
if let Ok(s) = v.to_str() {
out.push((k.to_string(), s.to_string()));
}
}
}
let bytes = resp
.bytes()
.map_err(|e| CoreError::Http(e.to_string()))?
.to_vec();
Ok(WireResponse {
status,
headers: out,
body: bytes,
transport: ResponseTransport::Normal,
})
}
}
// ───────────────────────────── Server ───────────────────────────────────────
/// The durable services the FIFA17 economy handlers need, wired into [`Server`]
/// ONCE at construction (never per request). All are process-lifetime `Arc`s:
/// the Core economy transport, the two host-owned durable SQLite stores (listing
/// and pile), the shared Tokio runtime bridge, and the pack-content candidate
/// pool (the resolvable FIFA∩Core card universe).
#[derive(Clone)]
pub struct EconomyServices {
pub econ: Arc<dyn CoreEconomy>,
pub market: Arc<crate::market_store::MarketStore>,
pub piles: Arc<crate::pile_store::PileStore>,
pub bridge: Arc<crate::async_bridge::AsyncBridge>,
/// The resolvable FIFA∩Core card universe a pack can award (empty → the Store
/// fail-closes: it draws nothing and debits nothing).
pub pool: Arc<Vec<GeneratedCandidate>>,
/// STAGING-ONLY sold-row experiment. `SoldExperiment::OFF` in production, where
/// it changes nothing.
pub sold_experiment: crate::sold_experiment::SoldExperiment,
}
/// Build the pack-content candidate pool. Prefers the FULL card universe (Core
/// content, `GET /cards` via [`CoreAccess::all_definitions`]) so a pack can award
/// any card in the game, not only cards the profile already owns; falls back to
/// owned inventory when content enumeration is unavailable (older Core / tests).
/// Each candidate resolves in the FIFA catalog (unmapped or non-player cards are
/// dropped, never faked); `gold` = rating ≥ 75, `special` = catalog `rareflag > 1`.
/// An empty pool (no content, or Core unreachable) is fail-closed by construction:
/// the generator returns no cards, so the Store neither mints nor debits.
pub fn build_content_pool(
core: &dyn CoreAccess,
resolver: &Fifa17IdentityResolver,
) -> Vec<GeneratedCandidate> {
// Full universe first; owned inventory only as a degrade path.
let items = match core.all_definitions() {
Ok(v) if !v.is_empty() => v,
_ => match core.all_owned() {
Ok(v) => v,
Err(_) => return Vec::new(),
},
};
let mut seen = std::collections::HashSet::new();
let mut pool = Vec::new();
for item in &items {
if !seen.insert(item.card_id.clone()) {
continue;
}
// Non-minting catalog lookup: drop unmapped cards and non-player content
// (consumables/staff never enter the player-card pack pool).
let Some((rareflag, kind)) = resolver.definition_identity(&item.card_id) else {
continue;
};
if kind != ContentKind::Player {
continue;
}
pool.push(GeneratedCandidate {
card_id: item.card_id.clone(),
rating: item.rating,
position: item.position.clone(),
nation: item.nation.clone(),
league: item.league.clone(),
club: item.club.clone(),
attributes: item.attributes,
gold: item.rating >= 75,
special: rareflag > 1,
});
}
pool
}
/// Production [`crate::economy_store::PurchasedPileSink`]: records a minted item
/// into the durable pile store's "purchased" pile via the runtime bridge, from
/// the synchronous dispatch thread. A pile-write failure is logged, never fatal
/// to the mint (Core already committed the item; the reveal is presentation
/// only, and a missing pile row just omits it from the reveal screen).
struct BridgedPurchasedSink {
bridge: Arc<crate::async_bridge::AsyncBridge>,
piles: Arc<crate::pile_store::PileStore>,
}
impl crate::economy_store::PurchasedPileSink for BridgedPurchasedSink {
fn record_purchased(&self, core_id: &str) {
let piles = self.piles.clone();
let id = core_id.to_string();
if let Err(e) = self
.bridge
.block_on(async move { piles.set(&id, "purchased").await })
{
eprintln!("utas-host WARN purchased-pile record {core_id} failed: {e}");
}
}
}
/// The migration host. Cheap to clone (all shared state is `Arc`).
#[derive(Clone)]
pub struct Server {
core: Arc<dyn CoreAccess>,
entities: Arc<Fifa17Entities>,
/// The single production identity resolver, shared by `/club`, `/squad/*`
/// and the userMassInfo overlay — one wire↔owned identity everywhere.
resolver: Arc<Fifa17IdentityResolver>,
pass: Arc<PassClient>,
/// The launcher-selected FIFA persona id (injected via `OPENFUT_PERSONA_ID`),
/// used to stamp `personaId` on the Core-backed `GET /squad/active` object.
/// Never baked in — it must match the persona LSX/Blaze/POW/UTAS agree on.
persona_id: i64,
/// Shared FIFA17 account identity. Rust owns club rename and every Rust
/// user/account response reads the same durable name/abbreviation.
account: Arc<AccountStore>,
/// Per-login FIFA session/capability authority (empty-My-Packs topology).
/// The economy (coins, unopened packs, BUY) stays Python's; this owns only
/// session/capability state — Rust reads Python's live body, never writes it.
sessions: Arc<Mutex<SessionStore>>,
/// The match id minted by `POST …/match`, consumed by `…/match/end` as that
/// match's exactly-once economic identity. FIFA plays one match at a time,
/// and `try_handle_economy` holds the economy gate across the whole
/// dispatch, so create and end cannot interleave.
current_match: Arc<PlMutex<Option<i64>>>,
/// Monotonic clock origin for the session/pending TTLs.
start: Instant,
/// FIFA17 economy authority services (Core transport + durable listing/pile
/// stores + runtime bridge + content pool). `None` until wired via
/// [`Server::with_economy`]; the economy dispatch is inert without it, and
/// `handle_with_ip` does not consult it until the classifier barrier.
economy: Option<Arc<EconomyServices>>,
/// Durable per-user client-data blob store (`clientdata`/`userHubData`).
/// [`Server::new`] gives each instance an ephemeral temp-file store;
/// [`Server::from_config`] wires the configured durable path.
clientdata: Arc<ClientDataStore>,
/// Serializes host-owned pile/listing transitions with SBC eligibility checks.
/// Core supplies the database transaction; this gate closes the cross-store race
/// within one host process.
economy_gate: Arc<Mutex<()>>,
/// Staging-only simulation of losing the successful SBC submit receipt.
sbc_post_commit_fault: SbcPostCommitFault,
}
impl Server {
/// Assemble from injected parts (used by `from_config` and tests).
pub fn new(
core: Arc<dyn CoreAccess>,
entities: Arc<Fifa17Entities>,
resolver: Arc<Fifa17IdentityResolver>,
pass: Arc<PassClient>,
persona_id: i64,
) -> Self {
Server {
core,
entities,
resolver,
pass,
persona_id,
account: Arc::new(AccountStore::open(ephemeral_account_path())),
sessions: Arc::new(Mutex::new(SessionStore::new())),
start: Instant::now(),
economy: None,
clientdata: Arc::new(ClientDataStore::open(ephemeral_clientdata_path())),
economy_gate: Arc::new(Mutex::new(())),
sbc_post_commit_fault: SbcPostCommitFault::Off,
current_match: Arc::new(PlMutex::new(None)),
}
}
/// Build from config: load entity tables + the FIFA 17 identity catalog, open
/// the persistent identity store, and wire the Core client + Python
/// passthrough. Fails clearly if a required production identity source cannot
/// be loaded — there is no placeholder fallback.
pub fn from_config(cfg: &HostConfig) -> Result<Self, String> {
let entities = Fifa17Entities::from_tables_dir(std::path::Path::new(&cfg.tables_dir))
.map_err(|e| format!("loading entity tables from {}: {e}", cfg.tables_dir))?;
let catalog = Fifa17CardCatalog::from_file(std::path::Path::new(&cfg.catalog_path))
.map_err(|e| format!("loading card-definition catalog {}: {e}", cfg.catalog_path))?;
let store = openfut_identity::JsonIdentityStore::open(&cfg.identity_store_path)
.map_err(|e| format!("opening identity store {}: {e}", cfg.identity_store_path))?;
let resolver = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)));
// One HTTP client, shared as both the read (`CoreAccess`) and the economy
// (`CoreEconomy`) transport — the same Core, one connection policy.
let client = Arc::new(HttpCoreClient::new(
cfg.core_url.clone(),
Fifa17WireItemIdPolicy::GAME,
));
let core: Arc<dyn CoreAccess> = client.clone();
let econ: Arc<dyn CoreEconomy> = client;
let entities = Arc::new(entities);
// Economy authority services: one runtime bridge + the two durable
// host-owned SQLite stores (opened here, at host lifetime, NEVER per
// request) + the content pool. A store that cannot open is a hard startup
// failure — NEVER a silent omission or a Python economy fallback.
let bridge = Arc::new(
crate::async_bridge::AsyncBridge::new()
.map_err(|e| format!("building economy runtime bridge: {e}"))?,
);
let market_path = cfg.market_db_path.clone();
let market = Arc::new(
bridge
.block_on(async move { crate::market_store::MarketStore::open(&market_path).await })
.map_err(|e| format!("opening market store {}: {e}", cfg.market_db_path))?,
);
let pile_path = cfg.pile_db_path.clone();
let piles = Arc::new(
bridge
.block_on(async move { crate::pile_store::PileStore::open(&pile_path).await })
.map_err(|e| format!("opening pile store {}: {e}", cfg.pile_db_path))?,
);
// Content pool from Core's current inventory (empty ⇒ Store fails closed,
// never mints/debits — an honest degrade if Core is not yet seeded).
let pool = Arc::new(build_content_pool(core.as_ref(), resolver.as_ref()));
// Read once at startup and logged, so a staging capture can never be
// mistaken for production output.
let sold_experiment = crate::sold_experiment::SoldExperiment::from_env();
eprintln!("utas-host {}", sold_experiment.banner());
let economy = Arc::new(EconomyServices {
econ,
market,
piles,
bridge,
pool,
sold_experiment,
});
let clientdata = Arc::new(ClientDataStore::open(cfg.clientdata_path.clone()));
let account = Arc::new(AccountStore::open(cfg.account_path.clone()));
eprintln!(
"utas-host sbc_post_commit_fault={:?}",
cfg.sbc_post_commit_fault
);
Ok(Server::new(
core,
entities,
resolver,
Arc::new(PassClient::new(cfg.python_upstream.clone())),
cfg.persona_id,
)
.with_economy(economy)
.with_clientdata(clientdata)
.with_account(account)
.with_sbc_post_commit_fault(cfg.sbc_post_commit_fault))
}
/// Assemble the shared squad dependencies (Core access + the one production
/// resolver + entity tables).
fn squad_deps(&self) -> SquadDeps<'_> {
SquadDeps {
core: self.core.as_ref(),
resolver: self.resolver.as_ref(),
entities: self.entities.as_ref(),
}
}
/// Attach the FIFA17 economy authority services. Kept separate from
/// construction so the (many) squad/club tests build a `Server` without a
/// database, while the economy integration path wires real durable stores +
/// the runtime bridge once.
pub fn with_economy(mut self, economy: Arc<EconomyServices>) -> Self {
self.economy = Some(economy);
self
}
/// Attach the durable client-data blob store (configured path). Kept separate
/// from construction so tests keep the ephemeral temp-file store.
pub fn with_clientdata(mut self, clientdata: Arc<ClientDataStore>) -> Self {
self.clientdata = clientdata;
self
}
/// Attach the shared durable FIFA17 account store.
pub fn with_account(mut self, account: Arc<AccountStore>) -> Self {
self.account = account;
self
}
fn with_sbc_post_commit_fault(mut self, fault: SbcPostCommitFault) -> Self {
self.sbc_post_commit_fault = fault;
self
}
fn sbc_views(&self) -> Result<Vec<fifa17_sbc::ChallengeView>, CoreError> {
let definitions = self.core.list_sbcs()?;
let completions = self.core.sbc_completion_counts()?;
Ok(definitions
.into_iter()
.filter_map(|definition| {
let identity = fifa17_sbc::identity_for_core(&definition.id)?;
Some(fifa17_sbc::ChallengeView {
identity,
name: definition.name,
description: definition.description,
repeatable: definition.repeatable,
times_completed: completions.get(&definition.id).copied().unwrap_or(0),
})
})
.collect())
}
fn sbc_core_ids_from_wire(
&self,
service: &EconomyServices,
wire_ids: &[i64],
) -> Result<Vec<String>, WireResponse> {
let owned: std::collections::HashSet<String> = self
.core
.all_owned()
.map_err(core_sbc_error_response)?
.into_iter()
.map(|item| item.owned_card_id)
.collect();
let mut seen = std::collections::HashSet::with_capacity(wire_ids.len());
let mut core_ids = Vec::with_capacity(wire_ids.len());
for wire_id in wire_ids {
if !seen.insert(*wire_id) {
return Err(json_status(
400,
&json!({ "error": format!("duplicate SBC item id {wire_id}") }),
));
}
let core_id = self.resolver.owned_id_for_wire(*wire_id).ok_or_else(|| {
json_status(
404,
&json!({ "error": format!("unknown SBC item id {wire_id}") }),
)
})?;
if !owned.contains(&core_id) {
return Err(json_status(
404,
&json!({ "error": format!("SBC item {wire_id} is not owned") }),
));
}
self.ensure_sbc_host_eligible(service, &core_id)?;
core_ids.push(core_id);
}
Ok(core_ids)
}
fn ensure_sbc_host_eligible(
&self,
service: &EconomyServices,
core_id: &str,
) -> Result<(), WireResponse> {
let piles = service.piles.clone();
let id = core_id.to_owned();
let pile = service
.bridge
.block_on(async move { piles.get(&id).await })
.map_err(|error| {
json_status(
503,
&json!({ "error": format!("SBC pile eligibility unavailable: {error}") }),
)
})?;
if matches!(pile.as_deref(), Some("purchased" | "unassigned")) {
return Err(json_status(
409,
&json!({ "error": "purchased/unassigned items are not SBC-eligible" }),
));
}
let market = service.market.clone();
let id = core_id.to_owned();
let listed = service
.bridge
.block_on(async move { market.has_active_for_core_item(&id).await })
.map_err(|error| {
json_status(
503,
&json!({ "error": format!("SBC listing eligibility unavailable: {error}") }),
)
})?;
if listed {
return Err(json_status(
409,
&json!({ "error": "actively listed items are not SBC-eligible" }),
));
}
Ok(())
}
fn sbc_wire_ids_from_core(&self, core_ids: &[String]) -> Result<Vec<i64>, WireResponse> {
let owned: std::collections::HashMap<String, CoreOwnedItem> = self
.core
.all_owned()
.map_err(core_sbc_error_response)?
.into_iter()
.map(|item| (item.owned_card_id.clone(), item))
.collect();
core_ids
.iter()
.map(|core_id| {
let item = owned.get(core_id).ok_or_else(|| {
json_status(409, &json!({ "error": "saved SBC squad is stale" }))
})?;
self.resolver
.resolve(item)
.map(|identity| i64::from(identity.item_id))
.ok_or_else(|| {
json_status(
409,
&json!({ "error": "saved SBC item has no FIFA identity" }),
)
})
})
.collect()
}
fn handle_sbc_route(
&self,
service: &EconomyServices,
route: EconomyRoute,
method: &str,
path: &str,
body: &[u8],
) -> WireResponse {
match route {
EconomyRoute::SbcSets => match self.sbc_views() {
Ok(views) => json_status(200, &fifa17_sbc::sets_body(&views)),
Err(error) => core_sbc_error_response(error),
},
EconomyRoute::SbcTag => json_status(200, &json!({})),
EconomyRoute::SbcChallenges => {
let Some(set_id) = ut_tail(path).and_then(sbc_set_id) else {
return json_status(400, &json!({ "error": "invalid SBC set id" }));
};
match self.sbc_views() {
Ok(views) => json_status(200, &fifa17_sbc::challenges_body(set_id, &views)),
Err(error) => core_sbc_error_response(error),
}
}
EconomyRoute::SbcChallengeSquad => {
let Some(challenge_id) = ut_tail(path).and_then(sbc_challenge_id) else {
return json_status(400, &json!({ "error": "invalid SBC challenge id" }));
};
let Some(identity) = fifa17_sbc::identity_for_challenge(challenge_id) else {
return json_status(404, &json!({ "error": "unknown SBC challenge" }));
};
if method.eq_ignore_ascii_case("GET") {
let core_ids = match self.core.load_sbc_squad(identity.core_id) {
Ok(ids) => ids,
Err(error) => return core_sbc_error_response(error),
};
let wire_ids = match self.sbc_wire_ids_from_core(&core_ids) {
Ok(ids) => ids,
Err(response) => return response,
};
return json_status(200, &fifa17_sbc::squad_body(challenge_id, &wire_ids));
}
let wire_ids = match fifa17_sbc::parse_wire_item_ids(body) {
Ok(ids) => ids,
Err(error) => return json_status(400, &json!({ "error": error.to_string() })),
};
let core_ids = match self.sbc_core_ids_from_wire(service, &wire_ids) {
Ok(ids) => ids,
Err(response) => return response,
};
match self.core.save_sbc_squad(identity.core_id, &core_ids) {
Ok(_) => json_status(200, &fifa17_sbc::save_body(challenge_id)),
Err(error) => core_sbc_error_response(error),
}
}
EconomyRoute::SbcChallenge => {
let Some(challenge_id) = ut_tail(path).and_then(sbc_challenge_id) else {
return json_status(400, &json!({ "error": "invalid SBC challenge id" }));
};
let Some(identity) = fifa17_sbc::identity_for_challenge(challenge_id) else {
return json_status(404, &json!({ "error": "unknown SBC challenge" }));
};
if method.eq_ignore_ascii_case("POST") && body.is_empty() {
return json_status(200, &fifa17_sbc::start_body(challenge_id));
}
let core_ids = match fifa17_sbc::parse_wire_item_ids(body) {
Ok(wire_ids) => match self.sbc_core_ids_from_wire(service, &wire_ids) {
Ok(ids) => ids,
Err(response) => return response,
},
Err(fifa17_sbc::SbcWireError::MissingSquad) => {
let ids = match self.core.load_sbc_squad(identity.core_id) {
Ok(ids) => ids,
Err(error) => return core_sbc_error_response(error),
};
let wire_ids = match self.sbc_wire_ids_from_core(&ids) {
Ok(ids) => ids,
Err(response) => return response,
};
match self.sbc_core_ids_from_wire(service, &wire_ids) {
Ok(ids) => ids,
Err(response) => return response,
}
}
Err(error) => return json_status(400, &json!({ "error": error.to_string() })),
};
let result = match self.core.submit_sbc(identity.core_id, &core_ids) {
Ok(result) => result,
Err(error) => return core_sbc_error_response(error),
};
if !result.passed {
return json_status(
400,
&json!({ "error": "SBC requirements not met", "failures": result.failures }),
);
}
for core_id in &core_ids {
let piles = service.piles.clone();
let id = core_id.clone();
if let Err(error) = service
.bridge
.block_on(async move { piles.remove(&id).await })
{
eprintln!(
"utas-host WARN SBC stale pile cleanup core_id={core_id} error={error}"
);
}
}
let credits = match service.econ.balance() {
Ok(balance) => balance,
Err(error) => return core_sbc_error_response(error),
};
let unopened_packs = match service.econ.entitlements() {
Ok(packs) => packs.len() as i64,
Err(error) => return core_sbc_error_response(error),
};
let mut response = json_status(
200,
&fifa17_sbc::submit_body(
challenge_id,
identity.set_id,
credits,
unopened_packs,
),
);
response.transport = self.sbc_post_commit_fault.into();
response
}
_ => json_status(500, &json!({ "error": "invalid SBC route dispatch" })),
}
}
/// Dispatch a FIFA17 economy route to its Rust handler, or `None` if the path
/// is not an economy route (or no economy services are wired). `handle_with_ip`
/// calls this before generic route classification, so matched routes never fall
/// through to Python. Sync handlers run inline; async store/market handlers use
/// the shared runtime bridge.
pub fn try_handle_economy(
&self,
method: &str,
target: &str,
headers: &[(String, String)],
body: &[u8],
client_ip: Option<&str>,
) -> Option<WireResponse> {
let svc = self.economy.as_ref()?;
let _economy_guard = self.economy_gate.lock().unwrap();
let path = target.split('?').next().unwrap_or(target);
let route = classify_economy(method, path)?;
use crate::economy_store::{
handle_pack_open, handle_quick_sell_body, handle_quick_sell_path, handle_store_buy,
CoreItemLookup, QuickSellDeps, StoreDeps,
};
let resp = match route {
EconomyRoute::Credits => handle_credits(svc.econ.as_ref()),
EconomyRoute::PurchaseGroup => {
let sid = header(headers, "x-ut-sid").unwrap_or("");
let mode =
self.sessions
.lock()
.unwrap()
.empty_mypacks_mode(sid, client_ip, self.now());
handle_purchasegroup(svc.econ.as_ref(), mode)
}
EconomyRoute::StoreBuy => {
let mut rng = rand::rngs::StdRng::from_entropy();
let sink = BridgedPurchasedSink {
bridge: svc.bridge.clone(),
piles: svc.piles.clone(),
};
let deps = StoreDeps {
econ: svc.econ.as_ref(),
assets: self.resolver.as_ref(),
entities: self.entities.as_ref(),
pool: svc.pool.as_ref(),
purchased: Some(&sink),
};
handle_store_buy(body, &deps, &mut rng)
}
EconomyRoute::PackOpen => {
let mut rng = rand::rngs::StdRng::from_entropy();
let sink = BridgedPurchasedSink {
bridge: svc.bridge.clone(),
piles: svc.piles.clone(),
};
let deps = StoreDeps {
econ: svc.econ.as_ref(),
assets: self.resolver.as_ref(),
entities: self.entities.as_ref(),
pool: svc.pool.as_ref(),
purchased: Some(&sink),
};
handle_pack_open(body, &deps, &mut rng)
}
EconomyRoute::PackReveal => {
// Async pile membership via the bridge; Core inventory read
// synchronously on this (non-runtime) dispatch thread; pure shape.
let (bridge, piles) = (svc.bridge.clone(), svc.piles.clone());
let purchased_ids: std::collections::HashSet<String> = bridge
.block_on(async move { piles.list_by_pile("purchased").await })
.unwrap_or_default()
.into_iter()
.collect();
let owned = self.core.all_owned().unwrap_or_default();
crate::economy_store::shape_purchased_reveal(
&owned,
&purchased_ids,
self.entities.as_ref(),
self.resolver.as_ref(),
)
}
EconomyRoute::QuickSellPath => {
let id = ut_tail(path)
.and_then(|t| t.strip_prefix("item/"))
.and_then(|d| d.parse::<i64>().ok())?;
let lookup = CoreItemLookup {
core: self.core.as_ref(),
};
let deps = QuickSellDeps {
econ: svc.econ.as_ref(),
reverse: self.resolver.as_ref(),
items: &lookup,
assets: self.resolver.as_ref(),
};
handle_quick_sell_path(id, &deps)
}
EconomyRoute::ConsumableApply => {
// Classification already guaranteed ASCII digits. A value that
// does not fit a FIFA resource id is not one of the known
// contract cards, so it is REFUSED here rather than `?`-ed:
// returning `None` from this function would let a mutation fall
// through to Python, i.e. a second writer.
match ut_tail(path)
.and_then(|t| t.strip_prefix("item/resource/"))
.and_then(|d| d.parse::<u32>().ok())
{
Some(rid) => self.handle_consumable_apply(rid, body, svc.econ.as_ref()),
None => error_response(409, "apply_effect_unproven"),
}
}
EconomyRoute::QuickSellResource => {
// The stack's resource id names a DEFINITION, so pick the owned
// copy deterministically: Core's own order, i.e. the same first
// copy whose wire id the consumables screen already published as
// the stack's `item`. The player therefore sells the card the
// screen showed them. Selling exactly ONE copy is the
// conservative reading of an empty-body request: the screen
// prices a CARD (per-card `discardValue`), so consuming a whole
// stack on one keypress would pay one card's price for N cards.
let rid = ut_tail(path)
.and_then(|t| t.strip_prefix("item/resource/"))
.and_then(|d| d.parse::<i64>().ok())?;
let owned = self.core.all_owned().ok()?;
let wire = owned.iter().find_map(|it| {
self.resolver
.resolve_consumable(it)
.filter(|c| i64::from(c.resource_id) == rid)
.map(|c| i64::from(c.item_id))
});
let Some(wire) = wire else {
eprintln!(
"utas-host owner=RUST route=economy quick-sell-resource \
resource={rid} status=404 outcome=not_owned"
);
return Some(error_response(404, "not_owned"));
};
let lookup = CoreItemLookup {
core: self.core.as_ref(),
};
let deps = QuickSellDeps {
econ: svc.econ.as_ref(),
reverse: self.resolver.as_ref(),
items: &lookup,
assets: self.resolver.as_ref(),
};
eprintln!(
"utas-host owner=RUST route=economy quick-sell-resource \
resource={rid} wire={wire} copies_sold=1"
);
handle_quick_sell_path(wire, &deps)
}
EconomyRoute::QuickSellBody => {
let lookup = CoreItemLookup {
core: self.core.as_ref(),
};
let deps = QuickSellDeps {
econ: svc.econ.as_ref(),
reverse: self.resolver.as_ref(),
items: &lookup,
assets: self.resolver.as_ref(),
};
handle_quick_sell_body(body, &deps)
}
EconomyRoute::MatchCreate => self.handle_match_create(body),
EconomyRoute::MatchReady => self.handle_match_ready(body),
// The id is READ, never taken. Clearing it here looked tidy and was
// a double-credit bug: a replayed `/match/end` then fell through to
// the body fingerprint, which is a DIFFERENT identity from
// `persona:match:<id>`, so Core saw a brand-new match and paid
// again. Measured on staging as a second +75 for one abandoned
// match. Holding the id means a replay reuses the same identity and
// Core's UNIQUE(profile_id, match_identity) refuses it; the next
// `POST …/match` overwrites the slot with a fresh id.
EconomyRoute::MatchEnd => {
let created = *self.current_match.lock();
handle_match_end(svc.econ.as_ref(), self.persona_id, created, body)
}
EconomyRoute::MoveItems => {
let (bridge, piles, resolver, market) = (
svc.bridge.clone(),
svc.piles.clone(),
self.resolver.clone(),
svc.market.clone(),
);
let body = body.to_vec();
bridge.block_on(async move {
crate::market::handle_move_items(
&body,
resolver.as_ref(),
piles.as_ref(),
market.as_ref(),
)
.await
})
}
EconomyRoute::MarketList => {
// Resolve the listed item synchronously (identity + one Core read)
// on the dispatch thread, so the async persist future holds only
// `Send` data (the trait-object resolvers are not `Send`).
let resolved = {
let lookup = CoreItemLookup {
core: self.core.as_ref(),
};
crate::market::resolve_market_list(
body,
self.resolver.as_ref(),
self.resolver.as_ref(),
&lookup,
self.entities.as_ref(),
)
};
let (bridge, market, econ) =
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
let m = method.to_string();
bridge.block_on(async move {
crate::market::handle_market_list(&m, resolved, econ.as_ref(), market.as_ref())
.await
})
}
EconomyRoute::MarketQuery => {
let (bridge, market, econ) =
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
let exp = svc.sold_experiment;
bridge.block_on(async move {
crate::market::handle_market_query(
"active",
econ.as_ref(),
market.as_ref(),
exp,
)
.await
})
}
EconomyRoute::MarketCounts => {
let (bridge, market) = (svc.bridge.clone(), svc.market.clone());
let exp = svc.sold_experiment;
bridge.block_on(async move {
crate::market::handle_market_counts(market.as_ref(), exp).await
})
}
EconomyRoute::MarketStatus => {
let (bridge, market, econ) =
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
let exp = svc.sold_experiment;
// The raw query carries `tradeIds`; `path` is already stripped.
let q = target.split_once('?').map(|(_, q)| q.to_string());
bridge.block_on(async move {
crate::market::handle_market_status(
q.as_deref(),
econ.as_ref(),
market.as_ref(),
exp,
)
.await
})
}
EconomyRoute::MarketBuy => {
let (bridge, market, econ) =
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
let (m, p, body) = (method.to_string(), path.to_string(), body.to_vec());
bridge.block_on(async move {
crate::market::handle_market_buy(&m, &p, &body, econ.as_ref(), market.as_ref())
.await
})
}
EconomyRoute::MarketCancel => {
let (bridge, market) = (svc.bridge.clone(), svc.market.clone());
let (p, owner) = (path.to_string(), client_ip.map(|s| s.to_string()));
bridge.block_on(async move {
crate::market::handle_market_cancel(&p, owner.as_deref(), market.as_ref()).await
})
}
route @ (EconomyRoute::SbcSets
| EconomyRoute::SbcTag
| EconomyRoute::SbcChallenges
| EconomyRoute::SbcChallengeSquad
| EconomyRoute::SbcChallenge) => {
self.handle_sbc_route(svc.as_ref(), route, method, path, body)
}
EconomyRoute::MarketClearSold => {
let (bridge, market) = (svc.bridge.clone(), svc.market.clone());
bridge.block_on(async move {
crate::market::handle_market_clear_sold(market.as_ref()).await
})
}
};
Some(resp)
}
/// 4-arg entrypoint (tests + callers without a peer address). Session-bound
/// routes fall back to a `None` client IP.
pub fn handle(
&self,
method: &str,
target: &str,
headers: &[(String, String)],
body: &[u8],
) -> WireResponse {
self.handle_with_ip(method, target, headers, body, None)
}
/// Route one request to a response. Classification happens here, once, before
/// either branch runs. `client_ip` is the peer address used to bind FIFA
/// session capability (auxiliary to the authoritative X-UT-SID).
pub fn handle_with_ip(
&self,
method: &str,
target: &str,
headers: &[(String, String)],
body: &[u8],
client_ip: Option<&str>,
) -> WireResponse {
let path = target.split('?').next().unwrap_or(target);
// ─────────────────────── Economy authority barrier ───────────────────
// Every economy-touching route is owned by Rust/Core. Classified and
// dispatched HERE, before `classify()`, so a migrated route can NEVER also
// reach the Python passthrough (NEVER BOTH). When the economy services are
// wired (production `from_config`), an economy route ALWAYS returns `Some`
// — fail-closed (503) on any Core error — so there is no Python economy
// fallback. `None` means "not an economy route" (or no economy wired, i.e.
// a bare test server), which falls through to the classifier below.
if let Some(resp) = self.try_handle_economy(method, target, headers, body, client_ip) {
eprintln!(
"utas-host owner=RUST route=economy method={} path={} status={}",
method, path, resp.status
);
return resp;
}
match classify(method, path) {
Route::Club => {
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
let hidden = self.club_hidden_ids();
let active_kits = self.core.get_active_kits().unwrap_or_else(|error| {
eprintln!("utas-host WARN active kit read unavailable: {error}");
CoreKitAssignments::default()
});
let deps = ClubDeps {
core: self.core.as_ref(),
entities: self.entities.as_ref(),
assets: self.resolver.as_ref(),
hidden: &hidden,
active_kits: &active_kits,
};
let (resp, log) = handle_club(query, &deps);
eprintln!(
"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
}
Route::ClubRename => self.handle_club_rename(body),
Route::SquadReplace => {
let deps = self.squad_deps();
let (resp, log) = handle_put_squad(body, &deps);
eprintln!(
"utas-host owner=RUST route=squad-replace status={} outcome={} detail=[{}]",
resp.status, log.outcome, log.detail
);
resp
}
Route::SquadList => {
let deps = self.squad_deps();
let (resp, log) = handle_squad_list(&deps);
eprintln!(
"utas-host owner=RUST route=squad-list status={} outcome={} detail=[{}]",
resp.status, log.outcome, log.detail
);
resp
}
Route::SquadActive => {
let deps = self.squad_deps();
let (resp, log) = handle_squad_active(&deps, self.persona_id);
eprintln!(
"utas-host owner=RUST route=squad-active status={} outcome={} detail=[{}]",
resp.status, log.outcome, log.detail
);
resp
}
Route::UserMassInfo => self.handle_user_mass_info_full(),
Route::Auth => self.handle_auth(method, target, headers, body, client_ip),
Route::AccountSync => self.handle_account_sync(body),
Route::ClientData => self.handle_client_data(method, path, body),
Route::Capability => self.handle_capability(body, client_ip),
Route::StorePurchaseGroup => {
self.handle_store_purchasegroup(method, target, headers, body, client_ip)
}
Route::AccountInfo => {
eprintln!("utas-host owner=RUST route=accountinfo status=200");
json_status(200, &non_economy::accountinfo_body())
}
Route::Settings => {
let commerce = commerce_settings_enabled();
eprintln!("utas-host owner=RUST route=settings status=200 commerce={commerce}");
json_status(200, &non_economy::settings_body(commerce))
}
Route::LeaderboardOptions => {
eprintln!("utas-host owner=RUST route=leaderboards-options status=200");
json_status(200, &non_economy::leaderboard_options_body())
}
Route::MatchReset => {
eprintln!("utas-host owner=RUST route=match-reset status=200");
json_status(200, &non_economy::match_reset_body())
}
Route::ClubStatsStaff => {
eprintln!("utas-host owner=RUST route=club-stats-staff status=200");
json_status(200, &non_economy::club_stats_staff_body())
}
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(),
Route::FeatureOffEmpty => self.handle_feature_off_empty(path),
Route::Season => self.handle_season(path),
Route::ItemDefs => self.handle_item_defs(target),
Route::ViewCards => self.handle_view_cards(target),
Route::MarketData => self.handle_marketdata(path, target),
Route::SecurityQuestion => self.handle_security_question(method, target, headers),
Route::Passthrough => self.passthrough(method, target, headers, body),
}
}
/// Proxy a request verbatim to the Python oracle. Extracted so every route
/// that declines to answer takes EXACTLY this one path, and so the log line
/// naming an unclaimed request has a single home.
fn passthrough(
&self,
method: &str,
target: &str,
headers: &[(String, String)],
body: &[u8],
) -> WireResponse {
let path = target.split('?').next().unwrap_or(target);
// Name the request BEFORE forwarding. On staging the upstream is
// deliberately dead, so this line is the only record of what the
// client asked for -- which is exactly how an unclaimed route is
// discovered (see docs/CLIENT_ROUTE_SURFACE.md).
eprintln!(
"utas-host owner=PYTHON route=passthrough method={method} path={target} body_len={}",
body.len()
);
// The BODY is what identifies an unknown mutation's operands, but
// it is also the one place a request can carry something we should
// not write to a log, so it is opt-in and capped. Staging probe
// only: OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1.
if passthrough_body_logging() && !body.is_empty() {
let cap = body.len().min(512);
eprintln!(
"utas-host PASSTHROUGH-BODY path={target} bytes={} body={}",
body.len(),
String::from_utf8_lossy(&body[..cap])
);
}
let resp = match self.pass.forward(method, target, headers, body) {
Ok(r) => r,
Err(e) => {
eprintln!("utas-host ERROR passthrough failed method={method} path={target}: {e}");
WireResponse {
status: 502,
headers: vec![("Content-Type".to_string(), "application/json".to_string())],
body: br#"{"error":"upstream unavailable"}"#.to_vec(),
transport: ResponseTransport::Normal,
}
}
};
eprintln!(
"utas-host owner=PYTHON_FALLBACK method={} path={} status={}",
method, path, resp.status
);
resp
}
/// Monotonic seconds since server start — the clock for session/pending TTLs.
fn now(&self) -> f64 {
self.start.elapsed().as_secs_f64()
}
/// `POST /ut/auth` — Rust mints the `X-UT-SID`, adopts the persona from the
/// request body (`nucleusPersonaId`/`nuc`, else the configured persona) and
/// opens a Rust session bound to the peer IP. Never proxied to Python. The
/// SID is not an auth gate — only the Rust `SessionStore` consults it — so
/// minting it in Rust is complete. `/ut/delete/auth` is a `{}` logout ack.
fn handle_auth(
&self,
method: &str,
target: &str,
_headers: &[(String, String)],
body: &[u8],
client_ip: Option<&str>,
) -> WireResponse {
let path = target.split('?').next().unwrap_or(target);
if path.starts_with("/ut/delete/auth") {
eprintln!("utas-host owner=RUST route=auth-delete status=200");
return json_status(200, &json!({}));
}
let persona = non_economy::parse_auth_persona(body).unwrap_or(self.persona_id);
let sid = format!(
"OPENFUT-SID-{:016X}",
rand::rngs::StdRng::from_entropy().gen::<u64>()
);
self.sessions.lock().unwrap().open_session(
&sid,
client_ip.map(|s| s.to_string()),
persona,
self.now(),
);
let epoch_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let server_time = non_economy::format_utc_datetime(epoch_secs);
eprintln!(
"utas-host owner=RUST route=auth status=200 method={} ip={:?} persona={} sid_opened=true",
method, client_ip, persona
);
json_status(200, &non_economy::auth_body(&sid, &server_time))
}
/// `PUT …/club` / `PUT|POST …/user/club` — persist the validated club
/// identity in the shared account file. The response class has zero atoms,
/// and the client disconnects on a 4xx, so every input returns `200 {}` just
/// like the oracle; rejected/persistence outcomes remain visible in logs.
fn handle_club_rename(&self, body: &[u8]) -> WireResponse {
let outcome = self.account.rename_from_body(body);
match &outcome {
RenameOutcome::Updated => {
eprintln!("utas-host owner=RUST route=club-rename status=200 outcome=updated")
}
RenameOutcome::Unchanged => {
eprintln!("utas-host owner=RUST route=club-rename status=200 outcome=unchanged")
}
RenameOutcome::Rejected(reason) => eprintln!(
"utas-host owner=RUST route=club-rename status=200 outcome=rejected detail={reason}"
),
RenameOutcome::PersistFailed(error) => eprintln!(
"utas-host ERROR owner=RUST route=club-rename status=200 outcome=persist-failed detail=[{error}]"
),
}
json_status(200, &json!({}))
}
/// `POST /openfut/account/sync` — launcher control-plane account summary. The
/// coins/unopened-pack counts are the AUTHORITATIVE Core economy (balance +
/// entitlements), NEVER Python's stale profile funds. Fail-closed 503 on any
/// Core error — never a Python fallback.
fn handle_account_sync(&self, body: &[u8]) -> WireResponse {
let svc = match &self.economy {
Some(s) => s,
None => {
eprintln!("utas-host owner=RUST route=account-sync status=503 error=no_economy");
return error_response(503, "core_unavailable");
}
};
let (coins, ents) = match (svc.econ.balance(), svc.econ.entitlements()) {
(Ok(c), Ok(e)) => (c, e),
_ => {
eprintln!("utas-host owner=RUST route=account-sync status=503 error=core");
return error_response(503, "core_unavailable");
}
};
let req = non_economy::parse_account_sync(body, self.persona_id);
eprintln!(
"utas-host owner=RUST route=account-sync status=200 persona={} coins={} packs={}",
req.persona_id,
coins,
ents.len()
);
let club = self.account.club();
json_status(
200,
&non_economy::account_sync_body(&req, coins, ents.len(), &club.name, &club.abbr),
)
}
/// `GET/PUT/POST …/clientdata/<key>` — opaque per-user client blob store. GET
/// returns the stored blob for `<persona>:<key>` (or `{}` if never written);
/// PUT/POST parse and store the body under the key and ALWAYS ack `{}`.
fn handle_client_data(&self, method: &str, path: &str, body: &[u8]) -> WireResponse {
let key = ut_tail(path)
.and_then(|t| t.strip_prefix("clientdata/"))
.unwrap_or("");
if method.eq_ignore_ascii_case("GET") {
let blob = self
.clientdata
.get(self.persona_id, key)
.unwrap_or_else(|| json!({}));
eprintln!("utas-host owner=RUST route=clientdata method=GET key={key} status=200");
json_status(200, &blob)
} else {
let val: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
self.clientdata.put(self.persona_id, key, val);
eprintln!(
"utas-host owner=RUST route=clientdata method={method} key={key} status=200 stored=true"
);
json_status(200, &json!({}))
}
}
/// Build the full Rust userMassInfo Value (userInfo + squad + settings +
/// pileSizeClientData) from Core, or a 503 response on Core error. Shared by
/// `GET …/userMassInfo` and `GET …/user` so both agree byte-for-byte.
fn build_user_mass_info(&self) -> Result<(Value, &'static str), WireResponse> {
let svc = match &self.economy {
Some(s) => s,
None => return Err(error_response(503, "core_unavailable")),
};
let (coins, ents) = match (svc.econ.balance(), svc.econ.entitlements()) {
(Ok(c), Ok(e)) => (c, e),
_ => return Err(error_response(503, "core_unavailable")),
};
let deps = self.squad_deps();
let (squad, squad_outcome) = match project_active_squad(&deps) {
HostProjection::Squad(v) => (user_mass_info_squad(v, self.persona_id), "ok"),
HostProjection::Stale => (empty_squad_overlay(self.persona_id), "stale_integrity"),
HostProjection::Missing => (empty_squad_overlay(self.persona_id), "missing_integrity"),
HostProjection::Error(_) => (empty_squad_overlay(self.persona_id), "core_error"),
};
let club = self.account.club();
Ok((
non_economy::user_mass_info_body(
squad,
coins,
ents.len(),
self.persona_id,
&club.name,
&club.abbr,
&club.established,
),
squad_outcome,
))
}
/// `GET …/userMassInfo` — served FULLY from Rust (no Python): the Core squad
/// projection (byte-identical to `GET …/squad/active`) plus the authoritative
/// Core economy (coins + unopened packs). Fail-closed 503 on Core error.
fn handle_user_mass_info_full(&self) -> WireResponse {
match self.build_user_mass_info() {
Ok((body, squad_outcome)) => {
eprintln!(
"utas-host owner=RUST route=userMassInfo status=200 squad_outcome={squad_outcome}"
);
json_status(200, &body)
}
Err(e) => {
eprintln!(
"utas-host owner=RUST route=userMassInfo status={} error=core",
e.status
);
e
}
}
}
/// `GET …/user` — the FUT user profile: `{"userInfo": …}`, the same userInfo
/// object `userMassInfo` embeds (shared builder). Fail-closed 503 on Core error.
fn handle_user(&self) -> WireResponse {
match self.build_user_mass_info() {
Ok((body, _)) => {
let user_info = body.get("userInfo").cloned().unwrap_or_else(|| json!({}));
eprintln!("utas-host owner=RUST route=user status=200");
json_status(200, &json!({ "userInfo": user_info }))
}
Err(e) => {
eprintln!(
"utas-host owner=RUST route=user status={} error=core",
e.status
);
e
}
}
}
/// `POST /openfut/fifa17/capability` — Rust-owned launcher registration (no
/// economy, no proxy). Fail-closed: an unsupported name/version is a 400 that
/// records nothing, so the session stays on the sentinel fallback.
fn handle_capability(&self, body: &[u8], client_ip: Option<&str>) -> WireResponse {
let req = match parse_capability_request(body) {
Ok(r) => r,
Err(()) => {
eprintln!("utas-host owner=RUST route=capability status=400 outcome=unsupported");
return json_status(400, &json!({"error": "unsupported capability"}));
}
};
let persona = req.persona_id.unwrap_or(self.persona_id);
let outcome = self.sessions.lock().unwrap().register_capability(
client_ip.map(|s| s.to_string()),
persona,
req.version,
self.now(),
);
eprintln!(
"utas-host owner=RUST route=capability status=200 ip={:?} persona={} -> {:?}",
client_ip, persona, outcome
);
json_status(200, &json!({"status": "OK"}))
}
/// `GET …/store/purchasegroup…` — proxy to Python for the authoritative economy
/// body (catalogue + owned packs + coins), then overlay ONLY the empty-My-Packs
/// topology from the Rust session mode. Python (which does not know the Rust
/// capability) always emits the 65534 sentinel when My Packs is empty; for a
/// verified clean-v1 SID we strip it. Rust never writes economy state.
fn handle_store_purchasegroup(
&self,
method: &str,
target: &str,
headers: &[(String, String)],
body: &[u8],
client_ip: Option<&str>,
) -> WireResponse {
let mut resp = match self.pass.forward(method, target, headers, body) {
Ok(r) => r,
Err(e) => {
eprintln!("utas-host ERROR purchasegroup proxy to Python failed: {e}");
return error_response(502, "upstream_unavailable");
}
};
let sid = header(headers, "x-ut-sid").unwrap_or("");
// Freeze the session's empty-My-Packs mode at this first store request.
let mode = self
.sessions
.lock()
.unwrap()
.empty_mypacks_mode(sid, client_ip, self.now());
let mut stripped = 0usize;
if (200..300).contains(&resp.status) {
if let Ok(mut root) = serde_json::from_slice::<Value>(&resp.body) {
stripped = overlay_empty_mypacks(&mut root, mode);
if stripped > 0 {
if let Ok(new_body) = serde_json::to_vec(&root) {
set_json_body(&mut resp, new_body);
}
}
}
}
eprintln!(
"utas-host owner=RUST_OVERLAY route=purchasegroup status={} sid={} mode={} sentinel_stripped={}",
resp.status, fifa17_sidlog(sid), mode.as_str(), stripped
);
resp
}
/// `GET/POST/PUT …/phishing/{trusteddevice,question,validate}` — the retired
/// FUT security-question service, owned entirely in Rust (no proxy, no Core).
/// Stateless: the client-transformed answer is never stored or compared, and
/// the trusted-device response is an invariant verified/trusted constant. The
/// `X-UT-SID` session gate mirrors the oracle — an unknown session is a 400
/// `invalid_session`; a malformed 32-hex device id/answer is `malformed_request`.
fn handle_security_question(
&self,
method: &str,
target: &str,
headers: &[(String, String)],
) -> WireResponse {
fn query_param(query: &str, key: &str) -> Option<String> {
query.split('&').find_map(|kv| {
let (k, v) = kv.split_once('=')?;
(k == key).then(|| v.to_string())
})
}
let path = target.split('?').next().unwrap_or(target);
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
let tail = ut_tail(path).unwrap_or("");
let action = non_economy::parse_security_action(tail);
let sid = header(headers, "x-ut-sid").unwrap_or("");
let known = self.sessions.lock().unwrap().session_known(sid);
let device_id = query_param(query, "deviceId").unwrap_or_default();
let question = query_param(query, "question");
let answer = query_param(query, "answer");
let (status, body) = non_economy::security_question_response(
method,
action,
known,
&device_id,
question.as_deref(),
answer.as_deref(),
);
eprintln!(
"utas-host owner=RUST route=security-question status={} action={:?} sid={} known={}",
status,
action,
fifa17_sidlog(sid),
known
);
json_status(status, &body)
}
/// Core owned-instance ids that are NOT part of the CLUB view: the cards with
/// an ACTIVE transfer-market listing. In FIFA a listed card has left the club,
/// so it must not appear in `/club` or the hub's `clubPlayers` tally while it
/// is on sale.
///
/// Keyed on the ACTIVE LISTING, deliberately NOT on the `trade` pile. The pile
/// can hold cards with no listing (a bare "Place on Transfer Market" move, or a
/// listing that was cancelled/sold), and `/tradePile` renders ONLY active
/// listings — so hiding the whole pile would make those cards invisible in BOTH
/// views. Keying on the listing makes visibility self-healing: the moment a
/// listing stops being active the card is back in the club, with no extra
/// transition to maintain and no need to invent an "unlisted transfer-list"
/// wire shape (`tradeState` has no verified spelling for that state).
///
/// Empty when no economy services are wired (bare test server); a market-store
/// read failure degrades to showing everything (never hides inventory silently).
fn club_hidden_ids(&self) -> std::collections::HashSet<String> {
let Some(svc) = self.economy.as_ref() else {
return std::collections::HashSet::new();
};
let (bridge, market) = (svc.bridge.clone(), svc.market.clone());
match bridge.block_on(async move { market.query_listings("active").await }) {
Ok(listings) => listings
.into_iter()
.filter_map(|l| l.core_item_id)
.collect(),
Err(e) => {
eprintln!("utas-host WARN market read failed (club shows all): {e}");
std::collections::HashSet::new()
}
}
}
/// `GET …/hub` — the FUT hub tile counts, owned in Rust (no Python). Derived
/// from authoritative state: `clubPlayers` is the count of owned PLAYER cards
/// in Core that are in the club (actively-listed cards excluded — they have
/// left the club), and the auction / tradePile counts are the user's active
/// listings in the durable market store. `clubPlayers` may be lower than the Python
/// oracle's profile count by exactly the deferred (unnameable Legend)
/// instances — DIFFERENT-BY-DESIGN, since deferred cards are not owned in
/// Core. Fail-closed on Core error (503); a market-store read failure degrades
/// the cosmetic listing counts to 0.
fn handle_hub(&self) -> WireResponse {
let hidden = self.club_hidden_ids();
let club_players = match self.core.all_owned() {
Ok(items) => items
.iter()
.filter(|it| !hidden.contains(&it.owned_card_id))
.filter(|it| self.resolver.kind_of(it) == ContentKind::Player)
.count(),
Err(e) => {
eprintln!("utas-host owner=RUST route=hub status=503 error=core:{e}");
return error_response(503, "core_unavailable");
}
};
let active = match &self.economy {
Some(svc) => {
let market = svc.market.clone();
match svc
.bridge
.block_on(async move { market.query_listings("active").await })
{
Ok(l) => l.len(),
Err(e) => {
eprintln!("utas-host WARN hub market read failed (counts=0): {e}");
0
}
}
}
None => 0,
};
let body = json!({
"clubPlayers": club_players,
"auctionCount": active,
"tradePile": { "count": active, "selling": active, "sold": 0 },
});
eprintln!(
"utas-host owner=RUST route=hub status=200 clubPlayers={club_players} auctionCount={active}"
);
json_status(200, &body)
}
/// Rust-owned UNCONDITIONAL static acks — `store` eligibility gate, match
/// keepalive, captcha, tfa, livemessage, activeMessage. Byte-identical to the
/// Python oracle's constant responses (no flag gating), so no Python.
fn handle_static_ack(&self, path: &str) -> WireResponse {
let tail = ut_tail(path).unwrap_or("");
let resp = match tail {
"store" => json_status(200, &json!({ "result": "SUCCESS" })),
"match/keepalive" => WireResponse {
status: 204,
headers: Vec::new(),
body: Vec::new(),
transport: ResponseTransport::Normal,
},
"captcha" => json_status(
200,
&json!({ "encodedImg": "", "sequence": 0, "sizeBeforeEncode": 0 }),
),
// tfa, livemessage, activeMessage
_ => json_status(200, &json!({})),
};
eprintln!(
"utas-host owner=RUST route=static-ack tail={} status={}",
tail, resp.status
);
resp
}
/// `…/watchList` — transfer watch list. The oracle persists no watches, so
/// add/remove (PUT/POST/DELETE) is a bare `{}` ack; GET returns an empty watch
/// list with the authoritative Core credits. Best-effort credits (0 on Core
/// error) — a cosmetic balance echo, not the authoritative wallet.
fn handle_watchlist(&self, method: &str) -> WireResponse {
if !method.eq_ignore_ascii_case("GET") {
eprintln!("utas-host owner=RUST route=watchlist method={method} status=200");
return json_status(200, &json!({}));
}
let credits = self
.economy
.as_ref()
.and_then(|s| s.econ.balance().ok())
.unwrap_or(0);
eprintln!("utas-host owner=RUST route=watchlist method=GET status=200 credits={credits}");
json_status(
200,
&json!({ "auctionInfo": [], "credits": credits, "total": 0 }),
)
}
/// `…/match` — FutCreateMatch, and FutPlayGame on the SAME path.
///
/// The two are discriminated by the body, exactly as the client serializes
/// them: an operation on an EXISTING match carries an integer `matchId`,
/// a create does not. FutPlayGame's response parses no fields at all, so it
/// is a bare `{}` ack and must NOT mint a second id for a match already in
/// flight.
///
/// The create response deliberately omits `squad` (atom 717): it is a nested
/// value the client only half-reads, which is the documented freeze mode,
/// and nothing needs it.
fn handle_match_create(&self, body: &[u8]) -> WireResponse {
let parsed: Option<Value> = serde_json::from_slice(body).ok();
let existing = parsed
.as_ref()
.and_then(|v| v.get("matchId"))
.and_then(Value::as_i64);
if let Some(match_id) = existing {
eprintln!("utas-host owner=RUST route=match-play id={match_id} status=200");
return json_status(200, &json!({}));
}
// A fresh core id per create: `resolve_or_allocate` is idempotent per
// core id, so reusing one would hand back the previous match's id and
// merge two matches into a single economic identity.
let core_id = format!(
"{}:match:{}",
self.persona_id,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let Some(id) = self.resolver.allocate_match_id(&core_id) else {
// Fail closed: without an id there is no exactly-once key, and a
// match played without one would either double-credit or not credit.
eprintln!("utas-host ERROR route=match-create status=503 identity_alloc_failed");
return error_response(503, "match_id_unavailable");
};
*self.current_match.lock() = Some(id);
let start_epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
eprintln!("utas-host owner=RUST route=match-create id={id} status=200");
json_status(200, &match_wire::create_response(id, start_epoch))
}
/// `…/match/ready` — FutMatchReady. Two scalars, zero economy.
///
/// The opponent persona is echoed from the request when present and is
/// otherwise `0`: an offline AI opponent has no persona, and defaulting to
/// the user's own would claim they are their own opponent.
fn handle_match_ready(&self, body: &[u8]) -> WireResponse {
let parsed: Option<Value> = serde_json::from_slice(body).ok();
let field = |key: &str| {
parsed
.as_ref()
.and_then(|v| v.get(key))
.and_then(Value::as_i64)
};
let match_id = field("matchId")
.or_else(|| *self.current_match.lock())
.unwrap_or(0);
let opponent = field("opponentPersonaId").unwrap_or(0);
eprintln!("utas-host owner=RUST route=match-ready id={match_id} status=200");
json_status(200, &match_wire::ready_response(match_id, opponent))
}
/// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and the
/// club-identity read service are off, so return `{}` (parity with the
/// flag-off Python oracle). Club rename is handled separately by
/// [`Server::handle_club_rename`].
fn handle_feature_off_empty(&self, path: &str) -> WireResponse {
let tail = ut_tail(path).unwrap_or("");
eprintln!("utas-host owner=RUST route=feature-off-empty tail={tail} status=200");
json_status(200, &non_economy::feature_off_body())
}
/// `…/season…` — FIFA 17 offline Seasons.
///
/// The client will not open the mode until it has a schedule: `season/list`
/// must carry a NON-EMPTY `matches` array or `StartSeason` dereferences NULL
/// (`CardsDLL+0xfc5b5`). Shapes live in the adapter; this only routes.
///
/// A season is currently a fixed division-10 ladder at round 1 — the client
/// renders and starts from that. Persisting progress across matches is a
/// separate piece of work, so the PUT that stores season state is
/// acknowledged (`{}`, which is what the retail wire answers) without
/// pretending the round advanced.
fn handle_season(&self, path: &str) -> WireResponse {
const SEASON_ID: i64 = 1;
const DIVISION_ID: i64 = 10;
let tail = ut_tail(path).unwrap_or("");
let sub = tail
.strip_prefix("season")
.unwrap_or("")
.trim_start_matches('/');
let (kind, body) = match sub {
"list" => (
"list",
season_wire::season_list_body(SEASON_ID, DIVISION_ID),
),
"user" => (
"user",
season_wire::season_user_body(SEASON_ID, DIVISION_ID, 1, 0),
),
s if s.starts_with("user/history") => ("history", season_wire::season_history_body()),
// Bare `season`, the state-storing PUT, and anything not yet
// reversed: an empty object, exactly as before. Logged with its tail
// so an unhandled sub-path is visible rather than silent.
_ => ("ack", String::from("{}")),
};
eprintln!("utas-host owner=RUST route=season kind={kind} tail={tail} status=200");
json_text_status(200, body)
}
/// `GET …/item/resource`, `…/defid` — FUT item-definition lookup. Builds
/// `{itemData:[…]}` for every integer id (≥ 3 digits) in the query, mirroring
/// the oracle's `defs_route` (`re.findall(r"\d{3,}")` over the raw query).
fn handle_item_defs(&self, target: &str) -> WireResponse {
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
let ids = extract_long_ints(query);
eprintln!(
"utas-host owner=RUST route=item-defs count={} status=200",
ids.len()
);
json_status(200, &non_economy::item_defs_body(&ids))
}
/// `GET …/item?idList=…` — FutViewCards.
///
/// The ids here are OWNED INSTANCE ids, not definition ids. The client builds
/// the query as `?idList=%lld` (CardsDLL `.rdata` `0x220080`) from ids it
/// already holds, and reads the returned items' real fields back.
///
/// This is on the active-kit path. The FUT "Assign Kit" popup
/// (`external.ion_fut.components.KitAssignmentPopup`, recovered from
/// `KitAssignmentPopup.BIG`) drives `OSDKCards_ViewCards` and
/// `OSDKCards_ActivateCard`, tracks `mHomeKitID`/`mAwayKitID`, and filters its
/// local card inventory by `SEARCH_STATE_ACTIVE_HOME_KIT` /
/// `SEARCH_STATE_ACTIVE_AWAY_KIT`. Those states can only come from the
/// `itemState` this route returns.
///
/// THE DEFECT THIS FIXES: answering with the definition body
/// ([`Self::handle_item_defs`]) echoes the queried id back as `resourceId` and
/// emits `cardsubtypeid: 0`, `itemType: "player"`, `itemState: "free"`. Asked
/// about the active home kit (instance 100004874) the server replied that it
/// was a free player — so no kit can ever be seen as active. Measured on
/// staging 2026-08-23.
///
/// Owned instances are shaped by the SAME projector `/club` uses, so a kit
/// carries `resourceId` 6300006, `cardsubtypeid` 9, `itemState`
/// `activeHomeKit`, `teamid`, `category` and `year` exactly as it does there —
/// one shaping, no second wire dialect to drift.
///
/// Ids that are NOT owned instances fall back to the definition placeholder,
/// preserving the oracle's behaviour for definition-style queries and for the
/// empty query (`{"itemData": []}`).
fn handle_view_cards(&self, target: &str) -> WireResponse {
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
let ids = extract_long_ints(query);
if ids.is_empty() {
eprintln!("utas-host owner=RUST route=view-cards count=0 owned=0 status=200");
return json_status(200, &json!({ "itemData": [] }));
}
// Shape the whole owned set once with the club projector, then select the
// requested instances from it. Selecting first is not possible: the wire
// instance id is assigned BY the projector, so there is nothing to match
// against until the items are shaped.
let wanted: std::collections::HashSet<i64> = ids.iter().copied().collect();
let mut owned: Vec<Value> = Vec::new();
if let Ok(page) = self.core.query_owned(&[]) {
let hidden = self.club_hidden_ids();
let active_kits = self.core.get_active_kits().unwrap_or_default();
let visible: Vec<CoreOwnedItem> = page
.items
.into_iter()
.filter(|item| !hidden.contains(&item.owned_card_id))
.collect();
let (body, _) = shape_club_response_with_kits(
&visible,
self.entities.as_ref(),
self.resolver.as_ref(),
ActiveKitAssignments {
home: active_kits.home_owned_card_id.as_deref(),
away: active_kits.away_owned_card_id.as_deref(),
},
);
owned = body
.get("itemData")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter(|it| {
it.get("id")
.and_then(Value::as_i64)
.is_some_and(|id| wanted.contains(&id))
})
.cloned()
.collect()
})
.unwrap_or_default();
}
let found: std::collections::HashSet<i64> = owned
.iter()
.filter_map(|it| it.get("id").and_then(Value::as_i64))
.collect();
let missing: Vec<i64> = ids.into_iter().filter(|id| !found.contains(id)).collect();
let mut items = owned;
if !missing.is_empty() {
if let Some(defs) = non_economy::item_defs_body(&missing)
.get("itemData")
.and_then(Value::as_array)
{
items.extend(defs.iter().cloned());
}
}
eprintln!(
"utas-host owner=RUST route=view-cards count={} owned={} defs={} status=200",
items.len(),
found.len(),
missing.len()
);
json_status(200, &json!({ "itemData": items }))
}
/// `POST …/item/resource/<resourceId>` — apply one consumable to one owned
/// target (`ApplyCardByRes`, task id `0x0e`), live-captured 2026-08-21 as
/// `{"apply":[{"id":<target>}]}`.
///
/// The MUTATION is Core's: it destroys the source instance and raises the
/// target's contracts in one transaction. The FORMULA is the caller's, which
/// is this: the number of matches granted is selected by the TARGET's rating
/// tier, not by the consumable's own tier, and the table is not monotonic, so
/// it can only be looked up ([`contract_grant`]) — never interpolated.
///
/// Only the CONTRACT family is served, in both halves: subtype 201 to a
/// player, 202 to a manager. Each half takes the TARGET's tier from where
/// that kind's rating actually lives — a player's from Core's `overall`, a
/// manager's from Core's `source_rating` (EA's staff `value`, which the client
/// itself re-rates from) — and refuses when the number is absent rather than
/// defaulting a tier. Everything else fails closed. A 200-and-do-nothing here
/// is precisely the defect this route was claimed to end: the Python oracle
/// maps `item/resource` method-agnostically to its definition route, so an
/// unclaimed apply returns a definition list, consumes nothing, and the
/// client reports success.
///
/// RESPONSE SHAPE, from static RE rather than convenience: the apply
/// completion handler (CardsDLL `0x180035520`) tests exactly one field,
/// `[obj+0x1c]`, and raises `EVENT_CARDS_APPLY_CARD_SUCCESS` when it is zero,
/// `EVENT_CARDS_APPLY_CARD_FAILURE` otherwise. It never inspects the body —
/// unlike the move ack (`0x180128600`), which builds per-item verdict records
/// and fails on an EMPTY vector. The response object's constructor
/// (`0x1800a4ce0`) initialises its record vector EMPTY, so `{"itemData":[]}`
/// is a legal parse result, and it is what the live client accepted.
fn handle_consumable_apply(
&self,
resource_id: u32,
body: &[u8],
econ: &dyn CoreEconomy,
) -> WireResponse {
let targets = parse_apply_targets(body);
// `apply` is an ARRAY, but only len == 1 has ever been observed. Batch
// semantics (atomic? partial? one source per target?) are unknown, and a
// consumable application is unreversed, so a multi-target request is
// reported and refused rather than guessed at.
if targets.len() != 1 {
eprintln!(
"utas-host owner=RUST route=economy consumable-apply status=400 \
resource={resource_id} targets={} outcome=apply_batch_unsupported",
targets.len()
);
return error_response(400, "apply_batch_unsupported");
}
let target_wire = targets[0];
let owned = match self.core.all_owned() {
Ok(o) => o,
Err(e) => {
eprintln!(
"utas-host ERROR route=economy consumable-apply status=503 \
resource={resource_id} wire={target_wire} err={e:?}"
);
return error_response(503, "core_unavailable");
}
};
// The path's resource id names a DEFINITION, so pick the owned copy the
// same deterministic way quick-sell does: the FIRST matching copy in
// Core's own order, which is the copy whose wire id the consumables
// screen already published as the stack's `item`. The card consumed is
// therefore the one the screen showed the player.
let source = owned.iter().find_map(|it| {
self.resolver
.resolve_consumable(it)
.filter(|c| c.resource_id == resource_id)
.map(|c| (it, c))
});
let Some((source_item, source_ident)) = source else {
eprintln!(
"utas-host owner=RUST route=economy consumable-apply status=404 \
resource={resource_id} wire={target_wire} outcome=not_owned"
);
return error_response(404, "not_owned");
};
// Two families are proven far enough to apply: CONTRACT (both halves)
// and attribute TRAINING. Fitness, healing, position, play-style,
// manager-league and the two SQUAD training cards are not, and answering
// 200 while changing nothing is the exact failure this route was claimed
// to end.
//
// Training resolves through the adapter's reversed subtype table, so a
// card whose magnitude is missing or whose subtype is squad-scoped
// yields `None` here and falls into the same refusal as an unreversed
// family.
let subtype = source_ident.subtype;
let training = training_effect(subtype, source_ident.amount);
let is_contract = subtype == PLAYER_CONTRACT_SUBTYPE || subtype == MANAGER_CONTRACT_SUBTYPE;
if !is_contract && training.is_none() {
eprintln!(
"utas-host owner=RUST route=economy consumable-apply status=409 \
resource={resource_id} wire={target_wire} subtype={subtype} \
outcome=apply_effect_unproven"
);
return error_response(409, "apply_effect_unproven");
}
// Reverse the target's wire id through the identity store — never a
// guess, and never the wire id itself. Both contract families resolve
// their target the same way; only the legal target KIND and the source of
// its tier differ, so the resolution is shared and the branch is below.
let target = self
.resolver
.owned_id_for_wire(target_wire)
.and_then(|core_id| {
owned
.iter()
.find(|it| it.owned_card_id == core_id)
.map(|it| (core_id, it))
});
let Some((target_core_id, target_item)) = target else {
eprintln!(
"utas-host owner=RUST route=economy consumable-apply status=404 \
resource={resource_id} wire={target_wire} outcome=not_owned"
);
return error_response(404, "not_owned");
};
let target_kind = self.resolver.kind_of(target_item);
// TRAINING resolves its whole effect here and skips the contract tier
// machinery entirely: a training card's magnitude is authored on the CARD
// (`fcc_trainingcards.amount`), not selected by the target's tier the way
// a contract grant is.
if let Some(t) = training {
// Attribute training writes an attribute slot, and only a player has
// attributes. Staff, club items and consumables have none, so this is
// a refusal rather than a write to a slot that means nothing.
if target_kind != ContentKind::Player {
eprintln!(
"utas-host owner=RUST route=economy consumable-apply status=409 \
resource={resource_id} wire={target_wire} target_kind={} \
outcome=training_target_not_a_player",
target_kind.as_str()
);
return error_response(409, "training_target_not_a_player");
}
// A keeper's six slots are DIV/HAN/KIC/REF/SPD/POS and an
// outfielder's are PAC/SHO/PAS/DRI/DEF/PHY. The slot number is the
// same; what it MEANS is not. Applying a GK card to an outfielder
// would silently train a different attribute from the one on the
// card, which is precisely the invisible corruption this gate exists
// to stop.
if !class_accepts_position(t.class, &target_item.position) {
eprintln!(
"utas-host owner=RUST route=economy consumable-apply status=409 \
resource={resource_id} wire={target_wire} subtype={subtype} \
target_position={} outcome=training_target_class_mismatch",
target_item.position
);
return error_response(409, "training_target_class_mismatch");
}
// The ceiling is per-family: 15 for a single attribute, 10 for the
// rare all-six card. Sending the single-attribute ceiling for an
// all-six card would let Core accept a +15 all-six boost that EA
// never authored.
let effect = ApplyEffect::ApplyTraining {
attribute_index: t.attribute_index,
amount: t.amount,
max_amount: ceiling_for(&t),
};
return self.finish_consumable_apply(
econ,
source_item,
target_item,
&target_core_id,
target_kind,
effect,
resource_id,
target_wire,
);
}
// The grant COLUMN is the TARGET's tier, and each family reads it from a
// different place because the two target kinds store their rating
// differently. Gate the legal kind first, then take the tier.
let tier = if subtype == PLAYER_CONTRACT_SUBTYPE {
// Subtype 201 is the PLAYER contract; the client's own family gating
// sends manager contracts (202) to staff. A player contract on a
// non-player has no proven effect at all.
if target_kind != ContentKind::Player {
eprintln!(
"utas-host owner=RUST route=economy consumable-apply status=409 \
resource={resource_id} wire={target_wire} target_kind={} \
outcome=contract_target_not_a_player",
target_kind.as_str()
);
return error_response(409, "contract_target_not_a_player");
}
// A player's rating IS Core's `overall`, so the card ladder reads it.
tier_for_rating(target_item.rating)
} else {
// Subtype 202 is the MANAGER contract, and a MANAGER specifically:
// all five staff families share `ContentKind::Staff`, so the kind
// alone would let a head coach, fitness coach, physio or GK coach
// through. Only `cardsubtypeid` 4 is the squad manager.
let target_subtype = self.resolver.subtype_of(target_item);
if target_subtype != MANAGER_SUBTYPE {
eprintln!(
"utas-host owner=RUST route=economy consumable-apply status=409 \
resource={resource_id} wire={target_wire} target_kind={} \
target_subtype={target_subtype} outcome=contract_target_not_a_manager",
target_kind.as_str()
);
return error_response(409, "contract_target_not_a_manager");
}
// A manager's authoritative rating is EA's `value` column, which Core
// carries as `source_rating`; its `overall` is deliberately 0 for a
// non-player because that number feeds pricing. Reading `overall`
// here would score every gold manager as bronze.
let Some(tier) = staff_tier(target_item.source_rating) else {
// FAIL CLOSED: an un-imported manager has no honest tier, and
// inventing one would silently grant the wrong number of matches,
// irreversibly.
eprintln!(
"utas-host owner=RUST route=economy consumable-apply status=409 \
resource={resource_id} wire={target_wire} target_kind={} \
outcome=manager_tier_unknown reason=core_carries_no_source_rating",
target_kind.as_str()
);
return error_response(409, "manager_tier_unknown");
};
tier
};
let Some(granted) = contract_grant(resource_id, tier) else {
// The subtype said "contract card" while the resource id is not one
// of the 13 known rows: a catalog inconsistency, not a licence to
// substitute a neighbouring row's number.
eprintln!(
"utas-host owner=RUST route=economy consumable-apply status=409 \
resource={resource_id} wire={target_wire} rating={} tier={} \
outcome=apply_effect_unproven reason=resource_not_a_contract_card",
target_item.rating,
tier.as_str()
);
return error_response(409, "apply_effect_unproven");
};
// The tier that selected the grant is contract-only, so it is logged
// here rather than in the shared tail.
eprintln!(
"utas-host owner=RUST route=economy consumable-apply resource={resource_id} \
wire={target_wire} subtype={subtype} tier={} granted={granted}",
tier.as_str()
);
let effect = ApplyEffect::AddContractMatches {
amount: granted,
cap: CONTRACT_MATCH_CAP,
default_when_unset: PACK_FRESH_CONTRACT_MATCHES,
};
self.finish_consumable_apply(
econ,
source_item,
target_item,
&target_core_id,
target_kind,
effect,
resource_id,
target_wire,
)
}
/// The half of an apply that is identical for every proven family: the
/// exactly-once key, Core's atomic transaction, and the client's answer.
///
/// Extracted so a new family cannot accidentally acquire its own idempotency
/// key format, its own error mapping, or its own success payload — the three
/// places where a second implementation would silently diverge from the one
/// the client was proven against.
#[allow(clippy::too_many_arguments)]
fn finish_consumable_apply(
&self,
econ: &dyn CoreEconomy,
source_item: &CoreOwnedItem,
target_item: &CoreOwnedItem,
target_core_id: &str,
target_kind: ContentKind,
effect: ApplyEffect,
resource_id: u32,
target_wire: i64,
) -> WireResponse {
// A successful apply DESTROYS the source instance, so a genuine second
// application necessarily names a different source id, while a transport
// retry of the same logical action replays this exact key and Core
// mutates nothing. FIFA 17 consumables are separate owned instances
// rather than `quantity` stacks — the consumables screen groups them for
// display only — so the source instance id is the honest per-action key.
let action_identity = format!(
"fifa17:apply:{}->{}",
source_item.owned_card_id, target_core_id
);
// Core's `require_kind` compares against ITS OWN token, not the FIFA
// catalog's: Core calls the squad manager `manager` where the catalog says
// `staff` + subtype 4, so sending the catalog kind would make Core refuse
// every manager apply. Use the token Core itself published for this
// instance, falling back to the catalog kind only when Core sent none.
let core_kind = target_item
.core_content_kind
.as_deref()
.unwrap_or_else(|| target_kind.as_str());
let req = ConsumableApplyRequest {
action_identity: &action_identity,
source_owned_card_id: &source_item.owned_card_id,
target_owned_card_id: target_core_id,
target_kind: core_kind,
effect,
};
let outcome = match econ.apply_consumable(&req) {
Ok(o) => o,
Err(e) => {
// Fail closed. NEVER a Python fallback: the oracle would answer
// 200 from its definition route and the player would be told an
// effect was applied that nothing recorded.
//
// Core's DETERMINISTIC refusals are passed through with their own
// status rather than collapsed into 503. A loan target, a kind
// mismatch, or a slot that already carries training will never
// succeed on retry, and 503 means "try again later" — reporting
// one as the other invites the client to re-send a request that
// cannot ever be accepted. 404 is reachable only when the source
// vanishes between our `all_owned` read and Core's transaction
// (the losing side of a concurrent double-submit), which is
// likewise permanent for that request.
let (status, code) = match e {
CoreError::Status(400) => (400, "apply_refused"),
CoreError::Status(404) => (404, "not_owned"),
CoreError::Status(409) => (409, "apply_refused"),
_ => (503, "core_unavailable"),
};
eprintln!(
"utas-host ERROR route=economy consumable-apply status={status} \
resource={resource_id} wire={target_wire} err={e:?}"
);
return error_response(status, code);
}
};
eprintln!(
"utas-host owner=RUST route=economy consumable-apply resource={resource_id} \
wire={target_wire} granted={} before={} after={} applied={} \
source_destroyed={}",
outcome.granted,
outcome.before,
outcome.after,
outcome.applied,
outcome.source_destroyed
);
json_text_status(200, "{\"itemData\":[]}".to_string())
}
/// `GET …/marketdata[/pricelimits]` — suggested pricing. `/pricelimits` returns
/// the bare ARRAY (one band per queried defId); any other `/marketdata` returns
/// the OBJECT band. Container type is chosen from the path (load-bearing: the
/// wrong one froze a live client).
fn handle_marketdata(&self, path: &str, target: &str) -> WireResponse {
if path.ends_with("/pricelimits") {
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
let ids = extract_defid_param(query);
eprintln!(
"utas-host owner=RUST route=marketdata kind=pricelimits count={} status=200",
ids.len()
);
json_status(200, &non_economy::marketdata_pricelimits_body(&ids))
} else {
eprintln!("utas-host owner=RUST route=marketdata kind=object status=200");
json_status(200, &non_economy::marketdata_object_body())
}
}
/// `GET …/club/stats/<mode>` — the MY CLUB stat set, computed Core-accurately
/// in Rust (no Python). Player tiers + rare from the Core collection,
/// staff/consumable families from the catalog kind+subtype, and per-context
/// buckets keyed by the screen's field: nation (year/consumables/club/…),
/// league for `country/<id>`, team for `league/<id>`. Fail-closed 503 on Core.
fn handle_club_stats(&self, path: &str) -> WireResponse {
let mode = ut_tail(path)
.and_then(|t| t.strip_prefix("club/stats/"))
.and_then(|rest| rest.split('/').next())
.unwrap_or("");
// The URL says which SCREEN: `country/<id>` lists LEAGUES (leagueId
// buckets), `league/<id>` lists TEAMS (teamid buckets); all others render
// the default screen (nation buckets). Mirrors fut_club_stats.stats_body.
let ctx = match mode {
"country" => ContextField::League,
"league" => ContextField::Team,
_ => ContextField::Nation,
};
let owned = match self.core.all_owned() {
Ok(o) => o,
Err(e) => {
eprintln!("utas-host owner=RUST route=club-stats status=503 error=core:{e}");
return error_response(503, "core_unavailable");
}
};
let items: Vec<ClubStatInput> = owned
.iter()
.map(|it| ClubStatInput {
kind: self.resolver.kind_of(it),
subtype: self.resolver.subtype_of(it),
rating: it.rating as i64,
rare: self.resolver.rareflag_of(it) != 0,
asset_id: self.resolver.asset_id_of(it),
nation_id: self.entities.nation_id(&it.nation).map(|n| n as i64),
league_id: self.entities.league_id(&it.league).map(|n| n as i64),
// A kit carries its own team in the kit table; every other kind
// inherits the owning player's club from the entity tables.
team_id: self
.resolver
.kit_team_id_of(it)
.or_else(|| self.entities.team_id(&it.club).map(|n| n as i64)),
})
.collect();
let players = items
.iter()
.filter(|i| matches!(i.kind, ContentKind::Player))
.count();
eprintln!(
"utas-host owner=RUST route=club-stats mode={} ctx={:?} status=200 owned={} players={}",
mode,
ctx,
items.len(),
players
);
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)?;
eprintln!("utas-host listening on {addr}");
self.serve_listener(listener);
Ok(())
}
/// Accept loop on an already-bound listener (lets tests bind an ephemeral
/// port and learn it before serving).
pub fn serve_listener(&self, listener: TcpListener) {
for stream in listener.incoming() {
let stream = match stream {
Ok(s) => s,
Err(_) => continue,
};
let server = self.clone();
std::thread::spawn(move || server.handle_conn(stream));
}
}
fn handle_conn(&self, stream: TcpStream) {
let mut reader = BufReader::new(match stream.try_clone() {
Ok(s) => s,
Err(_) => return,
});
let mut writer = stream;
let peer_ip = writer.peer_addr().ok().map(|a| a.ip().to_string());
loop {
match read_request(&mut reader) {
Ok(Some(req)) => {
let resp = self.handle_with_ip(
&req.method,
&req.target,
&req.headers,
&req.body,
peer_ip.as_deref(),
);
let close = match write_response(&mut writer, &resp) {
Ok(close) => close,
Err(_) => return,
};
if close || req.close {
return;
}
}
Ok(None) => return, // clean EOF
Err(_) => return,
}
}
}
}
// ─────────────────────── Session/capability route helpers ───────────────────
/// Case-insensitive header lookup.
fn header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
/// A short, non-secret tag for correlating a session in logs (last 6 chars).
fn fifa17_sidlog(sid: &str) -> String {
if sid.is_empty() {
"-".to_string()
} else {
format!("\u{2026}{}", &sid[sid.len().saturating_sub(6)..])
}
}
/// Whether `GET …/settings` should turn the client's commerce flags on.
///
/// OFF unless `OPENFUT_FIFA17_COMMERCE_SETTINGS=1`, because the flags are
/// recovered but UNTESTED and the empty config list is the live-proven body. The
/// house rule is that a flag defaults to the live-proven value.
///
/// Turning it on is the SERVER half of the transfer-list fix — the client's
/// `tradingEnabled` gate defaults to 0 and nothing has ever sent it. It needs a
/// launch to confirm, and it does not promise the market behind the menu works.
fn commerce_settings_enabled() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_COMMERCE_SETTINGS").as_deref() == Ok("1"))
}
/// Wire ids from a consumable-apply body: `{"apply":[{"id":N}, …]}`.
///
/// Captured live 2026-08-21. Only `len == 1` has ever been observed; the caller
/// refuses anything else rather than invent batch semantics.
fn parse_apply_targets(body: &[u8]) -> Vec<i64> {
serde_json::from_slice::<serde_json::Value>(body)
.ok()
.and_then(|v| v.get("apply").and_then(|a| a.as_array()).cloned())
.map(|a| {
a.iter()
.filter_map(|e| e.get("id").and_then(|i| i.as_i64()))
.collect()
})
.unwrap_or_default()
}
/// Whether to log unclaimed (passthrough) request BODIES.
///
/// OFF unless `OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1`, and capped at 512 bytes.
/// A body is what names an unknown mutation's operands -- it is how the
/// consumable-apply payload was recovered -- but it is also the one place a
/// request could carry something that should not reach a log, so it is opt-in
/// and intended for staging probes only.
fn passthrough_body_logging() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED
.get_or_init(|| std::env::var("OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY").as_deref() == Ok("1"))
}
/// Whether quick-sell pricing uses the client's own `fcc_discardcoins` table
/// instead of the legacy rating-only ladder.
///
/// OFF unless `OPENFUT_FIFA17_DISCARD_TABLE=1`. The table is the higher-fidelity
/// answer — it is the client's own data, reproduces its formula
/// `round_half_up(rating * price / 100)`, and was verified against 22 live club
/// items, 22 of 22 exact — but the ladder is what the DEPLOYED economy has been
/// paying, and switching revalues an existing club in BOTH directions (a
/// level-3 TOTW special goes 1500 -> 10980; a 50-rated bronze common goes
/// 150 -> 15). That is an operator's decision, not a silent upgrade, so the
/// house rule applies: the flag defaults to the deployed value.
///
/// It gates the wire and the wallet TOGETHER. `discardValue` is what the client
/// displays, and [`ItemIdentityResolver::discard_value`] is the single source
/// for both the shaped card and the coins credited on sale, so the two can never
/// disagree in either mode.
fn discard_table_enabled() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_DISCARD_TABLE").as_deref() == Ok("1"))
}
/// Answer `club?type=equippables` with a KITS-ONLY body.
///
/// Default OFF. The multi-family form of this response crashed the client on
/// 2026-08-05, so serving it at all is an experiment: the narrow two-kit body is
/// a hypothesis about why the pre-match kit selector reports every kit locked,
/// not a response we have established as safe. Flip the env var off to revert
/// with a restart and no rebuild.
fn equippables_enabled() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_EQUIPPABLES").as_deref() == Ok("1"))
}
/// A JSON response with an explicit status.
fn json_status(status: u16, v: &Value) -> WireResponse {
let body = serde_json::to_vec(v).unwrap_or_default();
WireResponse {
status,
headers: vec![
("Content-Type".to_string(), "application/json".to_string()),
("Content-Length".to_string(), body.len().to_string()),
],
body,
transport: ResponseTransport::Normal,
}
}
/// A JSON response from ALREADY-SERIALISED wire text.
///
/// Use when key ORDER is part of the contract. `Value` is a `BTreeMap` here
/// (no `preserve_order` feature), so routing a body through it silently
/// re-sorts keys alphabetically — which breaks the FIFA 17 season element,
/// whose parser needs `type` before `divisionId`.
fn json_text_status(status: u16, body: String) -> WireResponse {
let body = body.into_bytes();
WireResponse {
status,
headers: vec![
("Content-Type".to_string(), "application/json".to_string()),
("Content-Length".to_string(), body.len().to_string()),
],
body,
transport: ResponseTransport::Normal,
}
}
fn core_sbc_error_response(error: CoreError) -> WireResponse {
let status = match &error {
CoreError::Status(400) => 400,
CoreError::Status(404) => 404,
CoreError::Status(409) => 409,
_ => 503,
};
json_status(
status,
&json!({ "error": format!("Core SBC operation failed: {error}") }),
)
}
/// A unique ephemeral JSON-state path used by [`Server::new`] tests.
/// Production replaces both stores with configured durable paths.
fn ephemeral_state_path(kind: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static CTR: AtomicU64 = AtomicU64::new(0);
let n = CTR.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!(
"openfut-utas-{kind}-{}-{nanos}-{n}.json",
std::process::id()
))
}
fn ephemeral_clientdata_path() -> std::path::PathBuf {
ephemeral_state_path("clientdata")
}
fn ephemeral_account_path() -> std::path::PathBuf {
ephemeral_state_path("account")
}
/// A validated capability registration request.
struct CapabilityRequest {
persona_id: Option<i64>,
version: u32,
}
/// Parse + validate a capability POST body. Mirrors the Python route: anything but
/// `capability == "empty_mypacks_resolver"` at the supported version is rejected.
fn parse_capability_request(body: &[u8]) -> Result<CapabilityRequest, ()> {
let v: Value = serde_json::from_slice(body).map_err(|_| ())?;
let obj = v.as_object().ok_or(())?;
let name = obj.get("capability").and_then(|x| x.as_str()).ok_or(())?;
let version = obj
.get("version")
.and_then(|x| x.as_u64())
.and_then(|n| u32::try_from(n).ok())
.ok_or(())?;
validate_capability(name, version).map_err(|_| ())?;
let persona_id = obj.get("personaId").and_then(|x| x.as_i64());
Ok(CapabilityRequest {
persona_id,
version,
})
}
/// Overlay the empty-My-Packs topology on Python's `purchasegroup` body. Python
/// always emits the synthetic 65534 sentinel when My Packs is empty (it does not
/// know the Rust capability); for a verified clean-v1 session we remove it so the
/// client's resolver guard routes to Browse. Sentinel mode leaves it in place, and
/// real owned packs (no 65534) are untouched in either mode. Returns the count
/// removed. Pure — the topology decision is unit-testable.
fn overlay_empty_mypacks(root: &mut Value, mode: StoreMode) -> usize {
if mode != StoreMode::CleanV1 {
return 0;
}
let Some(arr) = root.get_mut("purchase").and_then(|p| p.as_array_mut()) else {
return 0;
};
let before = arr.len();
arr.retain(|entry| {
entry
.get("id")
.and_then(|x| x.as_u64())
.map(|id| id != SENTINEL_PACK_ID)
.unwrap_or(true)
});
before - arr.len()
}
// ───────────────────────────── HTTP/1.1 request reader ──────────────────────
/// A parsed request. `target` is the raw request target (path + optional query).
pub struct ParsedRequest {
pub method: String,
pub target: String,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
pub close: bool,
}
/// Read one HTTP/1.1 request. `Ok(None)` = clean connection close before a
/// request line. Body is read exactly per `Content-Length` (chunked is not used
/// by this client population — worker D).
pub fn read_request<R: BufRead>(reader: &mut R) -> std::io::Result<Option<ParsedRequest>> {
let mut line = String::new();
let n = reader.read_line(&mut line)?;
if n == 0 {
return Ok(None);
}
let request_line = line.trim_end();
if request_line.is_empty() {
// Tolerate a stray blank line before the request line.
return read_request(reader);
}
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or("").to_string();
let target = parts.next().unwrap_or("").to_string();
let mut headers = Vec::new();
let mut content_length = 0usize;
let mut close = false;
loop {
let mut h = String::new();
if reader.read_line(&mut h)? == 0 {
break;
}
let h = h.trim_end();
if h.is_empty() {
break;
}
if let Some((k, v)) = h.split_once(':') {
let k = k.trim().to_string();
let v = v.trim().to_string();
if k.eq_ignore_ascii_case("content-length") {
content_length = v.parse().unwrap_or(0);
} else if k.eq_ignore_ascii_case("connection") && v.eq_ignore_ascii_case("close") {
close = true;
}
headers.push((k, v));
}
}
let mut body = vec![0u8; content_length];
if content_length > 0 {
reader.read_exact(&mut body)?;
}
Ok(Some(ParsedRequest {
method,
target,
headers,
body,
close,
}))
}
fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
204 => "No Content",
400 => "Bad Request",
404 => "Not Found",
500 => "Internal Server Error",
502 => "Bad Gateway",
_ => "OK",
}
}
fn write_response<W: Write>(w: &mut W, resp: &WireResponse) -> std::io::Result<bool> {
match resp.transport {
ResponseTransport::Drop => return Ok(true),
ResponseTransport::Delay { millis } => {
std::thread::sleep(std::time::Duration::from_millis(millis));
}
ResponseTransport::Normal | ResponseTransport::Malformed => {}
}
let mut head = format!("HTTP/1.1 {} {}\r\n", resp.status, reason(resp.status));
for (k, v) in &resp.headers {
if is_hop_by_hop(k) {
continue;
}
head.push_str(&format!("{k}: {v}\r\n"));
}
head.push_str(&format!("Content-Length: {}\r\n", resp.body.len()));
head.push_str("\r\n");
w.write_all(head.as_bytes())?;
if resp.transport == ResponseTransport::Malformed {
w.write_all(b"{")?;
w.flush()?;
return Ok(true);
}
w.write_all(&resp.body)?;
w.flush()?;
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::async_bridge::AsyncBridge;
/// One recorded consumable apply. It captures the three things the caller —
/// not Core — decides: which instance is mutated, the KIND token Core will
/// dispatch on, and the resolved grant. A wrong tier shows up here as a wrong
/// `amount`, which is exactly the silent mis-credit the 202 arm had to avoid.
#[derive(Debug, Clone, PartialEq, Eq)]
struct RecordedApply {
target_owned_card_id: String,
target_kind: String,
amount: i64,
}
/// A configurable in-memory economy double: real balance/entitlements, or a
/// forced error to prove fail-closed behavior.
struct FakeEconomy {
balance: i64,
entitlements: Vec<EconomyEntitlement>,
fail: bool,
applies: PlMutex<Vec<RecordedApply>>,
}
impl FakeEconomy {
fn ok(balance: i64, ents: usize) -> Self {
FakeEconomy {
balance,
entitlements: (0..ents)
.map(|i| EconomyEntitlement {
id: format!("e{i}"),
definition_id: "pack".into(),
})
.collect(),
fail: false,
applies: PlMutex::new(Vec::new()),
}
}
fn failing() -> Self {
FakeEconomy {
balance: 0,
entitlements: vec![],
applies: PlMutex::new(Vec::new()),
fail: true,
}
}
fn with_entitlements(balance: i64, defs: &[&str]) -> Self {
FakeEconomy {
balance,
entitlements: defs
.iter()
.enumerate()
.map(|(i, d)| EconomyEntitlement {
id: format!("e{i}"),
definition_id: (*d).into(),
})
.collect(),
fail: false,
applies: PlMutex::new(Vec::new()),
}
}
}
impl CoreEconomy for FakeEconomy {
fn balance(&self) -> Result<i64, CoreError> {
if self.fail {
Err(CoreError::Status(500))
} else {
Ok(self.balance)
}
}
fn entitlements(&self) -> Result<Vec<EconomyEntitlement>, CoreError> {
if self.fail {
Err(CoreError::Status(500))
} else {
Ok(self.entitlements.clone())
}
}
fn purchase_entitlement(
&self,
_cost: i64,
definition_id: &str,
) -> Result<EconomyPurchase, CoreError> {
if self.fail {
return Err(CoreError::Status(500));
}
Ok(EconomyPurchase {
balance: self.balance,
entitlement_id: format!("bought:{definition_id}"),
})
}
fn redeem_entitlement(
&self,
_entitlement_id: &str,
_items: &[EconomyGrantItem],
) -> Result<String, CoreError> {
if self.fail {
return Err(CoreError::Status(500));
}
Ok("pack".into())
}
fn sell_item(&self, _item_id: &str, _price: i64) -> Result<i64, CoreError> {
if self.fail {
return Err(CoreError::Status(500));
}
Ok(self.balance)
}
fn grant_reward(&self, _amount: i64) -> Result<i64, CoreError> {
if self.fail {
return Err(CoreError::Status(500));
}
Ok(self.balance)
}
fn complete_match(
&self,
m: &CoreMatchCompletion<'_>,
) -> Result<CoreMatchReceipt, CoreError> {
if self.fail {
return Err(CoreError::Status(503));
}
let coins = match m.result {
"win" => 400,
"draw" => 200,
"loss" | "dnf" => 100,
_ => 0,
};
Ok(CoreMatchReceipt {
applied: true,
result: m.result.to_string(),
coins_awarded: coins,
coins_balance: self.balance,
})
}
/// Stands in for Core's atomic transaction: it records what the caller
/// asked for and echoes the arithmetic Core would perform, so a test can
/// assert BOTH the refusals (nothing recorded) and the accepted grants.
fn apply_consumable(
&self,
req: &ConsumableApplyRequest<'_>,
) -> Result<ConsumableApplyOutcome, CoreError> {
if self.fail {
return Err(CoreError::Status(503));
}
// Mirror Core's own arithmetic per effect, so a test asserts against
// what Core would really answer rather than a single family's shape.
let (amount, before, after) = match req.effect {
ApplyEffect::AddContractMatches {
amount,
cap,
default_when_unset,
} => (
amount,
default_when_unset,
(default_when_unset + amount).min(cap),
),
// Training records the boost held on the slot, and a slot that
// already carried one is refused before reaching Core, so
// `before` is always 0.
ApplyEffect::ApplyTraining { amount, .. } => (amount, 0, amount),
};
self.applies.lock().push(RecordedApply {
target_owned_card_id: req.target_owned_card_id.to_string(),
target_kind: req.target_kind.to_string(),
amount,
});
Ok(ConsumableApplyOutcome {
applied: true,
source_destroyed: true,
source_quantity_after: None,
granted: amount,
before,
after,
})
}
fn purchase_item(
&self,
_cost: i64,
_item_id: &str,
_card_id: &str,
) -> Result<i64, CoreError> {
if self.fail {
return Err(CoreError::Status(500));
}
Ok(self.balance)
}
fn purchase_items(
&self,
_cost: i64,
_items: &[EconomyGrantItem],
) -> Result<i64, CoreError> {
if self.fail {
return Err(CoreError::Status(500));
}
Ok(self.balance)
}
fn settle_sale(&self, _sale: &EconomySale<'_>) -> Result<EconomySaleReceipt, CoreError> {
// Sale settlement is not exercised through this double.
Err(CoreError::Status(501))
}
}
#[test]
fn settle_sale_body_omits_absent_club_ids() {
// Absent club ids are the wire signal for Core's defaults (active club
// as seller, outside counterparty as buyer), so they must not appear.
let outside = EconomySale {
item_id: "item-x",
seller_club_id: None,
buyer_club_id: None,
gross: 15_000,
fee: 750,
};
let b = sale_request_body(&outside);
assert_eq!(b["item_id"], "item-x");
assert_eq!(b["gross"], 15_000);
assert_eq!(b["fee"], 750);
assert!(b.get("seller_club_id").is_none());
assert!(b.get("buyer_club_id").is_none());
let between_clubs = EconomySale {
seller_club_id: Some("club-seller"),
buyer_club_id: Some("club-buyer"),
..outside
};
let b = sale_request_body(&between_clubs);
assert_eq!(b["seller_club_id"], "club-seller");
assert_eq!(b["buyer_club_id"], "club-buyer");
assert_eq!(b["item_id"], "item-x");
assert_eq!(b["gross"], 15_000);
assert_eq!(b["fee"], 750);
}
#[test]
fn credits_body_matches_oracle_shape() {
let b = build_credits_body(29_876_776, 0);
assert_eq!(b["credits"], 29_876_776);
assert_eq!(b["currencies"][0]["name"], "coins");
assert_eq!(b["currencies"][0]["funds"], 29_876_776);
assert_eq!(b["currencies"][0]["finalFunds"], 29_876_776);
assert_eq!(b["currencies"][1]["name"], "points");
assert_eq!(b["currencies"][1]["funds"], 0);
// No packs -> no unopenedPacks key (keeps the badge off).
assert!(b.get("unopenedPacks").is_none());
let b2 = build_credits_body(100, 3);
assert_eq!(b2["unopenedPacks"]["recoveredPacks"], 3);
assert_eq!(b2["unopenedPacks"]["preOrderPacks"], 0);
}
#[test]
fn handle_credits_reads_core_authority() {
let econ = FakeEconomy::ok(4600, 2);
let resp = handle_credits(&econ);
assert_eq!(resp.status, 200);
let body: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(body["currencies"][0]["funds"], 4600);
assert_eq!(body["unopenedPacks"]["recoveredPacks"], 2);
}
#[test]
fn handle_credits_fails_closed_on_core_error() {
// Core error -> controlled 503, NEVER a Python fallback or fabricated balance.
let resp = handle_credits(&FakeEconomy::failing());
assert_eq!(resp.status, 503);
}
fn pack_ids(body: &Value) -> Vec<u64> {
body["purchase"]
.as_array()
.unwrap()
.iter()
.map(|p| p["id"].as_u64().unwrap())
.collect()
}
#[test]
fn purchasegroup_full_gen_owned_pack_no_sentinel() {
let econ = FakeEconomy::with_entitlements(4600, &["70"]);
let resp = handle_purchasegroup(&econ, StoreMode::Sentinel);
assert_eq!(resp.status, 200);
let body: Value = serde_json::from_slice(&resp.body).unwrap();
let ids = pack_ids(&body);
assert!(ids.contains(&70), "owned pack 70 present");
assert!(
!ids.contains(&SENTINEL_PACK_ID),
"no sentinel when a pack is owned"
);
}
#[test]
fn purchasegroup_full_gen_empty_modes() {
// Sentinel mode + no packs -> 65534 shim present.
let sent = handle_purchasegroup(&FakeEconomy::ok(100, 0), StoreMode::Sentinel);
let sent_body: Value = serde_json::from_slice(&sent.body).unwrap();
assert!(pack_ids(&sent_body).contains(&SENTINEL_PACK_ID));
// CleanV1 + no packs -> no 65534, no My Packs group.
let clean = handle_purchasegroup(&FakeEconomy::ok(100, 0), StoreMode::CleanV1);
let clean_body: Value = serde_json::from_slice(&clean.body).unwrap();
assert!(!pack_ids(&clean_body).contains(&SENTINEL_PACK_ID));
}
#[test]
fn purchasegroup_fails_closed_on_core_error() {
let resp = handle_purchasegroup(&FakeEconomy::failing(), StoreMode::Sentinel);
assert_eq!(resp.status, 503);
}
#[test]
fn massinfo_economy_overlay_sets_coins_and_packs() {
let mut root = json!({
"userInfo": {
"currencies": [
{"name": "coins", "funds": 1, "finalFunds": 1, "active": true},
{"name": "points", "funds": 0, "finalFunds": 0, "active": true},
],
"won": 5,
},
"squad": {"keep": true},
});
assert!(overlay_massinfo_economy(&mut root, 29_876_776, 2));
assert_eq!(root["userInfo"]["currencies"][0]["funds"], 29_876_776);
assert_eq!(root["userInfo"]["currencies"][0]["finalFunds"], 29_876_776);
// points + other fields untouched; squad preserved.
assert_eq!(root["userInfo"]["currencies"][1]["funds"], 0);
assert_eq!(root["userInfo"]["won"], 5);
assert_eq!(root["squad"]["keep"], true);
assert_eq!(root["userInfo"]["unopenedPacks"]["recoveredPacks"], 2);
// Zero packs removes the key (badge off).
assert!(overlay_massinfo_economy(&mut root, 10, 0));
assert!(root["userInfo"].get("unopenedPacks").is_none());
}
#[test]
fn credits_massinfo_purchasegroup_agree_on_core_state() {
// The invariant the cutover must preserve: all three read one Core state.
let econ = FakeEconomy::with_entitlements(4600, &["70"]);
let coins = econ.balance().unwrap();
let count = econ.entitlements().unwrap().len();
let credits: Value = serde_json::from_slice(&handle_credits(&econ).body).unwrap();
let mut mass =
json!({"userInfo": {"currencies": [{"name":"coins","funds":0,"finalFunds":0}]}});
overlay_massinfo_economy(&mut mass, coins, count);
let pg: Value =
serde_json::from_slice(&handle_purchasegroup(&econ, StoreMode::Sentinel).body).unwrap();
assert_eq!(credits["currencies"][0]["funds"], coins);
assert_eq!(mass["userInfo"]["currencies"][0]["funds"], coins);
assert_eq!(credits["unopenedPacks"]["recoveredPacks"], count);
assert_eq!(mass["userInfo"]["unopenedPacks"]["recoveredPacks"], count);
assert!(pack_ids(&pg).contains(&70));
}
#[test]
fn match_reward_credits_core_and_shapes_body() {
let econ = FakeEconomy::ok(5400, 0);
let resp = handle_match_end(
&econ,
42,
Some(200_000_001),
br#"{"endReason":"WIN","myMatchStats":[2,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"opponentMatchStats":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}"#,
);
assert_eq!(resp.status, 200);
let body: Value = serde_json::from_slice(&resp.body).unwrap();
// Core's authoritative balance -> allCoins; granted coins -> matchCoins.
assert_eq!(body["allCoins"], 5400);
assert_eq!(body["matchCoins"], 400); // win
assert_eq!(body["gameModeAward"]["coins"], 400);
assert_eq!(body["seasonCoins"], 0);
// The freeze traps are never emitted.
assert!(body.get("bidTokens").is_none());
assert!(body.get("qualifiedChampionEventId").is_none());
// Draw default on a well-formed body without a known endReason.
let draw: Value =
serde_json::from_slice(&handle_match_end(&econ, 42, None, br#"{"foo":1}"#).body)
.unwrap();
assert_eq!(draw["matchCoins"], 200);
}
#[test]
fn match_end_fails_closed_on_core_error() {
// A Core failure NEVER falls back to Python — controlled 503.
let resp = handle_match_end(&FakeEconomy::failing(), 42, None, br#"{"endReason":"WIN"}"#);
assert_eq!(resp.status, 503);
}
#[test]
fn malformed_match_end_is_rejected() {
let resp = handle_match_end(&FakeEconomy::ok(1000, 0), 42, None, b"not json");
assert_eq!(resp.status, 400);
}
#[test]
fn match_identity_dedupes_retries_and_separates_matches() {
// An identical body (a network retry of the same match) yields the SAME
// identity so Core dedupes it; two decided matches with different stats
// get DIFFERENT identities so both credit.
let a = br#"{"matchReportId":0,"endReason":"WIN","myMatchStats":[3,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"opponentMatchStats":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}"#;
let b = br#"{"matchReportId":0,"endReason":"WIN","myMatchStats":[2,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"opponentMatchStats":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}"#;
let ea = match_wire::parse_match_end(a).unwrap();
let eb = match_wire::parse_match_end(b).unwrap();
assert_eq!(
match_identity(42, &ea, a, None),
match_identity(42, &ea, a, None)
);
assert_ne!(
match_identity(42, &ea, a, None),
match_identity(42, &eb, b, None)
);
// A non-zero report id keys on the report, independent of body bytes.
let r = br#"{"matchReportId":99,"endReason":"WIN"}"#;
let er = match_wire::parse_match_end(r).unwrap();
assert_eq!(match_identity(7, &er, r, None), "7:report:99");
}
/// THE regression that matters for a real session: every abandoned match
/// sends a BYTE-IDENTICAL body, so a body fingerprint gives them all one
/// identity and Core's unique guard silently refuses to credit any but the
/// first. The id minted by `POST …/match` is what separates them.
#[test]
fn identical_dnf_bodies_are_separated_by_their_created_match_id() {
let dnf = br#"{"matchReportId":0,"endReason":"DNF","items":[],"matchData":"","matchPerfTelemetry01":"","matchStatusFlags":0}"#;
let end = match_wire::parse_match_end(dnf).unwrap();
// Without a created id the two abandoned matches collide — the bug.
assert_eq!(
match_identity(42, &end, dnf, None),
match_identity(42, &end, dnf, None),
"identical bodies fingerprint identically, which is precisely why the \
fingerprint cannot be the primary key"
);
// With one, they are distinct and both credit.
let first = match_identity(42, &end, dnf, Some(200_000_001));
let second = match_identity(42, &end, dnf, Some(200_000_002));
assert_ne!(first, second, "two abandoned matches must both be payable");
assert_eq!(first, "42:match:200000001");
// A replay of ONE match still dedupes.
assert_eq!(first, match_identity(42, &end, dnf, Some(200_000_001)));
// The created id outranks a report id: it is unique by construction.
let reported = br#"{"matchReportId":99,"endReason":"DNF"}"#;
let re = match_wire::parse_match_end(reported).unwrap();
assert_eq!(
match_identity(7, &re, reported, Some(200_000_009)),
"7:match:200000009"
);
}
#[test]
fn special_filter_keeps_only_specials_and_paginates_filtered_set() {
let mk = |id: i64, rf: i64| serde_json::json!({ "id": id, "rareflag": rf });
// base rare(1)/common(0) interleaved with specials(3,11,24).
let items = vec![mk(1, 1), mk(2, 3), mk(3, 1), mk(4, 24), mk(5, 0), mk(6, 11)];
let (all, total) = special_filter_page(&items, None, None);
assert_eq!(total, 3, "only rareflag>1 counted");
assert!(all.iter().all(|it| it["rareflag"].as_i64().unwrap() > 1));
assert_eq!(
all.iter()
.map(|it| it["id"].as_i64().unwrap())
.collect::<Vec<_>>(),
vec![2, 4, 6],
"base rare/common excluded, order preserved"
);
// pagination is over the FILTERED set, no overlap, no base leakage.
let (p0, t0) = special_filter_page(&items, Some(0), Some(2));
let (p1, t1) = special_filter_page(&items, Some(2), Some(2));
assert_eq!((t0, t1), (3, 3));
assert_eq!(
p0.iter()
.map(|it| it["id"].as_i64().unwrap())
.collect::<Vec<_>>(),
vec![2, 4]
);
assert_eq!(
p1.iter()
.map(|it| it["id"].as_i64().unwrap())
.collect::<Vec<_>>(),
vec![6]
);
}
#[test]
fn classify_club_read_and_rename_methods_exactly() {
assert_eq!(classify("GET", "/ut/game/fifa17/club"), Route::Club);
assert_eq!(classify("get", "/ut/game/fifa18/club"), Route::Club);
assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::ClubRename);
// bare `club/stats` (no trailing slash) is not a migrated arm -> Python.
assert_eq!(
classify("GET", "/ut/game/fifa17/club/stats"),
Route::Passthrough
);
// club/stats/<mode> screens are Rust-owned (nation/league/team contexts).
assert_eq!(
classify("GET", "/ut/game/fifa17/club/stats/country/54"),
Route::ClubStats
);
// clubUser is now Rust-owned (`{}` while club identity is off).
assert_eq!(
classify("GET", "/ut/game/fifa17/clubUser"),
Route::FeatureOffEmpty
);
assert_eq!(
classify("GET", "/ut/game/fifa17/tradePile"),
Route::Passthrough
);
assert_eq!(
classify("POST", "/ut/game/fifa17/purchased/items"),
Route::Passthrough
);
assert_eq!(classify("GET", "/ut/game//club"), Route::Passthrough);
assert_eq!(classify("GET", "/club"), Route::Passthrough);
}
#[test]
fn parse_core_page_reads_collection_and_total() {
let v = json!({
"collection": [{
"owned_card_id": "oc1",
"effective_overall": 86,
"effective_position": "CDM",
"card": {"id":"card_ch_1","overall":85,"position":"CDM","nation":"Argentina","league":"Premier League","club":"Chelsea","pace":80,"shooting":70,"passing":75,"dribbling":78,"defending":84,"physical":82}
}],
"total": 42
});
let page = parse_core_page(&v).unwrap();
assert_eq!(page.total, 42);
assert_eq!(page.items.len(), 1);
let it = &page.items[0];
assert_eq!(it.owned_card_id, "oc1");
assert_eq!(it.card_id, "card_ch_1");
assert_eq!(it.rating, 86, "effective_overall wins over base");
assert_eq!(it.position, "CDM");
assert_eq!(it.attributes, [80, 70, 75, 78, 84, 82]);
}
// ── Fifa17IdentityResolver: the single production identity path ──────────
fn test_catalog(cards: &[(&str, u32)]) -> Fifa17CardCatalog {
let entries: Vec<String> = cards
.iter()
.map(|(id, asset)| format!("\"{id}\":{{\"asset_id\":{asset}}}"))
.collect();
let doc = format!(
"{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{}}}}}",
entries.join(",")
);
Fifa17CardCatalog::from_json_str(&doc).unwrap()
}
fn temp_store_path(tag: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::SeqCst);
std::env::temp_dir().join(format!(
"ofut-resolver-{tag}-{}-{n}.json",
std::process::id()
))
}
fn owned(owned_id: &str, card: &str) -> CoreOwnedItem {
CoreOwnedItem {
owned_card_id: owned_id.into(),
card_id: card.into(),
rating: 90,
position: "ST".into(),
nation: "Argentina".into(),
league: "Premier League".into(),
club: "Chelsea".into(),
attributes: [90, 90, 80, 91, 33, 80],
contract_matches: None,
source_rating: None,
core_content_kind: None,
}
}
fn resolver(path: &std::path::Path, cards: &[(&str, u32)]) -> Fifa17IdentityResolver {
let store = openfut_identity::JsonIdentityStore::open(path).unwrap();
Fifa17IdentityResolver::new(test_catalog(cards), Arc::new(store))
}
#[test]
fn resolver_maps_definition_and_allocates_wire_id() {
let p = temp_store_path("map");
let r = resolver(&p, &[("card_gold_001", 20801)]);
let id = r.resolve(&owned("oc1", "card_gold_001")).unwrap();
assert_eq!(id.asset_id, 20801, "real asset from the catalog");
assert_eq!(
id.item_id, 100_000_001,
"first wire id from the policy floor"
);
let _ = std::fs::remove_file(&p);
}
#[test]
fn resolver_drops_unmapped_definition_never_faking() {
let p = temp_store_path("drop");
let r = resolver(&p, &[("card_gold_001", 20801)]);
assert!(
r.resolve(&owned("oc1", "card_unknown")).is_none(),
"no catalog entry => dropped, never a fabricated id"
);
let _ = std::fs::remove_file(&p);
}
#[test]
fn two_copies_of_a_definition_share_resource_but_get_distinct_wire_ids() {
let p = temp_store_path("copies");
let r = resolver(&p, &[("card_gold_001", 20801)]);
let a = r.resolve(&owned("oc1", "card_gold_001")).unwrap();
let b = r.resolve(&owned("oc2", "card_gold_001")).unwrap();
assert_eq!(a.asset_id, b.asset_id, "same definition => same resourceId");
assert_ne!(a.item_id, b.item_id, "distinct copies => distinct wire ids");
// Idempotent: the same owned instance re-resolves to the same wire id.
assert_eq!(
r.resolve(&owned("oc1", "card_gold_001")).unwrap().item_id,
a.item_id
);
let _ = std::fs::remove_file(&p);
}
#[test]
fn wire_id_survives_restart_and_reverses_exactly() {
let p = temp_store_path("restart");
let first = {
let r = resolver(&p, &[("card_gold_001", 20801)]);
r.resolve(&owned("oc-stable", "card_gold_001"))
.unwrap()
.item_id
};
// Reopen the same store file (simulating a host restart).
let r2 = resolver(&p, &[("card_gold_001", 20801)]);
let again = r2
.resolve(&owned("oc-stable", "card_gold_001"))
.unwrap()
.item_id;
assert_eq!(
first, again,
"same owned instance keeps its wire id across restart"
);
assert_eq!(
r2.owned_id_for_wire(again as i64).as_deref(),
Some("oc-stable"),
"reverse lookup returns the exact owned instance"
);
assert_eq!(
r2.owned_id_for_wire(999_999_999),
None,
"unknown wire id => None"
);
let _ = std::fs::remove_file(&p);
}
// ── Session/capability vertical (this slice) ────────────────────────────
/// Every tail `handle_static_ack` can answer MUST also be produced by
/// `classify`, or the handler is dead code and the request silently falls
/// through to the Python upstream. That has now happened three times in this
/// file (`season/list`, `watchList`, and these four), so it gets a test.
#[test]
fn every_static_ack_tail_is_actually_routed() {
for tail in [
"store",
"match/keepalive",
"captcha",
"tfa",
"livemessage",
"activeMessage",
] {
assert_eq!(
classify("GET", &format!("/ut/game/fifa17/{tail}")),
Route::StaticAck,
"{tail} must reach handle_static_ack, not Python"
);
}
}
/// The mode reads the client can actually build MUST all be claimed. The
/// tails come from CardsDLL's own route literals (`ut/%s/tournament/user` at
/// 0x18021e540), read out of the live binary with
/// `fifa17-recon/tools/url_template_probe.py` — not from guesswork about
/// what the client might ask for.
#[test]
fn the_disabled_mode_reads_are_all_claimed() {
for tail in [
"tournament",
"tournament/user",
"champion",
"clubUser",
"user/list",
] {
assert_eq!(
classify("GET", &format!("/ut/game/fifa17/{tail}")),
Route::FeatureOffEmpty,
"{tail} must be Rust-owned, not proxied"
);
}
}
#[test]
fn classify_routes_session_vertical() {
assert_eq!(classify("POST", "/ut/auth"), Route::Auth);
assert_eq!(classify("POST", "/ut/auth/"), Route::Auth);
// Only POST is auth; a GET falls through to Python.
assert_eq!(classify("GET", "/ut/auth"), Route::Passthrough);
assert_eq!(
classify("POST", "/openfut/fifa17/capability"),
Route::Capability
);
assert_eq!(
classify("GET", "/ut/game/fifa17/store/purchasegroup/all"),
Route::StorePurchaseGroup
);
assert_eq!(
classify("GET", "/ut/game/fifa17/store/purchasegroup/all?ppInfo=true"),
Route::StorePurchaseGroup
);
// A store MUTATION stays on Python (never Rust): economy is not ours.
assert_eq!(
classify("PUT", "/ut/game/fifa17/store/transaction/0"),
Route::Passthrough
);
assert_eq!(
classify("POST", "/openfut/account/sync"),
Route::AccountSync
);
assert_eq!(classify("POST", "/ut/delete/auth"), Route::Auth);
assert_eq!(
classify("PUT", "/ut/game/fifa17/clientdata/userHubData"),
Route::ClientData
);
assert_eq!(
classify("GET", "/ut/game/fifa17/clientdata/userHubData"),
Route::ClientData
);
}
#[test]
fn parse_capability_request_validates() {
let ok = br#"{"capability":"empty_mypacks_resolver","version":1,"personaId":33068179,"fifaPid":42}"#;
let r = parse_capability_request(ok).expect("valid");
assert_eq!(r.version, 1);
assert_eq!(r.persona_id, Some(33068179));
assert!(parse_capability_request(
br#"{"capability":"empty_mypacks_resolver","version":2}"#
)
.is_err());
assert!(parse_capability_request(br#"{"capability":"other","version":1}"#).is_err());
assert!(parse_capability_request(b"[]").is_err());
assert!(parse_capability_request(b"nope").is_err());
}
#[test]
fn overlay_strips_sentinel_only_for_clean_v1() {
let base = serde_json::json!({
"purchase": [
{"id": 1, "packType": "BRONZE"},
{"id": 65534, "packType": "GOLD"},
{"id": 5, "packType": "GOLD"}
]
});
// clean-v1: the 65534 sentinel is stripped; real packs remain.
let mut clean = base.clone();
assert_eq!(overlay_empty_mypacks(&mut clean, StoreMode::CleanV1), 1);
let ids: Vec<u64> = clean["purchase"]
.as_array()
.unwrap()
.iter()
.map(|e| e["id"].as_u64().unwrap())
.collect();
assert_eq!(ids, vec![1, 5]);
// sentinel mode: unchanged (the compatibility shim is kept).
let mut sent = base.clone();
assert_eq!(overlay_empty_mypacks(&mut sent, StoreMode::Sentinel), 0);
assert_eq!(sent["purchase"].as_array().unwrap().len(), 3);
// no purchase array -> no-op.
let mut other = serde_json::json!({"other": 1});
assert_eq!(overlay_empty_mypacks(&mut other, StoreMode::CleanV1), 0);
}
#[test]
fn ut_tail_normalizes_v1_and_v2() {
for (path, want) in [
(
"/ut/game/fifa17/store/purchasegroup",
Some("store/purchasegroup"),
),
(
"/ut/v2/game/fifa17/store/purchasegroup",
Some("store/purchasegroup"),
),
(
"/ut/game/fifa17/store/transaction",
Some("store/transaction"),
),
(
"/ut/v2/game/fifa17/store/transaction/0",
Some("store/transaction/0"),
),
("/ut/game/fifa17/purchased", Some("purchased")),
("/ut/v2/game/fifa17/purchased", Some("purchased")),
("/ut/game/fifa17/user/credits", Some("user/credits")),
// generic sku — helper is not fifa17-string-specific.
(
"/ut/game/fifa23/store/transaction/7",
Some("store/transaction/7"),
),
(
"/ut/v2/game/fifa23/store/purchasegroup",
Some("store/purchasegroup"),
),
// negatives.
("/ut/auth", None),
("/openfut/account/sync", None),
("/ut/game/", None),
("/ut/game/fifa17", None),
("/ut/v2/game/fifa17", None),
("/ut/v2/other/thing", None),
("/ut/delete/game/fifa17/item", None),
] {
assert_eq!(ut_tail(path), want, "ut_tail({path})");
}
}
#[test]
fn is_store_transaction_tail_is_bounded() {
assert!(is_store_transaction_tail("store/transaction"));
assert!(is_store_transaction_tail("store/transaction/0"));
assert!(is_store_transaction_tail("store/transaction/123"));
assert!(!is_store_transaction_tail("store/transactions"));
assert!(!is_store_transaction_tail("store/transactionfoo"));
assert!(!is_store_transaction_tail("store/transaction/0/extra"));
assert!(!is_store_transaction_tail("store/transaction/"));
assert!(!is_store_transaction_tail("store/transaction/abc"));
assert!(!is_store_transaction_tail("store/purchasegroup"));
}
#[test]
fn classify_economy_covers_retail_v2_store_family() {
use EconomyRoute::*;
// The exact live-failure shape now classifies as Rust StoreBuy.
assert_eq!(
classify_economy("PUT", "/ut/v2/game/fifa17/store/transaction/0"),
Some(StoreBuy)
);
assert_eq!(
classify_economy("PUT", "/ut/game/fifa17/store/transaction"),
Some(StoreBuy)
);
assert_eq!(
classify_economy("PUT", "/ut/v2/game/fifa17/store/transaction/123"),
Some(StoreBuy)
);
// purchasegroup + purchased under both prefixes.
assert_eq!(
classify_economy("GET", "/ut/v2/game/fifa17/store/purchasegroup"),
Some(PurchaseGroup)
);
assert_eq!(
classify_economy("GET", "/ut/game/fifa17/store/purchasegroup/all"),
Some(PurchaseGroup)
);
assert_eq!(
classify_economy("POST", "/ut/v2/game/fifa17/purchased"),
Some(PackOpen)
);
assert_eq!(
classify_economy("GET", "/ut/v2/game/fifa17/purchased"),
Some(PackReveal)
);
// v1 non-store economy routes still classify (regression).
assert_eq!(
classify_economy("GET", "/ut/game/fifa17/user/credits"),
Some(Credits)
);
assert_eq!(
classify_economy("PUT", "/ut/game/fifa17/item"),
Some(MoveItems)
);
assert_eq!(
classify_economy("DELETE", "/ut/game/fifa17/item/100000001"),
Some(QuickSellPath)
);
assert_eq!(
classify_economy("GET", "/ut/game/fifa17/tradePile"),
Some(MarketQuery)
);
assert_eq!(
classify_economy("POST", "/ut/delete/game/fifa17/item"),
Some(QuickSellBody)
);
assert_eq!(
classify_economy("POST", "/ut/delete/game/fifa17/match"),
Some(MatchEnd)
);
// THE PATHS THE RETAIL CLIENT ACTUALLY SENDS. These fell through to
// Passthrough — i.e. the dead Python upstream — which the client
// reported as "There was an error creating your game session". Verb
// agnostic on purpose: the verb is not statically determinable from
// cardsdll.dll, so the path suffix is the discriminator.
for verb in ["POST", "PUT", "GET", "DELETE"] {
assert_eq!(
classify_economy(verb, "/ut/game/fifa17/match"),
Some(MatchCreate),
"{verb} /match must never reach Python"
);
assert_eq!(
classify_economy(verb, "/ut/game/fifa17/match/end"),
Some(MatchEnd),
"{verb} /match/end must never reach Python"
);
assert_eq!(
classify_economy(verb, "/ut/game/fifa17/match/ready"),
Some(MatchReady),
"{verb} /match/ready must never reach Python"
);
}
// …and the sibling tails keep their own owners.
assert_eq!(classify_economy("PUT", "/ut/game/fifa17/match/reset"), None);
assert_eq!(
classify_economy("POST", "/ut/game/fifa17/match/keepalive"),
None
);
// delete family under v2 prefix too (defense-in-depth symmetry).
assert_eq!(
classify_economy("POST", "/ut/v2/delete/game/fifa17/item"),
Some(QuickSellBody)
);
// negatives: non-economy stays None (proxied to Python).
assert_eq!(
classify_economy("PUT", "/ut/v2/game/fifa17/store/transaction/0/extra"),
None
);
assert_eq!(
classify_economy("PUT", "/ut/game/fifa17/store/transactions"),
None
);
assert_eq!(classify_economy("GET", "/ut/game/fifa17/hub"), None);
assert_eq!(classify_economy("GET", "/ut/v2/game/fifa17/store"), None);
assert_eq!(classify_economy("POST", "/ut/auth"), None);
}
/// RETAIL_ROUTE_MATRIX — the audited retail economy route contract. Every
/// economy row MUST classify to its Rust route (Python proxy forbidden);
/// every negative near-miss MUST stay `None` (proxied). Permanent gate against
/// "Python knows route X, Rust forgot route X".
#[test]
fn retail_route_matrix() {
use EconomyRoute::*;
let matrix: &[(&str, &str, Option<EconomyRoute>)] = &[
// credits
("GET", "/ut/game/fifa17/user/credits", Some(Credits)),
// purchasegroup (v1 + v2 + /all)
(
"GET",
"/ut/game/fifa17/store/purchasegroup",
Some(PurchaseGroup),
),
(
"GET",
"/ut/game/fifa17/store/purchasegroup/all",
Some(PurchaseGroup),
),
(
"GET",
"/ut/v2/game/fifa17/store/purchasegroup/all",
Some(PurchaseGroup),
),
// store transaction (v2 + trailing id) — round-1 fix
("PUT", "/ut/game/fifa17/store/transaction", Some(StoreBuy)),
(
"PUT",
"/ut/v2/game/fifa17/store/transaction/0",
Some(StoreBuy),
),
// purchased + purchased/items — round-2 fix (POST open, GET reveal)
("POST", "/ut/game/fifa17/purchased", Some(PackOpen)),
("POST", "/ut/game/fifa17/purchased/items", Some(PackOpen)),
("POST", "/ut/v2/game/fifa17/purchased/items", Some(PackOpen)),
("GET", "/ut/game/fifa17/purchased", Some(PackReveal)),
("GET", "/ut/game/fifa17/purchased/items", Some(PackReveal)),
// move
("PUT", "/ut/game/fifa17/item", Some(MoveItems)),
// quick-sell (path + body)
(
"DELETE",
"/ut/game/fifa17/item/100000001",
Some(QuickSellPath),
),
("POST", "/ut/delete/game/fifa17/item", Some(QuickSellBody)),
(
"POST",
"/ut/v2/delete/game/fifa17/item",
Some(QuickSellBody),
),
// match end
("POST", "/ut/delete/game/fifa17/match", Some(MatchEnd)),
// market list / query (case-insensitive tradePile + counts) / buy / cancel
("POST", "/ut/game/fifa17/auctionhouse", Some(MarketList)),
("POST", "/ut/game/fifa17/transfermarket", Some(MarketList)),
("GET", "/ut/game/fifa17/tradePile", Some(MarketQuery)),
("GET", "/ut/game/fifa17/tradepile", Some(MarketQuery)),
// The tally is a DISTINCT deserializer from the listing list.
(
"GET",
"/ut/game/fifa17/tradePile/counts",
Some(MarketCounts),
),
(
"GET",
"/ut/game/fifa17/tradepile/counts",
Some(MarketCounts),
),
("POST", "/ut/game/fifa17/trade/900000001", Some(MarketBuy)),
// The live-state poll MUST NOT land in the buy/view arm: `status` is
// not a trade id, so that arm answers every poll with an empty set.
("GET", "/ut/game/fifa17/trade/status", Some(MarketStatus)),
("GET", "/ut/game/fifa17/TRADE/STATUS", Some(MarketStatus)),
// Cancel: the oracle's `/ut/delete/game/…` spelling AND the plain
// `DELETE /ut/game/…/trade/<id>` used by FIFA 17 clients. The plain
// form previously fell into the buy/view arm and silently no-oped.
(
"DELETE",
"/ut/game/fifa17/trade/900000001",
Some(MarketCancel),
),
(
"DELETE",
"/ut/delete/game/fifa17/trade/900000001",
Some(MarketCancel),
),
// ── negatives: must stay None (proxied to Python) ──
("GET", "/ut/game/fifa17/store", None),
("GET", "/ut/game/fifa17/store/", None),
("PUT", "/ut/game/fifa17/store/transactions", None),
("PUT", "/ut/game/fifa17/store/transaction/0/extra", None),
("POST", "/ut/game/fifa17/purchasedfoo", None),
("POST", "/ut/game/fifa17/purchased/items/extra", None),
("GET", "/ut/game/fifa17/hub", None),
("GET", "/ut/game/fifa17/marketdata", None),
("POST", "/ut/auth", None),
("GET", "/ut/game/fifa17/watchList", None),
];
for (m, p, want) in matrix {
assert_eq!(
classify_economy(m, p),
*want,
"RETAIL_ROUTE_MATRIX: {m} {p}"
);
}
}
#[test]
fn non_economy_route_ownership() {
// The non-economy routes newly owned by Rust must classify to their arm,
// and lookalikes must stay Passthrough (proxied to Python).
let owned: &[(&str, &str, Route)] = &[
(
"GET",
"/ut/game/fifa17/user/accountinfo",
Route::AccountInfo,
),
("GET", "/ut/game/fifa17/settings", Route::Settings),
(
"GET",
"/ut/game/fifa17/leaderboards/options",
Route::LeaderboardOptions,
),
("PUT", "/ut/game/fifa17/match/reset", Route::MatchReset),
(
"GET",
"/ut/game/fifa17/phishing/trusteddevice",
Route::SecurityQuestion,
),
(
"POST",
"/ut/game/fifa17/phishing/question",
Route::SecurityQuestion,
),
(
"POST",
"/ut/game/fifa17/phishing/validate",
Route::SecurityQuestion,
),
(
"GET",
"/ut/game/fifa17/club/stats/staff",
Route::ClubStatsStaff,
),
("GET", "/ut/game/fifa17/hub", Route::Hub),
("GET", "/ut/game/fifa17/club/stats/year", Route::ClubStats),
(
"GET",
"/ut/game/fifa17/club/stats/consumables",
Route::ClubStats,
),
(
"PUT",
"/ut/game/fifa17/clientdata/userHubData",
Route::ClientData,
),
(
"GET",
"/ut/game/fifa17/clientdata/userHubData",
Route::ClientData,
),
("POST", "/openfut/account/sync", Route::AccountSync),
("GET", "/ut/game/fifa17/season", Route::Season),
("GET", "/ut/game/fifa17/season/list", Route::Season),
("GET", "/ut/game/fifa17/season/user", Route::Season),
("GET", "/ut/game/fifa17/season/user/history", Route::Season),
(
"PUT",
"/ut/game/fifa17/season/1/division/10/user",
Route::Season,
),
("GET", "/ut/game/fifa17/tournament", Route::FeatureOffEmpty),
("GET", "/ut/game/fifa17/champion", Route::FeatureOffEmpty),
("GET", "/ut/game/fifa17/clubUser", Route::FeatureOffEmpty),
("GET", "/ut/game/fifa17/user/list", Route::FeatureOffEmpty),
("PUT", "/ut/game/fifa17/user/club", Route::ClubRename),
("POST", "/ut/game/fifa17/user/club", Route::ClubRename),
("PUT", "/ut/game/fifa17/club", Route::ClubRename),
("GET", "/ut/game/fifa17/item/resource", Route::ItemDefs),
("GET", "/ut/game/fifa17/defid", Route::ItemDefs),
(
"GET",
"/ut/game/fifa17/marketdata/pricelimits",
Route::MarketData,
),
("GET", "/ut/game/fifa17/marketdata", Route::MarketData),
];
for (m, p, want) in owned {
assert_eq!(classify(m, p), *want, "OWN: {m} {p}");
}
// Still Python (not yet migrated) / lookalikes / wrong method.
let proxied: &[(&str, &str)] = &[
("GET", "/ut/game/fifa17/settingsfoo"),
("POST", "/ut/game/fifa17/match/reset"), // match/reset is PUT-only
("GET", "/ut/game/fifa17/match/reset"),
("PUT", "/ut/game/fifa17/user/accountinfo"),
("POST", "/ut/game/fifa17/season"), // FUT-mode reads are GET-only
("GET", "/ut/game/fifa17/user/club"), // unknown read, not rename
("POST", "/ut/game/fifa17/item/resource"), // item-defs are GET-only
("GET", "/ut/game/fifa17/marketdatafoo"), // not the marketdata route
];
for (m, p) in proxied {
assert_eq!(classify(m, p), Route::Passthrough, "PROXY: {m} {p}");
}
// These non-economy routes must NOT be economy-classified.
for (m, p, _) in owned {
assert_eq!(classify_economy(m, p), None, "NON-ECON: {m} {p}");
}
}
#[test]
fn item_defs_shape_matches_oracle() {
// Two ids: the one hardcoded card (asset 20801) + a placeholder.
assert_eq!(extract_long_ints("resourceId=20801"), vec![20801]);
assert_eq!(
extract_long_ints("idList=200389,200104&x=12"),
vec![200389, 200104]
);
assert!(extract_long_ints("foo=ab").is_empty());
let body = non_economy::item_defs_body(&[20801, 200389]);
let items = body["itemData"].as_array().unwrap();
assert_eq!(items.len(), 2);
// Hardcoded Ronaldo.
assert_eq!(items[0]["name"], "Ronaldo");
assert_eq!(items[0]["rating"], 94);
assert_eq!(items[0]["assetId"], 20801);
assert_eq!(items[0]["resourceId"], 20801);
// Placeholder: assetId = resourceId & 0xffffff, rating 75, "Player".
assert_eq!(items[1]["name"], "Player");
assert_eq!(items[1]["rating"], 75);
assert_eq!(items[1]["assetId"], 200389);
assert_eq!(items[1]["attributeList"].as_array().unwrap().len(), 6);
assert!(non_economy::item_defs_body(&[])["itemData"]
.as_array()
.unwrap()
.is_empty());
}
/// One path, three verbs. GET is the definition lookup, POST applies the
/// consumable, PUT quick-sells it. All three were live-captured; conflating
/// any two of them sells or consumes the wrong thing.
///
/// Apply and quick-sell are ECONOMY routes, classified before `classify()`
/// ever runs, so they must resolve there and never fall through to Python.
#[test]
fn item_resource_path_dispatches_on_verb() {
assert_eq!(
classify("GET", "/ut/game/fifa17/item/resource"),
Route::ItemDefs
);
assert_eq!(
classify_economy("POST", "/ut/game/fifa17/item/resource/5001004"),
Some(EconomyRoute::ConsumableApply)
);
assert_eq!(
classify_economy("PUT", "/ut/game/fifa17/item/resource/5003068"),
Some(EconomyRoute::QuickSellResource)
);
// The bare `item` PUT is the pile move and must not be captured.
assert_eq!(
classify_economy("PUT", "/ut/game/fifa17/item"),
Some(EconomyRoute::MoveItems)
);
// A non-numeric tail is not a resource id, so it is NEITHER route — and
// it must not become an apply, which would consume a card on a path the
// client never builds.
assert_eq!(
classify_economy("POST", "/ut/game/fifa17/item/resource/bogus"),
None
);
assert_eq!(
classify_economy("PUT", "/ut/game/fifa17/item/resource/bogus"),
None
);
assert_eq!(
classify("POST", "/ut/game/fifa17/item/resource/bogus"),
Route::Passthrough
);
}
/// `GET ut/%s/item` (FutViewCards) MUST be claimed, and claiming it must not
/// disturb the three other verbs that share the `item` prefix.
///
/// This route was unclaimed and fell through to the Python upstream, which is
/// invisible in production (the oracle answers) and appears only on staging as
/// a 502. That is the recurring dead-route defect in this project, so the
/// point of this test is that a handler existing is not the same as a request
/// reaching it.
#[test]
fn get_item_is_claimed_and_the_other_item_verbs_are_unaffected() {
// FutViewCards, with and without the definition query the client builds.
assert_eq!(classify("GET", "/ut/game/fifa17/item"), Route::ViewCards);
assert_eq!(
classify("GET", "/ut/game/fifa17/item?idList=100000003,100000004"),
Route::ViewCards
);
// v2 sku form resolves identically.
assert_eq!(classify("GET", "/ut/v2/game/fifa17/item"), Route::ViewCards);
// The more specific definition routes still win, and — the bug this test
// exists for — they must survive a query string too. `ut_tail` does not
// strip the query, so an equality-only arm misses every real request while
// looking correct in a no-query unit test.
assert_eq!(
classify("GET", "/ut/game/fifa17/item/resource"),
Route::ItemDefs
);
assert_eq!(
classify("GET", "/ut/game/fifa17/item/resource?resourceId=5003012"),
Route::ItemDefs
);
assert_eq!(
classify("GET", "/ut/game/fifa17/defid?definitionId=200389"),
Route::ItemDefs
);
// PUT `item` is FutMoveCard on the economy path, NOT a definition read.
assert_eq!(classify("PUT", "/ut/game/fifa17/item"), Route::Passthrough);
assert_eq!(
classify_economy("PUT", "/ut/game/fifa17/item"),
Some(EconomyRoute::MoveItems)
);
// DELETE `item/<id>` is single-card Quick Sell and must not be swallowed.
assert_eq!(
classify_economy("DELETE", "/ut/game/fifa17/item/100000240"),
Some(EconomyRoute::QuickSellPath)
);
}
/// Retail FIFA 17 issues part of the item family under `/ut/v2/game/`, so the
/// same three verbs must land identically under both prefixes: `ut_tail`
/// normalises them and nothing downstream may depend on which was used.
#[test]
fn item_resource_verbs_are_prefix_agnostic() {
assert_eq!(
classify("GET", "/ut/v2/game/fifa17/item/resource"),
Route::ItemDefs
);
assert_eq!(
classify_economy("POST", "/ut/v2/game/fifa17/item/resource/5001004"),
Some(EconomyRoute::ConsumableApply)
);
assert_eq!(
classify_economy("PUT", "/ut/v2/game/fifa17/item/resource/5003068"),
Some(EconomyRoute::QuickSellResource)
);
assert_eq!(
classify_economy("POST", "/ut/v2/game/fifa17/item/resource/bogus"),
None
);
}
#[test]
fn apply_targets_parse_from_the_captured_body() {
// The exact bytes the client sent, 2026-08-21.
assert_eq!(
parse_apply_targets(br#"{"apply":[{"id":100000003}]}"#),
vec![100000003]
);
// A batch is parsed but the handler refuses it: semantics unproven.
assert_eq!(
parse_apply_targets(br#"{"apply":[{"id":1},{"id":2}]}"#),
vec![1, 2]
);
// Never invent a target.
assert!(parse_apply_targets(b"").is_empty());
assert!(parse_apply_targets(br#"{"apply":[]}"#).is_empty());
assert!(parse_apply_targets(br#"{"nope":[{"id":7}]}"#).is_empty());
assert!(parse_apply_targets(br#"{"apply":[{"noid":7}]}"#).is_empty());
}
// ── Consumable apply: the contract family, both halves ───────────────────
/// The apply-path catalog, carrying the same kind+subtype fields the
/// production catalog carries so `resolve_consumable`, `kind_of` and
/// `subtype_of` all answer exactly as they do live. A contract card's
/// `asset_id` IS its `resourceId` (version 0), which is how the shipped
/// `fcc_contractcards` rows are keyed.
///
/// Both contract cards are authored `rating: 66` — SILVER as a card. That is
/// deliberate: it makes the card's own tier differ from a gold target's, so a
/// regression that reads the CARD's tier instead of the TARGET's is caught by
/// the grant number rather than passing silently.
fn apply_catalog() -> Fifa17CardCatalog {
Fifa17CardCatalog::from_json_str(
r#"{"schema_version":1,"game":"fifa17","cards":{
"card_player":{"asset_id":20801},
"card_manager":{"asset_id":1000509,"kind":"staff","subtype":4},
"card_coach":{"asset_id":1000601,"kind":"staff","subtype":5},
"contract_player":{"asset_id":5001002,"kind":"consumable","subtype":201,"rating":66},
"contract_manager":{"asset_id":5001008,"kind":"consumable","subtype":202,"rating":66}
}}"#,
)
.unwrap()
}
/// A NON-PLAYER owned instance as Core actually reports one: `overall` 0
/// (that number feeds pricing, so Core keeps it at 0), the authoritative EA
/// `value` in `source_rating`, and Core's OWN kind token — `manager` for the
/// squad manager, where the FIFA catalog says `staff` + subtype 4.
fn staff_owned(
owned_id: &str,
card: &str,
source_rating: Option<u8>,
core_kind: &str,
) -> CoreOwnedItem {
CoreOwnedItem {
rating: 0,
source_rating,
core_content_kind: Some(core_kind.to_string()),
..owned(owned_id, card)
}
}
fn apply_test_server(items: &[CoreOwnedItem]) -> (Server, Arc<Fifa17IdentityResolver>) {
let core = Arc::new(FakeSbcCore {
owned: Mutex::new(items.to_vec()),
..FakeSbcCore::default()
});
let store =
openfut_identity::JsonIdentityStore::open(temp_store_path("apply-identity")).unwrap();
let resolver = Arc::new(Fifa17IdentityResolver::new(
apply_catalog(),
Arc::new(store),
));
let server = Server::new(
core,
Arc::new(Fifa17Entities::default()),
resolver.clone(),
Arc::new(PassClient::new("http://127.0.0.1:9")),
1,
);
(server, resolver)
}
/// The wire id the client holds for this instance, minted through the
/// PRODUCTION resolver so the handler reverses exactly what production would.
fn wire_of(resolver: &Fifa17IdentityResolver, it: &CoreOwnedItem) -> i64 {
let minted = match ItemIdentityResolver::kind_of(resolver, it) {
ContentKind::Player => resolver.resolve(it).map(|i| i.item_id),
_ => resolver.resolve_staff(it).map(|i| i.item_id),
};
i64::from(minted.expect("a catalogued fixture must resolve to a wire id"))
}
fn apply_body(wire: i64) -> Vec<u8> {
format!("{{\"apply\":[{{\"id\":{wire}}}]}}").into_bytes()
}
/// THE discriminating case. `5001008` is `[bronze 8, silver 10, gold 8]`, the
/// manager target's `value` is 88 (GOLD) and the CARD is silver, so:
/// * 8 = the target's tier, which is the invariant.
/// * 10 = the card's own tier — the classic backwards read.
///
/// It also proves the `target_kind` Core receives is CORE's token (`manager`),
/// not the catalog's (`staff`), which is what Core's `require_kind` compares.
#[test]
fn manager_contract_grants_the_target_managers_tier() {
let source = owned("oc-contract-mgr", "contract_manager");
let target = staff_owned("oc-manager", "card_manager", Some(88), "manager");
let (server, resolver) = apply_test_server(&[source, target.clone()]);
let wire = wire_of(&resolver, &target);
let econ = FakeEconomy::ok(0, 0);
let resp = server.handle_consumable_apply(5_001_008, &apply_body(wire), &econ);
assert_eq!(resp.status, 200, "a gold manager is a servable 202 target");
assert_eq!(resp.body, br#"{"itemData":[]}"#);
let applied = econ.applies.lock().clone();
assert_eq!(
applied,
vec![RecordedApply {
target_owned_card_id: "oc-manager".to_string(),
target_kind: "manager".to_string(),
amount: 8,
}],
"gold TARGET column (8), not the silver CARD column (10)"
);
}
/// The regression that made 202 unservable: Core keeps a non-player's
/// `overall` at 0, and 0 scores BRONZE. `5001008`'s bronze column is 8 and its
/// silver column is 10, so a silver (`value` 66) manager separates the two
/// reads — 10 proves `source_rating` was read, 8 would prove `overall` was.
#[test]
fn a_manager_targets_tier_comes_from_source_rating_not_cores_zero_overall() {
let source = owned("oc-contract-mgr", "contract_manager");
let target = staff_owned("oc-manager", "card_manager", Some(66), "manager");
let (server, resolver) = apply_test_server(&[source, target.clone()]);
let wire = wire_of(&resolver, &target);
let econ = FakeEconomy::ok(0, 0);
let resp = server.handle_consumable_apply(5_001_008, &apply_body(wire), &econ);
assert_eq!(resp.status, 200);
assert_eq!(target.rating, 0, "Core's own `overall` for a non-player");
assert_eq!(
econ.applies.lock()[0].amount,
10,
"silver TARGET column (10); reading `overall` 0 would have paid bronze (8)"
);
}
/// A COACH is not a manager. All five staff families share
/// `ContentKind::Staff`, so only `cardsubtypeid` 4 may be granted manager
/// contracts — and the refusal must mutate nothing.
#[test]
fn manager_contract_refuses_a_coach_target() {
let source = owned("oc-contract-mgr", "contract_manager");
let target = staff_owned("oc-coach", "card_coach", Some(66), "staff");
let (server, resolver) = apply_test_server(&[source, target.clone()]);
let wire = wire_of(&resolver, &target);
let econ = FakeEconomy::ok(0, 0);
let resp = server.handle_consumable_apply(5_001_008, &apply_body(wire), &econ);
assert_eq!(resp.status, 409);
assert_eq!(resp.body, br#"{"error":"contract_target_not_a_manager"}"#);
assert!(econ.applies.lock().is_empty(), "nothing was mutated");
}
/// A PLAYER is not a manager either: 202 on a footballer has no proven effect,
/// and a player's catalog subtype is 0, so it fails the same gate.
#[test]
fn manager_contract_refuses_a_player_target() {
let source = owned("oc-contract-mgr", "contract_manager");
let target = owned("oc-player", "card_player");
let (server, resolver) = apply_test_server(&[source, target.clone()]);
let wire = wire_of(&resolver, &target);
let econ = FakeEconomy::ok(0, 0);
let resp = server.handle_consumable_apply(5_001_008, &apply_body(wire), &econ);
assert_eq!(resp.status, 409);
assert_eq!(resp.body, br#"{"error":"contract_target_not_a_manager"}"#);
assert!(econ.applies.lock().is_empty(), "nothing was mutated");
}
/// The mirror gate, unchanged: a PLAYER contract on a manager is refused by
/// kind, so implementing 202 did not loosen 201.
#[test]
fn player_contract_refuses_a_manager_target() {
let source = owned("oc-contract-plr", "contract_player");
let target = staff_owned("oc-manager", "card_manager", Some(88), "manager");
let (server, resolver) = apply_test_server(&[source, target.clone()]);
let wire = wire_of(&resolver, &target);
let econ = FakeEconomy::ok(0, 0);
let resp = server.handle_consumable_apply(5_001_002, &apply_body(wire), &econ);
assert_eq!(resp.status, 409);
assert_eq!(resp.body, br#"{"error":"contract_target_not_a_player"}"#);
assert!(econ.applies.lock().is_empty(), "nothing was mutated");
}
/// FAIL CLOSED. A manager Core carries no `source_rating` for has no honest
/// tier, and every possible default is a silent mis-credit: bronze under-pays
/// a gold manager, gold over-pays a bronze one. Refuse, mutate nothing.
#[test]
fn an_unrated_manager_target_is_refused_rather_than_defaulted() {
let source = owned("oc-contract-mgr", "contract_manager");
let target = staff_owned("oc-manager", "card_manager", None, "manager");
let (server, resolver) = apply_test_server(&[source, target.clone()]);
let wire = wire_of(&resolver, &target);
let econ = FakeEconomy::ok(0, 0);
let resp = server.handle_consumable_apply(5_001_008, &apply_body(wire), &econ);
assert_eq!(resp.status, 409);
assert_eq!(resp.body, br#"{"error":"manager_tier_unknown"}"#);
assert!(
econ.applies.lock().is_empty(),
"an unknown tier must never reach Core"
);
}
#[test]
fn marketdata_container_types_are_load_bearing() {
// /pricelimits MUST be a bare ARRAY (object-where-array froze a live client).
assert_eq!(
extract_defid_param("defId=200389,200104"),
vec![200389, 200104]
);
let arr = non_economy::marketdata_pricelimits_body(&[200389, 200104]);
assert!(arr.is_array(), "pricelimits must be a bare array");
let arr = arr.as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["defId"], 200389);
assert_eq!(arr[0]["minPrice"], 150);
assert_eq!(arr[0]["maxPrice"], 15000);
// plain /marketdata MUST be an OBJECT (array-where-object is the same freeze).
let obj = non_economy::marketdata_object_body();
assert!(obj.is_object(), "plain marketdata must be an object");
assert_eq!(obj["minPrice"], 150);
assert_eq!(obj["maxPrice"], 15000);
}
#[derive(Default)]
struct FakeSbcCore {
owned: Mutex<Vec<CoreOwnedItem>>,
saved: Mutex<std::collections::HashMap<String, Vec<String>>>,
completions: Mutex<std::collections::HashMap<String, i64>>,
}
impl CoreAccess for FakeSbcCore {
fn query_owned(&self, _params: &[(&str, String)]) -> Result<CorePage, CoreError> {
let items = self.owned.lock().unwrap().clone();
Ok(CorePage {
total: items.len() as i64,
items,
})
}
fn read_squad_ext(&self, _namespace: &str) -> Result<CoreSquadRead, CoreError> {
Err(CoreError::Status(501))
}
fn replace_squad(
&self,
_request: &CoreReplaceRequest,
) -> Result<CoreReplaceResult, CoreError> {
Err(CoreError::Status(501))
}
fn list_sbcs(&self) -> Result<Vec<CoreSbcDefinition>, CoreError> {
Ok(vec![
CoreSbcDefinition {
id: "sbc_bronze_upgrade".into(),
name: "Bronze Upgrade".into(),
description: "Submit two test players.".into(),
repeatable: true,
},
CoreSbcDefinition {
id: "sbc_hybrid_nations".into(),
name: "Hybrid Nations".into(),
description: "Submit a hybrid squad.".into(),
repeatable: false,
},
])
}
fn sbc_completion_counts(
&self,
) -> Result<std::collections::HashMap<String, i64>, CoreError> {
Ok(self.completions.lock().unwrap().clone())
}
fn load_sbc_squad(&self, sbc_id: &str) -> Result<Vec<String>, CoreError> {
Ok(self
.saved
.lock()
.unwrap()
.get(sbc_id)
.cloned()
.unwrap_or_default())
}
fn save_sbc_squad(
&self,
sbc_id: &str,
owned_card_ids: &[String],
) -> Result<Vec<String>, CoreError> {
self.saved
.lock()
.unwrap()
.insert(sbc_id.to_owned(), owned_card_ids.to_vec());
Ok(owned_card_ids.to_vec())
}
fn submit_sbc(
&self,
sbc_id: &str,
owned_card_ids: &[String],
) -> Result<CoreSbcResult, CoreError> {
if owned_card_ids.len() != 2 {
return Ok(CoreSbcResult {
passed: false,
failures: vec!["need exactly 2 cards".into()],
});
}
let mut owned = self.owned.lock().unwrap();
if owned_card_ids
.iter()
.any(|id| !owned.iter().any(|item| item.owned_card_id == *id))
{
return Err(CoreError::Status(409));
}
owned.retain(|item| !owned_card_ids.contains(&item.owned_card_id));
self.saved.lock().unwrap().remove(sbc_id);
*self
.completions
.lock()
.unwrap()
.entry(sbc_id.to_owned())
.or_default() += 1;
Ok(CoreSbcResult {
passed: true,
failures: vec![],
})
}
}
fn sbc_test_server() -> (
Server,
Arc<FakeSbcCore>,
Arc<EconomyServices>,
Arc<Fifa17IdentityResolver>,
[i64; 2],
) {
let first = owned("sbc-owned-1", "sbc-card-1");
let second = owned("sbc-owned-2", "sbc-card-2");
let core = Arc::new(FakeSbcCore {
owned: Mutex::new(vec![first.clone(), second.clone()]),
..FakeSbcCore::default()
});
let identity_path = temp_store_path("sbc-identity");
let resolver = Arc::new(resolver(
&identity_path,
&[("sbc-card-1", 20_801), ("sbc-card-2", 20_802)],
));
let wires = [
i64::from(resolver.resolve(&first).unwrap().item_id),
i64::from(resolver.resolve(&second).unwrap().item_id),
];
let bridge = Arc::new(AsyncBridge::new().unwrap());
let market_path = temp_store_path("sbc-market");
let pile_path = temp_store_path("sbc-piles");
let market = Arc::new(
bridge
.block_on(async move {
crate::market_store::MarketStore::open(
market_path.to_str().expect("UTF-8 temp market path"),
)
.await
})
.unwrap(),
);
let piles = Arc::new(
bridge
.block_on(async move {
crate::pile_store::PileStore::open(
pile_path.to_str().expect("UTF-8 temp pile path"),
)
.await
})
.unwrap(),
);
let services = Arc::new(EconomyServices {
econ: Arc::new(FakeEconomy::ok(1_100, 1)),
market,
piles,
bridge,
pool: Arc::new(vec![]),
sold_experiment: crate::sold_experiment::SoldExperiment::OFF,
});
let server = Server::new(
core.clone(),
Arc::new(Fifa17Entities::default()),
resolver.clone(),
Arc::new(PassClient::new("http://127.0.0.1:9")),
1,
)
.with_economy(services.clone());
(server, core, services, resolver, wires)
}
#[test]
fn fifa17_sbc_route_family_is_bounded_and_rust_owned() {
let routes = [
("GET", "/ut/game/fifa17/sbs/sets", EconomyRoute::SbcSets),
("PUT", "/ut/game/fifa17/sbs/sets/tag", EconomyRoute::SbcTag),
(
"GET",
"/ut/game/fifa17/sbs/setId/1/challenges",
EconomyRoute::SbcChallenges,
),
(
"GET",
"/ut/game/fifa17/sbs/challenge/101/squad",
EconomyRoute::SbcChallengeSquad,
),
(
"PUT",
"/ut/game/fifa17/sbs/challenge/101/squad",
EconomyRoute::SbcChallengeSquad,
),
(
"POST",
"/ut/game/fifa17/sbs/challenge/101",
EconomyRoute::SbcChallenge,
),
(
"PUT",
"/ut/game/fifa17/sbs/challenge/101",
EconomyRoute::SbcChallenge,
),
];
for (method, path, expected) in routes {
assert_eq!(classify_economy(method, path), Some(expected));
}
assert_eq!(
classify_economy("GET", "/ut/game/fifa17/sbs/challenge/x/squad"),
None
);
assert_eq!(
classify_economy("DELETE", "/ut/game/fifa17/sbs/challenge/101"),
None
);
assert_eq!(
classify_economy("GET", "/ut/game/fifa17/sbs/sets/extra"),
None
);
}
#[test]
fn fifa17_sbc_flow_enforces_host_eligibility_and_consumes_once() {
let (server, core, services, resolver, wires) = sbc_test_server();
let sets = server.handle("GET", "/ut/game/fifa17/sbs/sets", &[], b"");
assert_eq!(sets.status, 200);
let sets_body: Value = serde_json::from_slice(&sets.body).unwrap();
assert!(sets_body["categories"].is_array());
assert_eq!(
sets_body["categories"][0]["sets"].as_array().unwrap().len(),
2
);
let save_body = serde_json::to_vec(&json!({
"players": [
{ "index": 0, "itemData": { "id": wires[0] } },
{ "index": 1, "itemData": { "id": wires[1] } }
]
}))
.unwrap();
let piles = services.piles.clone();
let bridge = services.bridge.clone();
bridge
.block_on(async move { piles.set("sbc-owned-1", "purchased").await })
.unwrap();
let purchased = server.handle(
"PUT",
"/ut/game/fifa17/sbs/challenge/101/squad",
&[],
&save_body,
);
assert_eq!(purchased.status, 409);
assert!(core.saved.lock().unwrap().is_empty());
let piles = services.piles.clone();
services
.bridge
.block_on(async move { piles.set("sbc-owned-1", "unassigned").await })
.unwrap();
let unassigned = server.handle(
"PUT",
"/ut/game/fifa17/sbs/challenge/101/squad",
&[],
&save_body,
);
assert_eq!(unassigned.status, 409);
assert!(core.saved.lock().unwrap().is_empty());
let piles = services.piles.clone();
services
.bridge
.block_on(async move { piles.set("sbc-owned-1", "club").await })
.unwrap();
let market = services.market.clone();
services
.bridge
.block_on(async move {
market
.create_listing(
"900000001",
"sbc-card-1",
Some("sbc-owned-1"),
Some(wires[0]),
Some(20_801),
150,
200,
Some("owner"),
None,
Some(3600),
)
.await
})
.unwrap();
let listed = server.handle(
"PUT",
"/ut/game/fifa17/sbs/challenge/101/squad",
&[],
&save_body,
);
assert_eq!(listed.status, 409);
let market = services.market.clone();
services
.bridge
.block_on(async move { market.cancel_active_for_core_item("sbc-owned-1").await })
.unwrap();
let saved = server.handle(
"PUT",
"/ut/game/fifa17/sbs/challenge/101/squad",
&[],
&save_body,
);
assert_eq!(saved.status, 200);
let loaded = server.handle("GET", "/ut/game/fifa17/sbs/challenge/101/squad", &[], b"");
let loaded_body: Value = serde_json::from_slice(&loaded.body).unwrap();
assert_eq!(loaded_body["squad"][0]["itemData"]["id"], wires[0]);
assert_eq!(loaded_body["squad"][1]["itemData"]["id"], wires[1]);
let submitted = server.handle("PUT", "/ut/game/fifa17/sbs/challenge/101", &[], br#"{}"#);
assert_eq!(submitted.status, 200);
let submitted_body: Value = serde_json::from_slice(&submitted.body).unwrap();
assert_eq!(submitted_body["challengeId"], 101);
assert_eq!(submitted_body["setId"], 1);
assert_eq!(submitted_body["credits"], 1_100);
assert_eq!(submitted_body["recoveredPacks"], 1);
assert!(submitted_body["grantedChallengeAwards"].is_array());
assert!(core.owned.lock().unwrap().is_empty());
assert_eq!(
core.completions
.lock()
.unwrap()
.get("sbc_bronze_upgrade")
.copied(),
Some(1)
);
for core_id in ["sbc-owned-1", "sbc-owned-2"] {
let piles = services.piles.clone();
let id = core_id.to_owned();
assert_eq!(
services
.bridge
.block_on(async move { piles.get(&id).await })
.unwrap(),
None
);
}
assert_eq!(
resolver.owned_id_for_wire(wires[0]).as_deref(),
Some("sbc-owned-1"),
"durable mapping remains, but no ownership-backed projection can emit it"
);
let reveal = server.handle("GET", "/ut/v2/game/fifa17/purchased/items", &[], b"");
let reveal_body: Value = serde_json::from_slice(&reveal.body).unwrap();
assert!(reveal_body["itemData"].as_array().unwrap().is_empty());
let replay = server.handle("PUT", "/ut/game/fifa17/sbs/challenge/101", &[], &save_body);
assert_eq!(replay.status, 404);
assert_eq!(
core.completions
.lock()
.unwrap()
.get("sbc_bronze_upgrade")
.copied(),
Some(1)
);
}
fn submit_sbc_with_fault(fault: SbcPostCommitFault) -> (WireResponse, Arc<FakeSbcCore>) {
let (server, core, _services, _resolver, wires) = sbc_test_server();
let server = server.with_sbc_post_commit_fault(fault);
let save_body = serde_json::to_vec(&json!({
"squad": [
{ "index": 0, "itemData": { "id": wires[0] } },
{ "index": 1, "itemData": { "id": wires[1] } }
]
}))
.unwrap();
assert_eq!(
server
.handle(
"PUT",
"/ut/game/fifa17/sbs/challenge/101/squad",
&[],
&save_body,
)
.status,
200
);
let response = server.handle("PUT", "/ut/game/fifa17/sbs/challenge/101", &[], br#"{}"#);
(response, core)
}
#[test]
fn sbc_post_commit_fault_modes_lose_only_the_receipt() {
let (normal_response, normal_core) = submit_sbc_with_fault(SbcPostCommitFault::Off);
let mut normal = Vec::new();
assert!(!write_response(&mut normal, &normal_response).unwrap());
assert!(normal.ends_with(&normal_response.body));
assert_eq!(
normal_core
.completions
.lock()
.unwrap()
.get("sbc_bronze_upgrade")
.copied(),
Some(1)
);
let (drop_response, drop_core) = submit_sbc_with_fault(SbcPostCommitFault::Drop);
assert_eq!(drop_response.status, 200);
assert_eq!(
drop_core
.completions
.lock()
.unwrap()
.get("sbc_bronze_upgrade")
.copied(),
Some(1),
"Core commit precedes the simulated connection drop"
);
let mut dropped = Vec::new();
assert!(write_response(&mut dropped, &drop_response).unwrap());
assert!(dropped.is_empty(), "drop mode writes no receipt bytes");
let (malformed_response, malformed_core) =
submit_sbc_with_fault(SbcPostCommitFault::Malformed);
assert_eq!(
malformed_core
.completions
.lock()
.unwrap()
.get("sbc_bronze_upgrade")
.copied(),
Some(1)
);
let mut malformed = Vec::new();
assert!(write_response(&mut malformed, &malformed_response).unwrap());
assert!(malformed.ends_with(b"{"));
assert!(
malformed_response.body.len() > 1,
"advertised body is deliberately truncated"
);
let (delayed_response, delayed_core) =
submit_sbc_with_fault(SbcPostCommitFault::Delay { millis: 20 });
let started = Instant::now();
let mut delayed = Vec::new();
assert!(!write_response(&mut delayed, &delayed_response).unwrap());
assert!(started.elapsed() >= std::time::Duration::from_millis(20));
assert!(delayed.ends_with(&delayed_response.body));
assert_eq!(
delayed_core
.completions
.lock()
.unwrap()
.get("sbc_bronze_upgrade")
.copied(),
Some(1)
);
}
/// The `?type=` vocabulary must match the CLIENT's exactly — no arm missing,
/// and no arm invented.
///
/// The client resolves the token in `FUN_18012ec50`, a 30-case jump table
/// (`cmp ecx,0x1d`) where each case is `mov ecx,<atom>; jmp <atom->string>`.
/// Decoding that table against `docs/fut_atoms.tsv` on 2026-08-21 produced
/// exactly the list below, and it matched this function one-for-one.
///
/// A MISSING arm answers a real tab with `unsupported_type` and an empty
/// screen. An INVENTED arm is worse: it is a token the client cannot send,
/// so it is dead code that looks like coverage.
#[test]
fn club_type_vocabulary_matches_the_clients_thirty_arms() {
// FUN_18012ec50 cases 0..=29, in table order.
const CLIENT_TOKENS: [&str; 30] = [
"any",
"player",
"manager",
"headcoach",
"fitnesscoach",
"physio",
"development",
"custom",
"unlocks",
"gkcoach",
"staff",
"badge",
"kit",
"stadium",
"ball",
"equippables",
"leaguelogos",
"offlinetrophy",
"onlinetrophy",
"featuredofflinetrophy",
"featuredonlinetrophy",
"allofflinetrophy",
"allonlinetrophy",
"healing",
"contract",
"training",
"misc",
"playerdefender",
"playermidfielder",
"playerforward",
];
for token in CLIENT_TOKENS {
assert!(
club_type_filter(Some(token)).is_some(),
"the client can send type={token} and this host has no arm for it"
);
}
// A token outside the taxonomy stays unsupported — the honest, loud answer.
for bogus in ["playergoalkeeper", "trophies", "consumable", ""] {
assert!(
club_type_filter(Some(bogus)).is_none(),
"type={bogus} is not one of the client's 30 arms and must not be \
silently mapped onto a real set"
);
}
// Absent = the main club screen, which is live-proven to be the players.
assert!(club_type_filter(None).is_some());
}
}