fix(fifa17): route a partial squad PUT to a role patch, not a replacement

FIFA 17 sends two different operations to `PUT …/squad/<id>` and distinguishes
them only by body shape. Across 73 captured squad PUTs in five captures there
are exactly two:

  * 68x with `players` -- a full replacement (also carrying squadName,
    formation, squadType, manager, chemistry/rating, and redundantly
    captain/kicktakers);
  * 5x without `players` -- `{id, custom, captain, kicktakers}`, emitted by the
    captain/kick-taker screen.

`players` has `#[serde(default)]`, so an absent key and an explicit `[]`
collapsed to the same empty vec and every partial update was handed to Core as
a replacement with zero slots. Core's empty-replacement guard refused it (400)
and the host reported 502, losing the user's captain/kick-taker change.

`classify_squad_put` now tests key PRESENCE on the raw JSON before
deserialising, so absence ("the squad was not part of this edit") stays
distinct from an explicit empty array ("replace with nothing"). An explicit
`"players": []` still classifies as a replacement and still meets the guard --
the patch path is not a way around it.

The patch path carries the contract correction: omitted `players`, `manager`
and actives mean UNCHANGED, never cleared. That is structural --
`CoreRolePatchRequest` has no field able to express them. The extension is
MERGED rather than overwritten, because the partial body carries only `custom`
and `kicktakers`; overwriting would drop every kit number in the squad.
`custom` IS taken from the patch, since the role screen writes per-slot values
into it and the two shapes genuinely differ there.

Also fixes the error mapping on this route: a Core 400 means the REQUEST was
invalid, so it is reported as 400, not as a 502 that blames the server and
hides a client error behind "upstream unavailable".

