feat(utas-host): serve GET /squad/active from Core

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/<t>/squad/active -> Route::SquadActive. Numeric GET /squad/<n> 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.
This commit is contained in:
funman300
2026-08-12 17:10:04 +00:00
parent 7dbd878398
commit 37c2e5d7ee
5 changed files with 148 additions and 18 deletions
+2 -1
View File
@@ -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=<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-utas-host`
`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):
+18
View File
@@ -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<String, ConfigError> {
}
}
/// 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<i64, ConfigError> {
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<Self, ConfigError> {
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")?,
})
}
}
+65 -6
View File
@@ -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/<n>` (numeric id; `…/squad/active` is NOT numeric → Python)
/// * `PUT …/squad/<n>` (numeric id)
/// * `GET …/squad/list`
/// * `GET …/squad/active` (the active squad, projected from Core)
/// * `GET …/userMassInfo` (proxied, `.squad` overlaid)
///
/// Everything else — `GET …/squad/<n>`, `…/squad/active`, `/clubUser`, auth,
/// packs, market, other mutations — falls through to Python. There is no
/// 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 {
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/<digits>` — 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/<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).
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<Fifa17IdentityResolver>,
pass: Arc<PassClient>,
/// 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<Fifa17Entities>,
resolver: Arc<Fifa17IdentityResolver>,
pass: Arc<PassClient>,
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) =
+2 -2
View File
@@ -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,
+61 -9
View File
@@ -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/<n> 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/<n> 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()];