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()))
}
/// 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
/// (`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
+1 -1
View File
@@ -61,7 +61,7 @@ pub struct KicktakerRef {
}
/// 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 {
/// Opaque 33-int array as a JSON-encoded string, verbatim. Never decoded.
#[serde(default)]