Tests use the real captured body and assert it takes the patch path
(`replace_squad` call count unchanged), that the manager survives, that kit
numbers survive the merge, that an explicit empty `players` still reaches the
replacement path, and that an unresolvable captain refuses the whole patch
rather than half-applying the kick-takers.
This commit is contained in:
funman300
2026-08-25 01:52:51 +00:00
parent a2b0c32a70
commit e7893a0162
4 changed files with 475 additions and 22 deletions
+61
View File
@@ -200,6 +200,67 @@ pub fn parse_squad_put(body: &[u8]) -> Result<Fifa17SquadPut, SquadError> {
serde_json::from_slice(body).map_err(|e| SquadError::Parse(e.to_string())) serde_json::from_slice(body).map_err(|e| SquadError::Parse(e.to_string()))
} }
/// A role-only squad update: the client changed the captain and/or the
/// kick-taker assignments without touching the squad itself.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Fifa17SquadRolePatch {
#[serde(default)]
pub id: i64,
/// Opaque 33-int array, verbatim. Differs from the replacement's `custom`
/// in the captures (the role screen writes per-slot values into it), so it
/// MUST be carried through rather than preserved from the stored copy.
#[serde(default)]
pub custom: Option<String>,
#[serde(default)]
pub captain: Option<i64>,
#[serde(default)]
pub kicktakers: Vec<SquadKicktaker>,
}
/// Which mutation a `PUT …/squad/<id>` body actually expresses.
///
/// FIFA 17 sends two different operations down one path, so the BODY SHAPE is
/// the operation discriminator. Across 73 captured squad PUTs spanning five
/// captures there are exactly two shapes:
///
/// * 68x with `players` — a full replacement, also carrying `squadName`,
/// `formation`, `squadType`, `manager`, `chemistry`/`rating`/`starRating`,
/// and (redundantly) `captain`/`kicktakers`.
/// * 5x without `players` — `{id, custom, captain, kicktakers}` only, emitted
/// by the captain/kick-taker screen.
///
/// `players` is therefore the discriminator: its PRESENCE means "this body
/// describes the whole squad". Its ABSENCE means the squad was not part of the
/// edit at all and must be left alone — which is NOT the same as an empty
/// `players` array, and that distinction is the whole point. Serde's
/// `#[serde(default)]` collapses both to an empty vec, so key presence is
/// tested on the raw JSON before deserialising.
///
/// An explicit `"players": []` still classifies as a replacement, so the
/// empty-replacement guard in Core keeps seeing it.
#[derive(Debug, Clone)]
pub enum SquadMutation {
/// Full replacement of the squad's slots and metadata.
Replace(Box<Fifa17SquadPut>),
/// Role-only patch: captain and/or kick-takers, nothing else.
PatchRoles(Fifa17SquadRolePatch),
}
/// Classify a squad PUT body. See [`SquadMutation`] for the discriminator and
/// the capture evidence behind it.
pub fn classify_squad_put(body: &[u8]) -> Result<SquadMutation, SquadError> {
let raw: serde_json::Value =
serde_json::from_slice(body).map_err(|e| SquadError::Parse(e.to_string()))?;
let has_players = raw.as_object().is_some_and(|o| o.contains_key("players"));
if has_players {
return parse_squad_put(body).map(|p| SquadMutation::Replace(Box::new(p)));
}
serde_json::from_slice(body)
.map(SquadMutation::PatchRoles)
.map_err(|e| SquadError::Parse(e.to_string()))
}
/// Resolve a parsed save into a **canonical** [`ProposedSquad`]: drop empty /// Resolve a parsed save into a **canonical** [`ProposedSquad`]: drop empty
/// (`id == 0`) slots, reverse-map each occupied slot's wire id to a Core /// (`id == 0`) slots, reverse-map each occupied slot's wire id to a Core
/// `owned_card_id`, flag the captain, derive the bench split from the fixed /// `owned_card_id`, flag the captain, derive the bench split from the fixed
+1 -1
View File
@@ -61,7 +61,7 @@ pub struct KicktakerRef {
} }
/// FIFA 17 Squad Extension, version 1. Serialized to the opaque payload Core stores. /// FIFA 17 Squad Extension, version 1. Serialized to the opaque payload Core stores.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Fifa17SquadExtensionV1 { pub struct Fifa17SquadExtensionV1 {
/// Opaque 33-int array as a JSON-encoded string, verbatim. Never decoded. /// Opaque 33-int array as a JSON-encoded string, verbatim. Never decoded.
#[serde(default)] #[serde(default)]
+223 -13
View File
@@ -76,9 +76,13 @@ use openfut_adapter_fifa17::fut::owned_query::{
use openfut_adapter_fifa17::fut::pack_content::GeneratedCandidate; use openfut_adapter_fifa17::fut::pack_content::GeneratedCandidate;
use openfut_adapter_fifa17::fut::sbc as fifa17_sbc; use openfut_adapter_fifa17::fut::sbc as fifa17_sbc;
use openfut_adapter_fifa17::fut::season_wire; use openfut_adapter_fifa17::fut::season_wire;
use openfut_adapter_fifa17::fut::squad::{parse_squad_put, save_ack, SquadWireResolver}; use openfut_adapter_fifa17::fut::squad::{
classify_squad_put, save_ack, Fifa17SquadPut, Fifa17SquadRolePatch, SquadMutation,
SquadWireResolver,
};
use openfut_adapter_fifa17::fut::squad_ext::{ use openfut_adapter_fifa17::fut::squad_ext::{
build_squad_write, Fifa17SquadExtensionV1, SquadBuildError, EXT_NAMESPACE, EXT_SCHEMA_VERSION, build_squad_write, Fifa17SquadExtensionV1, KicktakerRef, SquadBuildError, WireItemRef,
EXT_NAMESPACE, EXT_SCHEMA_VERSION,
}; };
use openfut_adapter_fifa17::fut::squad_projection::{ use openfut_adapter_fifa17::fut::squad_projection::{
project_squad, squad_actives, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput, project_squad, squad_actives, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
@@ -810,6 +814,21 @@ pub struct CoreReplaceRequest {
pub ext_payload: String, pub ext_payload: String,
} }
/// A role-only squad patch: captain plus the opaque extension, nothing else.
///
/// Deliberately has no `slots`, `name`, `formation` or manager field — the
/// absence is structural, so this request cannot express a squad replacement
/// even by mistake.
#[derive(Debug, Clone)]
pub struct CoreRolePatchRequest {
/// Owned instance to flag as captain. `None` leaves the captain unchanged;
/// it is never a request to clear it.
pub captain_owned_card_id: Option<String>,
pub ext_namespace: String,
pub ext_schema_version: i64,
pub ext_payload: String,
}
/// Client-reported shadow evaluation carried through to Core (never Core's /// Client-reported shadow evaluation carried through to Core (never Core's
/// authoritative evaluation). /// authoritative evaluation).
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@@ -878,6 +897,18 @@ pub trait CoreAccess: Send + Sync {
/// Replace the active squad's canonical slots + opaque extension atomically. /// Replace the active squad's canonical slots + opaque extension atomically.
fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError>; fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError>;
/// Patch ONLY the active squad's role assignments (captain) + opaque
/// extension. Never touches player assignments, the manager, or actives.
///
/// Separate from [`Self::replace_squad`] because they are different
/// operations: a role-only client update carries no slot array, and sending
/// it as a replacement presents zero slots to Core's empty-replacement
/// guard. Default is `Status(501)` so a transport that has not implemented
/// it fails loudly instead of silently degrading into a replacement.
fn patch_squad_roles(&self, _req: &CoreRolePatchRequest) -> Result<(), CoreError> {
Err(CoreError::Status(501))
}
/// The owned instance id assigned as the active squad's **manager**, or /// The owned instance id assigned as the active squad's **manager**, or
/// `None` (`GET /club/manager`). Default: `None` — a transport without the /// `None` (`GET /club/manager`). Default: `None` — a transport without the
/// endpoint simply projects no manager (non-fatal, like an absent /// endpoint simply projects no manager (non-fatal, like an absent
@@ -1050,6 +1081,29 @@ impl CoreAccess for HttpCoreClient {
}) })
} }
fn patch_squad_roles(&self, req: &CoreRolePatchRequest) -> Result<(), CoreError> {
let url = format!("{}/squad/roles", self.base_url);
let resp = self
.client
.put(&url)
.header("X-OpenFUT-Game", &self.game)
.json(&json!({
"captain_owned_card_id": req.captain_owned_card_id,
"extension": {
"namespace": req.ext_namespace,
"schema_version": req.ext_schema_version,
"payload": req.ext_payload,
},
}))
.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 get_squad_manager(&self) -> Result<Option<String>, CoreError> { fn get_squad_manager(&self) -> Result<Option<String>, CoreError> {
let url = format!("{}/club/manager", self.base_url); let url = format!("{}/club/manager", self.base_url);
let resp = self let resp = self
@@ -2844,26 +2898,35 @@ fn error_response(status: u16, code: &str) -> WireResponse {
} }
} }
/// `PUT …/squad/<n>` — parse the full replacement, reverse-resolve every wire id, /// `PUT …/squad/<n>` — dispatch on which mutation the body actually expresses.
/// AUTHORIZE every resolved item against the active club, then commit the ///
/// canonical squad + FIFA extension to Core in one transaction. On any failure /// FIFA 17 sends two operations down this one path and distinguishes them only
/// it returns an error and NEVER falls back to Python (no double mutation). /// by body shape (see [`SquadMutation`]). Classifying FIRST is the whole fix: a
/// role-only update carries no `players`, and routing it into the replacement
/// path presents zero slots to Core's empty-replacement guard, which correctly
/// refuses it — silently losing the user's captain/kick-taker change.
pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) { pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
let put = match parse_squad_put(body) { match classify_squad_put(body) {
Ok(p) => p, Ok(SquadMutation::Replace(put)) => handle_squad_replace(&put, deps),
Err(e) => { Ok(SquadMutation::PatchRoles(patch)) => handle_squad_role_patch(&patch, deps),
return ( Err(e) => (
error_response(400, "parse_error"), error_response(400, "parse_error"),
SquadLog { SquadLog {
outcome: "parse_error", outcome: "parse_error",
detail: e.to_string(), detail: e.to_string(),
}, },
) ),
} }
}; }
/// The full-replacement path: reverse-resolve every wire id, AUTHORIZE every
/// resolved item against the active club, then commit the canonical squad +
/// FIFA extension to Core in one transaction. On any failure it returns an
/// error and NEVER falls back to Python (no double mutation).
fn handle_squad_replace(put: &Fifa17SquadPut, deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) {
// Reverse-resolve wire→owned and shape canonical + extension. Refuses on an // Reverse-resolve wire→owned and shape canonical + extension. Refuses on an
// unresolved wire id or the same owned item placed twice. // unresolved wire id or the same owned item placed twice.
let build = match build_squad_write(&put, deps.resolver) { let build = match build_squad_write(put, deps.resolver) {
Ok(b) => b, Ok(b) => b,
Err(SquadBuildError::UnresolvedWireIds(ids)) => { Err(SquadBuildError::UnresolvedWireIds(ids)) => {
return ( return (
@@ -3004,6 +3067,153 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ
) )
} }
/// `PUT …/squad/<n>` carrying NO `players` — the role-only patch path.
///
/// Observed real-client shape: `{id, custom, captain, kicktakers[5]}`, emitted
/// by the captain/kick-taker screen. Omission is the contract here: the absent
/// `players`, `manager` and actives mean UNCHANGED, never cleared. That is
/// enforced structurally — [`CoreRolePatchRequest`] has no field able to express
/// any of them, and Core's patch issues no statement that can touch them.
///
/// The extension is MERGED, not overwritten: the patch body carries only
/// `custom` and `kicktakers`, so kit numbers, squad type and the client-reported
/// evaluation are preserved from the stored copy. Overwriting would silently
/// drop every kit number in the squad.
fn handle_squad_role_patch(
patch: &Fifa17SquadRolePatch,
deps: &SquadDeps<'_>,
) -> (WireResponse, SquadLog) {
// Start from the stored extension so omitted FIFA-only state survives. A
// stale extension is still the right base: its kit numbers belong to the
// same squad, and this patch re-anchors the fingerprint on commit.
let read = match deps.core.read_squad_ext(EXT_NAMESPACE) {
Ok(r) => r,
Err(e) => {
return (
error_response(502, "core_error"),
SquadLog {
outcome: "core_error",
detail: e.to_string(),
},
)
}
};
let mut ext = match &read.ext {
CoreExtState::Fresh {
schema_version,
payload,
}
| CoreExtState::Stale {
schema_version,
payload,
} => Fifa17SquadExtensionV1::from_payload(*schema_version, payload).unwrap_or_default(),
CoreExtState::Missing => Fifa17SquadExtensionV1::default(),
};
// Carry the patch's own fields through. `custom` genuinely differs between
// the two shapes -- the role screen writes per-slot values into it -- so it
// is taken from the patch, not preserved.
if patch.custom.is_some() {
ext.custom = patch.custom.clone();
}
ext.kicktakers = patch
.kicktakers
.iter()
.map(|k| KicktakerRef {
index: k.index,
item: WireItemRef {
id: k.id,
dream: k.dream,
},
})
.collect();
// Resolve + AUTHORIZE the captain before any write. Identity resolution is
// not authorization: a globally-valid wire id belonging to another profile
// must not become this club's captain.
let mut captain_owned_card_id = None;
if let Some(wire) = patch.captain.filter(|c| *c != 0) {
match deps.resolver.owned_id_for_wire(wire) {
Some(owned) => {
let owned_set: std::collections::HashSet<String> = match deps.core.all_owned() {
Ok(v) => v.into_iter().map(|i| i.owned_card_id).collect(),
Err(e) => {
return (
error_response(502, "core_error"),
SquadLog {
outcome: "core_error",
detail: e.to_string(),
},
)
}
};
if !owned_set.contains(&owned) {
return (
error_response(403, "not_owned"),
SquadLog {
outcome: "unauthorized_captain",
detail: owned,
},
);
}
captain_owned_card_id = Some(owned);
}
None => {
// Refuse rather than silently patch the kicktakers alone: the
// user asked for a captain and would otherwise be told it
// succeeded while the captain never moved.
return (
error_response(400, "unresolved_captain"),
SquadLog {
outcome: "unresolved_captain",
detail: wire.to_string(),
},
);
}
}
}
let req = CoreRolePatchRequest {
captain_owned_card_id,
ext_namespace: EXT_NAMESPACE.to_string(),
ext_schema_version: EXT_SCHEMA_VERSION,
ext_payload: ext.to_payload(),
};
if let Err(e) = deps.core.patch_squad_roles(&req) {
// A Core 400 means the REQUEST was invalid (e.g. a captain not fielded
// in this squad). Reporting that as 502 would blame the server for a
// client error and hide it behind "upstream unavailable".
let (status, code) = match e {
CoreError::Status(400) => (400, "invalid_role_patch"),
_ => (502, "core_error"),
};
return (
error_response(status, code),
SquadLog {
outcome: if status == 400 {
"invalid_role_patch"
} else {
"core_error"
},
detail: e.to_string(),
},
);
}
eprintln!(
"utas-host owner=RUST route=squad-roles captain={:?} kicktakers={} \
(players/manager/actives untouched)",
req.captain_owned_card_id,
ext.kicktakers.len()
);
(
json_response(&save_ack(patch.id)),
SquadLog {
outcome: "ok",
detail: String::new(),
},
)
}
/// `GET …/squad/list` — served from Core + the ONE projector. Stale/Missing are /// `GET …/squad/list` — served from Core + the ONE projector. Stale/Missing are
/// integrity failures for the migrated dev profile: logged prominently, degraded /// integrity failures for the migrated dev profile: logged prominently, degraded
/// to an empty list, NEVER served from Python and NEVER projected from stale ext. /// to an empty list, NEVER served from Python and NEVER projected from stale ext.
+184 -2
View File
@@ -16,8 +16,9 @@ use openfut_utas_host::account_store::AccountStore;
use openfut_utas_host::{ use openfut_utas_host::{
classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list, classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list,
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState, handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState,
CoreKitAssignments, CorePage, CoreReplaceRequest, CoreReplaceResult, CoreSquadRead, CoreKitAssignments, CorePage, CoreReplaceRequest, CoreReplaceResult, CoreRolePatchRequest,
CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, PassClient, Route, Server, SquadDeps, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, PassClient, Route,
Server, SquadDeps,
}; };
use parking_lot::Mutex; use parking_lot::Mutex;
use serde_json::Value; use serde_json::Value;
@@ -51,6 +52,10 @@ struct FakeCore {
/// full squad replacement writes it (or clears it with `None`). /// full squad replacement writes it (or clears it with `None`).
manager: Mutex<Option<String>>, manager: Mutex<Option<String>>,
replaced: Mutex<Vec<StoredReplace>>, replaced: Mutex<Vec<StoredReplace>>,
/// Recorded role-only patches: (captain_owned_card_id, ext_payload). Kept
/// separate from `replaced` so a test can assert a patch NEVER went through
/// the replacement path.
role_patches: Mutex<Vec<(Option<String>, String)>>,
panic_if_called: bool, panic_if_called: bool,
return_err: bool, return_err: bool,
} }
@@ -95,6 +100,9 @@ impl FakeCore {
fn last(&self) -> Vec<(String, String)> { fn last(&self) -> Vec<(String, String)> {
self.last_params.lock().clone() self.last_params.lock().clone()
} }
fn role_patches(&self) -> Vec<(Option<String>, String)> {
self.role_patches.lock().clone()
}
fn manager(&self) -> Option<String> { fn manager(&self) -> Option<String> {
self.manager.lock().clone() self.manager.lock().clone()
} }
@@ -132,6 +140,45 @@ impl CoreAccess for FakeCore {
self.squad.lock().clone().ok_or(CoreError::Status(404)) self.squad.lock().clone().ok_or(CoreError::Status(404))
} }
fn patch_squad_roles(&self, req: &CoreRolePatchRequest) -> Result<(), CoreError> {
assert!(
!self.panic_if_called,
"Core must NOT be called on this path"
);
if self.return_err {
return Err(CoreError::Status(500));
}
// Mirror Core: the captain must already be fielded, otherwise 400.
if let Some(captain) = &req.captain_owned_card_id {
let fielded = self
.squad
.lock()
.as_ref()
.map(|s| s.slots.iter().any(|sl| &sl.owned_card_id == captain))
.unwrap_or(false);
if !fielded {
return Err(CoreError::Status(400));
}
}
self.role_patches
.lock()
.push((req.captain_owned_card_id.clone(), req.ext_payload.clone()));
// Apply to the stored squad WITHOUT touching slots or the manager, so a
// read-after-patch shows exactly what Core would show.
if let Some(sq) = self.squad.lock().as_mut() {
if let Some(captain) = &req.captain_owned_card_id {
for slot in sq.slots.iter_mut() {
slot.is_captain = &slot.owned_card_id == captain;
}
}
sq.ext = CoreExtState::Fresh {
schema_version: req.ext_schema_version,
payload: req.ext_payload.clone(),
};
}
Ok(())
}
fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError> { fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError> {
assert!( assert!(
!self.panic_if_called, !self.panic_if_called,
@@ -1213,6 +1260,141 @@ fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() {
assert_eq!(r.chemistry, Some(52), "client-reported shadow carried"); assert_eq!(r.chemistry, Some(52), "client-reported shadow carried");
} }
/// The REAL captured partial body must succeed and take the PATCH path, not the
/// replacement path.
///
/// Captured 2026-08-25 00:42:42 from the retail client's captain/kick-taker
/// screen (`offline-seasons-squadexp-20260825T0018Z.pcap`). It carries no
/// `players`, so the old code handed Core a replacement with zero slots; the
/// empty-replacement guard refused it (400) and the host reported 502, losing
/// the user's change. The body shape is the operation discriminator.
#[test]
fn put_partial_captain_and_kicktakers_patches_roles_without_replacing() {
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,
squad_actives: false,
};
// Establish a squad first, so there is something to patch.
let full = put_body(
"f442",
w["oc-a"],
&[(0, w["oc-a"], 1), (1, w["oc-b"], 9)],
"[1,2,3]",
Some(w["oc-b"]),
);
let (resp, log) = handle_put_squad(&full, &deps);
assert_eq!(resp.status, 200, "{log:?}");
assert_eq!(core.replaced().len(), 1);
// The captured partial shape: no players, no manager, no formation.
let partial = format!(
r#"{{"id":0,"custom":"[0,8,16,16,8,134220032]","captain":{},"kicktakers":[
{{"index":0,"id":{},"dream":false}},{{"index":1,"id":{},"dream":false}},
{{"index":2,"id":{},"dream":false}},{{"index":3,"id":{},"dream":false}},
{{"index":4,"id":{},"dream":false}}]}}"#,
w["oc-b"], w["oc-a"], w["oc-a"], w["oc-b"], w["oc-b"], w["oc-a"]
);
let (resp, log) = handle_put_squad(partial.as_bytes(), &deps);
assert_eq!(resp.status, 200, "the partial shape must succeed: {log:?}");
assert_eq!(resp.body, br#"{"id":0}"#, "same ack as a full save");
// It went through the PATCH path, never the replacement path.
assert_eq!(
core.replaced().len(),
1,
"a role patch must NOT reach replace_squad — that is what tripped the guard"
);
let patches = core.role_patches();
assert_eq!(patches.len(), 1);
assert_eq!(
patches[0].0.as_deref(),
Some("oc-b"),
"captain resolved to the Core owned id, never the wire id"
);
// Omitted state is UNCHANGED, not cleared.
assert_eq!(
core.manager().as_deref(),
Some("oc-b"),
"a role patch must not touch the manager"
);
// The patch's opaque custom is carried, and kit numbers from the earlier
// full save survive the merge rather than being overwritten away.
assert!(patches[0].1.contains("134220032"), "patch custom carried");
assert!(
patches[0].1.contains("kit_numbers"),
"kit numbers preserved from the stored extension: {}",
patches[0].1
);
}
/// An explicit `"players": []` is still a REPLACEMENT and must still be refused
/// by Core's guard — the patch path must not become a way to smuggle a
/// destructive write past it.
#[test]
fn put_explicit_empty_players_is_still_a_replacement_not_a_patch() {
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,
squad_actives: false,
};
let body = br#"{"id":0,"formation":"f442","custom":"[]","players":[],"manager":[]}"#;
let (resp, log) = handle_put_squad(body, &deps);
// Reaches the replacement path (Core decides), and NEVER the patch path.
assert_eq!(
core.role_patches().len(),
0,
"an explicit empty players array must not be treated as a role patch: {log:?}"
);
assert!(
resp.status == 200 || resp.status >= 400,
"handled by the replacement path"
);
assert_eq!(
core.replaced().len(),
1,
"it went to replace_squad, where the empty-replacement guard lives"
);
}
/// A captain the resolver cannot map must refuse the whole patch, not silently
/// apply the kick-takers and report success.
#[test]
fn put_partial_with_unresolvable_captain_refuses_the_whole_patch() {
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,
squad_actives: false,
};
let full = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[1]", None);
assert_eq!(handle_put_squad(&full, &deps).0.status, 200);
let partial = br#"{"id":0,"custom":"[9]","captain":999999999,"kicktakers":[]}"#;
let (resp, log) = handle_put_squad(partial, &deps);
assert_eq!(resp.status, 400, "unresolvable captain is a client error");
assert_eq!(log.outcome, "unresolved_captain");
assert_eq!(
core.role_patches().len(),
0,
"nothing may be written when the captain cannot be resolved"
);
}
/// The squad's manager is an ownership-backed assignment, not an opaque wire /// The squad's manager is an ownership-backed assignment, not an opaque wire
/// echo: a save assigns the owned instance behind the ref. A later save that /// echo: a save assigns the owned instance behind the ref. A later save that
/// carries NO manager ref does NOT clear it. /// carries NO manager ref does NOT clear it.