Migrate club rename and numeric squad reads

This commit is contained in:
funman300
2026-08-18 17:29:24 +00:00
parent fc55de19fa
commit bf6db98f0d
8 changed files with 645 additions and 136 deletions
+372
View File
@@ -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);
}
}
+20
View File
@@ -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()
}
+91 -39
View File
@@ -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
];
+5 -5
View File
@@ -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,