From 80a8bc4520809ec05dadedc04abc87355f193272 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 12 Aug 2026 02:19:22 +0000 Subject: [PATCH] feat(adapter): FIFA17 squad extension v1 + full-replacement PUT builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add fut::squad_ext::Fifa17SquadExtensionV1 — the versioned, adapter-owned payload Core stores opaquely alongside the canonical squad. Carries the FIFA-only wire state that is not Core-canonical: - custom[] opaque 33-int string, round-tripped verbatim - squad_type observed FIFA token - kit_numbers keyed by owned_card_id (kit follows the PLAYER, proven by the swap/formation captures), never by slot/definition - manager opaque item ref (not a squad player; not shaped) - kicktakers opaque role refs; relationship to captain UNKNOWN, so preserved verbatim and never normalized to the captain - client_reported chemistry/rating/starRating shadow, never authoritative from_payload enforces the payload schema version first (distinct from Core's DB schema); an unknown version is rejected, never coerced. build_squad_write turns a parsed PUT + host wire->owned resolver into a canonical ProposedSquad + extension, refusing on unresolved ids or a duplicate owned item. Identity resolution is explicitly NOT authorization. Refactor the 550a59d parser scaffold: ProposedSquad is now pure canonical (FIFA-only + shadow fields moved to the extension); the canonical formation is the FIFA wire token verbatim (drop the lossy f442->"4-4-2" map that could not even represent f433) so formation and index round-trip exactly with no derivation. Bench split is the fixed 23-slot array convention. --- openfut-adapter-fifa17/src/fut/mod.rs | 1 + openfut-adapter-fifa17/src/fut/squad.rs | 95 +++--- openfut-adapter-fifa17/src/fut/squad_ext.rs | 355 ++++++++++++++++++++ 3 files changed, 401 insertions(+), 50 deletions(-) create mode 100644 openfut-adapter-fifa17/src/fut/squad_ext.rs diff --git a/openfut-adapter-fifa17/src/fut/mod.rs b/openfut-adapter-fifa17/src/fut/mod.rs index 993f346..8167a87 100644 --- a/openfut-adapter-fifa17/src/fut/mod.rs +++ b/openfut-adapter-fifa17/src/fut/mod.rs @@ -10,3 +10,4 @@ pub mod item; pub mod entities; pub mod owned_query; pub mod squad; +pub mod squad_ext; diff --git a/openfut-adapter-fifa17/src/fut/squad.rs b/openfut-adapter-fifa17/src/fut/squad.rs index c9cb0a0..8d16b7f 100644 --- a/openfut-adapter-fifa17/src/fut/squad.rs +++ b/openfut-adapter-fifa17/src/fut/squad.rs @@ -29,7 +29,7 @@ //! * FIFA's chemistry/rating algorithm — client-reported values are carried in //! [`ClientReportedSquadEval`] and never reconciled with Core's own evaluation. -use serde::Deserialize; +use serde::{Deserialize, Serialize}; /// A `{ "id": , "dream": bool }` reference (player, manager, …). #[derive(Debug, Clone, Deserialize)] @@ -105,7 +105,7 @@ pub trait SquadWireResolver { /// Client-reported squad evaluation. Kept DISTINCT from Core's authoritative /// evaluation and never reconciled — FIFA's chemistry/rating algorithm is UNKNOWN. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ClientReportedSquadEval { pub chemistry: Option, pub rating: Option, @@ -124,25 +124,27 @@ pub struct ProposedSlot { pub is_on_bench: bool, } -/// A full-squad replacement in game-independent terms, ready for the host to map -/// onto Core's `SquadReplacement`/`SaveSquadRequest`. No `openfut-core` dependency. +/// A full-squad replacement in **canonical** (game-independent) terms, ready for +/// the host to map onto Core's `SquadReplacement`/`SaveSquadRequest`. Carries no +/// FIFA-only state (that is [`crate::fut::squad_ext::Fifa17SquadExtensionV1`]) +/// and no `openfut-core` dependency. No FIFA wire integer survives into a slot — +/// every player is a Core `owned_card_id`. #[derive(Debug, Clone)] pub struct ProposedSquad { + /// The FIFA wire squad id the PUT targeted (`0` = the active squad). Routing + /// only — it selects which Core squad to replace; it is never a Core field. pub squad_id: i64, pub name: Option, - /// The raw FIFA formation token (e.g. `"f442"`) … - pub formation_wire: Option, - /// … and its Core mapping if known (`None` = UNKNOWN formation, not guessed). - pub formation_core: Option<&'static str>, + /// The FIFA formation token exactly as sent (e.g. `"f442"`, `"f433"`). Core + /// stores it verbatim as its opaque formation token and never interprets it, + /// so it round-trips exactly — never mapped to a second representation and + /// never used to derive slot layout. + pub formation: Option, pub slots: Vec, - pub client_eval: ClientReportedSquadEval, /// Occupied wire item ids the resolver could not map. A caller MUST refuse the /// replacement if this is non-empty — a save must never silently drop an /// owned player it failed to identify. pub unresolved_wire_ids: Vec, - /// Opaque FIFA-only client state (the 33-int `custom` array) preserved - /// verbatim; must round-trip unchanged once GET reconstruction is designed. - pub custom_opaque: Option, } /// Parse errors — explicit, never a silent empty squad. @@ -160,28 +162,30 @@ impl std::fmt::Display for SquadError { } impl std::error::Error for SquadError {} -/// FIFA 17 f442 places its 11 starters at `players` indices `0..=10`; higher -/// indices are bench/reserve. Proven ONLY for f442 (the one captured squad). +/// The FIFA 17 squad wire is a fixed 23-slot array: indices `0..=10` are the +/// pitch (the 11 starters), `11..=22` are bench/reserves. This layout is a +/// property of the array, not of the formation — the captured f442 and f433 +/// saves both place their 11 starters at `0..=10`. Bench membership is therefore +/// derived from the index alone, NEVER from the formation token. pub const FIFA17_STARTER_SLOTS: i64 = 11; +/// Length of the fixed FIFA 17 squad slot array (evidence: every captured save +/// and read carries exactly 23 slots). +pub const FIFA17_SQUAD_SLOTS: i64 = 23; + /// Parse a squad-save body into the typed wire form. Structural only. pub fn parse_squad_put(body: &[u8]) -> Result { serde_json::from_slice(body).map_err(|e| SquadError::Parse(e.to_string())) } -/// Map a FIFA formation token to Core's formation string. `None` = UNKNOWN -/// formation — never guessed (only f442 is captured/proven). -pub fn map_formation(wire: &str) -> Option<&'static str> { - match wire { - "f442" => Some("4-4-2"), - _ => None, - } -} - -/// Resolve a parsed save into a game-independent [`ProposedSquad`]: drop empty -/// (`id == 0`) slots, reverse-map each occupied slot's wire id, flag the captain, -/// derive the bench split (f442), and carry client-reported evaluation + opaque -/// `custom`. Unresolvable occupied ids are reported, never guessed or dropped. +/// 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, and derive the bench split from the fixed +/// 23-slot array. FIFA-only state (`custom`, manager, kicktakers, kit numbers, +/// squadType) and client-reported evaluation are NOT canonical — they are built +/// separately into [`crate::fut::squad_ext::Fifa17SquadExtensionV1`]. The +/// formation token is carried verbatim (never mapped). Unresolvable occupied ids +/// are reported, never guessed or dropped. pub fn to_proposed(put: &Fifa17SquadPut, resolver: &dyn SquadWireResolver) -> ProposedSquad { let captain = put.captain.unwrap_or(0); let mut slots = Vec::new(); @@ -204,16 +208,9 @@ pub fn to_proposed(put: &Fifa17SquadPut, resolver: &dyn SquadWireResolver) -> Pr ProposedSquad { squad_id: put.id, name: put.squad_name.clone(), - formation_wire: put.formation.clone(), - formation_core: put.formation.as_deref().and_then(map_formation), + formation: put.formation.clone(), slots, - client_eval: ClientReportedSquadEval { - chemistry: put.chemistry, - rating: put.rating, - star_rating: put.star_rating, - }, unresolved_wire_ids: unresolved, - custom_opaque: put.custom.clone(), } } @@ -273,18 +270,10 @@ mod tests { sq.unresolved_wire_ids.is_empty(), "all occupied ids resolved" ); - assert_eq!(sq.formation_core, Some("4-4-2")); assert_eq!( - sq.custom_opaque, put.custom, - "opaque custom preserved verbatim" - ); - assert_eq!( - sq.client_eval, - ClientReportedSquadEval { - chemistry: Some(52), - rating: Some(90), - star_rating: Some(90) - } + sq.formation.as_deref(), + Some("f442"), + "formation token carried verbatim, never mapped" ); // Every occupied f442 slot is a starter (indices 0..=10). assert!(sq.slots.iter().all(|s| !s.is_on_bench)); @@ -329,10 +318,16 @@ mod tests { } #[test] - fn unknown_formation_is_never_guessed() { - assert_eq!(map_formation("f442"), Some("4-4-2")); - assert_eq!(map_formation("f433"), None); - assert_eq!(map_formation(""), None); + fn formation_token_round_trips_verbatim() { + // Canonical formation is the FIFA token as-sent; f433 is preserved as + // readily as f442 (the old lossy f442->"4-4-2" map is gone). + for tok in ["f442", "f433"] { + let body = format!( + r#"{{"id":0,"formation":"{tok}","captain":0,"players":[{{"index":0,"itemData":{{"id":100000003}},"kitNumber":1}}]}}"# + ); + let sq = to_proposed(&parse_squad_put(body.as_bytes()).unwrap(), &full_resolver()); + assert_eq!(sq.formation.as_deref(), Some(tok)); + } } #[test] diff --git a/openfut-adapter-fifa17/src/fut/squad_ext.rs b/openfut-adapter-fifa17/src/fut/squad_ext.rs new file mode 100644 index 0000000..447dbed --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/squad_ext.rs @@ -0,0 +1,355 @@ +//! 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` | a FIFA manager item ref; not a squad player, semantics opaque | +//! | `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, SquadEntityRef}; + +/// 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, +} + +impl From<&SquadEntityRef> for WireItemRef { + fn from(r: &SquadEntityRef) -> Self { + WireItemRef { id: r.id, dream: r.dream } + } +} + +/// 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, PartialEq, Eq, Serialize, Deserialize)] +pub struct Fifa17SquadExtensionV1 { + /// Opaque 33-int array as a JSON-encoded string, verbatim. Never decoded. + #[serde(default)] + pub custom: Option, + /// FIFA squad-type wire token (e.g. `"REGULAR_SQUAD"`). + #[serde(default)] + pub squad_type: Option, + /// 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, + /// Manager item ref(s), opaque. Not a squad player; not shaped as an item. + #[serde(default)] + pub manager: Vec, + /// Kicktaker role refs, opaque (see [`KicktakerRef`]). + #[serde(default)] + pub kicktakers: Vec, + /// 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, + manager: put.manager.iter().map(WireItemRef::from).collect(), + 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 { + 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), + /// 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 { + 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); + impl SquadWireResolver for MapResolver { + fn owned_id_for_wire(&self, wire: i64) -> Option { + self.0.get(&wire).cloned() + } + } + fn full_resolver() -> MapResolver { + let ids = [ + 100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009, + 100000001, 100000002, 100000025, + ]; + 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_and_kicktakers_preserved_opaquely() { + let ext = built().extension; + assert_eq!(ext.manager, vec![WireItemRef { id: 100000427, dream: false }]); + assert_eq!(ext.kicktakers.len(), 5); + // All five reference the same wire id in this capture; carried verbatim, + // NEVER normalized to the captain even though they coincide here. + assert!(ext.kicktakers.iter().all(|k| k.item.id == 100000001)); + assert_eq!(ext.kicktakers[0].index, 0); + } + + #[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-"))); + } +}