From 37c2e5d7eec2be6cb6ec7cfaea63eca2b94109f0 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 12 Aug 2026 17:10:04 +0000 Subject: [PATCH] feat(utas-host): serve GET /squad/active from Core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the active-squad READ off the Python oracle to the existing Core-backed projector, completing the squad authority (read + write + /squad/list + userMassInfo overlay) on one projector. - classify: GET /ut/game//squad/active -> Route::SquadActive. Numeric GET /squad/ stays on Python (no Core multi-squad model yet). - handle_squad_active returns the projector object via user_mass_info_squad(v, persona) — byte-identical to userMassInfo.squad; degrades to an empty overlay on stale/missing/Core-error, never falls back to Python. - persona: new REQUIRED OPENFUT_PERSONA_ID (non-zero) on HostConfig, injected not baked, must match LSX/Blaze/POW/UTAS identity. - tests: squad_active parity test; classify updated; README config table + A/B command. fmt + clippy -D warnings + tests (24 host + adapter) green. --- openfut-utas-host/README.md | 3 +- openfut-utas-host/src/config.rs | 18 +++++++ openfut-utas-host/src/lib.rs | 71 +++++++++++++++++++++++++--- openfut-utas-host/src/main.rs | 4 +- openfut-utas-host/tests/host_test.rs | 70 +++++++++++++++++++++++---- 5 files changed, 148 insertions(+), 18 deletions(-) diff --git a/openfut-utas-host/README.md b/openfut-utas-host/README.md index f015323..a4bfd00 100644 --- a/openfut-utas-host/README.md +++ b/openfut-utas-host/README.md @@ -36,6 +36,7 @@ the host holds the [`CoreAccess`] boundary (`GET {core_url}/collection?…` toda | `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_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 | @@ -79,7 +80,7 @@ Preconditions (mirror the proven blaze/roster switch discipline): 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=:8099 OPENFUT_UTAS_PYTHON_URL=http://127.0.0.1:8199 OPENFUT_CORE_URL=http://127.0.0.1:8080 OPENFUT_FIFA17_CATALOG= OPENFUT_IDENTITY_STORE= openfut-utas-host` + `OPENFUT_UTAS_HOST_ADDR=:8099 OPENFUT_UTAS_PYTHON_URL=http://127.0.0.1:8199 OPENFUT_CORE_URL=http://127.0.0.1:8080 OPENFUT_FIFA17_CATALOG= OPENFUT_IDENTITY_STORE= 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): diff --git a/openfut-utas-host/src/config.rs b/openfut-utas-host/src/config.rs index 1696076..7a4f0f5 100644 --- a/openfut-utas-host/src/config.rs +++ b/openfut-utas-host/src/config.rs @@ -23,6 +23,10 @@ pub struct HostConfig { /// id). Required: the wire `id` of every owned item is allocated/resolved /// here so it survives restart and reverses exactly. pub identity_store_path: String, + /// The launcher-selected FIFA persona id, injected via `OPENFUT_PERSONA_ID`. + /// Required, non-zero: it stamps `personaId` on the Core-backed + /// `GET /squad/active`, and must match the persona LSX/Blaze/POW/UTAS use. + pub persona_id: i64, } #[derive(Debug)] @@ -42,6 +46,19 @@ fn required(key: &str) -> Result { } } +/// Parse a required, non-zero i64 env var. A zero identity id is invalid (the +/// client rejects a zero persona), so unset/empty/non-integer/zero is a hard error. +fn required_i64_nonzero(key: &str) -> Result { + let raw = required(key)?; + let val: i64 = raw + .parse() + .map_err(|_| ConfigError(format!("{key} must be an integer, got {raw:?}")))?; + if val == 0 { + return Err(ConfigError(format!("{key} must be non-zero"))); + } + Ok(val) +} + impl HostConfig { pub fn from_env() -> Result { Ok(HostConfig { @@ -53,6 +70,7 @@ impl HostConfig { .unwrap_or_else(|_| "fifa17-recon/data/tables".into()), catalog_path: required("OPENFUT_FIFA17_CATALOG")?, identity_store_path: required("OPENFUT_IDENTITY_STORE")?, + persona_id: required_i64_nonzero("OPENFUT_PERSONA_ID")?, }) } } diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 74b6438..1198a9b 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -70,6 +70,8 @@ pub enum Route { SquadReplace, /// `GET …/squad/list` — the squad summary, projected from Core. SquadList, + /// `GET …/squad/active` — the active squad object, projected from Core. + SquadActive, /// `GET …/userMassInfo` — proxied to Python, with only `.squad` overlaid. UserMassInfo, /// Anything else — proxied verbatim to the Python oracle. @@ -78,12 +80,13 @@ pub enum Route { /// Classify a request ONCE, before execution. Rust owns exactly: /// * `GET …/club` -/// * `PUT …/squad/` (numeric id; `…/squad/active` is NOT numeric → Python) +/// * `PUT …/squad/` (numeric id) /// * `GET …/squad/list` +/// * `GET …/squad/active` (the active squad, projected from Core) /// * `GET …/userMassInfo` (proxied, `.squad` overlaid) /// -/// Everything else — `GET …/squad/`, `…/squad/active`, `/clubUser`, auth, -/// packs, market, other mutations — falls through to Python. There is no +/// Everything else — numeric `GET …/squad/`, `/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 { let get = method.eq_ignore_ascii_case("GET"); @@ -93,6 +96,7 @@ pub fn classify(method: &str, path: &str) -> Route { } match ut_tail(path) { Some("squad/list") if get => Route::SquadList, + Some("squad/active") if get => Route::SquadActive, Some("userMassInfo") if get => Route::UserMassInfo, Some(tail) if put && is_numeric_squad_tail(tail) => Route::SquadReplace, _ => Route::Passthrough, @@ -114,9 +118,10 @@ fn is_exact_club_path(path: &str) -> bool { ut_tail(path) == Some("club") } -/// `squad/` — the active/full squad save. `squad/active` (non-numeric) is -/// deliberately excluded so the multi-squad flow stays with Python until there is -/// retail evidence for it. +/// `squad/` — the numeric full-squad target used by `PUT`. The active +/// squad READ (`GET …/squad/active`) is routed separately (Core-backed); a +/// numeric `GET …/squad/` for a non-active squad stays on Python (there is no +/// Core model for multiple squads yet). 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()), @@ -904,6 +909,44 @@ pub fn handle_squad_list(deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) { } } +/// `GET …/squad/active` — the active squad projected from Core, returned as the +/// top-level squad object (byte-identical to what `userMassInfo.squad` embeds). +/// Never served from Python and NEVER projected from a stale extension; on a +/// stale/missing extension or a Core error it degrades to an honest empty squad +/// (never 401/403, never a Python fallback that could mask split authority). +pub fn handle_squad_active(deps: &SquadDeps<'_>, persona_id: i64) -> (WireResponse, SquadLog) { + match project_active_squad(deps) { + HostProjection::Squad(v) => ( + json_response(&user_mass_info_squad(v, persona_id)), + SquadLog { + outcome: "ok", + detail: String::new(), + }, + ), + HostProjection::Stale => ( + json_response(&empty_squad_overlay(persona_id)), + SquadLog { + outcome: "stale_integrity", + detail: "stale extension not applied".into(), + }, + ), + HostProjection::Missing => ( + json_response(&empty_squad_overlay(persona_id)), + SquadLog { + outcome: "missing_integrity", + detail: "no extension stored".into(), + }, + ), + HostProjection::Error(e) => ( + json_response(&empty_squad_overlay(persona_id)), + SquadLog { + outcome: "core_error", + detail: e, + }, + ), + } +} + /// An explicit empty active squad used only when Rust squad authority cannot /// produce a Fresh projection during a userMassInfo overlay. It is NOT Python's /// squad (that would reintroduce split authority) and NOT fabricated extension @@ -1138,6 +1181,10 @@ pub struct Server { /// and the userMassInfo overlay — one wire↔owned identity everywhere. resolver: Arc, pass: Arc, + /// The launcher-selected FIFA persona id (injected via `OPENFUT_PERSONA_ID`), + /// 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, } impl Server { @@ -1147,12 +1194,14 @@ impl Server { entities: Arc, resolver: Arc, pass: Arc, + persona_id: i64, ) -> Self { Server { core, entities, resolver, pass, + persona_id, } } @@ -1176,6 +1225,7 @@ impl Server { entities: Arc::new(entities), resolver, pass: Arc::new(PassClient::new(cfg.python_upstream.clone())), + persona_id: cfg.persona_id, }) } @@ -1232,6 +1282,15 @@ impl Server { ); resp } + Route::SquadActive => { + let deps = self.squad_deps(); + let (resp, log) = handle_squad_active(&deps, self.persona_id); + eprintln!( + "utas-host owner=RUST route=squad-active status={} outcome={} detail=[{}]", + resp.status, log.outcome, log.detail + ); + resp + } Route::UserMassInfo => { let deps = self.squad_deps(); let (resp, log) = diff --git a/openfut-utas-host/src/main.rs b/openfut-utas-host/src/main.rs index 354d997..44b0448 100644 --- a/openfut-utas-host/src/main.rs +++ b/openfut-utas-host/src/main.rs @@ -15,8 +15,8 @@ fn main() { } }; eprintln!( - "utas-host starting: listen={} python_upstream={} core_url={} tables_dir={} catalog={} identity_store={}", - cfg.listen_addr, cfg.python_upstream, cfg.core_url, cfg.tables_dir, cfg.catalog_path, cfg.identity_store_path + "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 ); let server = match Server::from_config(&cfg) { Ok(s) => s, diff --git a/openfut-utas-host/tests/host_test.rs b/openfut-utas-host/tests/host_test.rs index 6cece42..6c3c4ad 100644 --- a/openfut-utas-host/tests/host_test.rs +++ b/openfut-utas-host/tests/host_test.rs @@ -13,9 +13,10 @@ use openfut_adapter_fifa17::fut::club_response::{CoreOwnedItem, ItemIdentityReso use openfut_adapter_fifa17::fut::entities::Fifa17Entities; use openfut_identity::JsonIdentityStore; use openfut_utas_host::{ - classify, handle_put_squad, handle_squad_list, handle_user_mass_info, read_request, CoreAccess, - CoreError, CoreExtState, CorePage, CoreReplaceRequest, CoreReplaceResult, CoreSquadRead, - CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, PassClient, Route, Server, SquadDeps, + classify, handle_put_squad, handle_squad_active, handle_squad_list, handle_user_mass_info, + read_request, CoreAccess, CoreError, CoreExtState, CorePage, CoreReplaceRequest, + CoreReplaceResult, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, + PassClient, Route, Server, SquadDeps, }; use parking_lot::Mutex; use serde_json::Value; @@ -274,6 +275,7 @@ fn build_server( Arc::new(entities()), resolver, Arc::new(PassClient::new(upstream)), + 33_068_179, ) } @@ -632,6 +634,7 @@ fn club_end_to_end_through_real_resolver_and_sends_game_header() { Arc::new(entities()), resolver, Arc::new(PassClient::new("http://127.0.0.1:1")), + 33_068_179, ); let resp = server.handle("GET", "/ut/game/fifa17/club?level=gold", &[], b""); @@ -791,14 +794,14 @@ fn classify_squad_and_usermassinfo_routes() { classify("GET", "/ut/game/fifa17/userMassInfo"), Route::UserMassInfo ); - // /squad/active stays with Python (not numeric); GET squad/ is NOT a Rust - // read route; a squad PUT is never a GET. - assert_eq!( - classify("PUT", "/ut/game/fifa17/squad/active"), - Route::Passthrough - ); + // GET /squad/active is now Core-backed (SquadActive). A squad PUT is never a + // GET; a numeric GET /squad/ for a non-active squad stays on Python. assert_eq!( classify("GET", "/ut/game/fifa17/squad/active"), + Route::SquadActive + ); + assert_eq!( + classify("PUT", "/ut/game/fifa17/squad/active"), Route::Passthrough ); assert_eq!( @@ -1079,6 +1082,55 @@ fn coupled_read_after_write_list_and_usermassinfo_agree() { ); } +/// `GET /squad/active` serves the Core-backed squad object at top level, stamped +/// with the host-configured persona (NOT proxied from Python), byte-consistent +/// with the committed squad. +#[test] +fn squad_active_serves_core_backed_object_with_configured_persona() { + let items = vec![gk(), st()]; + let (resolver, w) = resolver_with_wires(&items, ASSETS); + let core = FakeCore::new(items.clone(), 2); + let ent = entities(); + let deps = SquadDeps { + core: &core, + resolver: &resolver, + entities: &ent, + }; + // Commit a squad so Core has a fresh canonical squad + extension. + let body = put_body( + "f442", + w["oc-a"], + &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], + "[1,2,3]", + ); + let (put, _) = handle_put_squad(&body, &deps); + assert_eq!(put.status, 200); + + const PERSONA: i64 = 33_068_179; + let (resp, log) = handle_squad_active(&deps, PERSONA); + assert_eq!(log.outcome, "ok"); + assert_eq!(resp.status, 200); + let sq: Value = serde_json::from_slice(&resp.body).unwrap(); + + // Top-level squad object (not wrapped), persona from host config not Python. + assert_eq!(sq["id"], 0); + assert_eq!(sq["personaId"], PERSONA, "persona from host config"); + assert_eq!(sq["formation"], "f442"); + assert_eq!(sq["captain"], w["oc-a"], "captain is the wire id"); + assert_eq!(sq["changed"], 0); + assert!(sq["actives"].as_array().unwrap().is_empty()); + let occ: Vec<&Value> = sq["players"] + .as_array() + .unwrap() + .iter() + .filter(|p| p["itemData"]["id"].as_i64().unwrap() != 0) + .collect(); + assert_eq!(occ.len(), 2, "the two committed players"); + let p0 = occ.iter().find(|p| p["index"] == 0).unwrap(); + assert_eq!(p0["itemData"]["id"], w["oc-a"]); + assert_eq!(p0["itemData"]["resourceId"], 20801); +} + #[test] fn read_path_is_bounded_no_per_slot_lookup() { let items = vec![gk(), st()];