From 550a59d12cac03ddae4ebce447b9d7244e578ad4 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 12 Aug 2026 00:53:46 +0000 Subject: [PATCH] feat(adapter): FIFA17 squad full-replacement wire parser + reverse-map scaffolding (unrouted) --- .../fixtures/utas/squad_put_f442.json | 1 + openfut-adapter-fifa17/src/fut/mod.rs | 1 + openfut-adapter-fifa17/src/fut/squad.rs | 351 ++++++++++++++++++ 3 files changed, 353 insertions(+) create mode 100644 openfut-adapter-fifa17/fixtures/utas/squad_put_f442.json create mode 100644 openfut-adapter-fifa17/src/fut/squad.rs diff --git a/openfut-adapter-fifa17/fixtures/utas/squad_put_f442.json b/openfut-adapter-fifa17/fixtures/utas/squad_put_f442.json new file mode 100644 index 0000000..df8f336 --- /dev/null +++ b/openfut-adapter-fifa17/fixtures/utas/squad_put_f442.json @@ -0,0 +1 @@ +{"id":0,"custom":"[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,50,50,0,50,40,65,0,65,50,50,1]","squadName":"OpenFUT","chemistry":52,"starRating":90,"rating":90,"formation":"f442","squadType":"REGULAR_SQUAD","manager":[{"id":100000427,"dream":false}],"players":[{"index":0,"itemData":{"id":100000003,"dream":false},"kitNumber":1},{"index":1,"itemData":{"id":100000010,"dream":false},"kitNumber":10},{"index":2,"itemData":{"id":100000005,"dream":false},"kitNumber":3},{"index":3,"itemData":{"id":100000008,"dream":false},"kitNumber":6},{"index":4,"itemData":{"id":100000007,"dream":false},"kitNumber":5},{"index":5,"itemData":{"id":100000006,"dream":false},"kitNumber":4},{"index":6,"itemData":{"id":100000004,"dream":false},"kitNumber":2},{"index":7,"itemData":{"id":100000009,"dream":false},"kitNumber":7},{"index":8,"itemData":{"id":100000001,"dream":false},"kitNumber":8},{"index":9,"itemData":{"id":100000002,"dream":false},"kitNumber":9},{"index":10,"itemData":{"id":100000025,"dream":false},"kitNumber":11},{"index":11,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":12,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":13,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":14,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":15,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":16,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":17,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":18,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":19,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":20,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":21,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":22,"itemData":{"id":0,"dream":false},"kitNumber":0}],"captain":100000001,"kicktakers":[{"index":0,"id":100000001,"dream":false},{"index":1,"id":100000001,"dream":false},{"index":2,"id":100000001,"dream":false},{"index":3,"id":100000001,"dream":false},{"index":4,"id":100000001,"dream":false}]} \ No newline at end of file diff --git a/openfut-adapter-fifa17/src/fut/mod.rs b/openfut-adapter-fifa17/src/fut/mod.rs index 20b91b0..30e5a11 100644 --- a/openfut-adapter-fifa17/src/fut/mod.rs +++ b/openfut-adapter-fifa17/src/fut/mod.rs @@ -8,3 +8,4 @@ pub mod catalog; pub mod club_response; pub mod entities; pub mod owned_query; +pub mod squad; diff --git a/openfut-adapter-fifa17/src/fut/squad.rs b/openfut-adapter-fifa17/src/fut/squad.rs new file mode 100644 index 0000000..c9cb0a0 --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/squad.rs @@ -0,0 +1,351 @@ +//! 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; + +/// 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)] +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 game-independent terms, ready for the host to map +/// onto Core's `SquadReplacement`/`SaveSquadRequest`. No `openfut-core` dependency. +#[derive(Debug, Clone)] +pub struct ProposedSquad { + 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>, + 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. +#[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 {} + +/// 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). +pub const FIFA17_STARTER_SLOTS: i64 = 11; + +/// 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. +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_wire: put.formation.clone(), + formation_core: put.formation.as_deref().and_then(map_formation), + slots, + client_eval: ClientReportedSquadEval { + chemistry: put.chemistry, + rating: put.rating, + star_rating: put.star_rating, + }, + unresolved_wire_ids: unresolved, + custom_opaque: put.custom.clone(), + } +} + +/// 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_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) + } + ); + // 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 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); + } + + #[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(_)) + )); + } +}