Files
OpenFUT/openfut-adapter-fifa17/src/fut/squad_ext.rs
T
funman300 e7893a0162 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.
2026-08-25 01:52:51 +00:00

399 lines
16 KiB
Rust

//! FIFA 17 **Squad Extension v1** — the adapter-owned, versioned payload that
//! carries the squad's FIFA-only wire state that OpenFUT Core does not model.
//!
//! Core stores this serialized payload **opaquely** (never interpreting it) next
//! to the canonical squad, anchored by a server fingerprint. This module owns its
//! schema and meaning; Core must never import this type.
//!
//! ## What lives here (and why it is not canonical)
//!
//! | field | ownership rationale |
//! |-------|---------------------|
//! | `custom` | opaque 33-int string; meaning UNKNOWN, round-tripped verbatim |
//! | `squad_type` | an observed FIFA wire token; no matching generic Core concept |
//! | `kit_numbers` | keyed by **`owned_card_id`** — evidence: kit follows the player |
//! | ~~manager~~ | MOVED to ownership-backed canonical Core state (migration 0023 `squad_managers`); resolved to an `owned_card_id`, no longer opaque here |
//! | `kicktakers` | role→item refs; relationship to captain UNKNOWN, kept opaque |
//! | `client_reported` | chemistry/rating/starRating — client shadow, NOT authority |
//!
//! ## Versioning
//!
//! Two independent version numbers must not be confused:
//! * [`EXT_SCHEMA_VERSION`] — the version of **this** payload schema. Stored in
//! Core's `game_entity_ext.schema_version` and re-checked on read
//! ([`Fifa17SquadExtensionV1::from_payload`] rejects any other version).
//! * Core's own DB storage schema version — a Core concern, unrelated to this.
//!
//! [`EXT_NAMESPACE`] is the opaque scope key Core files the row under.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::fut::squad::{ClientReportedSquadEval, Fifa17SquadPut, ProposedSquad};
/// Opaque scope key Core files this extension under (`game_entity_ext.namespace`).
pub const EXT_NAMESPACE: &str = "fifa17.squad";
/// The version of THIS payload schema (goes into `OpaqueExtensionWrite.schema_version`).
/// Distinct from Core's DB storage schema version.
pub const EXT_SCHEMA_VERSION: i64 = 1;
/// A FIFA item reference `{ id, dream }` preserved verbatim from the wire. `id`
/// is the FIFA wire item id (opaque to this extension — used for manager and
/// kicktaker refs whose semantics are not modelled).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WireItemRef {
pub id: i64,
#[serde(default)]
pub dream: bool,
}
/// A kicktaker slot preserved verbatim. `index` is the role slot (0..=4 observed);
/// `item` is the referenced FIFA wire item. The role→player meaning and any
/// relationship to the captain are UNKNOWN, so this is stored opaquely and never
/// normalized to the captain or to a Core `owned_card_id`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KicktakerRef {
pub index: i64,
#[serde(flatten)]
pub item: WireItemRef,
}
/// FIFA 17 Squad Extension, version 1. Serialized to the opaque payload Core stores.
#[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)]
pub custom: Option<String>,
/// FIFA squad-type wire token (e.g. `"REGULAR_SQUAD"`).
#[serde(default)]
pub squad_type: Option<String>,
/// kit number per player, keyed by Core `owned_card_id`. Keyed by the player
/// instance — NEVER by slot/index or by card definition — because the wire
/// proves the kit number follows the player across swaps and formation change.
#[serde(default)]
pub kit_numbers: BTreeMap<String, i64>,
// NOTE: the squad manager is NO LONGER carried here. It is ownership-backed
// canonical Core state (migration 0023 `squad_managers`), resolved to an
// `owned_card_id` on the ProposedSquad — never a dangling opaque wire ref.
/// Kicktaker role refs, opaque (see [`KicktakerRef`]).
#[serde(default)]
pub kicktakers: Vec<KicktakerRef>,
/// Client-reported evaluation (shadow state). NEVER Core's authoritative
/// evaluation and never reconciled with it.
#[serde(default)]
pub client_reported: ClientReportedSquadEval,
}
/// Errors reading a stored extension payload.
#[derive(Debug, PartialEq, Eq)]
pub enum ExtError {
/// The stored `schema_version` is not one this adapter understands. Never
/// silently coerced — the caller decides (e.g. treat as unreadable).
UnsupportedSchemaVersion(i64),
/// The payload bytes did not deserialize as this schema.
Parse(String),
}
impl std::fmt::Display for ExtError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ExtError::UnsupportedSchemaVersion(v) => {
write!(f, "unsupported fifa17 squad extension schema version {v} (want {EXT_SCHEMA_VERSION})")
}
ExtError::Parse(e) => write!(f, "fifa17 squad extension parse error: {e}"),
}
}
}
impl std::error::Error for ExtError {}
impl Fifa17SquadExtensionV1 {
/// Build the extension from the parsed PUT plus the resolved canonical squad.
/// `custom`/`squad_type`/manager/kicktakers/client eval come straight off the
/// wire; kit numbers are re-keyed from slot index onto the resolved
/// `owned_card_id` so they stay bound to the player, not the slot.
pub fn from_put(put: &Fifa17SquadPut, canonical: &ProposedSquad) -> Self {
let kit_numbers = canonical
.slots
.iter()
.map(|s| (s.owned_card_id.clone(), s.kit_number))
.collect();
Fifa17SquadExtensionV1 {
custom: put.custom.clone(),
squad_type: put.squad_type.clone(),
kit_numbers,
kicktakers: put
.kicktakers
.iter()
.map(|k| KicktakerRef {
index: k.index,
item: WireItemRef {
id: k.id,
dream: k.dream,
},
})
.collect(),
client_reported: ClientReportedSquadEval {
chemistry: put.chemistry,
rating: put.rating,
star_rating: put.star_rating,
},
}
}
/// Serialize to the opaque payload string Core stores.
pub fn to_payload(&self) -> String {
// Infallible for this type (no maps with non-string keys, no floats).
serde_json::to_string(self).expect("fifa17 squad extension serializes")
}
/// Parse a stored payload, enforcing the schema version FIRST. A version this
/// adapter does not understand is rejected — never coerced or ignored.
pub fn from_payload(schema_version: i64, payload: &str) -> Result<Self, ExtError> {
if schema_version != EXT_SCHEMA_VERSION {
return Err(ExtError::UnsupportedSchemaVersion(schema_version));
}
serde_json::from_str(payload).map_err(|e| ExtError::Parse(e.to_string()))
}
}
/// Errors building a squad write from a PUT. A save is refused, never silently
/// degraded, when it cannot be expressed faithfully as a canonical replacement.
#[derive(Debug, PartialEq, Eq)]
pub enum SquadBuildError {
/// One or more occupied wire ids did not reverse-map to a Core owned item.
/// Saving would silently drop an owned player — refused.
UnresolvedWireIds(Vec<i64>),
/// The same Core owned item appears in two slots. A full replacement cannot
/// place one instance twice (two *copies* of a definition are distinct owned
/// items and are fine — this is the same `owned_card_id` twice).
DuplicateOwnedItem(String),
}
impl std::fmt::Display for SquadBuildError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SquadBuildError::UnresolvedWireIds(ids) => {
write!(f, "unresolved FIFA wire item ids (save refused): {ids:?}")
}
SquadBuildError::DuplicateOwnedItem(id) => {
write!(f, "owned item {id} placed in two slots (save refused)")
}
}
}
}
impl std::error::Error for SquadBuildError {}
/// The adapter's PUT-build output: a canonical replacement plus the FIFA-only
/// extension. The host maps `canonical` onto Core's `SquadReplacement` and
/// serializes `extension` into an `OpaqueExtensionWrite`
/// (`namespace = EXT_NAMESPACE`, `schema_version = EXT_SCHEMA_VERSION`,
/// `payload = extension.to_payload()`) so both commit in one Core transaction.
#[derive(Debug, Clone)]
pub struct SquadWriteBuild {
pub canonical: ProposedSquad,
pub extension: Fifa17SquadExtensionV1,
}
/// Build a full-replacement squad write from a parsed PUT and a host-supplied
/// wire→owned resolver. Refuses (never degrades) on any unresolved occupied id
/// or a duplicate owned item.
///
/// The resolver only maps identity; it is NOT authorization. The host still
/// verifies every resolved `owned_card_id` belongs to the active FIFA 17
/// profile/club before committing — a resolvable id is not proof of ownership.
pub fn build_squad_write(
put: &Fifa17SquadPut,
resolver: &dyn crate::fut::squad::SquadWireResolver,
) -> Result<SquadWriteBuild, SquadBuildError> {
let canonical = crate::fut::squad::to_proposed(put, resolver);
if !canonical.unresolved_wire_ids.is_empty() {
return Err(SquadBuildError::UnresolvedWireIds(
canonical.unresolved_wire_ids.clone(),
));
}
let mut seen = std::collections::HashSet::new();
for slot in &canonical.slots {
if !seen.insert(slot.owned_card_id.as_str()) {
return Err(SquadBuildError::DuplicateOwnedItem(
slot.owned_card_id.clone(),
));
}
}
let extension = Fifa17SquadExtensionV1::from_put(put, &canonical);
Ok(SquadWriteBuild {
canonical,
extension,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fut::squad::{parse_squad_put, SquadWireResolver};
use std::collections::HashMap;
const PUT_F442: &str = include_str!("../../fixtures/utas/squad_put_f442.json");
struct MapResolver(HashMap<i64, String>);
impl SquadWireResolver for MapResolver {
fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
self.0.get(&wire).cloned()
}
}
fn full_resolver() -> MapResolver {
let ids = [
100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009,
100000001, 100000002, 100000025,
// The f442 fixture's manager ref — the host resolves it like any other
// owned instance, so the ownership-backed manager assignment is present.
100000427,
];
MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect())
}
fn built() -> SquadWriteBuild {
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
build_squad_write(&put, &full_resolver()).unwrap()
}
#[test]
fn serde_round_trips_the_whole_extension() {
let ext = built().extension;
let payload = ext.to_payload();
let back = Fifa17SquadExtensionV1::from_payload(EXT_SCHEMA_VERSION, &payload).unwrap();
assert_eq!(ext, back);
}
#[test]
fn custom_is_preserved_byte_for_byte() {
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
let ext = built().extension;
assert_eq!(ext.custom, put.custom, "opaque custom carried verbatim");
// survives a serialize/parse cycle unchanged.
let back =
Fifa17SquadExtensionV1::from_payload(EXT_SCHEMA_VERSION, &ext.to_payload()).unwrap();
assert_eq!(back.custom, put.custom);
}
#[test]
fn kit_number_is_keyed_by_owned_item_not_slot() {
let ext = built().extension;
// In the f442 fixture, owned oc-100000001 (captain) wears kit 8 at index 8;
// oc-100000025 wears kit 11 at index 10. Keyed by owned id, not index.
assert_eq!(ext.kit_numbers.get("oc-100000001"), Some(&8));
assert_eq!(ext.kit_numbers.get("oc-100000025"), Some(&11));
assert_eq!(ext.kit_numbers.len(), 11, "one per occupied slot");
}
#[test]
fn client_reported_eval_is_carried_as_shadow() {
let ext = built().extension;
assert_eq!(
ext.client_reported,
ClientReportedSquadEval {
chemistry: Some(52),
rating: Some(90),
star_rating: Some(90)
}
);
}
#[test]
fn manager_is_canonical_and_kicktakers_stay_opaque() {
let build = built();
// Manager is now ownership-backed canonical state: the wire ref resolved
// to a Core owned_card_id on the ProposedSquad, not an opaque ext blob.
assert_eq!(
build.canonical.manager_owned_card_id.as_deref(),
Some("oc-100000427")
);
// Kicktakers remain opaque in the extension.
let ext = build.extension;
assert_eq!(ext.kicktakers.len(), 5);
assert!(ext.kicktakers.iter().all(|k| k.item.id == 100000001));
assert_eq!(ext.kicktakers[0].index, 0);
}
/// A manager ref that does not resolve must NOT refuse the save: FIFA always
/// sends one, and on a real profile it is dangling (production points at
/// 100000427, absent from its own /club/staff). The save commits with no
/// ownership-backed manager and reports the id it could not map.
#[test]
fn an_unresolvable_manager_ref_clears_the_assignment_without_refusing() {
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
let mut ids = full_resolver().0;
ids.remove(&100000427);
let build = build_squad_write(&put, &MapResolver(ids)).expect("save must still commit");
assert_eq!(build.canonical.manager_owned_card_id, None);
assert_eq!(
build.canonical.unresolved_manager_wire_id,
Some(100000427),
"the ref we could not map is reported, not swallowed"
);
// The starting XI is untouched -- only the manager assignment is dropped.
assert_eq!(build.canonical.slots.len(), 11);
}
#[test]
fn unknown_schema_version_is_rejected_not_coerced() {
let payload = built().extension.to_payload();
assert_eq!(
Fifa17SquadExtensionV1::from_payload(2, &payload),
Err(ExtError::UnsupportedSchemaVersion(2))
);
assert_eq!(
Fifa17SquadExtensionV1::from_payload(0, &payload),
Err(ExtError::UnsupportedSchemaVersion(0))
);
}
#[test]
fn malformed_payload_errors() {
assert!(matches!(
Fifa17SquadExtensionV1::from_payload(EXT_SCHEMA_VERSION, "not json"),
Err(ExtError::Parse(_))
));
}
#[test]
fn build_refuses_unresolved_wire_ids() {
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
let mut ids = full_resolver().0;
ids.remove(&100000025);
let err = build_squad_write(&put, &MapResolver(ids)).unwrap_err();
assert_eq!(err, SquadBuildError::UnresolvedWireIds(vec![100000025]));
}
#[test]
fn build_refuses_duplicate_owned_item() {
// Two occupied slots resolving to the SAME owned id (one instance twice).
let body = br#"{"id":0,"formation":"f442","captain":100000003,"players":[
{"index":0,"itemData":{"id":100000003},"kitNumber":1},
{"index":1,"itemData":{"id":100000004},"kitNumber":2}]}"#;
let put = parse_squad_put(body).unwrap();
let mut m = HashMap::new();
m.insert(100000003, "oc-dup".to_string());
m.insert(100000004, "oc-dup".to_string()); // collide onto same owned id
let err = build_squad_write(&put, &MapResolver(m)).unwrap_err();
assert_eq!(
err,
SquadBuildError::DuplicateOwnedItem("oc-dup".to_string())
);
}
#[test]
fn full_replacement_carries_every_occupied_slot_no_diff() {
let build = built();
assert_eq!(build.canonical.slots.len(), 11, "whole squad, not a diff");
assert_eq!(build.canonical.formation.as_deref(), Some("f442"));
// No FIFA wire integer survives into the canonical slots.
assert!(build
.canonical
.slots
.iter()
.all(|s| s.owned_card_id.starts_with("oc-")));
}
}