//! FIFA 17 squad **full-replacement** wire (`PUT /ut/game/fifa17/squad/`) → //! a game-independent proposed replacement OpenFUT Core can apply. //! //! ## Scope (deliberately preparatory — nothing is routed yet) //! //! This module parses the captured squad-save wire and reverse-maps each slot's //! FIFA wire item id to a Core owned-instance id, producing a [`ProposedSquad`]. //! It does **not** open a socket, call Core, or mutate state — squad is a //! *stateful* slice and its GET (retrieval) and PUT (save) must migrate together //! as one authority, which needs more captured evidence first. Until then this is //! pure, unit-tested scaffolding. //! //! ## What the wire proves (2 captured retail saves, `fixtures/utas/`) //! //! * The client sends the **whole** squad on every save — a fixed 23-slot array //! plus `formation`, `captain`, `kicktakers`, a 33-int `custom` string, and //! client-reported `chemistry`/`rating`/`starRating`. A two-player edit changed //! nine slots, so slot deltas never describe intent: the only honest operation //! is *this is the squad now*. //! * Each occupied slot carries its FIFA **wire item id** (`itemData.id`, the same //! namespace as `/club` and the identity store); an **empty** slot is `id == 0`. //! * `captain` and `kicktakers` reference the same wire item id space. //! //! ## What it does NOT prove (kept UNKNOWN, never invented) //! //! * The `index → (slot, bench)` layout for formations other than **f442**, and //! the meaning/stability of the `custom` 33-int array. `custom` is preserved //! **opaquely** so it can round-trip unchanged; its integers are not decoded. //! * FIFA's chemistry/rating algorithm — client-reported values are carried in //! [`ClientReportedSquadEval`] and never reconciled with Core's own evaluation. use serde::{Deserialize, Serialize}; /// A `{ "id": , "dream": bool }` reference (player, manager, …). #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SquadEntityRef { pub id: i64, #[serde(default)] pub dream: bool, } /// One entry of the fixed-length `players` array. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SquadSlotWire { pub index: i64, pub item_data: SquadEntityRef, #[serde(default)] pub kit_number: i64, } /// A `kicktakers` entry (penalty/corner/free-kick roles). Carries a wire item id; /// role semantics are UNKNOWN and not modelled. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SquadKicktaker { pub index: i64, pub id: i64, #[serde(default)] pub dream: bool, } /// The squad-save body exactly as FIFA 17 sends it. FIFA-only fields /// (`custom`, `kicktakers`, `kit_number`, `manager`, `squad_type`) are captured /// verbatim; their persistence/reconstruction rules await more evidence. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Fifa17SquadPut { /// Squad id (the path `…/squad/0` and the body agree; `0` = active). #[serde(default)] pub id: i64, #[serde(default)] pub squad_name: Option, #[serde(default)] pub formation: Option, #[serde(default)] pub squad_type: Option, #[serde(default)] pub chemistry: Option, #[serde(default)] pub rating: Option, #[serde(default)] pub star_rating: Option, /// Wire item id of the captain (must be one of the occupied slots). #[serde(default)] pub captain: Option, /// Opaque 33-int array as a JSON-encoded string. Semantics UNKNOWN — carried /// verbatim, never parsed or interpreted. #[serde(default)] pub custom: Option, #[serde(default)] pub manager: Vec, #[serde(default)] pub players: Vec, #[serde(default)] pub kicktakers: Vec, } /// Reverse-maps a FIFA wire item id to a Core owned-instance id. Implemented by /// the host over `Fifa17IdentityResolver`; `None` = unknown id (never guessed). pub trait SquadWireResolver { fn owned_id_for_wire(&self, wire: i64) -> Option; } /// 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, Serialize, Deserialize)] pub struct ClientReportedSquadEval { pub chemistry: Option, pub rating: Option, pub star_rating: Option, } /// One resolved slot of a proposed replacement (game-independent). #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProposedSlot { pub owned_card_id: String, /// FIFA player-array index (0-based). Core imposes its own slot numbering; /// the adapter carries the index plus a bench flag from the f442 convention. pub index: i64, pub kit_number: i64, pub is_captain: bool, pub is_on_bench: bool, } /// 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 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, /// 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, } /// Parse errors — explicit, never a silent empty squad. #[derive(Debug, PartialEq, Eq)] pub enum SquadError { Parse(String), } impl std::fmt::Display for SquadError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { SquadError::Parse(e) => write!(f, "squad wire parse error: {e}"), } } } impl std::error::Error for SquadError {} /// 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())) } /// 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(); let mut unresolved = Vec::new(); for p in &put.players { if p.item_data.id == 0 { continue; // empty slot — never a Core player } match resolver.owned_id_for_wire(p.item_data.id) { Some(owned_card_id) => slots.push(ProposedSlot { owned_card_id, index: p.index, kit_number: p.kit_number, is_captain: captain != 0 && p.item_data.id == captain, is_on_bench: p.index >= FIFA17_STARTER_SLOTS, }), None => unresolved.push(p.item_data.id), } } ProposedSquad { squad_id: put.id, name: put.squad_name.clone(), formation: put.formation.clone(), slots, unresolved_wire_ids: unresolved, } } /// The save acknowledgement FIFA expects: just the squad id (matches the oracle's /// `{"id": }`, 9 bytes — it does NOT echo the squad). pub fn save_ack(squad_id: i64) -> serde_json::Value { serde_json::json!({ "id": squad_id }) } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; /// The real captured f442 save body (decoded from `session-001.jsonl:21`). const PUT_F442: &str = include_str!("../../fixtures/utas/squad_put_f442.json"); /// A resolver mapping every occupied wire id in the fixture to a Core id. 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 { // indices 0..=10 (11 starters); the rest of the 23 slots are id==0 (empty). let ids = [ 100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009, 100000001, 100000002, 100000025, ]; MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect()) } #[test] fn parses_the_captured_full_squad_wire() { let put = parse_squad_put(PUT_F442.as_bytes()).unwrap(); assert_eq!(put.id, 0, "path/body squad id 0 = active"); assert_eq!(put.formation.as_deref(), Some("f442")); assert_eq!(put.squad_type.as_deref(), Some("REGULAR_SQUAD")); assert_eq!(put.chemistry, Some(52)); assert_eq!(put.rating, Some(90)); assert_eq!(put.star_rating, Some(90)); assert_eq!(put.captain, Some(100000001)); assert_eq!(put.players.len(), 23, "fixed 23-slot array"); assert_eq!(put.kicktakers.len(), 5); // custom is carried opaquely, never parsed. assert!(put.custom.as_deref().unwrap().starts_with("[0,0,0")); } #[test] fn resolves_occupied_slots_drops_empties_and_flags_captain() { let put = parse_squad_put(PUT_F442.as_bytes()).unwrap(); let sq = to_proposed(&put, &full_resolver()); assert_eq!(sq.slots.len(), 11, "11 occupied; 12 empty (id==0) dropped"); assert!( sq.unresolved_wire_ids.is_empty(), "all occupied ids resolved" ); assert_eq!( 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)); // The captain flag lands on exactly the captain's slot (id 100000001 @ index 8). let caps: Vec<_> = sq.slots.iter().filter(|s| s.is_captain).collect(); assert_eq!(caps.len(), 1); assert_eq!(caps[0].owned_card_id, "oc-100000001"); assert_eq!(caps[0].index, 8); assert_eq!(caps[0].kit_number, 8); } #[test] fn unresolved_occupied_id_is_reported_never_dropped_silently() { let put = parse_squad_put(PUT_F442.as_bytes()).unwrap(); // Resolver missing one occupied id (100000025). let mut ids = full_resolver().0; ids.remove(&100000025); let sq = to_proposed(&put, &MapResolver(ids)); assert_eq!( sq.slots.len(), 10, "the unresolved slot is not emitted as a player" ); assert_eq!( sq.unresolved_wire_ids, vec![100000025], "reported for the caller to refuse" ); } #[test] fn empty_slot_is_dropped_not_resolved() { // A minimal body: one occupied + one empty slot. let body = br#"{"id":0,"formation":"f442","captain":0,"players":[ {"index":0,"itemData":{"id":100000003},"kitNumber":1}, {"index":11,"itemData":{"id":0},"kitNumber":0}]}"#; let put = parse_squad_put(body).unwrap(); let sq = to_proposed(&put, &full_resolver()); assert_eq!(sq.slots.len(), 1); assert_eq!(sq.slots[0].index, 0); assert!(sq.unresolved_wire_ids.is_empty()); } #[test] 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] fn save_ack_is_the_bare_id() { assert_eq!(save_ack(0), serde_json::json!({ "id": 0 })); assert_eq!(save_ack(7), serde_json::json!({ "id": 7 })); } #[test] fn malformed_body_errors_never_empty_squad() { assert!(matches!( parse_squad_put(b"not json"), Err(SquadError::Parse(_)) )); } }