Migrate club rename and numeric squad reads
This commit is contained in:
@@ -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.
|
/// `POST /openfut/account/sync` — the launcher control-plane account summary.
|
||||||
/// `coins`/`unopened_packs` are the AUTHORITATIVE Core values (balance +
|
/// `coins`/`unopened_packs` are the AUTHORITATIVE Core values (balance +
|
||||||
/// entitlement count), never Python's stale profile funds.
|
/// 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!({
|
json!({
|
||||||
"account": {
|
"account": {
|
||||||
"personaId": req.persona_id,
|
"personaId": req.persona_id,
|
||||||
"personaName": req.persona_name,
|
"personaName": req.persona_name,
|
||||||
"clubName": "OpenFUT",
|
"clubName": club_name,
|
||||||
"clubAbbr": "OFC",
|
"clubAbbr": club_abbr,
|
||||||
"level": req.level,
|
"level": req.level,
|
||||||
"experience": req.experience,
|
"experience": req.experience,
|
||||||
"experienceMax": req.experience_max,
|
"experienceMax": req.experience_max,
|
||||||
@@ -301,6 +307,9 @@ pub fn user_mass_info_body(
|
|||||||
coins: i64,
|
coins: i64,
|
||||||
unopened_packs: usize,
|
unopened_packs: usize,
|
||||||
persona_id: i64,
|
persona_id: i64,
|
||||||
|
club_name: &str,
|
||||||
|
club_abbr: &str,
|
||||||
|
established: &str,
|
||||||
) -> Value {
|
) -> Value {
|
||||||
let actives: Vec<Value> = squad
|
let actives: Vec<Value> = squad
|
||||||
.get("actives")
|
.get("actives")
|
||||||
@@ -310,9 +319,9 @@ pub fn user_mass_info_body(
|
|||||||
let squad_list = crate::fut::squad_projection::squad_list(&squad);
|
let squad_list = crate::fut::squad_projection::squad_list(&squad);
|
||||||
let mut user_info = json!({
|
let mut user_info = json!({
|
||||||
"personaId": persona_id,
|
"personaId": persona_id,
|
||||||
"clubName": "OpenFUT",
|
"clubName": club_name,
|
||||||
"clubAbbr": "OFC",
|
"clubAbbr": club_abbr,
|
||||||
"established": "2026",
|
"established": established,
|
||||||
"accountCreatedPlatformName": "pc",
|
"accountCreatedPlatformName": "pc",
|
||||||
"currencies": [
|
"currencies": [
|
||||||
{"name": "coins", "funds": coins, "finalFunds": coins, "active": true},
|
{"name": "coins", "funds": coins, "finalFunds": coins, "active": true},
|
||||||
@@ -583,10 +592,10 @@ mod tests {
|
|||||||
assert_eq!(req.level, 1);
|
assert_eq!(req.level, 1);
|
||||||
assert_eq!(req.experience_max, 1000);
|
assert_eq!(req.experience_max, 1000);
|
||||||
assert_eq!(req.account_funds_cap, 100_000);
|
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"];
|
let acc = &body["account"];
|
||||||
assert_eq!(acc["clubName"], "OpenFUT");
|
assert_eq!(acc["clubName"], "Real FUT");
|
||||||
assert_eq!(acc["clubAbbr"], "OFC");
|
assert_eq!(acc["clubAbbr"], "RF");
|
||||||
assert_eq!(acc["profilePath"], "accounts/33068179/fifa17_profile.json");
|
assert_eq!(acc["profilePath"], "accounts/33068179/fifa17_profile.json");
|
||||||
assert_eq!(acc["coins"], 29_859_876);
|
assert_eq!(acc["coins"], 29_859_876);
|
||||||
assert_eq!(acc["unopenedPacks"], 2);
|
assert_eq!(acc["unopenedPacks"], 2);
|
||||||
@@ -617,7 +626,7 @@ mod tests {
|
|||||||
"actives": [],
|
"actives": [],
|
||||||
"players": [],
|
"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.
|
// Flat top-level envelope.
|
||||||
assert_eq!(body["pileSizeClientData"]["entries"][0], json!({"key": 2, "value": 100}));
|
assert_eq!(body["pileSizeClientData"]["entries"][0], json!({"key": 2, "value": 100}));
|
||||||
assert_eq!(body["pileSizeClientData"]["entries"][1], json!({"key": 4, "value": 50}));
|
assert_eq!(body["pileSizeClientData"]["entries"][1], json!({"key": 4, "value": 50}));
|
||||||
@@ -626,9 +635,9 @@ mod tests {
|
|||||||
// userInfo economy + club identity.
|
// userInfo economy + club identity.
|
||||||
let ui = &body["userInfo"];
|
let ui = &body["userInfo"];
|
||||||
assert_eq!(ui["personaId"], 33_068_179);
|
assert_eq!(ui["personaId"], 33_068_179);
|
||||||
assert_eq!(ui["clubName"], "OpenFUT");
|
assert_eq!(ui["clubName"], "Real FUT");
|
||||||
assert_eq!(ui["clubAbbr"], "OFC");
|
assert_eq!(ui["clubAbbr"], "RF");
|
||||||
assert_eq!(ui["established"], "2026"); // string, not number
|
assert_eq!(ui["established"], "2016"); // string, not number
|
||||||
assert_eq!(ui["accountCreatedPlatformName"], "pc");
|
assert_eq!(ui["accountCreatedPlatformName"], "pc");
|
||||||
assert_eq!(ui["currencies"][0]["name"], "coins");
|
assert_eq!(ui["currencies"][0]["name"], "coins");
|
||||||
assert_eq!(ui["currencies"][0]["funds"], 29_859_876);
|
assert_eq!(ui["currencies"][0]["funds"], 29_859_876);
|
||||||
@@ -645,7 +654,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn user_mass_info_includes_unopened_packs_when_present() {
|
fn user_mass_info_includes_unopened_packs_when_present() {
|
||||||
let squad = json!({"id": 0, "actives": [], "players": []});
|
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"]["recoveredPacks"], 3);
|
||||||
assert_eq!(body["userInfo"]["unopenedPacks"]["preOrderPacks"], 0);
|
assert_eq!(body["userInfo"]["unopenedPacks"]["preOrderPacks"], 0);
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-60
@@ -1,47 +1,51 @@
|
|||||||
# openfut-utas-host
|
# openfut-utas-host
|
||||||
|
|
||||||
The first live FIFA 17 **UTAS migration host**. It fronts the client-visible UTAS
|
The FIFA 17 UTAS migration boundary. It accepts the client-visible HTTP surface,
|
||||||
port and migrates one route at a time to OpenFUT Core, proxying everything else to
|
serves migrated routes from Rust/Core plus host-owned durable stores, and proxies only
|
||||||
the Python UTAS oracle so the rest of FUT keeps working unchanged.
|
the unclassified tail to the Python behavioral oracle.
|
||||||
|
|
||||||
```
|
```
|
||||||
FIFA 17 ──HTTP──▶ openfut-utas-host
|
FIFA 17 ──HTTP──▶ openfut-utas-host
|
||||||
├── GET …/club ──▶ FIFA17 adapter ──▶ OpenFUT Core (/collection)
|
├── migrated route ──▶ Rust adapter / Core / host stores
|
||||||
└── everything else ──▶ Python UTAS oracle (verbatim reverse proxy)
|
└── unclassified tail ──▶ Python UTAS oracle
|
||||||
```
|
```
|
||||||
|
|
||||||
## What it owns / does not own
|
`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,
|
||||||
Owns: socket + HTTP/1.1 keep-alive transport, route classification, the Core
|
packs, owned-item moves, market/trade-pile, and the observed hub support routes.
|
||||||
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).
|
|
||||||
|
|
||||||
## Safety model
|
## Safety model
|
||||||
|
|
||||||
- **Classification happens once, before execution.** Exact `GET /ut/game/<title>/club`
|
- Classification happens exactly once before execution. There is no "try Rust then
|
||||||
→ Rust; everything else → Python. No shared path, no "try Rust then Python".
|
Python"; a mutation cannot be double-applied.
|
||||||
- A Core failure on `/club` degrades to a valid empty `{"itemData":[]}` and logs
|
- A route classified to Rust never falls back to Python on a Core/store/projection
|
||||||
an error — it never falls back to Python (which could double-apply a mutation on
|
failure. Each handler uses its captured fail-closed or honest-empty wire contract.
|
||||||
other routes). `/club` is read-only, but the rule is absolute.
|
- `PUT …/club` and `PUT|POST …/user/club` atomically update the shared account JSON.
|
||||||
- Mutating routes (PUT/POST, `/squad`, `/purchased`, quick-sell, market, auth, SBC,
|
Every input returns the required zero-atom `200 {}` response; rejection and
|
||||||
`/club/stats/*`, `/clubUser`) all classify to passthrough and are untouched.
|
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)
|
## Configuration (env)
|
||||||
|
|
||||||
| Var | Required | Default | Meaning |
|
| Var | Required | Default | Meaning |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `OPENFUT_UTAS_HOST_ADDR` | yes | — | where this host listens (client-visible UTAS addr) |
|
| `OPENFUT_UTAS_HOST_ADDR` | yes | — | client-visible host listen address |
|
||||||
| `OPENFUT_UTAS_PYTHON_URL` | yes | — | Python UTAS oracle base URL for fallback (must differ from this host) |
|
| `OPENFUT_UTAS_PYTHON_URL` | yes | — | Python oracle base for the unclassified tail; must differ from this host |
|
||||||
| `OPENFUT_FIFA17_CATALOG` | yes | — | FIFA 17 card-definition identity catalog (`Fifa17CardCatalog` JSON: card id → asset id) |
|
| `OPENFUT_FIFA17_CATALOG` | yes | — | FIFA 17 definition identity catalog |
|
||||||
| `OPENFUT_IDENTITY_STORE` | yes | — | persistent external-identity store file (owned instance → stable wire id) |
|
| `OPENFUT_IDENTITY_STORE` | yes | — | persistent owned-instance ↔ wire-id store |
|
||||||
| `OPENFUT_PERSONA_ID` | yes | — | FIFA persona id stamped on `GET /squad/active` (must match the persona LSX/Blaze/POW/UTAS agree on) |
|
| `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_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 —
|
Startup fails if required identity or durable economy state cannot be opened. No
|
||||||
there is no placeholder fallback (exactly one production identity path).
|
placeholder production identity source is substituted.
|
||||||
|
|
||||||
## Identity model (resolved)
|
## 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
|
within `(fifa17, owned-item)` — no per-account column is needed because Core
|
||||||
owned-instance ids are globally-unique UUIDs.
|
owned-instance ids are globally-unique UUIDs.
|
||||||
|
|
||||||
**Remaining prerequisite for a *rendering* retail `/club`:** Core inventory must
|
Production has a frozen post-P1 baseline and a hot Python rollback. A source change
|
||||||
reference cards that exist in the catalog. The catalog + store + resolver are
|
passing local tests is **not** deployment approval. Build verification, staging, host
|
||||||
built and tested; wiring a controlled real FIFA 17 dev-content inventory (the
|
restart, and live-client promotion remain operator-gated; the current state and
|
||||||
curated per-game dev pack) is the next slice. `rare=SP` ("Special") stays
|
promotion evidence live in the OpenFUT Obsidian vault.
|
||||||
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).
|
|
||||||
|
|
||||||
Logs are safe by construction: no auth/session/device/token material — only owner,
|
Logs are safe by construction: no auth/session/device/token material — only owner,
|
||||||
route, filter summary, counts, status.
|
route, filter summary, counts, status.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,11 @@ pub struct HostConfig {
|
|||||||
/// identity store's parent directory + `clientdata.json`. The blobs are
|
/// identity store's parent directory + `clientdata.json`. The blobs are
|
||||||
/// non-authoritative client UI state, so a default path is safe.
|
/// non-authoritative client UI state, so a default path is safe.
|
||||||
pub clientdata_path: String,
|
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)]
|
#[derive(Debug)]
|
||||||
@@ -78,6 +83,11 @@ impl HostConfig {
|
|||||||
.ok()
|
.ok()
|
||||||
.filter(|v| !v.is_empty())
|
.filter(|v| !v.is_empty())
|
||||||
.unwrap_or_else(|| default_clientdata_path(&identity_store_path));
|
.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 {
|
Ok(HostConfig {
|
||||||
listen_addr: required("OPENFUT_UTAS_HOST_ADDR")?,
|
listen_addr: required("OPENFUT_UTAS_HOST_ADDR")?,
|
||||||
python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?,
|
python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?,
|
||||||
@@ -91,6 +101,7 @@ impl HostConfig {
|
|||||||
pile_db_path: required("OPENFUT_PILE_DB")?,
|
pile_db_path: required("OPENFUT_PILE_DB")?,
|
||||||
identity_store_path,
|
identity_store_path,
|
||||||
clientdata_path,
|
clientdata_path,
|
||||||
|
account_path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,3 +116,12 @@ fn default_clientdata_path(identity_store_path: &str) -> String {
|
|||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.into_owned()
|
.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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
//! faked (see `club_response`). With today's empty mapping, `/club` returns
|
//! faked (see `club_response`). With today's empty mapping, `/club` returns
|
||||||
//! `{"itemData":[]}` — the honest state until Core inventory is asset-backed.
|
//! `{"itemData":[]}` — the honest state until Core inventory is asset-backed.
|
||||||
|
|
||||||
|
pub mod account_store;
|
||||||
pub mod async_bridge;
|
pub mod async_bridge;
|
||||||
pub mod clientdata_store;
|
pub mod clientdata_store;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
@@ -79,6 +80,7 @@ use openfut_identity::ExternalIdentityStore;
|
|||||||
use rand::{Rng, SeedableRng};
|
use rand::{Rng, SeedableRng};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use account_store::{AccountStore, RenameOutcome};
|
||||||
use clientdata_store::ClientDataStore;
|
use clientdata_store::ClientDataStore;
|
||||||
use config::HostConfig;
|
use config::HostConfig;
|
||||||
|
|
||||||
@@ -89,6 +91,9 @@ use config::HostConfig;
|
|||||||
pub enum Route {
|
pub enum Route {
|
||||||
/// `GET …/club` — the owned-player search, served from Core.
|
/// `GET …/club` — the owned-player search, served from Core.
|
||||||
Club,
|
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.
|
/// `PUT …/squad/<n>` — full squad replacement, committed to Core.
|
||||||
SquadReplace,
|
SquadReplace,
|
||||||
/// `GET …/squad/list` — the squad summary, projected from Core.
|
/// `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.
|
/// object `userMassInfo` embeds (shared builder). POST /user (create) stays Python.
|
||||||
User,
|
User,
|
||||||
/// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and
|
/// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and
|
||||||
/// the club-identity service are disabled in this emulator, so these reads
|
/// the club-identity read service are disabled in this emulator, so these
|
||||||
/// return `{}`, byte-identical to the Python oracle with `FUT_MODES` /
|
/// reads return `{}`, byte-identical to the Python oracle with `FUT_MODES` /
|
||||||
/// `FUT_CLUB_IDENTITY` off. The mutating club-identity route (`user/club`
|
/// `FUT_CLUB_IDENTITY` off. Club rename is a separate Rust-owned route.
|
||||||
/// rename) stays on Python. Turning a feature on later means a real Rust
|
|
||||||
/// handler here — never a Python fallback.
|
|
||||||
FeatureOffEmpty,
|
FeatureOffEmpty,
|
||||||
/// `GET …/item/resource`, `…/defid` — FUT item-definition lookups. Rust builds
|
/// `GET …/item/resource`, `…/defid` — FUT item-definition lookups. Rust builds
|
||||||
/// `{itemData:[…]}` (one placeholder-or-Ronaldo def per queried id), mirroring
|
/// `{itemData:[…]}` (one placeholder-or-Ronaldo def per queried id), mirroring
|
||||||
@@ -169,16 +172,10 @@ pub enum Route {
|
|||||||
Passthrough,
|
Passthrough,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Classify a request ONCE, before execution. Rust owns exactly:
|
/// Classify a request ONCE, before execution. Rust owns complete route families;
|
||||||
/// * `GET …/club`
|
/// there is no "try Rust then Python", so a mutation can never be double-applied.
|
||||||
/// * `PUT …/squad/<n>` (numeric id)
|
/// Numeric `GET …/squad/<n>` follows the oracle's single-current-squad behavior:
|
||||||
/// * `GET …/squad/list`
|
/// every numeric id returns the one Core-backed active squad.
|
||||||
/// * `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.
|
|
||||||
pub fn classify(method: &str, path: &str) -> Route {
|
pub fn classify(method: &str, path: &str) -> Route {
|
||||||
let get = method.eq_ignore_ascii_case("GET");
|
let get = method.eq_ignore_ascii_case("GET");
|
||||||
let put = method.eq_ignore_ascii_case("PUT");
|
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) {
|
if get && is_exact_club_path(path) {
|
||||||
return Route::Club;
|
return Route::Club;
|
||||||
}
|
}
|
||||||
|
if put && is_exact_club_path(path) {
|
||||||
|
return Route::ClubRename;
|
||||||
|
}
|
||||||
match ut_tail(path) {
|
match ut_tail(path) {
|
||||||
Some("squad/list") if get => Route::SquadList,
|
Some("squad/list") if get => Route::SquadList,
|
||||||
Some("squad/active") if get => Route::SquadActive,
|
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("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 tail.starts_with("clientdata/") => Route::ClientData,
|
||||||
Some(tail) if get && tail.starts_with("store/purchasegroup") => Route::StorePurchaseGroup,
|
Some(tail) if get && tail.starts_with("store/purchasegroup") => Route::StorePurchaseGroup,
|
||||||
Some(tail) if put && is_numeric_squad_tail(tail) => Route::SquadReplace,
|
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")
|
ut_tail(path) == Some("club")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `squad/<digits>` — the numeric full-squad target used by `PUT`. The active
|
/// `squad/<digits>` — the numeric full-squad target used by PUT and GET. Core
|
||||||
/// squad READ (`GET …/squad/active`) is routed separately (Core-backed); a
|
/// stores one current squad, matching the oracle: every numeric GET returns that
|
||||||
/// numeric `GET …/squad/<n>` for a non-active squad stays on Python (there is no
|
/// same squad regardless of the requested id.
|
||||||
/// Core model for multiple squads yet).
|
|
||||||
fn is_numeric_squad_tail(tail: &str) -> bool {
|
fn is_numeric_squad_tail(tail: &str) -> bool {
|
||||||
match tail.strip_prefix("squad/") {
|
match tail.strip_prefix("squad/") {
|
||||||
Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()),
|
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.
|
/// 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.
|
/// Never baked in — it must match the persona LSX/Blaze/POW/UTAS agree on.
|
||||||
persona_id: i64,
|
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).
|
/// Per-login FIFA session/capability authority (empty-My-Packs topology).
|
||||||
/// The economy (coins, unopened packs, BUY) stays Python's; this owns only
|
/// The economy (coins, unopened packs, BUY) stays Python's; this owns only
|
||||||
/// session/capability state — Rust reads Python's live body, never writes it.
|
/// session/capability state — Rust reads Python's live body, never writes it.
|
||||||
@@ -2216,6 +2219,7 @@ impl Server {
|
|||||||
resolver,
|
resolver,
|
||||||
pass,
|
pass,
|
||||||
persona_id,
|
persona_id,
|
||||||
|
account: Arc::new(AccountStore::open(ephemeral_account_path())),
|
||||||
sessions: Arc::new(Mutex::new(SessionStore::new())),
|
sessions: Arc::new(Mutex::new(SessionStore::new())),
|
||||||
start: Instant::now(),
|
start: Instant::now(),
|
||||||
economy: None,
|
economy: None,
|
||||||
@@ -2282,6 +2286,7 @@ impl Server {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let clientdata = Arc::new(ClientDataStore::open(cfg.clientdata_path.clone()));
|
let clientdata = Arc::new(ClientDataStore::open(cfg.clientdata_path.clone()));
|
||||||
|
let account = Arc::new(AccountStore::open(cfg.account_path.clone()));
|
||||||
Ok(Server::new(
|
Ok(Server::new(
|
||||||
core,
|
core,
|
||||||
entities,
|
entities,
|
||||||
@@ -2290,7 +2295,8 @@ impl Server {
|
|||||||
cfg.persona_id,
|
cfg.persona_id,
|
||||||
)
|
)
|
||||||
.with_economy(economy)
|
.with_economy(economy)
|
||||||
.with_clientdata(clientdata))
|
.with_clientdata(clientdata)
|
||||||
|
.with_account(account))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assemble the shared squad dependencies (Core access + the one production
|
/// Assemble the shared squad dependencies (Core access + the one production
|
||||||
@@ -2319,6 +2325,12 @@ impl Server {
|
|||||||
self
|
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
|
/// 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
|
/// 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
|
/// handler-wiring entry point exercised by the integration harness; it is
|
||||||
@@ -2586,6 +2598,7 @@ impl Server {
|
|||||||
);
|
);
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
|
Route::ClubRename => self.handle_club_rename(body),
|
||||||
Route::SquadReplace => {
|
Route::SquadReplace => {
|
||||||
let deps = self.squad_deps();
|
let deps = self.squad_deps();
|
||||||
let (resp, log) = handle_put_squad(body, &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))
|
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
|
/// `POST /openfut/account/sync` — launcher control-plane account summary. The
|
||||||
/// coins/unopened-pack counts are the AUTHORITATIVE Core economy (balance +
|
/// coins/unopened-pack counts are the AUTHORITATIVE Core economy (balance +
|
||||||
/// entitlements), NEVER Python's stale profile funds. Fail-closed 503 on any
|
/// entitlements), NEVER Python's stale profile funds. Fail-closed 503 on any
|
||||||
@@ -2746,9 +2782,10 @@ impl Server {
|
|||||||
coins,
|
coins,
|
||||||
ents.len()
|
ents.len()
|
||||||
);
|
);
|
||||||
|
let club = self.account.club();
|
||||||
json_status(
|
json_status(
|
||||||
200,
|
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::Missing => (empty_squad_overlay(self.persona_id), "missing_integrity"),
|
||||||
HostProjection::Error(_) => (empty_squad_overlay(self.persona_id), "core_error"),
|
HostProjection::Error(_) => (empty_squad_overlay(self.persona_id), "core_error"),
|
||||||
};
|
};
|
||||||
|
let club = self.account.club();
|
||||||
Ok((
|
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,
|
squad_outcome,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -3086,9 +3132,9 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and the
|
/// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and the
|
||||||
/// club-identity service are off, so return `{}` (parity with the flag-off
|
/// club-identity read service are off, so return `{}` (parity with the
|
||||||
/// Python oracle). The mutating `user/club` rename is NOT here — it stays on
|
/// flag-off Python oracle). Club rename is handled separately by
|
||||||
/// Python until a real Rust club-identity handler exists.
|
/// [`Server::handle_club_rename`].
|
||||||
fn handle_feature_off_empty(&self, path: &str) -> WireResponse {
|
fn handle_feature_off_empty(&self, path: &str) -> WireResponse {
|
||||||
let tail = ut_tail(path).unwrap_or("");
|
let tail = ut_tail(path).unwrap_or("");
|
||||||
eprintln!("utas-host owner=RUST route=feature-off-empty tail={tail} status=200");
|
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
|
/// A unique ephemeral JSON-state path used by [`Server::new`] tests.
|
||||||
/// [`Server::new`] (tests). Production wires a durable path via
|
/// Production replaces both stores with configured durable paths.
|
||||||
/// [`Server::from_config`]/[`Server::with_clientdata`]. Uniqueness (pid + nanos +
|
fn ephemeral_state_path(kind: &str) -> std::path::PathBuf {
|
||||||
/// a process-local counter) keeps concurrent test servers isolated.
|
|
||||||
fn ephemeral_clientdata_path() -> std::path::PathBuf {
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
static CTR: AtomicU64 = AtomicU64::new(0);
|
static CTR: AtomicU64 = AtomicU64::new(0);
|
||||||
let n = CTR.fetch_add(1, Ordering::Relaxed);
|
let n = CTR.fetch_add(1, Ordering::Relaxed);
|
||||||
@@ -3275,13 +3319,19 @@ fn ephemeral_clientdata_path() -> std::path::PathBuf {
|
|||||||
.map(|d| d.as_nanos())
|
.map(|d| d.as_nanos())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
std::env::temp_dir().join(format!(
|
std::env::temp_dir().join(format!(
|
||||||
"openfut-utas-clientdata-{}-{}-{}.json",
|
"openfut-utas-{kind}-{}-{nanos}-{n}.json",
|
||||||
std::process::id(),
|
std::process::id()
|
||||||
nanos,
|
|
||||||
n
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
/// A validated capability registration request.
|
||||||
struct CapabilityRequest {
|
struct CapabilityRequest {
|
||||||
persona_id: Option<i64>,
|
persona_id: Option<i64>,
|
||||||
@@ -3753,11 +3803,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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/fifa17/club"), Route::Club);
|
||||||
assert_eq!(classify("get", "/ut/game/fifa18/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::ClubRename);
|
||||||
assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::Passthrough);
|
|
||||||
// bare `club/stats` (no trailing slash) is not a migrated arm -> Python.
|
// bare `club/stats` (no trailing slash) is not a migrated arm -> Python.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify("GET", "/ut/game/fifa17/club/stats"),
|
classify("GET", "/ut/game/fifa17/club/stats"),
|
||||||
@@ -4307,6 +4356,9 @@ mod tests {
|
|||||||
("GET", "/ut/game/fifa17/champion", Route::FeatureOffEmpty),
|
("GET", "/ut/game/fifa17/champion", Route::FeatureOffEmpty),
|
||||||
("GET", "/ut/game/fifa17/clubUser", Route::FeatureOffEmpty),
|
("GET", "/ut/game/fifa17/clubUser", Route::FeatureOffEmpty),
|
||||||
("GET", "/ut/game/fifa17/user/list", 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/item/resource", Route::ItemDefs),
|
||||||
("GET", "/ut/game/fifa17/defid", Route::ItemDefs),
|
("GET", "/ut/game/fifa17/defid", Route::ItemDefs),
|
||||||
(
|
(
|
||||||
@@ -4326,7 +4378,7 @@ mod tests {
|
|||||||
("GET", "/ut/game/fifa17/match/reset"),
|
("GET", "/ut/game/fifa17/match/reset"),
|
||||||
("PUT", "/ut/game/fifa17/user/accountinfo"),
|
("PUT", "/ut/game/fifa17/user/accountinfo"),
|
||||||
("POST", "/ut/game/fifa17/season"), // FUT-mode reads are GET-only
|
("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
|
("POST", "/ut/game/fifa17/item/resource"), // item-defs are GET-only
|
||||||
("GET", "/ut/game/fifa17/marketdatafoo"), // not the marketdata route
|
("GET", "/ut/game/fifa17/marketdatafoo"), // not the marketdata route
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! FIFA 17 UTAS migration host entrypoint.
|
//! FIFA 17 UTAS migration host entrypoint.
|
||||||
//!
|
//!
|
||||||
//! Serves `GET …/club` from OpenFUT Core and proxies every other UTAS route to
|
//! Serves migrated FIFA 17 UTAS routes from Rust/Core and proxies only the
|
||||||
//! the Python oracle. Config is env-only (see [`openfut_utas_host::config`]);
|
//! remaining tail to the Python oracle. Config is env-only (see
|
||||||
//! bind and Python upstream are required with no default.
|
//! [`openfut_utas_host::config`]).
|
||||||
|
|
||||||
use openfut_utas_host::{config::HostConfig, Server};
|
use openfut_utas_host::{config::HostConfig, Server};
|
||||||
|
|
||||||
@@ -15,8 +15,8 @@ fn main() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"utas-host starting: listen={} python_upstream={} core_url={} tables_dir={} catalog={} identity_store={} 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.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) {
|
let server = match Server::from_config(&cfg) {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
|
|||||||
@@ -451,9 +451,7 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
|||||||
"POST",
|
"POST",
|
||||||
"/ut/game/fifa17/auctionhouse",
|
"/ut/game/fifa17/auctionhouse",
|
||||||
&[],
|
&[],
|
||||||
format!(
|
format!(r#"{{"itemData":{{"id":{list_wire}}},"buyNowPrice":1000,"startingBid":500}}"#)
|
||||||
r#"{{"itemData":{{"id":{list_wire}}},"buyNowPrice":1000,"startingBid":500}}"#
|
|
||||||
)
|
|
||||||
.as_bytes(),
|
.as_bytes(),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -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(),
|
market_db_path: dir.join("market.db").to_string_lossy().into_owned(),
|
||||||
pile_db_path: dir.join("pile.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(),
|
clientdata_path: dir.join("clientdata.json").to_string_lossy().into_owned(),
|
||||||
|
account_path: dir
|
||||||
|
.join("active_account.json")
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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::club_response::{CoreOwnedItem, ItemIdentityResolver};
|
||||||
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
||||||
use openfut_identity::JsonIdentityStore;
|
use openfut_identity::JsonIdentityStore;
|
||||||
|
use openfut_utas_host::account_store::AccountStore;
|
||||||
use openfut_utas_host::{
|
use openfut_utas_host::{
|
||||||
classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list,
|
classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list,
|
||||||
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState, CorePage,
|
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() {
|
fn club_excludes_listed_items_and_paginates_the_visible_set() {
|
||||||
let core = Arc::new(FakeCore::new(
|
let core = Arc::new(FakeCore::new(
|
||||||
vec![
|
vec![
|
||||||
item("oc1", "card_a", 86, "CDM", "Argentina", "Premier League", "Chelsea"),
|
item(
|
||||||
item("oc2", "card_b", 85, "ST", "Argentina", "Premier League", "Chelsea"),
|
"oc1",
|
||||||
item("oc3", "card_c", 84, "CB", "Argentina", "Premier League", "Chelsea"),
|
"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,
|
3,
|
||||||
));
|
));
|
||||||
@@ -502,21 +527,43 @@ fn passthrough_forwards_verbatim_and_never_calls_core() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mutating_route_classifies_to_passthrough_not_rust() {
|
fn club_rename_is_rust_owned_persistent_and_never_calls_python_or_core() {
|
||||||
// A PUT to the club PATH is NOT the read route; it must go to Python, never
|
assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::ClubRename);
|
||||||
// execute Rust/Core (guards against double-applying a mutation).
|
assert_eq!(
|
||||||
assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::Passthrough);
|
classify("PUT", "/ut/game/fifa17/user/club"),
|
||||||
|
Route::ClubRename
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify("POST", "/ut/game/fifa17/user/club"),
|
||||||
|
Route::ClubRename
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify("POST", "/ut/game/fifa17/squad/0"),
|
classify("POST", "/ut/game/fifa17/squad/0"),
|
||||||
Route::Passthrough
|
Route::Passthrough
|
||||||
);
|
);
|
||||||
|
|
||||||
let (upstream, _rec) = spawn_mock_python();
|
let (upstream, rec) = spawn_mock_python();
|
||||||
let core = Arc::new(FakeCore::forbidden());
|
let core = Arc::new(FakeCore::forbidden());
|
||||||
let server = build_server(core.clone(), &upstream, None);
|
let account_path = unique_store_path();
|
||||||
let resp = server.handle("PUT", "/ut/game/fifa17/club", &[], br#"{"x":1}"#);
|
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!(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!(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) ─────
|
// ── 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"),
|
classify("GET", "/ut/game/fifa17/userMassInfo"),
|
||||||
Route::UserMassInfo
|
Route::UserMassInfo
|
||||||
);
|
);
|
||||||
// GET /squad/active is now Core-backed (SquadActive). A squad PUT is never a
|
// GET /squad/active and every numeric GET are the one Core-backed current
|
||||||
// GET. GET /squad/0 IS the active squad (id 0) -> SquadActive; a numeric
|
// squad, matching the oracle's single-squad response regardless of URL id.
|
||||||
// GET /squad/<n> for a NON-active squad (n != 0) stays on Python.
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify("GET", "/ut/game/fifa17/squad/active"),
|
classify("GET", "/ut/game/fifa17/squad/active"),
|
||||||
Route::SquadActive
|
Route::SquadActive
|
||||||
@@ -891,7 +937,7 @@ fn classify_squad_and_usermassinfo_routes() {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify("GET", "/ut/game/fifa17/squad/5"),
|
classify("GET", "/ut/game/fifa17/squad/5"),
|
||||||
Route::Passthrough
|
Route::SquadActive
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify("PUT", "/ut/game/fifa17/squad/list"),
|
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);
|
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 ────────────────────────────────────────────────────────────
|
// ── PUT pipeline ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user