Migrate club rename and numeric squad reads
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
//! faked (see `club_response`). With today's empty mapping, `/club` returns
|
||||
//! `{"itemData":[]}` — the honest state until Core inventory is asset-backed.
|
||||
|
||||
pub mod account_store;
|
||||
pub mod async_bridge;
|
||||
pub mod clientdata_store;
|
||||
pub mod config;
|
||||
@@ -79,6 +80,7 @@ use openfut_identity::ExternalIdentityStore;
|
||||
use rand::{Rng, SeedableRng};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use account_store::{AccountStore, RenameOutcome};
|
||||
use clientdata_store::ClientDataStore;
|
||||
use config::HostConfig;
|
||||
|
||||
@@ -89,6 +91,9 @@ use config::HostConfig;
|
||||
pub enum Route {
|
||||
/// `GET …/club` — the owned-player search, served from Core.
|
||||
Club,
|
||||
/// `PUT …/club` or `PUT/POST …/user/club` — validates and persists the
|
||||
/// FIFA club name/abbreviation, then returns the zero-atom `{}` ack.
|
||||
ClubRename,
|
||||
/// `PUT …/squad/<n>` — full squad replacement, committed to Core.
|
||||
SquadReplace,
|
||||
/// `GET …/squad/list` — the squad summary, projected from Core.
|
||||
@@ -148,11 +153,9 @@ pub enum Route {
|
||||
/// object `userMassInfo` embeds (shared builder). POST /user (create) stays Python.
|
||||
User,
|
||||
/// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and
|
||||
/// the club-identity service are disabled in this emulator, so these reads
|
||||
/// return `{}`, byte-identical to the Python oracle with `FUT_MODES` /
|
||||
/// `FUT_CLUB_IDENTITY` off. The mutating club-identity route (`user/club`
|
||||
/// rename) stays on Python. Turning a feature on later means a real Rust
|
||||
/// handler here — never a Python fallback.
|
||||
/// the club-identity read service are disabled in this emulator, so these
|
||||
/// reads return `{}`, byte-identical to the Python oracle with `FUT_MODES` /
|
||||
/// `FUT_CLUB_IDENTITY` off. Club rename is a separate Rust-owned route.
|
||||
FeatureOffEmpty,
|
||||
/// `GET …/item/resource`, `…/defid` — FUT item-definition lookups. Rust builds
|
||||
/// `{itemData:[…]}` (one placeholder-or-Ronaldo def per queried id), mirroring
|
||||
@@ -169,16 +172,10 @@ pub enum Route {
|
||||
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.
|
||||
/// Classify a request ONCE, before execution. Rust owns complete route families;
|
||||
/// there is no "try Rust then Python", so a mutation can never be double-applied.
|
||||
/// Numeric `GET …/squad/<n>` follows the oracle's single-current-squad behavior:
|
||||
/// every numeric id returns the one Core-backed active squad.
|
||||
pub fn classify(method: &str, path: &str) -> Route {
|
||||
let get = method.eq_ignore_ascii_case("GET");
|
||||
let put = method.eq_ignore_ascii_case("PUT");
|
||||
@@ -201,11 +198,15 @@ pub fn classify(method: &str, path: &str) -> Route {
|
||||
if get && is_exact_club_path(path) {
|
||||
return Route::Club;
|
||||
}
|
||||
if put && is_exact_club_path(path) {
|
||||
return Route::ClubRename;
|
||||
}
|
||||
match ut_tail(path) {
|
||||
Some("squad/list") if get => Route::SquadList,
|
||||
Some("squad/active") if get => Route::SquadActive,
|
||||
Some("squad/0") if get => Route::SquadActive,
|
||||
Some(tail) if get && is_numeric_squad_tail(tail) => Route::SquadActive,
|
||||
Some("userMassInfo") if get => Route::UserMassInfo,
|
||||
Some("user/club") if put || post => Route::ClubRename,
|
||||
Some(tail) if tail.starts_with("clientdata/") => Route::ClientData,
|
||||
Some(tail) if get && tail.starts_with("store/purchasegroup") => Route::StorePurchaseGroup,
|
||||
Some(tail) if put && is_numeric_squad_tail(tail) => Route::SquadReplace,
|
||||
@@ -305,10 +306,9 @@ 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).
|
||||
/// `squad/<digits>` — the numeric full-squad target used by PUT and GET. Core
|
||||
/// stores one current squad, matching the oracle: every numeric GET returns that
|
||||
/// same squad regardless of the requested id.
|
||||
fn is_numeric_squad_tail(tail: &str) -> bool {
|
||||
match tail.strip_prefix("squad/") {
|
||||
Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()),
|
||||
@@ -2184,6 +2184,9 @@ pub struct Server {
|
||||
/// used to stamp `personaId` on the Core-backed `GET /squad/active` object.
|
||||
/// Never baked in — it must match the persona LSX/Blaze/POW/UTAS agree on.
|
||||
persona_id: i64,
|
||||
/// Shared FIFA17 account identity. Rust owns club rename and every Rust
|
||||
/// user/account response reads the same durable name/abbreviation.
|
||||
account: Arc<AccountStore>,
|
||||
/// Per-login FIFA session/capability authority (empty-My-Packs topology).
|
||||
/// The economy (coins, unopened packs, BUY) stays Python's; this owns only
|
||||
/// session/capability state — Rust reads Python's live body, never writes it.
|
||||
@@ -2216,6 +2219,7 @@ impl Server {
|
||||
resolver,
|
||||
pass,
|
||||
persona_id,
|
||||
account: Arc::new(AccountStore::open(ephemeral_account_path())),
|
||||
sessions: Arc::new(Mutex::new(SessionStore::new())),
|
||||
start: Instant::now(),
|
||||
economy: None,
|
||||
@@ -2282,6 +2286,7 @@ impl Server {
|
||||
});
|
||||
|
||||
let clientdata = Arc::new(ClientDataStore::open(cfg.clientdata_path.clone()));
|
||||
let account = Arc::new(AccountStore::open(cfg.account_path.clone()));
|
||||
Ok(Server::new(
|
||||
core,
|
||||
entities,
|
||||
@@ -2290,7 +2295,8 @@ impl Server {
|
||||
cfg.persona_id,
|
||||
)
|
||||
.with_economy(economy)
|
||||
.with_clientdata(clientdata))
|
||||
.with_clientdata(clientdata)
|
||||
.with_account(account))
|
||||
}
|
||||
|
||||
/// Assemble the shared squad dependencies (Core access + the one production
|
||||
@@ -2319,6 +2325,12 @@ impl Server {
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach the shared durable FIFA17 account store.
|
||||
pub fn with_account(mut self, account: Arc<AccountStore>) -> Self {
|
||||
self.account = account;
|
||||
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
|
||||
@@ -2586,6 +2598,7 @@ impl Server {
|
||||
);
|
||||
resp
|
||||
}
|
||||
Route::ClubRename => self.handle_club_rename(body),
|
||||
Route::SquadReplace => {
|
||||
let deps = self.squad_deps();
|
||||
let (resp, log) = handle_put_squad(body, &deps);
|
||||
@@ -2720,6 +2733,29 @@ impl Server {
|
||||
json_status(200, &non_economy::auth_body(&sid, &server_time))
|
||||
}
|
||||
|
||||
/// `PUT …/club` / `PUT|POST …/user/club` — persist the validated club
|
||||
/// identity in the shared account file. The response class has zero atoms,
|
||||
/// and the client disconnects on a 4xx, so every input returns `200 {}` just
|
||||
/// like the oracle; rejected/persistence outcomes remain visible in logs.
|
||||
fn handle_club_rename(&self, body: &[u8]) -> WireResponse {
|
||||
let outcome = self.account.rename_from_body(body);
|
||||
match &outcome {
|
||||
RenameOutcome::Updated => {
|
||||
eprintln!("utas-host owner=RUST route=club-rename status=200 outcome=updated")
|
||||
}
|
||||
RenameOutcome::Unchanged => {
|
||||
eprintln!("utas-host owner=RUST route=club-rename status=200 outcome=unchanged")
|
||||
}
|
||||
RenameOutcome::Rejected(reason) => eprintln!(
|
||||
"utas-host owner=RUST route=club-rename status=200 outcome=rejected detail={reason}"
|
||||
),
|
||||
RenameOutcome::PersistFailed(error) => eprintln!(
|
||||
"utas-host ERROR owner=RUST route=club-rename status=200 outcome=persist-failed detail=[{error}]"
|
||||
),
|
||||
}
|
||||
json_status(200, &json!({}))
|
||||
}
|
||||
|
||||
/// `POST /openfut/account/sync` — launcher control-plane account summary. The
|
||||
/// coins/unopened-pack counts are the AUTHORITATIVE Core economy (balance +
|
||||
/// entitlements), NEVER Python's stale profile funds. Fail-closed 503 on any
|
||||
@@ -2746,9 +2782,10 @@ impl Server {
|
||||
coins,
|
||||
ents.len()
|
||||
);
|
||||
let club = self.account.club();
|
||||
json_status(
|
||||
200,
|
||||
&non_economy::account_sync_body(&req, coins, ents.len()),
|
||||
&non_economy::account_sync_body(&req, coins, ents.len(), &club.name, &club.abbr),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2795,8 +2832,17 @@ impl Server {
|
||||
HostProjection::Missing => (empty_squad_overlay(self.persona_id), "missing_integrity"),
|
||||
HostProjection::Error(_) => (empty_squad_overlay(self.persona_id), "core_error"),
|
||||
};
|
||||
let club = self.account.club();
|
||||
Ok((
|
||||
non_economy::user_mass_info_body(squad, coins, ents.len(), self.persona_id),
|
||||
non_economy::user_mass_info_body(
|
||||
squad,
|
||||
coins,
|
||||
ents.len(),
|
||||
self.persona_id,
|
||||
&club.name,
|
||||
&club.abbr,
|
||||
&club.established,
|
||||
),
|
||||
squad_outcome,
|
||||
))
|
||||
}
|
||||
@@ -3086,9 +3132,9 @@ impl Server {
|
||||
}
|
||||
|
||||
/// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and the
|
||||
/// club-identity service are off, so return `{}` (parity with the flag-off
|
||||
/// Python oracle). The mutating `user/club` rename is NOT here — it stays on
|
||||
/// Python until a real Rust club-identity handler exists.
|
||||
/// club-identity read service are off, so return `{}` (parity with the
|
||||
/// flag-off Python oracle). Club rename is handled separately by
|
||||
/// [`Server::handle_club_rename`].
|
||||
fn handle_feature_off_empty(&self, path: &str) -> WireResponse {
|
||||
let tail = ut_tail(path).unwrap_or("");
|
||||
eprintln!("utas-host owner=RUST route=feature-off-empty tail={tail} status=200");
|
||||
@@ -3262,11 +3308,9 @@ fn json_status(status: u16, v: &Value) -> WireResponse {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// A unique ephemeral JSON-state path used by [`Server::new`] tests.
|
||||
/// Production replaces both stores with configured durable paths.
|
||||
fn ephemeral_state_path(kind: &str) -> std::path::PathBuf {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static CTR: AtomicU64 = AtomicU64::new(0);
|
||||
let n = CTR.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -3275,13 +3319,19 @@ fn ephemeral_clientdata_path() -> std::path::PathBuf {
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
std::env::temp_dir().join(format!(
|
||||
"openfut-utas-clientdata-{}-{}-{}.json",
|
||||
std::process::id(),
|
||||
nanos,
|
||||
n
|
||||
"openfut-utas-{kind}-{}-{nanos}-{n}.json",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
fn ephemeral_clientdata_path() -> std::path::PathBuf {
|
||||
ephemeral_state_path("clientdata")
|
||||
}
|
||||
|
||||
fn ephemeral_account_path() -> std::path::PathBuf {
|
||||
ephemeral_state_path("account")
|
||||
}
|
||||
|
||||
/// A validated capability registration request.
|
||||
struct CapabilityRequest {
|
||||
persona_id: Option<i64>,
|
||||
@@ -3753,11 +3803,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_club_only_on_exact_get() {
|
||||
fn classify_club_read_and_rename_methods_exactly() {
|
||||
assert_eq!(classify("GET", "/ut/game/fifa17/club"), Route::Club);
|
||||
assert_eq!(classify("get", "/ut/game/fifa18/club"), Route::Club);
|
||||
// method must be GET
|
||||
assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::Passthrough);
|
||||
assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::ClubRename);
|
||||
// bare `club/stats` (no trailing slash) is not a migrated arm -> Python.
|
||||
assert_eq!(
|
||||
classify("GET", "/ut/game/fifa17/club/stats"),
|
||||
@@ -4307,6 +4356,9 @@ mod tests {
|
||||
("GET", "/ut/game/fifa17/champion", Route::FeatureOffEmpty),
|
||||
("GET", "/ut/game/fifa17/clubUser", Route::FeatureOffEmpty),
|
||||
("GET", "/ut/game/fifa17/user/list", Route::FeatureOffEmpty),
|
||||
("PUT", "/ut/game/fifa17/user/club", Route::ClubRename),
|
||||
("POST", "/ut/game/fifa17/user/club", Route::ClubRename),
|
||||
("PUT", "/ut/game/fifa17/club", Route::ClubRename),
|
||||
("GET", "/ut/game/fifa17/item/resource", Route::ItemDefs),
|
||||
("GET", "/ut/game/fifa17/defid", Route::ItemDefs),
|
||||
(
|
||||
@@ -4326,7 +4378,7 @@ mod tests {
|
||||
("GET", "/ut/game/fifa17/match/reset"),
|
||||
("PUT", "/ut/game/fifa17/user/accountinfo"),
|
||||
("POST", "/ut/game/fifa17/season"), // FUT-mode reads are GET-only
|
||||
("GET", "/ut/game/fifa17/user/club"), // mutating rename stays Python
|
||||
("GET", "/ut/game/fifa17/user/club"), // unknown read, not rename
|
||||
("POST", "/ut/game/fifa17/item/resource"), // item-defs are GET-only
|
||||
("GET", "/ut/game/fifa17/marketdatafoo"), // not the marketdata route
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user