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
+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()];