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:
@@ -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):
|
||||
|
||||
@@ -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")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()];
|
||||
|
||||
Reference in New Issue
Block a user