|
|
|
@@ -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"),
|
|
|
|
|