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:
+316
-95
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user