//! 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, /// 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, // 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, /// 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 { 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, // 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-"))); } }