feat(host): migrate non-economy UTAS routes to Rust + launcher redesign

Host/adapter (deployed to prod-host):
- POST /ut/auth (+/ut/delete/auth): Rust mints sid, opens Rust session, adopts
  persona from body; POST /openfut/account/sync full Rust envelope.
- GET /userMassInfo: full Rust (was proxy+overlay), shared build_user_mass_info.
- GET/PUT /clientdata/<key>: new clientdata_store.rs (JSON-persisted).
- GET /club/stats/{country,league,team}: context-aware club_stats_body
  (nation/league/team buckets).
- GET /store,/match/keepalive,/captcha,/tfa,/livemessage,/activeMessage: StaticAck.
- GET /watchList, /squad/0, /user: Rust handlers.
- host_test.rs updated for the new routing.

Launcher: bump gitlink to c277213 (shareholder-grade redesign + live account panel).

Docs: PRODUCTION_AUTHORITY_MATRIX, PYTHON_RETIREMENT_PLAN, MATCH_LIFECYCLE, and
route-shapes-2026-08-17 reference fixtures for the still-Python tail.
This commit is contained in:
funman300
2026-08-17 16:04:20 +00:00
parent 42fd3c7e90
commit e06fd57211
25 changed files with 2061 additions and 147 deletions
+102
View File
@@ -0,0 +1,102 @@
//! Durable FIFA 17 **client-data blob** store (`clientdata` / `userHubData`).
//!
//! FIFA persists opaque per-user client blobs via `PUT/POST …/clientdata/<key>`
//! and reads them back via `GET …/clientdata/<key>`. The blobs are entirely
//! client-defined (UI/hub state) — the server only round-trips them and never
//! interprets their contents. This store keeps them in memory keyed by
//! `<persona>:<key>` and persists the whole map to a JSON file on every write, so
//! the client's saved state survives a host restart.
//!
//! The blobs are non-authoritative client presentation state (NOT economy or
//! ownership), so a missing/unreadable backing file starts empty rather than
//! being a hard failure.
use std::collections::HashMap;
use std::path::PathBuf;
use parking_lot::Mutex;
use serde_json::Value;
/// In-memory client-data blobs, persisted to a JSON file on write.
pub struct ClientDataStore {
path: PathBuf,
map: Mutex<HashMap<String, Value>>,
}
impl ClientDataStore {
/// Open the store, loading any previously-persisted blobs. A missing or
/// unreadable file starts empty.
pub fn open(path: impl Into<PathBuf>) -> Self {
let path = path.into();
let map = std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice::<HashMap<String, Value>>(&b).ok())
.unwrap_or_default();
ClientDataStore {
path,
map: Mutex::new(map),
}
}
fn compound_key(persona: i64, key: &str) -> String {
format!("{persona}:{key}")
}
/// The stored blob for `<persona>:<key>`, or `None` if never written.
pub fn get(&self, persona: i64, key: &str) -> Option<Value> {
self.map.lock().get(&Self::compound_key(persona, key)).cloned()
}
/// Store `value` under `<persona>:<key>` and persist the whole map to disk.
/// The serialized snapshot is taken under the lock; the file write happens
/// after the lock is released.
pub fn put(&self, persona: i64, key: &str, value: Value) {
let snapshot = {
let mut map = self.map.lock();
map.insert(Self::compound_key(persona, key), value);
serde_json::to_vec(&*map).unwrap_or_default()
};
if let Some(parent) = self.path.parent() {
if !parent.as_os_str().is_empty() {
let _ = std::fs::create_dir_all(parent);
}
}
let _ = std::fs::write(&self.path, snapshot);
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn temp_path(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"openfut-clientdata-test-{}-{}.json",
std::process::id(),
tag
))
}
#[test]
fn round_trips_and_persists_across_reopen() {
let path = temp_path("roundtrip");
let _ = std::fs::remove_file(&path);
let store = ClientDataStore::open(&path);
assert_eq!(store.get(33_068_179, "userHubData"), None);
store.put(33_068_179, "userHubData", json!({"tiles": [1, 2, 3]}));
assert_eq!(
store.get(33_068_179, "userHubData"),
Some(json!({"tiles": [1, 2, 3]}))
);
// A different persona under the same key is isolated.
assert_eq!(store.get(1, "userHubData"), None);
// Reopening reads the persisted blob back.
let reopened = ClientDataStore::open(&path);
assert_eq!(
reopened.get(33_068_179, "userHubData"),
Some(json!({"tiles": [1, 2, 3]}))
);
let _ = std::fs::remove_file(&path);
}
}
+23 -1
View File
@@ -34,6 +34,11 @@ pub struct HostConfig {
/// Durable FIFA17 item-pile metadata DB (host-owned SQLite). Required; must
/// survive host restart. Env `OPENFUT_PILE_DB`.
pub pile_db_path: String,
/// Durable client-data blob store (`clientdata`/`userHubData`), host-owned
/// JSON file. NOT required: defaults to env `OPENFUT_CLIENTDATA_DB`, else the
/// identity store's parent directory + `clientdata.json`. The blobs are
/// non-authoritative client UI state, so a default path is safe.
pub clientdata_path: String,
}
#[derive(Debug)]
@@ -68,6 +73,11 @@ fn required_i64_nonzero(key: &str) -> Result<i64, ConfigError> {
impl HostConfig {
pub fn from_env() -> Result<Self, ConfigError> {
let identity_store_path = required("OPENFUT_IDENTITY_STORE")?;
let clientdata_path = env::var("OPENFUT_CLIENTDATA_DB")
.ok()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| default_clientdata_path(&identity_store_path));
Ok(HostConfig {
listen_addr: required("OPENFUT_UTAS_HOST_ADDR")?,
python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?,
@@ -76,10 +86,22 @@ impl HostConfig {
tables_dir: env::var("OPENFUT_FIFA17_TABLES_DIR")
.unwrap_or_else(|_| "fifa17-recon/data/tables".into()),
catalog_path: required("OPENFUT_FIFA17_CATALOG")?,
identity_store_path: required("OPENFUT_IDENTITY_STORE")?,
persona_id: required_i64_nonzero("OPENFUT_PERSONA_ID")?,
market_db_path: required("OPENFUT_MARKET_DB")?,
pile_db_path: required("OPENFUT_PILE_DB")?,
identity_store_path,
clientdata_path,
})
}
}
/// Default client-data blob path: the identity store's parent directory +
/// `clientdata.json` (co-located with the other host-owned durable state).
fn default_clientdata_path(identity_store_path: &str) -> String {
std::path::Path::new(identity_store_path)
.parent()
.map(|p| p.join("clientdata.json"))
.unwrap_or_else(|| std::path::PathBuf::from("clientdata.json"))
.to_string_lossy()
.into_owned()
}
+316 -95
View File
@@ -35,6 +35,7 @@
//! `{"itemData":[]}` — the honest state until Core inventory is asset-backed.
pub mod async_bridge;
pub mod clientdata_store;
pub mod config;
pub mod economy_store;
pub mod market;
@@ -44,13 +45,13 @@ 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 std::time::{Instant, SystemTime, UNIX_EPOCH};
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::club_stats::{club_stats_body, ClubStatInput};
use openfut_adapter_fifa17::fut::club_stats::{club_stats_body, ClubStatInput, ContextField};
use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind;
use openfut_adapter_fifa17::fut::economy_policy::{
match_reward_total, result_from_end_reason, MatchResult,
@@ -74,9 +75,10 @@ use openfut_adapter_fifa17::fut::store_session::{
validate_capability, SessionStore, StoreMode, SENTINEL_PACK_ID,
};
use openfut_identity::ExternalIdentityStore;
use rand::SeedableRng;
use rand::{Rng, SeedableRng};
use serde_json::{json, Value};
use clientdata_store::ClientDataStore;
use config::HostConfig;
// ───────────────────────────── Route classification ─────────────────────────
@@ -92,11 +94,18 @@ pub enum Route {
SquadList,
/// `GET …/squad/active` — the active squad object, projected from Core.
SquadActive,
/// `GET …/userMassInfo` — proxied to Python, with only `.squad` overlaid.
/// `GET …/userMassInfo` — served FULLY from Rust: the Core squad projection
/// plus the Rust/Core economy (coins + unopened packs). No Python.
UserMassInfo,
/// `POST /ut/auth` — proxied to Python (persona adoption + SID mint); the
/// returned `X-UT-SID` is observed to open a Rust session.
/// `POST /ut/auth` — Rust mints the `X-UT-SID`, adopts the persona from the
/// body (or the configured default), and opens a Rust session. Never proxied.
Auth,
/// `POST /openfut/account/sync` — launcher control-plane account summary,
/// served from Rust with authoritative Core coins/entitlements. Never Python.
AccountSync,
/// `GET/PUT/POST …/clientdata/<key>` — opaque per-user client blob store
/// (`userHubData` etc.), round-tripped through the Rust client-data store.
ClientData,
/// `POST /openfut/fifa17/capability` — launcher capability registration,
/// owned entirely in Rust (no economy, no proxy).
Capability,
@@ -125,6 +134,18 @@ pub enum Route {
/// Core-accurately in Rust (player tiers, staff/consumable families, nation
/// buckets). club/stats/staff stays a separate empty-set route.
ClubStats,
/// `GET …/store` (eligibility gate), `…/match/keepalive`, `…/captcha`,
/// `…/tfa`, `…/livemessage`, `…/activeMessage` — Rust-owned UNCONDITIONAL
/// static acks, byte-identical to the Python oracle's constant responses
/// (these are not flag-gated in the oracle, so a constant is exact parity).
StaticAck,
/// `GET …/watchList` — the transfer watch list, served empty from Rust with
/// authoritative Core credits (the oracle persists no watches; add/remove is a
/// no-op ack). Body: `{auctionInfo:[], credits, total:0}`.
WatchList,
/// `GET …/user` — the FUT user profile `{"userInfo": …}`, the same userInfo
/// object `userMassInfo` embeds (shared builder). POST /user (create) stays Python.
User,
/// Anything else — proxied verbatim to the Python oracle.
Passthrough,
}
@@ -143,10 +164,18 @@ 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).
// Session/capability vertical (Rust session authority).
if post && path.starts_with("/ut/auth") {
return Route::Auth;
}
// Rust owns the /ut/delete/auth logout ack (any method).
if path.starts_with("/ut/delete/auth") {
return Route::Auth;
}
// Launcher control-plane account summary (not under /ut/game).
if post && path == "/openfut/account/sync" {
return Route::AccountSync;
}
if post && path == "/openfut/fifa17/capability" {
return Route::Capability;
}
@@ -156,17 +185,27 @@ pub fn classify(method: &str, path: &str) -> Route {
match ut_tail(path) {
Some("squad/list") if get => Route::SquadList,
Some("squad/active") if get => Route::SquadActive,
Some("squad/0") if get => Route::SquadActive,
Some("userMassInfo") if get => Route::UserMassInfo,
Some(tail) if tail.starts_with("clientdata/") => Route::ClientData,
Some(tail) if get && tail.starts_with("store/purchasegroup") => Route::StorePurchaseGroup,
Some(tail) if put && is_numeric_squad_tail(tail) => Route::SquadReplace,
Some("user/accountinfo") if get => Route::AccountInfo,
Some("user") if get => Route::User,
Some("settings") if get => Route::Settings,
Some("leaderboards/options") if get => Route::LeaderboardOptions,
Some("match/reset") if put => Route::MatchReset,
Some(tail) if tail.starts_with("phishing/") => Route::SecurityQuestion,
Some("club/stats/staff") if get => Route::ClubStatsStaff,
Some("club/stats/year") | Some("club/stats/consumables") if get => Route::ClubStats,
Some(t) if get && t.starts_with("club/stats/") => Route::ClubStats,
Some("hub") if get => Route::Hub,
Some("store") => Route::StaticAck,
Some("match/keepalive") => Route::StaticAck,
Some("captcha") if get => Route::StaticAck,
Some("tfa") => Route::StaticAck,
Some("livemessage") => Route::StaticAck,
Some("activeMessage") => Route::StaticAck,
Some("watchList") => Route::WatchList,
_ => Route::Passthrough,
}
}
@@ -1896,6 +1935,10 @@ pub struct Server {
/// [`Server::with_economy`]; the economy dispatch is inert without it, and
/// `handle_with_ip` does not consult it until the classifier barrier.
economy: Option<Arc<EconomyServices>>,
/// Durable per-user client-data blob store (`clientdata`/`userHubData`).
/// [`Server::new`] gives each instance an ephemeral temp-file store;
/// [`Server::from_config`] wires the configured durable path.
clientdata: Arc<ClientDataStore>,
}
impl Server {
@@ -1916,6 +1959,7 @@ impl Server {
sessions: Arc::new(Mutex::new(SessionStore::new())),
start: Instant::now(),
economy: None,
clientdata: Arc::new(ClientDataStore::open(ephemeral_clientdata_path())),
}
}
@@ -1972,6 +2016,7 @@ impl Server {
pool,
});
let clientdata = Arc::new(ClientDataStore::open(cfg.clientdata_path.clone()));
Ok(Server::new(
core,
entities,
@@ -1979,7 +2024,8 @@ impl Server {
Arc::new(PassClient::new(cfg.python_upstream.clone())),
cfg.persona_id,
)
.with_economy(economy))
.with_economy(economy)
.with_clientdata(clientdata))
}
/// Assemble the shared squad dependencies (Core access + the one production
@@ -2001,6 +2047,13 @@ impl Server {
self
}
/// Attach the durable client-data blob store (configured path). Kept separate
/// from construction so tests keep the ephemeral temp-file store.
pub fn with_clientdata(mut self, clientdata: Arc<ClientDataStore>) -> Self {
self.clientdata = clientdata;
self
}
/// 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
@@ -2243,37 +2296,10 @@ impl Server {
);
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::UserMassInfo => self.handle_user_mass_info_full(),
Route::Auth => self.handle_auth(method, target, headers, body, client_ip),
Route::AccountSync => self.handle_account_sync(body),
Route::ClientData => self.handle_client_data(method, path, body),
Route::Capability => self.handle_capability(body, client_ip),
Route::StorePurchaseGroup => {
self.handle_store_purchasegroup(method, target, headers, body, client_ip)
@@ -2299,7 +2325,10 @@ impl Server {
json_status(200, &non_economy::club_stats_staff_body())
}
Route::Hub => self.handle_hub(),
Route::ClubStats => self.handle_club_stats(),
Route::ClubStats => self.handle_club_stats(path),
Route::StaticAck => self.handle_static_ack(path),
Route::WatchList => self.handle_watchlist(method),
Route::User => self.handle_user(),
Route::SecurityQuestion => self.handle_security_question(method, target, headers),
Route::Passthrough => {
let resp = match self.pass.forward(method, target, headers, body) {
@@ -2330,43 +2359,157 @@ impl Server {
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.
/// `POST /ut/auth` — Rust mints the `X-UT-SID`, adopts the persona from the
/// request body (`nucleusPersonaId`/`nuc`, else the configured persona) and
/// opens a Rust session bound to the peer IP. Never proxied to Python. The
/// SID is not an auth gate — only the Rust `SessionStore` consults it — so
/// minting it in Rust is complete. `/ut/delete/auth` is a `{}` logout ack.
fn handle_auth(
&self,
method: &str,
target: &str,
headers: &[(String, String)],
_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 path = target.split('?').next().unwrap_or(target);
if path.starts_with("/ut/delete/auth") {
eprintln!("utas-host owner=RUST route=auth-delete status=200");
return json_status(200, &json!({}));
}
let persona = non_economy::parse_auth_persona(body).unwrap_or(self.persona_id);
let sid = format!(
"OPENFUT-SID-{:016X}",
rand::rngs::StdRng::from_entropy().gen::<u64>()
);
self.sessions.lock().unwrap().open_session(
&sid,
client_ip.map(|s| s.to_string()),
persona,
self.now(),
);
let epoch_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let server_time = non_economy::format_utc_datetime(epoch_secs);
eprintln!(
"utas-host owner=RUST route=auth status=200 method={} ip={:?} persona={} sid_opened=true",
method, client_ip, persona
);
json_status(200, &non_economy::auth_body(&sid, &server_time))
}
/// `POST /openfut/account/sync` — launcher control-plane account summary. The
/// coins/unopened-pack counts are the AUTHORITATIVE Core economy (balance +
/// entitlements), NEVER Python's stale profile funds. Fail-closed 503 on any
/// Core error — never a Python fallback.
fn handle_account_sync(&self, body: &[u8]) -> WireResponse {
let svc = match &self.economy {
Some(s) => s,
None => {
eprintln!("utas-host owner=RUST route=account-sync status=503 error=no_economy");
return error_response(503, "core_unavailable");
}
};
let 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(),
let (coins, ents) = match (svc.econ.balance(), svc.econ.entitlements()) {
(Ok(c), Ok(e)) => (c, e),
_ => {
eprintln!("utas-host owner=RUST route=account-sync status=503 error=core");
return error_response(503, "core_unavailable");
}
};
let req = non_economy::parse_account_sync(body, self.persona_id);
eprintln!(
"utas-host owner=RUST route=account-sync status=200 persona={} coins={} packs={}",
req.persona_id,
coins,
ents.len()
);
json_status(200, &non_economy::account_sync_body(&req, coins, ents.len()))
}
/// `GET/PUT/POST …/clientdata/<key>` — opaque per-user client blob store. GET
/// returns the stored blob for `<persona>:<key>` (or `{}` if never written);
/// PUT/POST parse and store the body under the key and ALWAYS ack `{}`.
fn handle_client_data(&self, method: &str, path: &str, body: &[u8]) -> WireResponse {
let key = ut_tail(path)
.and_then(|t| t.strip_prefix("clientdata/"))
.unwrap_or("");
if method.eq_ignore_ascii_case("GET") {
let blob = self
.clientdata
.get(self.persona_id, key)
.unwrap_or_else(|| json!({}));
eprintln!("utas-host owner=RUST route=clientdata method=GET key={key} status=200");
json_status(200, &blob)
} else {
let val: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
self.clientdata.put(self.persona_id, key, val);
eprintln!(
"utas-host owner=RUST route=clientdata method={method} key={key} status=200 stored=true"
);
json_status(200, &json!({}))
}
}
/// Build the full Rust userMassInfo Value (userInfo + squad + settings +
/// pileSizeClientData) from Core, or a 503 response on Core error. Shared by
/// `GET …/userMassInfo` and `GET …/user` so both agree byte-for-byte.
fn build_user_mass_info(&self) -> Result<(Value, &'static str), WireResponse> {
let svc = match &self.economy {
Some(s) => s,
None => return Err(error_response(503, "core_unavailable")),
};
let (coins, ents) = match (svc.econ.balance(), svc.econ.entitlements()) {
(Ok(c), Ok(e)) => (c, e),
_ => return Err(error_response(503, "core_unavailable")),
};
let deps = self.squad_deps();
let (squad, squad_outcome) = match project_active_squad(&deps) {
HostProjection::Squad(v) => (user_mass_info_squad(v, self.persona_id), "ok"),
HostProjection::Stale => (empty_squad_overlay(self.persona_id), "stale_integrity"),
HostProjection::Missing => (empty_squad_overlay(self.persona_id), "missing_integrity"),
HostProjection::Error(_) => (empty_squad_overlay(self.persona_id), "core_error"),
};
Ok((
non_economy::user_mass_info_body(squad, coins, ents.len(), self.persona_id),
squad_outcome,
))
}
/// `GET …/userMassInfo` — served FULLY from Rust (no Python): the Core squad
/// projection (byte-identical to `GET …/squad/active`) plus the authoritative
/// Core economy (coins + unopened packs). Fail-closed 503 on Core error.
fn handle_user_mass_info_full(&self) -> WireResponse {
match self.build_user_mass_info() {
Ok((body, squad_outcome)) => {
eprintln!(
"utas-host owner=RUST route=userMassInfo status=200 squad_outcome={squad_outcome}"
);
outcome = "session_opened";
json_status(200, &body)
}
Err(e) => {
eprintln!("utas-host owner=RUST route=userMassInfo status={} error=core", e.status);
e
}
}
}
/// `GET …/user` — the FUT user profile: `{"userInfo": …}`, the same userInfo
/// object `userMassInfo` embeds (shared builder). Fail-closed 503 on Core error.
fn handle_user(&self) -> WireResponse {
match self.build_user_mass_info() {
Ok((body, _)) => {
let user_info = body.get("userInfo").cloned().unwrap_or_else(|| json!({}));
eprintln!("utas-host owner=RUST route=user status=200");
json_status(200, &json!({ "userInfo": user_info }))
}
Err(e) => {
eprintln!("utas-host owner=RUST route=user status={} error=core", e.status);
e
}
}
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
@@ -2530,11 +2673,68 @@ impl Server {
json_status(200, &body)
}
/// `GET …/club/stats/{year,consumables}` — the MY CLUB stat set, computed
/// Core-accurately in Rust (no Python). Player tiers + rare from the Core
/// collection, staff/consumable families from the catalog kind+subtype, nation
/// buckets from the reverse entity resolver. Fail-closed 503 on Core error.
fn handle_club_stats(&self) -> WireResponse {
/// Rust-owned UNCONDITIONAL static acks — `store` eligibility gate, match
/// keepalive, captcha, tfa, livemessage, activeMessage. Byte-identical to the
/// Python oracle's constant responses (no flag gating), so no Python.
fn handle_static_ack(&self, path: &str) -> WireResponse {
let tail = ut_tail(path).unwrap_or("");
let resp = match tail {
"store" => json_status(200, &json!({ "result": "SUCCESS" })),
"match/keepalive" => WireResponse {
status: 204,
headers: Vec::new(),
body: Vec::new(),
},
"captcha" => json_status(
200,
&json!({ "encodedImg": "", "sequence": 0, "sizeBeforeEncode": 0 }),
),
// tfa, livemessage, activeMessage
_ => json_status(200, &json!({})),
};
eprintln!(
"utas-host owner=RUST route=static-ack tail={} status={}",
tail, resp.status
);
resp
}
/// `…/watchList` — transfer watch list. The oracle persists no watches, so
/// add/remove (PUT/POST/DELETE) is a bare `{}` ack; GET returns an empty watch
/// list with the authoritative Core credits. Best-effort credits (0 on Core
/// error) — a cosmetic balance echo, not the authoritative wallet.
fn handle_watchlist(&self, method: &str) -> WireResponse {
if !method.eq_ignore_ascii_case("GET") {
eprintln!("utas-host owner=RUST route=watchlist method={method} status=200");
return json_status(200, &json!({}));
}
let credits = self
.economy
.as_ref()
.and_then(|s| s.econ.balance().ok())
.unwrap_or(0);
eprintln!("utas-host owner=RUST route=watchlist method=GET status=200 credits={credits}");
json_status(200, &json!({ "auctionInfo": [], "credits": credits, "total": 0 }))
}
/// `GET …/club/stats/<mode>` — the MY CLUB stat set, computed Core-accurately
/// in Rust (no Python). Player tiers + rare from the Core collection,
/// staff/consumable families from the catalog kind+subtype, and per-context
/// buckets keyed by the screen's field: nation (year/consumables/club/…),
/// league for `country/<id>`, team for `league/<id>`. Fail-closed 503 on Core.
fn handle_club_stats(&self, path: &str) -> WireResponse {
let mode = ut_tail(path)
.and_then(|t| t.strip_prefix("club/stats/"))
.and_then(|rest| rest.split('/').next())
.unwrap_or("");
// The URL says which SCREEN: `country/<id>` lists LEAGUES (leagueId
// buckets), `league/<id>` lists TEAMS (teamid buckets); all others render
// the default screen (nation buckets). Mirrors fut_club_stats.stats_body.
let ctx = match mode {
"country" => ContextField::League,
"league" => ContextField::Team,
_ => ContextField::Nation,
};
let owned = match self.core.all_owned() {
Ok(o) => o,
Err(e) => {
@@ -2550,6 +2750,8 @@ impl Server {
rating: it.rating as i64,
rare: self.resolver.rareflag_of(it) != 0,
nation_id: self.entities.nation_id(&it.nation).map(|n| n as i64),
league_id: self.entities.league_id(&it.league).map(|n| n as i64),
team_id: self.entities.team_id(&it.club).map(|n| n as i64),
})
.collect();
let players = items
@@ -2557,11 +2759,13 @@ impl Server {
.filter(|i| matches!(i.kind, ContentKind::Player))
.count();
eprintln!(
"utas-host owner=RUST route=club-stats status=200 owned={} players={}",
"utas-host owner=RUST route=club-stats mode={} ctx={:?} status=200 owned={} players={}",
mode,
ctx,
items.len(),
players
);
json_status(200, &club_stats_body(&items))
json_status(200, &club_stats_body(&items, ctx))
}
/// Serve forever on `addr` (thread-per-connection, HTTP/1.1 keep-alive).
@@ -2648,13 +2852,24 @@ fn json_status(status: u16, v: &Value) -> WireResponse {
}
}
/// 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 unique ephemeral temp-file path for the client-data blob store used by
/// [`Server::new`] (tests). Production wires a durable path via
/// [`Server::from_config`]/[`Server::with_clientdata`]. Uniqueness (pid + nanos +
/// a process-local counter) keeps concurrent test servers isolated.
fn ephemeral_clientdata_path() -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static CTR: AtomicU64 = AtomicU64::new(0);
let n = CTR.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!(
"openfut-utas-clientdata-{}-{}-{}.json",
std::process::id(),
nanos,
n
))
}
/// A validated capability registration request.
@@ -3098,15 +3313,15 @@ mod tests {
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: bare club/stats and the country sub-screen are
// NOT the exact /club route and are not (yet) migrated arms.
// bare `club/stats` (no trailing slash) is not a migrated arm -> Python.
assert_eq!(
classify("GET", "/ut/game/fifa17/club/stats"),
Route::Passthrough
);
// club/stats/<mode> screens are Rust-owned (nation/league/team contexts).
assert_eq!(
classify("GET", "/ut/game/fifa17/club/stats/country/54"),
Route::Passthrough
Route::ClubStats
);
assert_eq!(
classify("GET", "/ut/game/fifa17/clubUser"),
@@ -3286,19 +3501,17 @@ mod tests {
);
assert_eq!(
classify("POST", "/openfut/account/sync"),
Route::Passthrough
Route::AccountSync
);
}
#[test]
fn observe_sid_extracts_minted_sid() {
let body = br#"{"protocol":1,"sid":"OPENFUT-SID-42C15A6F78DC6E74","serverTime":"x"}"#;
assert_eq!(classify("POST", "/ut/delete/auth"), Route::Auth);
assert_eq!(
observe_sid(body).as_deref(),
Some("OPENFUT-SID-42C15A6F78DC6E74")
classify("PUT", "/ut/game/fifa17/clientdata/userHubData"),
Route::ClientData
);
assert_eq!(
classify("GET", "/ut/game/fifa17/clientdata/userHubData"),
Route::ClientData
);
assert_eq!(observe_sid(b"{}"), None);
assert_eq!(observe_sid(b"not json"), None);
}
#[test]
@@ -3611,15 +3824,23 @@ mod tests {
"/ut/game/fifa17/club/stats/consumables",
Route::ClubStats,
),
(
"PUT",
"/ut/game/fifa17/clientdata/userHubData",
Route::ClientData,
),
(
"GET",
"/ut/game/fifa17/clientdata/userHubData",
Route::ClientData,
),
("POST", "/openfut/account/sync", Route::AccountSync),
];
for (m, p, want) in owned {
assert_eq!(classify(m, p), *want, "OWN: {m} {p}");
}
// Still Python (not yet migrated) / lookalikes / wrong method.
let proxied: &[(&str, &str)] = &[
("GET", "/ut/game/fifa17/club/stats/country/54"),
("PUT", "/ut/game/fifa17/clientdata/userHubData"),
("POST", "/openfut/account/sync"),
("GET", "/ut/game/fifa17/settingsfoo"),
("POST", "/ut/game/fifa17/match/reset"), // match/reset is PUT-only
("GET", "/ut/game/fifa17/match/reset"),