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
+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) =