Files
OpenFUT/openfut-utas-host/src/lib.rs
T
funman300 ce5d4204ac feat(host): staging-only consumable-apply probe; reverse the success contract
Claims POST ut/<sku>/item/resource/<resourceId> -- the consumable apply captured
live 2026-08-21 -- behind OPENFUT_FIFA17_APPLY_PROBE=1, default OFF. With the
gate off the route takes the extracted `passthrough` method, i.e. byte-for-byte
the behaviour that existed before this commit, so production cannot serve a
diagnostic even if the route is reached.

The handler is NON-AUTHORITATIVE BY CONSTRUCTION: it consumes no source card,
mutates no target, touches no contract/fitness/chemistry/training/injury state,
mints no coins and changes no ownership. It exists only to observe the client's
success path, because the EFFECT of a consumable is still unreversed and
implementing one on an inferred value is not acceptable.

RESPONSE SHAPE, from static RE rather than convenience (the brief was explicit
that `{}` must not be chosen because it is easy):

  * The apply completion handler is CardsDLL 0x180035520. It does
    `mov ecx,[rdx+0x1c]; test ecx,ecx; jne FAILURE`, raising
    EVENT_CARDS_APPLY_CARD_SUCCESS (0x1801f37f0) on zero and
    EVENT_CARDS_APPLY_CARD_FAILURE (0x1801f3810) otherwise. It tests exactly one
    field -- the transport code -- and never inspects the body.
  * That is materially different from the MOVE ack (0x180128600), which builds
    per-item verdict records and reports FAILURE when the vector is EMPTY. The
    `{}`-is-broken precedent does not transfer.
  * The response object's constructor (0x1800a4ce0) initialises its record vector
    (+0x50/+0x58/+0x60, 0x20-byte elements) EMPTY, so an empty parse result is a
    legal state here, and the destructor (0x1800682b0) frees it accordingly.
  * The legacy oracle routes `item/resource` method-agnostically to defs_route,
    so historically this path answered with an `itemData` OBJECT.

`{"itemData":[]}` is the smallest candidate consistent with all four, and it is
labelled a PROBE, not a proven contract.

`apply` is an array, but only len==1 has ever been observed, so a multi-target
request is logged and refused (400 apply_batch_unsupported) rather than given
invented batch semantics.

Operands are identified READ-ONLY for the capture: the source by Core card id
(`<sku>_<resourceId>`, no new resolver method for a probe) with a copy count, the
target by reversing the wire id through the identity store -- never a guess,
`UNRESOLVED_WIRE_ID` when unknown.

Also records the reversed protocol and the `development` finding in
CLIENT_ROUTE_SURFACE.md.

122 host tests (+2: the verb/resource-id classification boundary, and target
parsing incl. the exact captured bytes). clippy and fmt clean.
2026-08-22 00:49:23 +00:00

