feat(host): wire ownership-backed squad manager through Core

Thread the manager assignment between the FIFA squad path and Core:
- CoreAccess gains get_squad_manager/set_squad_manager (GET/PUT
  /club/manager); HttpCoreClient implements both.
- project_active_squad fetches the assigned manager owned item and passes
  it to the projector (non-fatal on error/absence).
- handle_put_squad authorizes the resolved manager against the active
  club (like a slot) and persists it via set_squad_manager after the
  atomic squad replace; fails loudly, never silently drops it.
This commit is contained in:
funman300
2026-08-20 16:44:40 +00:00
parent d37a9d5b5e
commit 25f4ad12bc
+100 -10
View File
@@ -682,6 +682,24 @@ pub trait CoreAccess: Send + Sync {
/// Replace the active squad's canonical slots + opaque extension atomically.
fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError>;
/// The owned instance id assigned as the active squad's **manager**, or
/// `None` (`GET /club/manager`). Default: `None` — a transport without the
/// endpoint simply projects no manager (non-fatal, like an absent
/// assignment). The production `HttpCoreClient` overrides it.
fn get_squad_manager(&self) -> Result<Option<String>, CoreError> {
Ok(None)
}
/// Assign (`Some`) or clear (`None`) the active squad's **manager**
/// (`PUT /club/manager`). Default: unimplemented — the production
/// `HttpCoreClient` overrides it; a transport that cannot persist the
/// assignment MUST fail loudly rather than silently drop it.
fn set_squad_manager(&self, _owned_card_id: Option<&str>) -> Result<(), CoreError> {
Err(CoreError::Parse(
"Core squad manager write is not implemented".into(),
))
}
fn list_sbcs(&self) -> Result<Vec<CoreSbcDefinition>, CoreError> {
Err(CoreError::Parse(
"Core SBC access is not implemented".into(),
@@ -821,6 +839,41 @@ impl CoreAccess for HttpCoreClient {
})
}
fn get_squad_manager(&self) -> Result<Option<String>, CoreError> {
let url = format!("{}/club/manager", self.base_url);
let resp = self
.client
.get(&url)
.header("X-OpenFUT-Game", &self.game)
.send()
.map_err(|e| CoreError::Http(e.to_string()))?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
return Err(CoreError::Status(status));
}
let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?;
Ok(v.get("manager")
.and_then(|m| m.get("owned_card_id"))
.and_then(|x| x.as_str())
.map(str::to_string))
}
fn set_squad_manager(&self, owned_card_id: Option<&str>) -> Result<(), CoreError> {
let url = format!("{}/club/manager", self.base_url);
let resp = self
.client
.put(&url)
.header("X-OpenFUT-Game", &self.game)
.json(&json!({ "owned_card_id": owned_card_id }))
.send()
.map_err(|e| CoreError::Http(e.to_string()))?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
return Err(CoreError::Status(status));
}
Ok(())
}
fn list_sbcs(&self) -> Result<Vec<CoreSbcDefinition>, CoreError> {
let response = self
.client
@@ -1839,6 +1892,15 @@ fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection {
is_on_bench: s.is_on_bench,
})
.collect();
// The ownership-backed manager assignment (Core `squad_managers`). A fetch
// error or absent endpoint yields no manager — non-fatal, a squad renders
// without one, never fabricated.
let manager = deps
.core
.get_squad_manager()
.ok()
.flatten()
.and_then(|id| owned_by_id.get(&id).cloned());
let input = SquadProjectionInput {
fifa_squad_id: ACTIVE_SQUAD_WIRE_ID,
name: read.name,
@@ -1846,6 +1908,7 @@ fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection {
slots,
ext: SquadExtInput::Fresh(ext),
owned: &owned_by_id,
manager,
};
match project_squad(&input, deps.resolver, deps.entities) {
Ok(SquadProjection::Projected(v)) => HostProjection::Squad(v),
@@ -1932,6 +1995,18 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ
);
}
}
// The manager is a resolved, owned assignment too: authorize it like a slot.
if let Some(mgr) = &build.canonical.manager_owned_card_id {
if !owned_set.contains(mgr) {
return (
error_response(403, "not_owned"),
SquadLog {
outcome: "unauthorized_manager",
detail: mgr.clone(),
},
);
}
}
// Commit canonical + extension atomically. No Python fallback on failure.
let req = CoreReplaceRequest {
name: build.canonical.name.clone(),
@@ -1956,22 +2031,37 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ
ext_schema_version: EXT_SCHEMA_VERSION,
ext_payload: build.extension.to_payload(),
};
match deps.core.replace_squad(&req) {
Ok(_) => (
json_response(&save_ack(put.id)),
SquadLog {
outcome: "ok",
detail: String::new(),
},
),
Err(e) => (
if let Err(e) = deps.core.replace_squad(&req) {
return (
error_response(502, "core_error"),
SquadLog {
outcome: "core_error",
detail: e.to_string(),
},
),
);
}
// Persist the ownership-backed manager assignment (migration 0023). It was
// authorized above and Core re-validates club ownership; fail loudly on a
// transport error rather than silently dropping the manager.
if let Err(e) = deps
.core
.set_squad_manager(build.canonical.manager_owned_card_id.as_deref())
{
return (
error_response(502, "core_error"),
SquadLog {
outcome: "manager_error",
detail: e.to_string(),
},
);
}
(
json_response(&save_ack(put.id)),
SquadLog {
outcome: "ok",
detail: String::new(),
},
)
}
/// `GET …/squad/list` — served from Core + the ONE projector. Stale/Missing are