From bf6db98f0d5892a1017fa08da55acb99bb95f6f7 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 18 Aug 2026 17:29:24 +0000 Subject: [PATCH] Migrate club rename and numeric squad reads --- openfut-adapter-fifa17/src/fut/non_economy.rs | 37 +- openfut-utas-host/README.md | 95 ++--- openfut-utas-host/src/account_store.rs | 372 ++++++++++++++++++ openfut-utas-host/src/config.rs | 20 + openfut-utas-host/src/lib.rs | 130 ++++-- openfut-utas-host/src/main.rs | 10 +- .../tests/economy_integration.rs | 10 +- openfut-utas-host/tests/host_test.rs | 107 ++++- 8 files changed, 645 insertions(+), 136 deletions(-) create mode 100644 openfut-utas-host/src/account_store.rs diff --git a/openfut-adapter-fifa17/src/fut/non_economy.rs b/openfut-adapter-fifa17/src/fut/non_economy.rs index 9e457c4..5c75324 100644 --- a/openfut-adapter-fifa17/src/fut/non_economy.rs +++ b/openfut-adapter-fifa17/src/fut/non_economy.rs @@ -267,13 +267,19 @@ pub fn parse_account_sync(body: &[u8], default_persona: i64) -> AccountSyncReque /// `POST /openfut/account/sync` — the launcher control-plane account summary. /// `coins`/`unopened_packs` are the AUTHORITATIVE Core values (balance + /// entitlement count), never Python's stale profile funds. -pub fn account_sync_body(req: &AccountSyncRequest, coins: i64, unopened_packs: usize) -> Value { +pub fn account_sync_body( + req: &AccountSyncRequest, + coins: i64, + unopened_packs: usize, + club_name: &str, + club_abbr: &str, +) -> Value { json!({ "account": { "personaId": req.persona_id, "personaName": req.persona_name, - "clubName": "OpenFUT", - "clubAbbr": "OFC", + "clubName": club_name, + "clubAbbr": club_abbr, "level": req.level, "experience": req.experience, "experienceMax": req.experience_max, @@ -301,6 +307,9 @@ pub fn user_mass_info_body( coins: i64, unopened_packs: usize, persona_id: i64, + club_name: &str, + club_abbr: &str, + established: &str, ) -> Value { let actives: Vec = squad .get("actives") @@ -310,9 +319,9 @@ pub fn user_mass_info_body( let squad_list = crate::fut::squad_projection::squad_list(&squad); let mut user_info = json!({ "personaId": persona_id, - "clubName": "OpenFUT", - "clubAbbr": "OFC", - "established": "2026", + "clubName": club_name, + "clubAbbr": club_abbr, + "established": established, "accountCreatedPlatformName": "pc", "currencies": [ {"name": "coins", "funds": coins, "finalFunds": coins, "active": true}, @@ -583,10 +592,10 @@ mod tests { assert_eq!(req.level, 1); assert_eq!(req.experience_max, 1000); assert_eq!(req.account_funds_cap, 100_000); - let body = account_sync_body(&req, 29_859_876, 2); + let body = account_sync_body(&req, 29_859_876, 2, "Real FUT", "RF"); let acc = &body["account"]; - assert_eq!(acc["clubName"], "OpenFUT"); - assert_eq!(acc["clubAbbr"], "OFC"); + assert_eq!(acc["clubName"], "Real FUT"); + assert_eq!(acc["clubAbbr"], "RF"); assert_eq!(acc["profilePath"], "accounts/33068179/fifa17_profile.json"); assert_eq!(acc["coins"], 29_859_876); assert_eq!(acc["unopenedPacks"], 2); @@ -617,7 +626,7 @@ mod tests { "actives": [], "players": [], }); - let body = user_mass_info_body(squad, 29_859_876, 0, 33_068_179); + let body = user_mass_info_body(squad, 29_859_876, 0, 33_068_179, "Real FUT", "RF", "2016"); // Flat top-level envelope. assert_eq!(body["pileSizeClientData"]["entries"][0], json!({"key": 2, "value": 100})); assert_eq!(body["pileSizeClientData"]["entries"][1], json!({"key": 4, "value": 50})); @@ -626,9 +635,9 @@ mod tests { // userInfo economy + club identity. let ui = &body["userInfo"]; assert_eq!(ui["personaId"], 33_068_179); - assert_eq!(ui["clubName"], "OpenFUT"); - assert_eq!(ui["clubAbbr"], "OFC"); - assert_eq!(ui["established"], "2026"); // string, not number + assert_eq!(ui["clubName"], "Real FUT"); + assert_eq!(ui["clubAbbr"], "RF"); + assert_eq!(ui["established"], "2016"); // string, not number assert_eq!(ui["accountCreatedPlatformName"], "pc"); assert_eq!(ui["currencies"][0]["name"], "coins"); assert_eq!(ui["currencies"][0]["funds"], 29_859_876); @@ -645,7 +654,7 @@ mod tests { #[test] fn user_mass_info_includes_unopened_packs_when_present() { let squad = json!({"id": 0, "actives": [], "players": []}); - let body = user_mass_info_body(squad, 100, 3, 33_068_179); + let body = user_mass_info_body(squad, 100, 3, 33_068_179, "OpenFUT", "OFC", "2026"); assert_eq!(body["userInfo"]["unopenedPacks"]["recoveredPacks"], 3); assert_eq!(body["userInfo"]["unopenedPacks"]["preOrderPacks"], 0); } diff --git a/openfut-utas-host/README.md b/openfut-utas-host/README.md index a4bfd00..8fdd2bb 100644 --- a/openfut-utas-host/README.md +++ b/openfut-utas-host/README.md @@ -1,47 +1,51 @@ # openfut-utas-host -The first live FIFA 17 **UTAS migration host**. It fronts the client-visible UTAS -port and migrates one route at a time to OpenFUT Core, proxying everything else to -the Python UTAS oracle so the rest of FUT keeps working unchanged. +The FIFA 17 UTAS migration boundary. It accepts the client-visible HTTP surface, +serves migrated routes from Rust/Core plus host-owned durable stores, and proxies only +the unclassified tail to the Python behavioral oracle. ``` FIFA 17 ──HTTP──▶ openfut-utas-host - ├── GET …/club ──▶ FIFA17 adapter ──▶ OpenFUT Core (/collection) - └── everything else ──▶ Python UTAS oracle (verbatim reverse proxy) + ├── migrated route ──▶ Rust adapter / Core / host stores + └── unclassified tail ──▶ Python UTAS oracle ``` -## What it owns / does not own - -Owns: socket + HTTP/1.1 keep-alive transport, route classification, the Core -access client, the Python passthrough, and diagnostics. It owns **no** game -domain state — filtering/pagination is Core's; wire parsing/shaping is the -adapter's. The adapter never learns how Core is reached (the architecture rule): -the host holds the [`CoreAccess`] boundary (`GET {core_url}/collection?…` today). +`src/lib.rs::classify` is the route-level source of truth. The current Rust surface +includes club/squad/user reads, club rename, auth/session/client data, Store/economy, +packs, owned-item moves, market/trade-pile, and the observed hub support routes. ## Safety model -- **Classification happens once, before execution.** Exact `GET /ut/game//club` - → Rust; everything else → Python. No shared path, no "try Rust then Python". -- A Core failure on `/club` degrades to a valid empty `{"itemData":[]}` and logs - an error — it never falls back to Python (which could double-apply a mutation on - other routes). `/club` is read-only, but the rule is absolute. -- Mutating routes (PUT/POST, `/squad`, `/purchased`, quick-sell, market, auth, SBC, - `/club/stats/*`, `/clubUser`) all classify to passthrough and are untouched. +- Classification happens exactly once before execution. There is no "try Rust then + Python"; a mutation cannot be double-applied. +- A route classified to Rust never falls back to Python on a Core/store/projection + failure. Each handler uses its captured fail-closed or honest-empty wire contract. +- `PUT …/club` and `PUT|POST …/user/club` atomically update the shared account JSON. + Every input returns the required zero-atom `200 {}` response; rejection and + persistence failures remain visible in logs. +- Numeric `GET …/squad/<n>` returns the one Core-backed current squad, matching the + Python oracle's single-current-squad behavior. +- Python remains the behavioral oracle and rollback backend for routes not yet + classified to Rust. New economy behavior belongs in Rust/Core, never Python. ## Configuration (env) | Var | Required | Default | Meaning | |---|---|---|---| -| `OPENFUT_UTAS_HOST_ADDR` | yes | — | where this host listens (client-visible UTAS addr) | -| `OPENFUT_UTAS_PYTHON_URL` | yes | — | Python UTAS oracle base URL for fallback (must differ from this host) | -| `OPENFUT_FIFA17_CATALOG` | yes | — | FIFA 17 card-definition identity catalog (`Fifa17CardCatalog` JSON: card id → asset id) | -| `OPENFUT_IDENTITY_STORE` | yes | — | persistent external-identity store file (owned instance → stable wire id) | -| `OPENFUT_PERSONA_ID` | yes | — | FIFA persona id stamped on `GET /squad/active` (must match the persona LSX/Blaze/POW/UTAS agree on) | +| `OPENFUT_UTAS_HOST_ADDR` | yes | — | client-visible host listen address | +| `OPENFUT_UTAS_PYTHON_URL` | yes | — | Python oracle base for the unclassified tail; must differ from this host | +| `OPENFUT_FIFA17_CATALOG` | yes | — | FIFA 17 definition identity catalog | +| `OPENFUT_IDENTITY_STORE` | yes | — | persistent owned-instance ↔ wire-id store | +| `OPENFUT_PERSONA_ID` | yes | — | non-zero FIFA persona id shared by LSX/Blaze/POW/UTAS | +| `OPENFUT_MARKET_DB` | yes | — | durable host-owned transfer-market SQLite DB | +| `OPENFUT_PILE_DB` | yes | — | durable host-owned item-pile SQLite DB | | `OPENFUT_CORE_URL` | no | `http://127.0.0.1:8080` | OpenFUT Core base | -| `OPENFUT_FIFA17_TABLES_DIR` | no | `fifa17-recon/data/tables` | `leagues/nations/teams.json` for id⇄name | +| `OPENFUT_FIFA17_TABLES_DIR` | no | `fifa17-recon/data/tables` | FIFA entity tables | +| `OPENFUT_CLIENTDATA_DB` | no | identity-store sibling `clientdata.json` | durable opaque client-data JSON | +| `OPENFUT_ACCOUNT_PATH` | no | `FUT_ACCOUNT_PATH`, then identity-store sibling `active_account.json` | shared FIFA account/club JSON | -Startup **fails clearly** if the catalog or identity store cannot be loaded — -there is no placeholder fallback (exactly one production identity path). +Startup fails if required identity or durable economy state cannot be opened. No +placeholder production identity source is substituted. ## Identity model (resolved) @@ -61,39 +65,10 @@ conflated, are resolved by [`Fifa17IdentityResolver`] (the single production pat within `(fifa17, owned-item)` — no per-account column is needed because Core owned-instance ids are globally-unique UUIDs. -**Remaining prerequisite for a *rendering* retail `/club`:** Core inventory must -reference cards that exist in the catalog. The catalog + store + resolver are -built and tested; wiring a controlled real FIFA 17 dev-content inventory (the -curated per-game dev pack) is the next slice. `rare=SP` ("Special") stays -UNSUPPORTED (semantics unproven; parsed, reported, never guessed). - -## Retail A/B runbook (first `/club` gate) - -Change **only** the UTAS routing layer; keep the validated Rust Redirector/Roster -and the current Blaze path. Python remains the rollback oracle — do not modify it. - -Preconditions (mirror the proven blaze/roster switch discipline): -1. `cargo test -p openfut-utas-host -p openfut-adapter-fifa17` green; `clippy -D warnings` clean; `fmt --check` clean. -2. Built binary identity == HEAD (`scripts/verify-build-identity.sh`); no dirty tree. -3. Python UTAS directly reachable; the Rust host directly probeable; no stale NAT/switch rules; FIFA fully closed. - -Bring-up: -1. Move Python UTAS to an alternate port (`FUT_PORT=8199` in the container/`openfut-fut.sh`); it keeps serving there. -2. Start this host on the client-visible UTAS addr: - `OPENFUT_UTAS_HOST_ADDR=<lan>:8099 OPENFUT_UTAS_PYTHON_URL=http://127.0.0.1:8199 OPENFUT_CORE_URL=http://127.0.0.1:8080 OPENFUT_FIFA17_CATALOG=<catalog.json> OPENFUT_IDENTITY_STORE=<store.json> OPENFUT_PERSONA_ID=33068179 openfut-utas-host` -3. Launch FIFA → FUT → **My Squad** player picker and exercise: no-filter, position, nation, league, league+team, Gold+position, then scroll beyond page one. - -Evidence to capture (all six): -- **Switch**: client traffic hits the Rust host. -- **Rust positive**: host log `owner=RUST route=club …` for the client IP. -- **Python negative for /club**: Python logs no `/club` request in the window. -- **Python positive for other UTAS**: unimplemented routes still reach Python. -- **Core positive**: Core logs the `/collection` query and returns the expected set. -- **Application + pagination**: the UI shows filtered results; later pages differ - from page one (no repeated-first-page amplification). - -Rollback: point the UTAS addr back at Python directly; confirm FUT still usable; -then re-enable the host and confirm `/club` again (proves reversibility). +Production has a frozen post-P1 baseline and a hot Python rollback. A source change +passing local tests is **not** deployment approval. Build verification, staging, host +restart, and live-client promotion remain operator-gated; the current state and +promotion evidence live in the OpenFUT Obsidian vault. Logs are safe by construction: no auth/session/device/token material — only owner, route, filter summary, counts, status. diff --git a/openfut-utas-host/src/account_store.rs b/openfut-utas-host/src/account_store.rs new file mode 100644 index 0000000..496b557 --- /dev/null +++ b/openfut-utas-host/src/account_store.rs @@ -0,0 +1,372 @@ +//! Durable FIFA 17 club identity backed by the shared `fut_account.json` shape. +//! +//! The Python oracle and the launcher use snake-case keys in one account file. +//! Rust owns the rename mutation now, but preserves every unrelated account key +//! so Blaze/POW and rollback keep reading the same source of truth. + +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use parking_lot::Mutex; +use serde_json::{Map, Value}; + +pub const DEFAULT_CLUB_NAME: &str = "OpenFUT"; +pub const DEFAULT_CLUB_ABBR: &str = "OFC"; +pub const DEFAULT_ESTABLISHED: &str = "2026"; + +const CLUB_NAME_MIN: usize = 5; +const CLUB_NAME_MAX: usize = 15; +const CLUB_ABBR_MIN: usize = 1; +const CLUB_ABBR_MAX: usize = 3; + +/// The FIFA-facing club fields read by `user`, `userMassInfo`, and account sync. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClubIdentity { + pub name: String, + pub abbr: String, + pub established: String, +} + +type FileSignature = (u64, u64, i64, i64, u64); + +#[derive(Debug)] +struct AccountState { + raw: Map<String, Value>, + club: ClubIdentity, + signature: Option<FileSignature>, +} + +/// Result of applying a rename body. Every result maps to the protocol's `200 {}`; +/// the distinction exists for logging and tests, not for changing wire behavior. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RenameOutcome { + Updated, + Unchanged, + Rejected(&'static str), + PersistFailed(String), +} + +/// Thread-safe account state persisted by atomic replacement on a valid rename. +pub struct AccountStore { + path: PathBuf, + state: Mutex<AccountState>, +} + +impl AccountStore { + /// Load the shared account file. Missing or malformed files use the proven + /// production defaults; the first valid rename creates/repairs the file. + pub fn open(path: impl Into<PathBuf>) -> Self { + let path = path.into(); + let raw = read_raw(&path); + let club = club_from_raw(&raw); + let signature = file_signature(&path); + Self { + path, + state: Mutex::new(AccountState { + raw, + club, + signature, + }), + } + } + + pub fn club(&self) -> ClubIdentity { + let mut state = self.state.lock(); + self.refresh_if_changed(&mut state); + state.club.clone() + } + + /// Apply `{clubName, clubAbbr}` with the exact Python-oracle semantics: + /// either field may be omitted/null, invalid input is ignored, and the caller + /// always acknowledges with `200 {}`. A valid change is durable before it + /// becomes visible to Rust readers. + pub fn rename_from_body(&self, body: &[u8]) -> RenameOutcome { + let value: Value = match serde_json::from_slice(body) { + Ok(value) => value, + Err(_) => return RenameOutcome::Rejected("malformed_json"), + }; + let Value::Object(request) = value else { + return RenameOutcome::Rejected("body_not_object"); + }; + + let mut state = self.state.lock(); + self.refresh_if_changed(&mut state); + let name = match optional_string(&request, "clubName") { + Ok(Some(value)) => value.trim().to_owned(), + Ok(None) => state.club.name.clone(), + Err(()) => return RenameOutcome::Rejected("club_name_not_string"), + }; + let abbr = match optional_string(&request, "clubAbbr") { + Ok(Some(value)) => value.trim().to_owned(), + Ok(None) => state.club.abbr.clone(), + Err(()) => return RenameOutcome::Rejected("club_abbr_not_string"), + }; + + if !valid_name(&name) { + return RenameOutcome::Rejected("club_name_length"); + } + if !valid_abbr(&abbr) { + return RenameOutcome::Rejected("club_abbr_length"); + } + if name == state.club.name && abbr == state.club.abbr { + return RenameOutcome::Unchanged; + } + + let mut updated = state.raw.clone(); + updated.insert("club_name".into(), Value::String(name.clone())); + updated.insert("club_abbr".into(), Value::String(abbr.clone())); + let bytes = match serde_json::to_vec_pretty(&updated) { + Ok(bytes) => bytes, + Err(error) => return RenameOutcome::PersistFailed(error.to_string()), + }; + if let Err(error) = persist_atomically(&self.path, &bytes) { + return RenameOutcome::PersistFailed(error.to_string()); + } + + state.raw = updated; + state.club.name = name; + state.club.abbr = abbr; + state.signature = file_signature(&self.path); + RenameOutcome::Updated + } + + fn refresh_if_changed(&self, state: &mut AccountState) { + let signature = file_signature(&self.path); + if signature == state.signature { + return; + } + let raw = read_raw(&self.path); + state.club = club_from_raw(&raw); + state.raw = raw; + state.signature = file_signature(&self.path); + } +} + +fn read_raw(path: &Path) -> Map<String, Value> { + fs::read(path) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default() +} + +fn club_from_raw(raw: &Map<String, Value>) -> ClubIdentity { + ClubIdentity { + name: valid_stored(raw, "club_name", valid_name) + .unwrap_or(DEFAULT_CLUB_NAME) + .to_owned(), + abbr: valid_stored(raw, "club_abbr", valid_abbr) + .unwrap_or(DEFAULT_CLUB_ABBR) + .to_owned(), + established: stored_established(raw).unwrap_or_else(|| DEFAULT_ESTABLISHED.to_owned()), + } +} + +fn file_signature(path: &Path) -> Option<FileSignature> { + let metadata = fs::metadata(path).ok()?; + Some(( + metadata.dev(), + metadata.ino(), + metadata.mtime(), + metadata.mtime_nsec(), + metadata.len(), + )) +} + +fn optional_string<'a>(object: &'a Map<String, Value>, key: &str) -> Result<Option<&'a str>, ()> { + match object.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => Ok(Some(value)), + Some(_) => Err(()), + } +} + +fn valid_stored<'a>( + raw: &'a Map<String, Value>, + key: &str, + valid: fn(&str) -> bool, +) -> Option<&'a str> { + raw.get(key) + .and_then(Value::as_str) + .filter(|value| valid(value)) +} + +fn stored_established(raw: &Map<String, Value>) -> Option<String> { + match raw.get("established")? { + Value::String(value) if valid_established(value) => Some(value.clone()), + Value::Number(value) => value.as_u64().map(|year| year.to_string()), + _ => None, + } +} + +fn valid_name(value: &str) -> bool { + let len = value.chars().count(); + (CLUB_NAME_MIN..=CLUB_NAME_MAX).contains(&len) && value == value.trim() +} + +fn valid_abbr(value: &str) -> bool { + let len = value.chars().count(); + (CLUB_ABBR_MIN..=CLUB_ABBR_MAX).contains(&len) && value == value.trim() +} + +fn valid_established(value: &str) -> bool { + !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +fn persist_atomically(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent)?; + } + static COUNTER: AtomicU64 = AtomicU64::new(0); + let temp = path.with_extension(format!( + "openfut-tmp-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let result = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp)?; + file.write_all(bytes)?; + file.sync_all()?; + drop(file); + fs::rename(&temp, path) + })(); + if result.is_err() { + let _ = fs::remove_file(&temp); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn temp_path(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "openfut-account-store-{}-{tag}.json", + std::process::id() + )) + } + + #[test] + fn valid_rename_preserves_unrelated_account_fields_and_reopens() { + let path = temp_path("persist"); + let _ = fs::remove_file(&path); + fs::write( + &path, + serde_json::to_vec(&json!({ + "persona_id": 33068179, + "persona_name": "CAGE", + "club_name": "OpenFUT", + "club_abbr": "OFC", + "established": 2016, + "pow_level": 7 + })) + .unwrap(), + ) + .unwrap(); + + let store = AccountStore::open(&path); + assert_eq!( + store.rename_from_body(br#"{"clubName":" Real FUT ","clubAbbr":"RF"}"#), + RenameOutcome::Updated + ); + assert_eq!( + store.club(), + ClubIdentity { + name: "Real FUT".into(), + abbr: "RF".into(), + established: "2016".into() + } + ); + + let raw: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(raw["persona_id"], 33_068_179); + assert_eq!(raw["persona_name"], "CAGE"); + assert_eq!(raw["pow_level"], 7); + assert_eq!(raw["club_name"], "Real FUT"); + assert_eq!(raw["club_abbr"], "RF"); + assert_eq!(AccountStore::open(&path).club(), store.club()); + let _ = fs::remove_file(path); + } + + #[test] + fn external_atomic_replacement_is_reloaded_before_read_and_rename() { + let path = temp_path("external-replace"); + let _ = fs::remove_file(&path); + fs::write( + &path, + serde_json::to_vec(&json!({ + "club_name": "OpenFUT", + "club_abbr": "OFC", + "pow_level": 7 + })) + .unwrap(), + ) + .unwrap(); + let store = AccountStore::open(&path); + + let replacement = serde_json::to_vec(&json!({ + "club_name": "Other Club", + "club_abbr": "OC", + "established": "2015", + "pow_level": 11 + })) + .unwrap(); + persist_atomically(&path, &replacement).unwrap(); + assert_eq!( + store.club(), + ClubIdentity { + name: "Other Club".into(), + abbr: "OC".into(), + established: "2015".into() + } + ); + + assert_eq!( + store.rename_from_body(br#"{"clubName":"Third Club"}"#), + RenameOutcome::Updated + ); + let raw: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(raw["club_name"], "Third Club"); + assert_eq!(raw["club_abbr"], "OC"); + assert_eq!(raw["pow_level"], 11); + let _ = fs::remove_file(path); + } + + #[test] + fn partial_invalid_and_malformed_renames_match_oracle_no_change_semantics() { + let path = temp_path("reject"); + let _ = fs::remove_file(&path); + let store = AccountStore::open(&path); + + assert_eq!( + store.rename_from_body(br#"{"clubName":"Open FUT"}"#), + RenameOutcome::Updated + ); + assert_eq!(store.club().abbr, DEFAULT_CLUB_ABBR); + let before = store.club(); + assert_eq!( + store.rename_from_body(br#"{"clubName":"x","clubAbbr":"TOOLONG"}"#), + RenameOutcome::Rejected("club_name_length") + ); + assert_eq!( + store.rename_from_body(br#"{"clubName":3}"#), + RenameOutcome::Rejected("club_name_not_string") + ); + assert_eq!( + store.rename_from_body(b"not-json"), + RenameOutcome::Rejected("malformed_json") + ); + assert_eq!(store.club(), before); + let _ = fs::remove_file(path); + } +} diff --git a/openfut-utas-host/src/config.rs b/openfut-utas-host/src/config.rs index 2699418..421921e 100644 --- a/openfut-utas-host/src/config.rs +++ b/openfut-utas-host/src/config.rs @@ -39,6 +39,11 @@ pub struct HostConfig { /// 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, + /// Shared FIFA17 account JSON (`fut_account.py` shape), used for club-name + /// reads and the Rust-owned rename mutation. Defaults to + /// `OPENFUT_ACCOUNT_PATH`, then existing `FUT_ACCOUNT_PATH`, then the + /// identity store's parent directory + `active_account.json`. + pub account_path: String, } #[derive(Debug)] @@ -78,6 +83,11 @@ impl HostConfig { .ok() .filter(|v| !v.is_empty()) .unwrap_or_else(|| default_clientdata_path(&identity_store_path)); + let account_path = env::var("OPENFUT_ACCOUNT_PATH") + .ok() + .filter(|v| !v.is_empty()) + .or_else(|| env::var("FUT_ACCOUNT_PATH").ok().filter(|v| !v.is_empty())) + .unwrap_or_else(|| default_account_path(&identity_store_path)); Ok(HostConfig { listen_addr: required("OPENFUT_UTAS_HOST_ADDR")?, python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?, @@ -91,6 +101,7 @@ impl HostConfig { pile_db_path: required("OPENFUT_PILE_DB")?, identity_store_path, clientdata_path, + account_path, }) } } @@ -105,3 +116,12 @@ fn default_clientdata_path(identity_store_path: &str) -> String { .to_string_lossy() .into_owned() } + +fn default_account_path(identity_store_path: &str) -> String { + std::path::Path::new(identity_store_path) + .parent() + .map(|p| p.join("active_account.json")) + .unwrap_or_else(|| std::path::PathBuf::from("active_account.json")) + .to_string_lossy() + .into_owned() +} diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index b1ba5ec..494a120 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -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 ]; diff --git a/openfut-utas-host/src/main.rs b/openfut-utas-host/src/main.rs index 44b0448..7d12851 100644 --- a/openfut-utas-host/src/main.rs +++ b/openfut-utas-host/src/main.rs @@ -1,8 +1,8 @@ //! FIFA 17 UTAS migration host entrypoint. //! -//! Serves `GET …/club` from OpenFUT Core and proxies every other UTAS route to -//! the Python oracle. Config is env-only (see [`openfut_utas_host::config`]); -//! bind and Python upstream are required with no default. +//! Serves migrated FIFA 17 UTAS routes from Rust/Core and proxies only the +//! remaining tail to the Python oracle. Config is env-only (see +//! [`openfut_utas_host::config`]). use openfut_utas_host::{config::HostConfig, Server}; @@ -15,8 +15,8 @@ fn main() { } }; eprintln!( - "utas-host starting: listen={} python_upstream={} core_url={} tables_dir={} catalog={} identity_store={} persona_id={}", - cfg.listen_addr, cfg.python_upstream, cfg.core_url, cfg.tables_dir, cfg.catalog_path, cfg.identity_store_path, cfg.persona_id + "utas-host starting: listen={} python_upstream={} core_url={} tables_dir={} catalog={} identity_store={} account={} persona_id={}", + cfg.listen_addr, cfg.python_upstream, cfg.core_url, cfg.tables_dir, cfg.catalog_path, cfg.identity_store_path, cfg.account_path, cfg.persona_id ); let server = match Server::from_config(&cfg) { Ok(s) => s, diff --git a/openfut-utas-host/tests/economy_integration.rs b/openfut-utas-host/tests/economy_integration.rs index 14ca30b..73080e2 100644 --- a/openfut-utas-host/tests/economy_integration.rs +++ b/openfut-utas-host/tests/economy_integration.rs @@ -451,10 +451,8 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult { "POST", "/ut/game/fifa17/auctionhouse", &[], - format!( - r#"{{"itemData":{{"id":{list_wire}}},"buyNowPrice":1000,"startingBid":500}}"# - ) - .as_bytes(), + format!(r#"{{"itemData":{{"id":{list_wire}}},"buyNowPrice":1000,"startingBid":500}}"#) + .as_bytes(), None, ) .expect("market list routed"); @@ -767,6 +765,10 @@ fn from_config(base: &str, dir: &std::path::Path) -> openfut_utas_host::config:: market_db_path: dir.join("market.db").to_string_lossy().into_owned(), pile_db_path: dir.join("pile.db").to_string_lossy().into_owned(), clientdata_path: dir.join("clientdata.json").to_string_lossy().into_owned(), + account_path: dir + .join("active_account.json") + .to_string_lossy() + .into_owned(), } } diff --git a/openfut-utas-host/tests/host_test.rs b/openfut-utas-host/tests/host_test.rs index f8f1630..29f7818 100644 --- a/openfut-utas-host/tests/host_test.rs +++ b/openfut-utas-host/tests/host_test.rs @@ -12,6 +12,7 @@ use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog; use openfut_adapter_fifa17::fut::club_response::{CoreOwnedItem, ItemIdentityResolver}; use openfut_adapter_fifa17::fut::entities::Fifa17Entities; use openfut_identity::JsonIdentityStore; +use openfut_utas_host::account_store::AccountStore; use openfut_utas_host::{ classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list, handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState, CorePage, @@ -302,9 +303,33 @@ fn resolver_for(cards: &[(&str, u32)]) -> Arc<Fifa17IdentityResolver> { fn club_excludes_listed_items_and_paginates_the_visible_set() { let core = Arc::new(FakeCore::new( vec![ - item("oc1", "card_a", 86, "CDM", "Argentina", "Premier League", "Chelsea"), - item("oc2", "card_b", 85, "ST", "Argentina", "Premier League", "Chelsea"), - item("oc3", "card_c", 84, "CB", "Argentina", "Premier League", "Chelsea"), + item( + "oc1", + "card_a", + 86, + "CDM", + "Argentina", + "Premier League", + "Chelsea", + ), + item( + "oc2", + "card_b", + 85, + "ST", + "Argentina", + "Premier League", + "Chelsea", + ), + item( + "oc3", + "card_c", + 84, + "CB", + "Argentina", + "Premier League", + "Chelsea", + ), ], 3, )); @@ -502,21 +527,43 @@ fn passthrough_forwards_verbatim_and_never_calls_core() { } #[test] -fn mutating_route_classifies_to_passthrough_not_rust() { - // A PUT to the club PATH is NOT the read route; it must go to Python, never - // execute Rust/Core (guards against double-applying a mutation). - assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::Passthrough); +fn club_rename_is_rust_owned_persistent_and_never_calls_python_or_core() { + assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::ClubRename); + assert_eq!( + classify("PUT", "/ut/game/fifa17/user/club"), + Route::ClubRename + ); + assert_eq!( + classify("POST", "/ut/game/fifa17/user/club"), + Route::ClubRename + ); assert_eq!( classify("POST", "/ut/game/fifa17/squad/0"), Route::Passthrough ); - let (upstream, _rec) = spawn_mock_python(); + let (upstream, rec) = spawn_mock_python(); let core = Arc::new(FakeCore::forbidden()); - let server = build_server(core.clone(), &upstream, None); - let resp = server.handle("PUT", "/ut/game/fifa17/club", &[], br#"{"x":1}"#); + let account_path = unique_store_path(); + let account = Arc::new(AccountStore::open(&account_path)); + let server = build_server(core.clone(), &upstream, None).with_account(account.clone()); + let resp = server.handle( + "PUT", + "/ut/game/fifa17/user/club", + &[], + br#"{"clubName":"Real FUT","clubAbbr":"RF"}"#, + ); assert_eq!(resp.status, 200); + assert_eq!( + serde_json::from_slice::<Value>(&resp.body).unwrap(), + json!({}) + ); + assert_eq!(account.club().name, "Real FUT"); + assert_eq!(account.club().abbr, "RF"); + assert_eq!(AccountStore::open(&account_path).club(), account.club()); assert_eq!(core.calls(), 0); + assert_eq!(rec.lock().len(), 0, "rename never reached Python"); + let _ = std::fs::remove_file(account_path); } // ── End-to-end over a socket (read_request + write_response + keep-alive) ───── @@ -874,9 +921,8 @@ fn classify_squad_and_usermassinfo_routes() { classify("GET", "/ut/game/fifa17/userMassInfo"), Route::UserMassInfo ); - // GET /squad/active is now Core-backed (SquadActive). A squad PUT is never a - // GET. GET /squad/0 IS the active squad (id 0) -> SquadActive; a numeric - // GET /squad/<n> for a NON-active squad (n != 0) stays on Python. + // GET /squad/active and every numeric GET are the one Core-backed current + // squad, matching the oracle's single-squad response regardless of URL id. assert_eq!( classify("GET", "/ut/game/fifa17/squad/active"), Route::SquadActive @@ -891,7 +937,7 @@ fn classify_squad_and_usermassinfo_routes() { ); assert_eq!( classify("GET", "/ut/game/fifa17/squad/5"), - Route::Passthrough + Route::SquadActive ); assert_eq!( classify("PUT", "/ut/game/fifa17/squad/list"), @@ -900,6 +946,39 @@ fn classify_squad_and_usermassinfo_routes() { assert_eq!(classify("GET", "/ut/game/fifa17/club"), Route::Club); } +#[test] +fn numbered_squad_get_returns_current_core_squad_without_python() { + let items = vec![gk(), st()]; + let (resolver, wires) = resolver_with_wires(&items, ASSETS); + let core = Arc::new(FakeCore::new(items, 2)); + let (python, recorded) = spawn_mock_python(); + let server = Server::new( + core, + Arc::new(entities()), + Arc::new(resolver), + Arc::new(PassClient::new(&python)), + 33_068_179, + ); + let put = server.handle( + "PUT", + "/ut/game/fifa17/squad/0", + &[], + &put_body( + "f442", + wires["oc-a"], + &[(0, wires["oc-a"], 1), (1, wires["oc-b"], 9)], + "[1,2,3]", + ), + ); + assert_eq!(put.status, 200); + + let active = server.handle("GET", "/ut/game/fifa17/squad/active", &[], b""); + let numbered = server.handle("GET", "/ut/game/fifa17/squad/5", &[], b""); + assert_eq!(numbered.status, 200); + assert_eq!(numbered.body, active.body); + assert_eq!(recorded.lock().len(), 0, "numeric GET never reached Python"); +} + // ── PUT pipeline ──────────────────────────────────────────────────────────── #[test]