6989 lines
283 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,
};
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_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 …/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,
/// `POST …/item/resource/<resourceId>` — consumable APPLICATION
/// (`ApplyCardByRes`, task id `0x0e`), captured live 2026-08-21 as
/// `{"apply":[{"id":<target>}]}`. The source consumable is the RESOURCE id in
/// the path; the targets are owned-item wire ids in the body.
///
/// This classifies unconditionally so the route table stays a pure function of
/// (method, path) and remains testable, but the handler is a STAGING-ONLY
/// DIAGNOSTIC: without `OPENFUT_FIFA17_APPLY_PROBE=1` it declines and the
/// request falls through to the Python passthrough exactly as it does today.
/// The effect of a consumable is UNREVERSED, so nothing is ever mutated here.
ConsumableApplyProbe,
/// 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("item/resource") if get => Route::ItemDefs,
// The apply re-uses the item-definition PATH with a different VERB and a
// trailing resource id, which is why it fell through to Python: the
// `item/resource` arm above is GET-only. Live-captured 2026-08-21.
Some(t)
if post
&& t.strip_prefix("item/resource/")
.is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit())) =>
{
Route::ConsumableApplyProbe
}
Some("defid") if get => Route::ItemDefs,
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,
/// `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),
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,
}
/// 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>;
}
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")?,
})
}
}
/// 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")?;
let attr = |k: &str| 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"),
],
})
}
/// 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"),
],
})
}
// ───────────────────────────── 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,
})
}
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). A single-family answer here is not available either —
// the view is by definition multi-family — and the kit swap it feeds
// needs `item+0x60 == 4`, which a server can never produce (it can
// only ever produce 1 = club and 6 = purchased). So this stays empty
// until that is a client-side change, not a wire one.
selector: ClubSelector::Withheld("multi_family_crash_2026_08_05"),
},
Some("leaguelogos") => ClubTypeFilter {
label: "leaguelogos",
// Subtype 31 is by elimination and unprobed, there is no
// `FUT_UC_LEAGUELOGO` caption anywhere in the DLL, and the family's
// only display name would be `localizedName` — "the parser reads it"
// is not "sending it is safe". Also not an ownable Core content kind.
selector: ClubSelector::Withheld("subtype_by_elimination_unprobed"),
},
Some("healing") | Some("contract") | Some("training") | Some("development") => {
ClubTypeFilter {
label: "consumable_arm",
// Consumables are NOT served through `club?type=`. A previous
// round shipped four `?type=` arms for exactly these tokens and
// the screen stayed empty: the client asks
// `GET club/consumables/<category>`, whose element is a STACK
// wrapper, and a bare item in this envelope is accepted and
// silently discarded.
selector: ClubSelector::Withheld("served_by_club_consumables_route"),
}
}
Some("unlocks") => ClubTypeFilter {
label: "unlocks",
selector: ClubSelector::Withheld("not_owned_inventory"),
},
Some("offlinetrophy")
| Some("onlinetrophy")
| Some("featuredofflinetrophy")
| Some("featuredonlinetrophy")
| Some("allofflinetrophy")
| Some("allonlinetrophy") => ClubTypeFilter {
label: "trophy",
// Trophies are the `0x91..=0x96` tournament/season records, not owned
// club items, and Core models no trophy ownership. The club/stats
// trophy rows stay honest zeros for the same reason.
selector: ClubSelector::Withheld("no_trophy_ownership_in_core"),
},
Some(_) => return None,
};
Some(filter)
}
/// Filter already-shaped `/club` items to SPECIALS (`rareflag > 1`) and paginate
/// the filtered set locally. Returns `(page, total_specials)`. Pure — the whole
/// point is that "special" pagination is over the filtered set, never Core's
/// 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::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::ConsumableApplyProbe => {
match self.handle_consumable_apply_probe(target, body) {
Some(resp) => resp,
// Gate off: identical to today — proxy it verbatim.
None => self.passthrough(method, target, headers, body),
}
}
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 the declined
/// consumable-apply probe takes EXACTLY this path — with the gate off there is
/// no behavioural difference from before the probe existed.
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))
}
/// `POST …/item/resource/<resourceId>` — STAGING-ONLY consumable-apply
/// diagnostic. Returns `None` (→ Python passthrough, today's behaviour) unless
/// `OPENFUT_FIFA17_APPLY_PROBE=1`.
///
/// NON-AUTHORITATIVE BY CONSTRUCTION. It consumes no source card, mutates no
/// target, touches no contract/fitness/chemistry/training/injury state, mints
/// no coins and changes no ownership. It exists to observe what the client
/// does with a success, because the EFFECT of a consumable is unreversed and
/// implementing one on an inferred value is not acceptable.
///
/// 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 empty is a legal
/// parse result here. `{"itemData":[]}` is therefore the smallest candidate
/// consistent with both the client and the oracle, whose `item/resource` route
/// is method-agnostic and answers this path with an `itemData` object.
/// It is a PROBE, not a proven contract.
fn handle_consumable_apply_probe(&self, target: &str, body: &[u8]) -> Option<WireResponse> {
if !apply_probe_enabled() {
return None;
}
let path = target.split('?').next().unwrap_or(target);
let resource_id: i64 = path.rsplit('/').next().and_then(|s| s.parse().ok())?;
let targets = parse_apply_targets(body);
// `apply` is an ARRAY, but only len==1 has ever been observed. Batch
// semantics (atomic? partial?) are unknown, so a multi-target request is
// reported and refused rather than guessed at.
if targets.len() != 1 {
eprintln!(
"utas-host owner=RUST route=apply-probe status=refused resource={resource_id} \
targets={} reason=batch_semantics_unproven body={}",
targets.len(),
String::from_utf8_lossy(&body[..body.len().min(256)])
);
return Some(error_response(400, "apply_batch_unsupported"));
}
// Read-only identification of both operands, so the capture names what was
// applied to what. No write path is reachable from here.
let owned = self.core.all_owned().unwrap_or_default();
// A Core card id is "<sku>_<resourceId>", so the path's resource id names
// the DEFINITION directly; no new resolver method is needed for a probe.
let is_source = |it: &CoreOwnedItem| {
it.card_id
.rsplit_once('_')
.and_then(|(_, n)| n.parse::<i64>().ok())
== Some(resource_id)
};
let copies = owned.iter().filter(|it| is_source(it)).count();
let source_desc = match owned.iter().find(|it| is_source(it)) {
Some(it) => format!(
"owned kind={:?} subtype={} copies={}",
self.resolver.kind_of(it),
self.resolver.subtype_of(it),
copies
),
None => "NOT_OWNED".to_string(),
};
// Reverse the wire id through the identity store -- never a guess.
let target_desc = match self.resolver.owned_id_for_wire(targets[0]) {
Some(core_id) => match owned.iter().find(|it| it.owned_card_id == core_id) {
Some(it) => format!(
"owned card={} rating={} kind={:?}",
it.card_id,
it.rating,
self.resolver.kind_of(it)
),
None => format!("known_wire_id={core_id} NOT_IN_CLUB"),
},
None => "UNRESOLVED_WIRE_ID".to_string(),
};
eprintln!(
"utas-host owner=RUST route=apply-probe status=200 PROBE_ONLY resource={resource_id} \
source={source_desc} target={} target_item={target_desc} mutated=NOTHING",
targets[0]
);
Some(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 the STAGING-ONLY consumable-apply diagnostic answers.
///
/// OFF unless `OPENFUT_FIFA17_APPLY_PROBE=1`. With it off the route falls through
/// to the Python passthrough, i.e. byte-for-byte today's behaviour, so production
/// cannot accidentally serve a diagnostic. The probe exists ONLY to observe the
/// client's success path: the consumable EFFECT is unreversed, so it consumes
/// nothing and mutates nothing.
fn apply_probe_enabled() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_APPLY_PROBE").as_deref() == Ok("1"))
}
/// 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"))
}
/// 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;
/// 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,
}
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,
}
}
fn failing() -> Self {
FakeEconomy {
balance: 0,
entitlements: vec![],
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,
}
}
}
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,
})
}
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],
}
}
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());
}
/// The apply re-uses the definition-lookup PATH with a different VERB, which
/// is exactly why it went unclaimed. Lock that boundary.
#[test]
fn consumable_apply_is_classified_by_verb_and_resource_id() {
// Live-captured 2026-08-21: POST ut/<sku>/item/resource/<resourceId>.
assert_eq!(
classify("POST", "/ut/game/fifa17/item/resource/5001004"),
Route::ConsumableApplyProbe
);
assert_eq!(
classify("POST", "/ut/v2/game/fifa17/item/resource/5001004"),
Route::ConsumableApplyProbe
);
// A non-numeric tail is not a resource id, so it is not the apply.
assert_eq!(
classify("POST", "/ut/game/fifa17/item/resource/bogus"),
Route::Passthrough
);
// The definition lookup keeps the path under its own verb.
assert_eq!(
classify("GET", "/ut/game/fifa17/item/resource"),
Route::ItemDefs
);
}
#[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());
}
#[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());
}
}