a6416a3f1d
Round-2 live staging (candidate 47ced22) showed the CONFIRMED retail Store BUY
uses POST /ut/game/fifa17/purchased/items (reveal GET .../purchased/items),
which the exact-tail 'purchased' match missed -> Python (Core coins unchanged).
Comprehensive audited fix in classify_economy:
- is_purchased_tail: 'purchased' AND 'purchased/items' (POST->PackOpen,
GET->PackReveal); bounded (rejects purchasedfoo, purchased/items/extra).
- is_tradepile_tail: 'tradePile' family CASE-INSENSITIVE incl 'tradePile/counts'
(hub tile polls lowercase; oracle routes via re.I); allocation-free.
- (kept) v1/v2 prefix normalization + store/transaction[/<digits>].
Adds retail_route_matrix unit test = the machine-auditable route contract gate
(all economy shapes + negative near-misses). Host lib 75.
3362 lines
128 KiB
Rust
3362 lines
128 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 async_bridge;
|
|
pub mod config;
|
|
pub mod economy_store;
|
|
pub mod market;
|
|
pub mod market_store;
|
|
pub mod pile_store;
|
|
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::net::{TcpListener, TcpStream};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Instant;
|
|
|
|
use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy};
|
|
use openfut_adapter_fifa17::fut::club_response::{
|
|
shape_club_response, CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats,
|
|
};
|
|
use openfut_adapter_fifa17::fut::economy_policy::{
|
|
match_reward_total, result_from_end_reason, MatchResult,
|
|
};
|
|
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
|
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::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;
|
|
use openfut_adapter_fifa17::fut::store_session::{
|
|
validate_capability, SessionStore, StoreMode, SENTINEL_PACK_ID,
|
|
};
|
|
use openfut_identity::ExternalIdentityStore;
|
|
use rand::SeedableRng;
|
|
use serde_json::{json, Value};
|
|
|
|
use config::HostConfig;
|
|
|
|
// ───────────────────────────── 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 …/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` — proxied to Python, with only `.squad` overlaid.
|
|
UserMassInfo,
|
|
/// `POST /ut/auth` — proxied to Python (persona adoption + SID mint); the
|
|
/// returned `X-UT-SID` is observed to open a Rust session.
|
|
Auth,
|
|
/// `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,
|
|
/// Anything else — proxied verbatim to the Python oracle.
|
|
Passthrough,
|
|
}
|
|
|
|
/// Classify a request ONCE, before execution. Rust owns exactly:
|
|
/// * `GET …/club`
|
|
/// * `PUT …/squad/<n>` (numeric id)
|
|
/// * `GET …/squad/list`
|
|
/// * `GET …/squad/active` (the active squad, projected from Core)
|
|
/// * `GET …/userMassInfo` (proxied, `.squad` overlaid)
|
|
///
|
|
/// Everything else — numeric `GET …/squad/<n>`, `/clubUser`, auth, packs,
|
|
/// market, other mutations — falls through to Python. There is no
|
|
/// "try Rust then Python", so a squad mutation can never be double-applied.
|
|
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; economy stays Python).
|
|
if post && path.starts_with("/ut/auth") {
|
|
return Route::Auth;
|
|
}
|
|
if post && path == "/openfut/fifa17/capability" {
|
|
return Route::Capability;
|
|
}
|
|
if get && is_exact_club_path(path) {
|
|
return Route::Club;
|
|
}
|
|
match ut_tail(path) {
|
|
Some("squad/list") if get => Route::SquadList,
|
|
Some("squad/active") if get => Route::SquadActive,
|
|
Some("userMassInfo") if get => Route::UserMassInfo,
|
|
Some(tail) if get && tail.starts_with("store/purchasegroup") => Route::StorePurchaseGroup,
|
|
Some(tail) if put && is_numeric_squad_tail(tail) => Route::SquadReplace,
|
|
_ => 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)
|
|
}
|
|
}
|
|
|
|
fn is_exact_club_path(path: &str) -> bool {
|
|
ut_tail(path) == Some("club")
|
|
}
|
|
|
|
/// `squad/<digits>` — the numeric full-squad target used by `PUT`. The active
|
|
/// squad READ (`GET …/squad/active`) is routed separately (Core-backed); a
|
|
/// numeric `GET …/squad/<n>` for a non-active squad stays on Python (there is no
|
|
/// Core model for multiple squads yet).
|
|
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,
|
|
/// `POST /ut/delete/game/<sku>/match` — match END (the coin-crediting call).
|
|
MatchEnd,
|
|
/// `…/auctionhouse` | `…/transfermarket` — list-for-sale / browse.
|
|
MarketList,
|
|
/// `GET …/tradePile` — the user's own active listings.
|
|
MarketQuery,
|
|
/// `…/trade/<id>` — view / buy-now.
|
|
MarketBuy,
|
|
/// `DELETE /ut/delete/game/<sku>/trade/<id>` — cancel a listing.
|
|
MarketCancel,
|
|
}
|
|
|
|
/// `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,
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
if tail.starts_with("trade") && delete {
|
|
return Some(EconomyRoute::MarketCancel);
|
|
}
|
|
if tail == "match" && post {
|
|
return Some(EconomyRoute::MatchEnd);
|
|
}
|
|
}
|
|
return None;
|
|
}
|
|
|
|
match ut_tail(path) {
|
|
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),
|
|
Some(t) if get && is_tradepile_tail(t) => Some(EconomyRoute::MarketQuery),
|
|
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,
|
|
}
|
|
|
|
/// How the host reaches Core. The adapter never sees this — the host owns the
|
|
/// transport, mirroring the architecture rule. Tests inject a fake.
|
|
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)
|
|
}
|
|
|
|
/// 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>;
|
|
}
|
|
|
|
/// 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 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,
|
|
})
|
|
}
|
|
}
|
|
|
|
// ───────────────────────────── 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,
|
|
}
|
|
|
|
/// 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>;
|
|
}
|
|
|
|
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()))
|
|
}
|
|
}
|
|
|
|
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}`")))
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
/// 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"),
|
|
],
|
|
})
|
|
}
|
|
|
|
// ───────────────────────────── 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)
|
|
}
|
|
}
|
|
|
|
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 = match self.store.resolve_or_allocate(
|
|
Fifa17WireItemIdPolicy::GAME,
|
|
Fifa17WireItemIdPolicy::OWNED_ITEM_KIND,
|
|
&item.owned_card_id,
|
|
Fifa17WireItemIdPolicy::owned_item_base_floor(),
|
|
) {
|
|
Ok(w) => w,
|
|
Err(e) => {
|
|
// Infrastructure failure allocating a wire id: drop this item
|
|
// (freeze-safe) and log — never emit an unstable/fake id.
|
|
eprintln!(
|
|
"utas-host ERROR identity store alloc failed for {}: {e}",
|
|
item.owned_card_id
|
|
);
|
|
return None;
|
|
}
|
|
};
|
|
Some(Fifa17Identity {
|
|
// Wire ids live in 1e8..9e8 (policy) — well within u32.
|
|
item_id: wire as u32,
|
|
asset_id: ident.asset_id,
|
|
resource_id: ident.resource_id,
|
|
rareflag: ident.rareflag,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|
|
|
|
/// Reverse a FIFA wire `resourceId` to the authoritative Core `card_id`, via the
|
|
/// same catalog `/club` shaping uses — so a synthetic market buy mints real Core
|
|
/// content, never the raw FIFA number. Out-of-range or unmapped → `None`
|
|
/// (fail closed; Core never sees a FIFA resource id).
|
|
impl crate::market::MarketCardResolver for Fifa17IdentityResolver {
|
|
fn card_id_for_resource(&self, resource_id: i64) -> Option<String> {
|
|
let rid = u32::try_from(resource_id).ok()?;
|
|
self.catalog.card_id_for_resource(rid).map(str::to_string)
|
|
}
|
|
}
|
|
|
|
// ───────────────────────────── /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,
|
|
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),
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// 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 core_q = match map_to_core(&raw, deps.entities) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
// Unknown FIFA id — never a raw-id passthrough, never a guess.
|
|
return (
|
|
json_response(&json!({ "itemData": [] })),
|
|
ClubLog {
|
|
outcome: "unknown_id",
|
|
filter: describe_map_error(&e),
|
|
total: 0,
|
|
emitted: 0,
|
|
dropped_no_asset: 0,
|
|
offset: raw.start.map(|s| s as i64),
|
|
limit: raw.count.map(|c| c as i64),
|
|
},
|
|
);
|
|
}
|
|
};
|
|
let pairs = core_q.to_query_pairs();
|
|
let filter = summarize(&pairs);
|
|
let (offset, limit) = (core_q.offset, core_q.limit);
|
|
|
|
// "Special" (rare=SP): rareflag lives in the FIFA catalog, not Core, so Core
|
|
// cannot filter it. Fetch everything matching the OTHER filters (no
|
|
// offset/limit), shape (which resolves each item's rareflag), keep only
|
|
// specials (rareflag > 1), then paginate the filtered set locally.
|
|
if core_q.special {
|
|
let mut base = core_q.clone();
|
|
base.offset = None;
|
|
base.limit = None;
|
|
return match deps.core.query_owned(&base.to_query_pairs()) {
|
|
Ok(page) => {
|
|
let (body, stats) = shape_club_response(&page.items, deps.entities, deps.assets);
|
|
let all = body
|
|
.get("itemData")
|
|
.and_then(|v| v.as_array())
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
let (paged, total) = special_filter_page(&all, offset, limit);
|
|
let emitted = paged.len();
|
|
(
|
|
json_response(&json!({ "itemData": paged })),
|
|
ClubLog {
|
|
outcome: "ok",
|
|
filter: format!("{filter},rare=SP"),
|
|
total,
|
|
emitted,
|
|
dropped_no_asset: stats.dropped_no_asset,
|
|
offset,
|
|
limit,
|
|
},
|
|
)
|
|
}
|
|
Err(e) => {
|
|
eprintln!("utas-host ERROR /club (special) core query failed: {e}");
|
|
(
|
|
json_response(&json!({ "itemData": [] })),
|
|
ClubLog {
|
|
outcome: "core_error",
|
|
filter: format!("{filter},rare=SP"),
|
|
total: 0,
|
|
emitted: 0,
|
|
dropped_no_asset: 0,
|
|
offset,
|
|
limit,
|
|
},
|
|
)
|
|
}
|
|
};
|
|
}
|
|
match deps.core.query_owned(&pairs) {
|
|
Ok(page) => {
|
|
let (body, stats): (Value, ShapeStats) =
|
|
shape_club_response(&page.items, deps.entities, deps.assets);
|
|
(
|
|
json_response(&body),
|
|
ClubLog {
|
|
outcome: "ok",
|
|
filter,
|
|
total: page.total,
|
|
emitted: stats.emitted,
|
|
dropped_no_asset: stats.dropped_no_asset,
|
|
offset,
|
|
limit,
|
|
},
|
|
)
|
|
}
|
|
Err(e) => {
|
|
// Degrade to a valid empty page; DO NOT fall back to Python.
|
|
eprintln!("utas-host ERROR /club core query failed: {e}");
|
|
(
|
|
json_response(&json!({ "itemData": [] })),
|
|
ClubLog {
|
|
outcome: "core_error",
|
|
filter,
|
|
total: 0,
|
|
emitted: 0,
|
|
dropped_no_asset: 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();
|
|
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,
|
|
};
|
|
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(),
|
|
}
|
|
}
|
|
|
|
/// `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(),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
// 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(),
|
|
};
|
|
match deps.core.replace_squad(&req) {
|
|
Ok(_) => (
|
|
json_response(&save_ack(put.id)),
|
|
SquadLog {
|
|
outcome: "ok",
|
|
detail: String::new(),
|
|
},
|
|
),
|
|
Err(e) => (
|
|
error_response(502, "core_error"),
|
|
SquadLog {
|
|
outcome: "core_error",
|
|
detail: e.to_string(),
|
|
},
|
|
),
|
|
}
|
|
}
|
|
|
|
/// `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 unopened pack ids (definition_id parsed as the
|
|
/// numeric pack id; unparseable entries are skipped, never faked).
|
|
fn entitlement_pack_ids(ents: &[EconomyEntitlement]) -> Vec<u64> {
|
|
ents.iter()
|
|
.filter_map(|e| e.definition_id.parse::<u64>().ok())
|
|
.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
|
|
}
|
|
|
|
/// Build the `destroy_match_body` reward response (oracle shape). `total` is the
|
|
/// post-credit balance; `result_coins` is the per-result amount.
|
|
pub fn build_match_reward_body(total: i64, result: MatchResult) -> Value {
|
|
let result_coins = openfut_adapter_fifa17::fut::economy_policy::match_result_coins(result);
|
|
let total_award = match_reward_total(result);
|
|
json!({
|
|
"allCoins": total,
|
|
"matchCoins": result_coins,
|
|
"seasonCoins": 0,
|
|
"tournamentCoins": 0,
|
|
"boostConis": 0,
|
|
"participationAward": openfut_adapter_fifa17::fut::economy_policy::MATCH_PARTICIPATION,
|
|
"teamOfTournamentWinner": false,
|
|
"gameModeAward": { "coins": total_award },
|
|
})
|
|
}
|
|
|
|
/// Handle the coin-crediting `/match` end call: derive the outcome from
|
|
/// `endReason`, credit the reward through Core `grant_reward`, and render the
|
|
/// oracle-shaped body. Fail-closed on Core error (503, never Python).
|
|
pub fn handle_match_end(econ: &dyn CoreEconomy, body: &[u8]) -> WireResponse {
|
|
let end_reason = serde_json::from_slice::<Value>(body).ok().and_then(|v| {
|
|
v.get("endReason")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string)
|
|
});
|
|
let result = result_from_end_reason(end_reason.as_deref());
|
|
match econ.grant_reward(match_reward_total(result)) {
|
|
Ok(total) => json_response(&build_match_reward_body(total, result)),
|
|
Err(_) => error_response(503, "core_unavailable"),
|
|
}
|
|
}
|
|
|
|
// ───────────────────────────── HTTP wire types ──────────────────────────────
|
|
|
|
/// A response ready to write: status, headers, body.
|
|
#[derive(Debug, Clone)]
|
|
pub struct WireResponse {
|
|
pub status: u16,
|
|
pub headers: Vec<(String, String)>,
|
|
pub body: Vec<u8>,
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
}
|
|
|
|
// ───────────────────────────── 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>>,
|
|
}
|
|
|
|
/// Build the pack-content candidate pool from Core's current content, evidenced
|
|
/// by the owned inventory: every distinct owned card definition that resolves to
|
|
/// a real FIFA asset id is a candidate (`gold` = rating ≥ 75; `special` from the
|
|
/// catalog `rareflag > 1`). This is the resolvable FIFA∩Core card universe — the
|
|
/// cards a pack can award and the shared shaper can render. 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> {
|
|
let owned = 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 &owned {
|
|
if !seen.insert(item.card_id.clone()) {
|
|
continue;
|
|
}
|
|
let Some(id) = resolver.resolve(item) else {
|
|
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: id.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,
|
|
/// 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>>,
|
|
/// 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>>,
|
|
}
|
|
|
|
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,
|
|
sessions: Arc::new(Mutex::new(SessionStore::new())),
|
|
start: Instant::now(),
|
|
economy: 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()));
|
|
let economy = Arc::new(EconomyServices {
|
|
econ,
|
|
market,
|
|
piles,
|
|
bridge,
|
|
pool,
|
|
});
|
|
|
|
Ok(Server::new(
|
|
core,
|
|
entities,
|
|
resolver,
|
|
Arc::new(PassClient::new(cfg.python_upstream.clone())),
|
|
cfg.persona_id,
|
|
)
|
|
.with_economy(economy))
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
/// 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). This is the
|
|
/// handler-wiring entry point exercised by the integration harness; it is
|
|
/// deliberately NOT called by `handle_with_ip` yet — handler wiring and the
|
|
/// authority cutover are distinct steps, and the classifier barrier is one
|
|
/// later coherent flip. Sync handlers run inline; the async transfer-market /
|
|
/// move handlers run on the shared runtime via the 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 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,
|
|
};
|
|
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,
|
|
};
|
|
handle_quick_sell_body(body, &deps)
|
|
}
|
|
EconomyRoute::MatchEnd => handle_match_end(svc.econ.as_ref(), body),
|
|
EconomyRoute::MoveItems => {
|
|
let (bridge, piles, resolver) =
|
|
(svc.bridge.clone(), svc.piles.clone(), self.resolver.clone());
|
|
let body = body.to_vec();
|
|
bridge.block_on(async move {
|
|
crate::market::handle_move_items(&body, resolver.as_ref(), piles.as_ref()).await
|
|
})
|
|
}
|
|
EconomyRoute::MarketList => {
|
|
let (bridge, market, econ, resolver) = (
|
|
svc.bridge.clone(),
|
|
svc.market.clone(),
|
|
svc.econ.clone(),
|
|
self.resolver.clone(),
|
|
);
|
|
let (m, body) = (method.to_string(), body.to_vec());
|
|
bridge.block_on(async move {
|
|
crate::market::handle_market_list(
|
|
&m,
|
|
&body,
|
|
econ.as_ref(),
|
|
market.as_ref(),
|
|
resolver.as_ref(),
|
|
)
|
|
.await
|
|
})
|
|
}
|
|
EconomyRoute::MarketQuery => {
|
|
let (bridge, market, econ) =
|
|
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
|
bridge.block_on(async move {
|
|
crate::market::handle_market_query("active", econ.as_ref(), market.as_ref())
|
|
.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
|
|
})
|
|
}
|
|
};
|
|
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 deps = ClubDeps {
|
|
core: self.core.as_ref(),
|
|
entities: self.entities.as_ref(),
|
|
assets: self.resolver.as_ref(),
|
|
};
|
|
let (resp, log) = handle_club(query, &deps);
|
|
eprintln!(
|
|
"utas-host owner=RUST route=club status={} outcome={} filter=[{}] total={} emitted={} dropped_no_asset={} offset={:?} limit={:?}",
|
|
resp.status, log.outcome, log.filter, log.total, log.emitted, log.dropped_no_asset, log.offset, log.limit
|
|
);
|
|
resp
|
|
}
|
|
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 => {
|
|
let deps = self.squad_deps();
|
|
let (mut resp, log) =
|
|
handle_user_mass_info(method, target, headers, body, &deps, self.pass.as_ref());
|
|
// Overlay the authoritative Core economy (coins + unopened-pack
|
|
// count) so NO stale Python economy value is visible post-barrier.
|
|
// userMassInfo remains a hybrid by design: Python supplies the
|
|
// non-economy envelope; Rust owns the squad AND the economy fields.
|
|
let mut econ_overlaid = false;
|
|
if let Some(svc) = &self.economy {
|
|
if (200..300).contains(&resp.status) {
|
|
if let (Ok(coins), Ok(ents)) = (svc.econ.balance(), svc.econ.entitlements())
|
|
{
|
|
if let Ok(mut root) = serde_json::from_slice::<Value>(&resp.body) {
|
|
if overlay_massinfo_economy(&mut root, coins, ents.len()) {
|
|
if let Ok(nb) = serde_json::to_vec(&root) {
|
|
set_json_body(&mut resp, nb);
|
|
econ_overlaid = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
eprintln!(
|
|
"utas-host owner=RUST_OVERLAY route=userMassInfo status={} squad_outcome={} econ_overlaid={} detail=[{}]",
|
|
resp.status, log.outcome, econ_overlaid, log.detail
|
|
);
|
|
resp
|
|
}
|
|
Route::Auth => self.handle_auth(method, target, headers, body, client_ip),
|
|
Route::Capability => self.handle_capability(body, client_ip),
|
|
Route::StorePurchaseGroup => {
|
|
self.handle_store_purchasegroup(method, target, headers, body, client_ip)
|
|
}
|
|
Route::Passthrough => {
|
|
let resp = match self.pass.forward(method, target, headers, body) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
eprintln!("utas-host ERROR passthrough to Python failed: {e}");
|
|
WireResponse {
|
|
status: 502,
|
|
headers: vec![(
|
|
"Content-Type".to_string(),
|
|
"application/json".to_string(),
|
|
)],
|
|
body: br#"{"error":"upstream unavailable"}"#.to_vec(),
|
|
}
|
|
}
|
|
};
|
|
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` — proxy to Python (which mints the X-UT-SID, adopts the
|
|
/// persona and refreshes its save), then OBSERVE the returned SID to open a
|
|
/// Rust session bound to the peer IP + configured persona. Account/economy
|
|
/// stays Python-authoritative; Rust only tracks the session. Python's response
|
|
/// is returned byte-for-byte.
|
|
fn handle_auth(
|
|
&self,
|
|
method: &str,
|
|
target: &str,
|
|
headers: &[(String, String)],
|
|
body: &[u8],
|
|
client_ip: Option<&str>,
|
|
) -> WireResponse {
|
|
let resp = match self.pass.forward(method, target, headers, body) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
eprintln!("utas-host ERROR auth proxy to Python failed: {e}");
|
|
return error_response(502, "upstream_unavailable");
|
|
}
|
|
};
|
|
let mut outcome = "no_sid";
|
|
if (200..300).contains(&resp.status) {
|
|
if let Some(sid) = observe_sid(&resp.body) {
|
|
self.sessions.lock().unwrap().open_session(
|
|
&sid,
|
|
client_ip.map(|s| s.to_string()),
|
|
self.persona_id,
|
|
self.now(),
|
|
);
|
|
outcome = "session_opened";
|
|
}
|
|
}
|
|
eprintln!(
|
|
"utas-host owner=RUST_OBSERVE route=auth status={} ip={:?} outcome={}",
|
|
resp.status, client_ip, outcome
|
|
);
|
|
resp
|
|
}
|
|
|
|
/// `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
|
|
}
|
|
|
|
/// 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(),
|
|
);
|
|
if write_response(&mut writer, &resp).is_err() {
|
|
return;
|
|
}
|
|
if 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)..])
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
}
|
|
|
|
/// Observe the minted `sid` from Python's `/ut/auth` JSON response body.
|
|
fn observe_sid(body: &[u8]) -> Option<String> {
|
|
serde_json::from_slice::<Value>(body)
|
|
.ok()?
|
|
.get("sid")?
|
|
.as_str()
|
|
.map(str::to_string)
|
|
}
|
|
|
|
/// 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<()> {
|
|
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())?;
|
|
w.write_all(&resp.body)?;
|
|
w.flush()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// 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 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)
|
|
}
|
|
}
|
|
|
|
#[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); // grant_reward echoes balance
|
|
let resp = handle_match_end(&econ, br#"{"endReason":"WIN"}"#);
|
|
assert_eq!(resp.status, 200);
|
|
let body: Value = serde_json::from_slice(&resp.body).unwrap();
|
|
assert_eq!(body["allCoins"], 5400);
|
|
assert_eq!(body["matchCoins"], 400); // win
|
|
assert_eq!(body["gameModeAward"]["coins"], 400);
|
|
assert_eq!(body["seasonCoins"], 0);
|
|
// Draw default on unknown reason.
|
|
let draw: Value =
|
|
serde_json::from_slice(&handle_match_end(&econ, br#"{"foo":1}"#).body).unwrap();
|
|
assert_eq!(draw["matchCoins"], 200);
|
|
}
|
|
|
|
#[test]
|
|
fn match_reward_fails_closed_on_core_error() {
|
|
let resp = handle_match_end(&FakeEconomy::failing(), br#"{"endReason":"WIN"}"#);
|
|
assert_eq!(resp.status, 503);
|
|
}
|
|
|
|
#[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_only_on_exact_get() {
|
|
assert_eq!(classify("GET", "/ut/game/fifa17/club"), Route::Club);
|
|
assert_eq!(classify("get", "/ut/game/fifa18/club"), Route::Club);
|
|
// method must be GET
|
|
assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::Passthrough);
|
|
// near-misses stay on Python
|
|
assert_eq!(
|
|
classify("GET", "/ut/game/fifa17/club/stats/staff"),
|
|
Route::Passthrough
|
|
);
|
|
assert_eq!(
|
|
classify("GET", "/ut/game/fifa17/clubUser"),
|
|
Route::Passthrough
|
|
);
|
|
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) ────────────────────────────
|
|
|
|
#[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::Passthrough
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn observe_sid_extracts_minted_sid() {
|
|
let body = br#"{"protocol":1,"sid":"OPENFUT-SID-42C15A6F78DC6E74","serverTime":"x"}"#;
|
|
assert_eq!(
|
|
observe_sid(body).as_deref(),
|
|
Some("OPENFUT-SID-42C15A6F78DC6E74")
|
|
);
|
|
assert_eq!(observe_sid(b"{}"), None);
|
|
assert_eq!(observe_sid(b"not json"), None);
|
|
}
|
|
|
|
#[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)
|
|
);
|
|
// 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)),
|
|
("GET", "/ut/game/fifa17/tradePile/counts", Some(MarketQuery)),
|
|
("GET", "/ut/game/fifa17/tradepile/counts", Some(MarketQuery)),
|
|
("POST", "/ut/game/fifa17/trade/900000001", Some(MarketBuy)),
|
|
(
|
|
"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}"
|
|
);
|
|
}
|
|
}
|
|
}
|