diff --git a/openfut-adapter-fifa17/fixtures/utas/squad_list.json b/openfut-adapter-fifa17/fixtures/utas/squad_list.json new file mode 100644 index 0000000..571c323 --- /dev/null +++ b/openfut-adapter-fifa17/fixtures/utas/squad_list.json @@ -0,0 +1 @@ +{"squad": [{"rating": 89, "chemistry": 59, "formation": "f442", "id": 0, "squadName": "OpenFUT", "squadType": "REGULAR_SQUAD"}]} \ No newline at end of file diff --git a/openfut-adapter-fifa17/fixtures/utas/squad_put_f433.json b/openfut-adapter-fifa17/fixtures/utas/squad_put_f433.json new file mode 100644 index 0000000..ec40cab --- /dev/null +++ b/openfut-adapter-fifa17/fixtures/utas/squad_put_f433.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": 49, "starRating": 90, "rating": 90, "formation": "f433", "squadType": "REGULAR_SQUAD", "manager": [{"id": 100000427, "dream": false}], "players": [{"index": 0, "itemData": {"id": 100000003, "dream": false}, "kitNumber": 1}, {"index": 1, "itemData": {"id": 100000006, "dream": false}, "kitNumber": 4}, {"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": 100000002, "dream": false}, "kitNumber": 9}, {"index": 6, "itemData": {"id": 100000004, "dream": false}, "kitNumber": 2}, {"index": 7, "itemData": {"id": 100000009, "dream": false}, "kitNumber": 7}, {"index": 8, "itemData": {"id": 100000010, "dream": false}, "kitNumber": 10}, {"index": 9, "itemData": {"id": 100000025, "dream": false}, "kitNumber": 11}, {"index": 10, "itemData": {"id": 100000001, "dream": false}, "kitNumber": 8}, {"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/fixtures/utas/squad_put_swap_f442.json b/openfut-adapter-fifa17/fixtures/utas/squad_put_swap_f442.json new file mode 100644 index 0000000..4c70d92 --- /dev/null +++ b/openfut-adapter-fifa17/fixtures/utas/squad_put_swap_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": 58, "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": 100000006, "dream": false}, "kitNumber": 4}, {"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": 100000002, "dream": false}, "kitNumber": 9}, {"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": 100000010, "dream": false}, "kitNumber": 10}, {"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/fixtures/utas/squad_read_usermassinfo.json b/openfut-adapter-fifa17/fixtures/utas/squad_read_usermassinfo.json new file mode 100644 index 0000000..01c5886 --- /dev/null +++ b/openfut-adapter-fifa17/fixtures/utas/squad_read_usermassinfo.json @@ -0,0 +1 @@ +{"id": 0, "personaId": 33068179, "squadName": "OpenFUT", "formation": "f442", "squadType": "REGULAR_SQUAD", "chemistry": 59, "starRating": 90, "captain": 100000001, "changed": 0, "manager": [{"id": 100000427, "dream": false}], "actives": [], "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]", "players": [{"index": 0, "itemData": {"id": 100000003, "resourceId": 200389, "assetId": 200389, "cardassetid": 200389, "definitionId": 200389, "cardsubtypeid": 0, "itemType": "player", "rareflag": 1, "rating": 87, "preferredPosition": "GK", "nation": 44, "teamid": 240, "leagueId": 53, "playStyle": 250, "attributeList": [{"index": 0, "value": 83}, {"index": 1, "value": 90}, {"index": 2, "value": 77}, {"index": 3, "value": 82}, {"index": 4, "value": 50}, {"index": 5, "value": 87}], "itemState": "free", "owners": 1, "untradeable": false, "contract": 7, "fitness": 99, "discardValue": 696}, "kitNumber": 1}, {"index": 1, "itemData": {"id": 100000006, "resourceId": 197445, "assetId": 197445, "cardassetid": 197445, "definitionId": 197445, "cardsubtypeid": 0, "itemType": "player", "rareflag": 1, "rating": 87, "preferredPosition": "LB", "nation": 4, "teamid": 21, "leagueId": 19, "playStyle": 250, "attributeList": [{"index": 0, "value": 86}, {"index": 1, "value": 73}, {"index": 2, "value": 81}, {"index": 3, "value": 83}, {"index": 4, "value": 83}, {"index": 5, "value": 73}], "itemState": "free", "owners": 1, "untradeable": false, "contract": 7, "fitness": 99, "discardValue": 696}, "kitNumber": 4}, {"index": 2, "itemData": {"id": 100000005, "resourceId": 155862, "assetId": 155862, "cardassetid": 155862, "definitionId": 155862, "cardsubtypeid": 0, "itemType": "player", "rareflag": 1, "rating": 89, "preferredPosition": "CB", "nation": 45, "teamid": 243, "leagueId": 53, "playStyle": 250, "attributeList": [{"index": 0, "value": 78}, {"index": 1, "value": 63}, {"index": 2, "value": 70}, {"index": 3, "value": 70}, {"index": 4, "value": 87}, {"index": 5, "value": 83}], "itemState": "free", "owners": 1, "untradeable": false, "contract": 7, "fitness": 99, "discardValue": 712}, "kitNumber": 3}, {"index": 3, "itemData": {"id": 100000008, "resourceId": 182521, "assetId": 182521, "cardassetid": 182521, "definitionId": 182521, "cardsubtypeid": 0, "itemType": "player", "rareflag": 1, "rating": 88, "preferredPosition": "CM", "nation": 21, "teamid": 243, "leagueId": 53, "playStyle": 250, "attributeList": [{"index": 0, "value": 45}, {"index": 1, "value": 80}, {"index": 2, "value": 88}, {"index": 3, "value": 79}, {"index": 4, "value": 69}, {"index": 5, "value": 70}], "itemState": "free", "owners": 1, "untradeable": false, "contract": 7, "fitness": 99, "discardValue": 704}, "kitNumber": 6}, {"index": 4, "itemData": {"id": 100000007, "resourceId": 189332, "assetId": 189332, "cardassetid": 189332, "definitionId": 189332, "cardsubtypeid": 0, "itemType": "player", "rareflag": 1, "rating": 86, "preferredPosition": "LB", "nation": 45, "teamid": 241, "leagueId": 53, "playStyle": 250, "attributeList": [{"index": 0, "value": 93}, {"index": 1, "value": 69}, {"index": 2, "value": 75}, {"index": 3, "value": 83}, {"index": 4, "value": 81}, {"index": 5, "value": 75}], "itemState": "free", "owners": 1, "untradeable": false, "contract": 7, "fitness": 99, "discardValue": 688}, "kitNumber": 5}, {"index": 5, "itemData": {"id": 100000002, "resourceId": 158023, "assetId": 158023, "cardassetid": 158023, "definitionId": 158023, "cardsubtypeid": 0, "itemType": "player", "rareflag": 1, "rating": 93, "preferredPosition": "RW", "nation": 52, "teamid": 241, "leagueId": 53, "playStyle": 250, "attributeList": [{"index": 0, "value": 89}, {"index": 1, "value": 90}, {"index": 2, "value": 86}, {"index": 3, "value": 96}, {"index": 4, "value": 26}, {"index": 5, "value": 61}], "itemState": "free", "owners": 1, "untradeable": false, "contract": 7, "fitness": 99, "discardValue": 744}, "kitNumber": 9}, {"index": 6, "itemData": {"id": 100000004, "resourceId": 183907, "assetId": 183907, "cardassetid": 183907, "definitionId": 183907, "cardsubtypeid": 0, "itemType": "player", "rareflag": 1, "rating": 90, "preferredPosition": "CB", "nation": 21, "teamid": 21, "leagueId": 19, "playStyle": 250, "attributeList": [{"index": 0, "value": 79}, {"index": 1, "value": 50}, {"index": 2, "value": 72}, {"index": 3, "value": 68}, {"index": 4, "value": 90}, {"index": 5, "value": 85}], "itemState": "free", "owners": 1, "untradeable": false, "contract": 7, "fitness": 99, "discardValue": 720}, "kitNumber": 2}, {"index": 7, "itemData": {"id": 100000009, "resourceId": 183277, "assetId": 183277, "cardassetid": 183277, "definitionId": 183277, "cardsubtypeid": 0, "itemType": "player", "rareflag": 1, "rating": 88, "preferredPosition": "LM", "nation": 7, "teamid": 5, "leagueId": 13, "playStyle": 250, "attributeList": [{"index": 0, "value": 90}, {"index": 1, "value": 81}, {"index": 2, "value": 82}, {"index": 3, "value": 91}, {"index": 4, "value": 32}, {"index": 5, "value": 64}], "itemState": "free", "owners": 1, "untradeable": false, "contract": 7, "fitness": 99, "discardValue": 704}, "kitNumber": 7}, {"index": 8, "itemData": {"id": 100000001, "resourceId": 20801, "assetId": 20801, "cardassetid": 20801, "definitionId": 20801, "cardsubtypeid": 0, "itemType": "player", "rareflag": 1, "rating": 94, "preferredPosition": "LW", "nation": 38, "teamid": 243, "leagueId": 53, "playStyle": 250, "attributeList": [{"index": 0, "value": 92}, {"index": 1, "value": 92}, {"index": 2, "value": 81}, {"index": 3, "value": 91}, {"index": 4, "value": 33}, {"index": 5, "value": 80}], "itemState": "free", "owners": 1, "untradeable": false, "contract": 7, "fitness": 99, "discardValue": 752}, "kitNumber": 8}, {"index": 9, "itemData": {"id": 100000025, "resourceId": 188545, "assetId": 188545, "cardassetid": 188545, "definitionId": 188545, "cardsubtypeid": 0, "itemType": "player", "rareflag": 1, "rating": 90, "preferredPosition": "ST", "nation": 37, "teamid": 21, "leagueId": 19, "playStyle": 250, "attributeList": [{"index": 0, "value": 81}, {"index": 1, "value": 87}, {"index": 2, "value": 74}, {"index": 3, "value": 85}, {"index": 4, "value": 38}, {"index": 5, "value": 82}], "itemState": "free", "owners": 1, "untradeable": false, "contract": 7, "fitness": 99, "pile": "club", "discardValue": 720}, "kitNumber": 11}, {"index": 10, "itemData": {"id": 100000010, "resourceId": 176580, "assetId": 176580, "cardassetid": 176580, "definitionId": 176580, "cardsubtypeid": 0, "itemType": "player", "rareflag": 1, "rating": 92, "preferredPosition": "ST", "nation": 60, "teamid": 241, "leagueId": 53, "playStyle": 250, "attributeList": [{"index": 0, "value": 82}, {"index": 1, "value": 90}, {"index": 2, "value": 79}, {"index": 3, "value": 87}, {"index": 4, "value": 42}, {"index": 5, "value": 79}], "itemState": "free", "owners": 1, "untradeable": false, "contract": 7, "fitness": 99, "discardValue": 736}, "kitNumber": 10}, {"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}], "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}], "rating": 90} \ 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 8167a87..d4a43ff 100644 --- a/openfut-adapter-fifa17/src/fut/mod.rs +++ b/openfut-adapter-fifa17/src/fut/mod.rs @@ -11,3 +11,4 @@ pub mod entities; pub mod owned_query; pub mod squad; pub mod squad_ext; +pub mod squad_projection; diff --git a/openfut-adapter-fifa17/src/fut/squad_projection.rs b/openfut-adapter-fifa17/src/fut/squad_projection.rs new file mode 100644 index 0000000..441caae --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/squad_projection.rs @@ -0,0 +1,364 @@ +//! The **single** FIFA 17 squad projector: canonical Core squad + Fresh FIFA +//! extension → the FIFA 17 squad wire object. +//! +//! One projector serves every squad read shape. `userMassInfo.squad` embeds the +//! full object; `GET /squad/list` is a summary *subset* of it; a future +//! `/squad/active` is the same object again. Endpoint wrappers ([`user_mass_info_squad`], +//! [`squad_list`]) only shape the outer envelope — there is deliberately no +//! second squad domain model per endpoint. +//! +//! ## Purity / no N+1 +//! +//! The projector touches no database, socket, or Core API. It consumes a +//! [`SquadProjectionInput`] the host assembles from ONE bounded batch — Core's +//! `read_squad_with_ext` (canonical squad + players + extension freshness) plus a +//! single "all owned cards for this club" fetch joined against the in-memory card +//! definitions. There is no per-slot lookup here or above. +//! +//! ## Item identity is shared, never reconstructed +//! +//! Each occupied slot is shaped by the shared [`crate::fut::item::shape_item`], +//! the same primitive `/club` uses — the projector never rebuilds the card shape +//! itself, so squad items and `/club` items cannot drift, and two owned copies of +//! one definition stay distinct (each carries its own resolved wire id). +//! +//! ## Fresh / Stale / Missing +//! +//! Freshness comes from Core and is surfaced, never buried in a default: +//! * **Fresh** → project the full object. +//! * **Stale** → NEVER overlay the stale extension on the newer canonical squad; +//! return [`SquadProjection::Stale`] for the host to act on (e.g. fall back). +//! * **Missing** → return [`SquadProjection::Missing`]; the projector does NOT +//! fabricate a manager/custom/kicktakers/kit numbers just to emit a response. + +use std::collections::HashMap; + +use serde_json::{json, Value}; + +use crate::fut::entities::ReverseEntityResolver; +use crate::fut::item::{shape_item, CoreOwnedItem, ItemIdentityResolver}; +use crate::fut::squad::FIFA17_SQUAD_SLOTS; +use crate::fut::squad_ext::Fifa17SquadExtensionV1; + +/// Freshness of the FIFA 17 extension relative to the current canonical squad, +/// as reported by Core's `read_squad_with_ext`. This is a game-independent mirror +/// the host populates from Core's `SquadExtState`; the adapter never computes the +/// canonical fingerprint itself (that is Core's server-side job). +#[derive(Debug, Clone)] +pub enum SquadExtInput { + Fresh(Fifa17SquadExtensionV1), + /// The stored extension whose fingerprint no longer matches the canonical + /// squad. Carried so the host can log/inspect it, but the projector NEVER + /// applies it over the newer canonical squad (mirrors Core's + /// `SquadExtState::Stale { stored, .. }`). + Stale(Fifa17SquadExtensionV1), + Missing, +} + +/// One canonical slot as read back from Core. `is_on_bench` is carried through +/// from Core, never re-derived from the formation. +#[derive(Debug, Clone)] +pub struct ProjectionSlot { + pub owned_card_id: String, + pub index: i64, + pub is_captain: bool, + pub is_on_bench: bool, +} + +/// Everything the pure projector needs to render one full squad. +pub struct SquadProjectionInput<'a> { + /// FIFA wire squad id (`0` = active). + pub fifa_squad_id: i64, + pub name: String, + /// FIFA formation token, verbatim from the canonical squad (never mapped). + pub formation: String, + pub slots: Vec, + pub ext: SquadExtInput, + /// Every owned item a slot references, keyed by `owned_card_id`. Assembled by + /// the host in one batch — the projector only reads from it. + pub owned: &'a HashMap, +} + +/// Result of a projection, with the extension-freshness verdict surfaced. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SquadProjection { + /// A fully projected FIFA 17 squad object (the `userMassInfo.squad` shape, + /// minus session envelope fields the endpoint wrapper adds). + Projected(Value), + /// The stored extension is stale vs the canonical squad — not applied. + Stale, + /// No extension stored — nothing fabricated. + Missing, +} + +/// Hard projection failures — a squad cannot be rendered faithfully. Never +/// silently degraded (a squad cannot drop a starter the way `/club` drops a card). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SquadProjectError { + /// A slot references an `owned_card_id` absent from the projection input. + MissingOwnedItem(String), + /// An occupied slot's owned item has no real FIFA asset identity — it cannot + /// be rendered and MUST NOT be faked. + NoFifaIdentity(String), +} + +impl std::fmt::Display for SquadProjectError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SquadProjectError::MissingOwnedItem(id) => { + write!(f, "projection input missing owned item {id}") + } + SquadProjectError::NoFifaIdentity(id) => { + write!(f, "owned item {id} has no real FIFA asset identity (cannot render)") + } + } + } +} +impl std::error::Error for SquadProjectError {} + +/// Project a squad. On `Fresh`, returns the full FIFA squad object; on +/// `Stale`/`Missing`, returns that verdict without fabricating anything. +pub fn project_squad( + input: &SquadProjectionInput<'_>, + ident: &I, + ent: &impl ReverseEntityResolver, +) -> Result { + let ext = match &input.ext { + SquadExtInput::Stale(_) => return Ok(SquadProjection::Stale), + SquadExtInput::Missing => return Ok(SquadProjection::Missing), + SquadExtInput::Fresh(ext) => ext, + }; + + // Index occupied slots by their FIFA array index for O(1) fill. + let by_index: HashMap = + input.slots.iter().map(|s| (s.index, s)).collect(); + + let mut players = Vec::with_capacity(FIFA17_SQUAD_SLOTS as usize); + let mut captain_wire: i64 = 0; + + for index in 0..FIFA17_SQUAD_SLOTS { + match by_index.get(&index) { + Some(slot) => { + let item = input + .owned + .get(&slot.owned_card_id) + .ok_or_else(|| SquadProjectError::MissingOwnedItem(slot.owned_card_id.clone()))?; + let id = ident + .resolve(item) + .ok_or_else(|| SquadProjectError::NoFifaIdentity(slot.owned_card_id.clone()))?; + if slot.is_captain { + captain_wire = id.item_id as i64; + } + // kit follows the player: look it up by owned id, never by index. + let kit = ext.kit_numbers.get(&slot.owned_card_id).copied().unwrap_or(0); + players.push(json!({ + "index": index, + "itemData": shape_item(item, id, ent), + "kitNumber": kit, + })); + } + None => players.push(json!({ + "index": index, + "itemData": { "id": 0, "dream": false }, + "kitNumber": 0, + })), + } + } + + let squad = json!({ + "id": input.fifa_squad_id, + "squadName": input.name, + "formation": input.formation, + "squadType": ext.squad_type, + "chemistry": ext.client_reported.chemistry, + "starRating": ext.client_reported.star_rating, + "rating": ext.client_reported.rating, + "captain": captain_wire, + "manager": ext.manager, + "custom": ext.custom, + "players": players, + "kicktakers": ext.kicktakers, + }); + Ok(SquadProjection::Projected(squad)) +} + +/// Wrap a projected squad object into the `userMassInfo.squad` shape, injecting +/// the session-envelope fields the projector does not own (`personaId`, plus the +/// observed constants `changed: 0`, `actives: []`). +pub fn user_mass_info_squad(projected: Value, persona_id: i64) -> Value { + let mut obj = projected; + if let Value::Object(map) = &mut obj { + map.insert("personaId".into(), json!(persona_id)); + map.insert("changed".into(), json!(0)); + map.insert("actives".into(), json!([])); + } + obj +} + +/// The `GET /squad/list` summary response — a subset of the SAME projected +/// object, wrapped in `{"squad":[ … ]}`. Not a separate domain projection. +pub fn squad_list(projected: &Value) -> Value { + let summary = json!({ + "id": projected.get("id").cloned().unwrap_or(Value::Null), + "squadName": projected.get("squadName").cloned().unwrap_or(Value::Null), + "formation": projected.get("formation").cloned().unwrap_or(Value::Null), + "squadType": projected.get("squadType").cloned().unwrap_or(Value::Null), + "rating": projected.get("rating").cloned().unwrap_or(Value::Null), + "chemistry": projected.get("chemistry").cloned().unwrap_or(Value::Null), + }); + json!({ "squad": [summary] }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fut::entities::Fifa17Entities; + use crate::fut::item::Fifa17Identity; + + // A resolver that mints a distinct wire id per owned item and a fixed asset. + struct TableIdentity(HashMap); + impl ItemIdentityResolver for TableIdentity { + fn resolve(&self, it: &CoreOwnedItem) -> Option { + self.0.get(&it.owned_card_id).copied() + } + } + + fn ent() -> Fifa17Entities { + Fifa17Entities::from_maps(HashMap::new(), HashMap::new(), HashMap::new()) + } + + fn owned_item(id: &str, card: &str) -> CoreOwnedItem { + CoreOwnedItem { + owned_card_id: id.into(), + card_id: card.into(), + rating: 84, + position: "ST".into(), + nation: "n".into(), + league: "l".into(), + club: "c".into(), + attributes: [80, 80, 80, 80, 40, 80], + } + } + + fn one_slot_input<'a>( + owned: &'a HashMap, + ext: SquadExtInput, + ) -> SquadProjectionInput<'a> { + SquadProjectionInput { + fifa_squad_id: 0, + name: "OpenFUT".into(), + formation: "f442".into(), + slots: vec![ProjectionSlot { + owned_card_id: "oc1".into(), + index: 0, + is_captain: true, + is_on_bench: false, + }], + ext, + owned, + } + } + + fn fresh_ext() -> Fifa17SquadExtensionV1 { + let mut kit = std::collections::BTreeMap::new(); + kit.insert("oc1".to_string(), 9); + Fifa17SquadExtensionV1 { + custom: Some("[1,2,3]".into()), + squad_type: Some("REGULAR_SQUAD".into()), + kit_numbers: kit, + manager: vec![], + kicktakers: vec![], + client_reported: Default::default(), + } + } + + #[test] + fn fresh_projects_full_23_slot_array_with_captain_wire_id() { + let mut owned = HashMap::new(); + owned.insert("oc1".to_string(), owned_item("oc1", "card_x")); + let ident = TableIdentity(HashMap::from([( + "oc1".to_string(), + Fifa17Identity { item_id: 100000042, asset_id: 20801 }, + )])); + let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext())); + let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else { + panic!("expected Projected"); + }; + assert_eq!(v["players"].as_array().unwrap().len(), 23, "fixed 23-slot array"); + assert_eq!(v["players"][0]["itemData"]["id"], 100000042, "wire id, not resourceId"); + assert_eq!(v["players"][0]["itemData"]["resourceId"], 20801); + assert_eq!(v["players"][0]["kitNumber"], 9, "kit from ext by owned id"); + assert_eq!(v["players"][1]["itemData"]["id"], 0, "empty slot"); + assert_eq!(v["captain"], 100000042, "captain is the resolved WIRE id"); + assert_ne!(v["captain"], 20801, "captain must NOT be the resourceId"); + assert_eq!(v["custom"], "[1,2,3]"); + assert_eq!(v["formation"], "f442"); + } + + #[test] + fn stale_is_never_applied() { + let owned = HashMap::new(); + // A stale extension IS carried (host may log it) but must not be applied. + let input = one_slot_input(&owned, SquadExtInput::Stale(fresh_ext())); + let ident = TableIdentity(HashMap::new()); + assert_eq!(project_squad(&input, &ident, &ent()).unwrap(), SquadProjection::Stale); + } + + #[test] + fn missing_is_explicit_never_fabricated() { + let owned = HashMap::new(); + let input = one_slot_input(&owned, SquadExtInput::Missing); + let ident = TableIdentity(HashMap::new()); + assert_eq!(project_squad(&input, &ident, &ent()).unwrap(), SquadProjection::Missing); + } + + #[test] + fn occupied_starter_without_asset_identity_is_refused_not_faked() { + let mut owned = HashMap::new(); + owned.insert("oc1".to_string(), owned_item("oc1", "card_x")); + let ident = TableIdentity(HashMap::new()); // resolves nothing + let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext())); + assert_eq!( + project_squad(&input, &ident, &ent()), + Err(SquadProjectError::NoFifaIdentity("oc1".into())) + ); + } + + #[test] + fn two_owned_copies_of_one_definition_project_as_distinct_players() { + // Same card definition -> same resourceId; two distinct owned instances + // in two slots with distinct kits must stay distinct on the wire. + let mut owned = HashMap::new(); + owned.insert("oc-a".to_string(), owned_item("oc-a", "fifa17_101490")); + owned.insert("oc-b".to_string(), owned_item("oc-b", "fifa17_101490")); + let ident = TableIdentity(HashMap::from([ + ("oc-a".to_string(), Fifa17Identity { item_id: 100000030, asset_id: 101490 }), + ("oc-b".to_string(), Fifa17Identity { item_id: 100000031, asset_id: 101490 }), + ])); + let mut kit = std::collections::BTreeMap::new(); + kit.insert("oc-a".to_string(), 7); + kit.insert("oc-b".to_string(), 19); + let ext = Fifa17SquadExtensionV1 { kit_numbers: kit, ..fresh_ext() }; + let owned_ref = &owned; + let input = SquadProjectionInput { + fifa_squad_id: 0, + name: "OpenFUT".into(), + formation: "f442".into(), + slots: vec![ + ProjectionSlot { owned_card_id: "oc-a".into(), index: 0, is_captain: false, is_on_bench: false }, + ProjectionSlot { owned_card_id: "oc-b".into(), index: 1, is_captain: false, is_on_bench: false }, + ], + ext: SquadExtInput::Fresh(ext), + owned: owned_ref, + }; + let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else { + panic!(); + }; + let a = &v["players"][0]["itemData"]; + let b = &v["players"][1]["itemData"]; + assert_eq!(a["resourceId"], b["resourceId"], "same definition => same asset"); + assert_ne!(a["id"], b["id"], "distinct owned copies keep distinct wire ids"); + assert_eq!(v["players"][0]["kitNumber"], 7); + assert_eq!(v["players"][1]["kitNumber"], 19, "kit stays with the instance"); + } +} diff --git a/openfut-adapter-fifa17/tests/squad_projection.rs b/openfut-adapter-fifa17/tests/squad_projection.rs new file mode 100644 index 0000000..cfe270b --- /dev/null +++ b/openfut-adapter-fifa17/tests/squad_projection.rs @@ -0,0 +1,354 @@ +//! Adapter-level squad read-after-write / round-trip fidelity, driven entirely by +//! committed sanitized capture evidence. +//! +//! Pipeline exercised end to end, with NO database, socket, or Core: +//! +//! ```text +//! captured PUT ─parse─▶ Fifa17SquadPut +//! ─build─▶ ProposedSquad (canonical) + Fifa17SquadExtensionV1 +//! (simulate committed canonical state: the ProposedSquad IS what Core stored) +//! ─project─▶ FIFA 17 squad wire object +//! ``` +//! +//! Fidelity is asserted by ownership class: +//! CANONICAL player instance per index, formation, captain, bench split +//! EXTENSION custom, kicktakers, manager, kit numbers (by player), squadType +//! SHADOW chemistry/rating/starRating (client-reported, round-tripped as-is) +//! DERIVED correct FIFA 17 item identity (wire id + resourceId) +//! +//! We assert *semantic wire fidelity*, not byte equality: the read oracle was +//! produced by the pre-migration Python backend, whose display-only shadow fields +//! (`untradeable`, `discardValue`) and server-*recomputed* chemistry are not the +//! adapter's to reproduce. + +use std::collections::HashMap; + +use openfut_adapter_fifa17::fut::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver}; +use openfut_adapter_fifa17::fut::squad::{parse_squad_put, Fifa17SquadPut, SquadWireResolver}; +use openfut_adapter_fifa17::fut::squad_ext::{build_squad_write, SquadWriteBuild}; +use openfut_adapter_fifa17::fut::squad_projection::{ + project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput, + SquadProjection, SquadProjectionInput, +}; +use serde_json::Value; + +const PUT_BASELINE: &str = include_str!("../fixtures/utas/squad_put_f442.json"); +const PUT_SWAP: &str = include_str!("../fixtures/utas/squad_put_swap_f442.json"); +const PUT_F433: &str = include_str!("../fixtures/utas/squad_put_f433.json"); +const READ_ORACLE: &str = include_str!("../fixtures/utas/squad_read_usermassinfo.json"); + +// ---- host-role stand-ins (identity resolution, entity resolver) ------------- + +/// Wire→owned reverse map. In production the host builds this from the identity +/// store; here every occupied wire id maps to a stable `oc-`. +struct OcResolver; +impl SquadWireResolver for OcResolver { + fn owned_id_for_wire(&self, wire: i64) -> Option { + Some(format!("oc-{wire}")) + } +} + +/// owned_card_id → FIFA identity, so two copies of one definition stay distinct. +struct TableIdentity(HashMap); +impl ItemIdentityResolver for TableIdentity { + fn resolve(&self, it: &CoreOwnedItem) -> Option { + self.0.get(&it.owned_card_id).copied() + } +} + +/// Neutral entity resolver — badge/flag ids are covered by `fut::item` tests; the +/// projector round-trip asserts item *identity* (wire id + asset), not entity ids. +struct NoEntities; +impl openfut_adapter_fifa17::fut::entities::ReverseEntityResolver for NoEntities { + fn league_id(&self, _: &str) -> Option { None } + fn team_id(&self, _: &str) -> Option { None } + fn nation_id(&self, _: &str) -> Option { None } +} + +/// Build the owned-item map + identity table from the persisted read oracle, so +/// every wire id used by the captures resolves to its real FIFA asset id/rating. +/// Keyed by `oc-` to match `OcResolver`. +fn oracle_tables() -> (HashMap, TableIdentity) { + let oracle: Value = serde_json::from_str(READ_ORACLE).unwrap(); + let mut owned = HashMap::new(); + let mut ident = HashMap::new(); + for p in oracle["players"].as_array().unwrap() { + let it = &p["itemData"]; + let wire = it["id"].as_i64().unwrap(); + if wire == 0 { + continue; // empty slot + } + let oc = format!("oc-{wire}"); + let asset = it["resourceId"].as_u64().unwrap() as u32; + let attrs: Vec = it["attributeList"] + .as_array() + .unwrap() + .iter() + .map(|a| a["value"].as_u64().unwrap() as u8) + .collect(); + owned.insert( + oc.clone(), + CoreOwnedItem { + owned_card_id: oc.clone(), + card_id: format!("def-{asset}"), + rating: it["rating"].as_u64().unwrap() as u8, + position: it["preferredPosition"].as_str().unwrap().to_string(), + nation: String::new(), + league: String::new(), + club: String::new(), + attributes: [attrs[0], attrs[1], attrs[2], attrs[3], attrs[4], attrs[5]], + }, + ); + ident.insert(oc, Fifa17Identity { item_id: wire as u32, asset_id: asset }); + } + (owned, TableIdentity(ident)) +} + +/// The full pipeline: parse a captured PUT, build the canonical + extension, then +/// project — treating the just-built canonical squad as Core's committed state. +fn project_put<'a>( + put: &Fifa17SquadPut, + owned: &'a HashMap, + ident: &TableIdentity, +) -> Value { + let SquadWriteBuild { canonical, extension } = + build_squad_write(put, &OcResolver).expect("build must succeed for a full valid squad"); + let slots: Vec = canonical + .slots + .iter() + .map(|s| ProjectionSlot { + owned_card_id: s.owned_card_id.clone(), + index: s.index, + is_captain: s.is_captain, + is_on_bench: s.is_on_bench, + }) + .collect(); + let input = SquadProjectionInput { + fifa_squad_id: canonical.squad_id, + name: canonical.name.clone().unwrap_or_default(), + formation: canonical.formation.clone().unwrap(), + slots, + ext: SquadExtInput::Fresh(extension), + owned, + }; + match project_squad(&input, ident, &NoEntities).unwrap() { + SquadProjection::Projected(v) => v, + other => panic!("expected Projected, got {other:?}"), + } +} + +/// Map FIFA-array index → (wire item id, kit number) for the occupied slots of a +/// projected or captured squad object. +fn occupied(v: &Value) -> HashMap { + v["players"] + .as_array() + .unwrap() + .iter() + .filter(|p| p["itemData"]["id"].as_i64().unwrap() != 0) + .map(|p| { + ( + p["index"].as_i64().unwrap(), + (p["itemData"]["id"].as_i64().unwrap(), p["kitNumber"].as_i64().unwrap()), + ) + }) + .collect() +} + +#[test] +fn baseline_projects_the_known_squad_round_trip() { + let (owned, ident) = oracle_tables(); + let put = parse_squad_put(PUT_BASELINE.as_bytes()).unwrap(); + let projected = project_put(&put, &owned, &ident); + + // Fixed 23-slot array; 11 occupied at 0..=10. + assert_eq!(projected["players"].as_array().unwrap().len(), 23); + let put_v: Value = serde_json::from_str(PUT_BASELINE).unwrap(); + assert_eq!(occupied(&projected), occupied(&put_v), "wire id + kit per index round-trip"); + + // CANONICAL: formation verbatim, captain follows the semantic player. + assert_eq!(projected["formation"], "f442"); + assert_eq!(projected["captain"], 100000001, "captain is the player's WIRE id"); + // EXTENSION: custom byte-identical, manager + squadType preserved. + assert_eq!(projected["custom"], put_v["custom"]); + assert_eq!(projected["manager"], put_v["manager"]); + assert_eq!(projected["squadType"], "REGULAR_SQUAD"); + // SHADOW: client-reported values carried as-is (baseline chemistry 52). + assert_eq!(projected["chemistry"], 52); + assert_eq!(projected["rating"], 90); + assert_eq!(projected["starRating"], 90); +} + +#[test] +fn swap_moves_two_players_with_their_kits_and_round_trips() { + // The swap PUT is baseline with two players rotated between slots. Projecting + // build(swap) must reproduce the swap wire exactly, and the affected players' + // kit numbers must have travelled with them (kit follows the player). + let (owned, ident) = oracle_tables(); + let projected = project_put(&parse_squad_put(PUT_SWAP.as_bytes()).unwrap(), &owned, &ident); + let swap_v: Value = serde_json::from_str(PUT_SWAP).unwrap(); + let base_v: Value = serde_json::from_str(PUT_BASELINE).unwrap(); + + // CANONICAL: the projected occupancy per index matches the swap PUT exactly. + assert_eq!(occupied(&projected), occupied(&swap_v), "player+kit per index round-trip"); + + // The swap is real: at least two indices carry a different player than baseline. + let (proj_occ, base_occ) = (occupied(&projected), occupied(&base_v)); + let moved: Vec = proj_occ + .iter() + .filter(|(idx, pair)| base_occ.get(idx).map(|b| b.0) != Some(pair.0)) + .map(|(idx, _)| *idx) + .collect(); + assert!(moved.len() >= 2, "a swap changes at least two slots, got {moved:?}"); + + // kit follows the PLAYER: for every player, its kit in baseline == its kit + // in the swap projection, regardless of which slot it now occupies. + let kit_by_player = |occ: &HashMap| -> HashMap { + occ.values().map(|(id, kit)| (*id, *kit)).collect() + }; + assert_eq!( + kit_by_player(&proj_occ), + kit_by_player(&base_occ), + "each player kept its kit number through the swap" + ); + + assert_eq!(projected["captain"], 100000001, "captain follows the semantic player"); + assert_eq!(projected["custom"], swap_v["custom"], "opaque custom unchanged by the swap"); + // SHADOW: the client-reported chemistry from THIS PUT (58) is round-tripped + // as-is — never reconciled to a server recompute. + assert_eq!(projected["chemistry"], 58); +} + +#[test] +fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() { + // Simulate Core's committed state for the persisted (post-relaunch) squad by + // reconstructing the canonical slots + FIFA extension straight from the read + // evidence, then project and require the read back — the strongest fidelity + // check across all four ownership classes. + use openfut_adapter_fifa17::fut::squad::ClientReportedSquadEval; + use openfut_adapter_fifa17::fut::squad_ext::{Fifa17SquadExtensionV1, KicktakerRef, WireItemRef}; + use std::collections::BTreeMap; + + let oracle: Value = serde_json::from_str(READ_ORACLE).unwrap(); + let (owned, ident) = oracle_tables(); + let captain = oracle["captain"].as_i64().unwrap(); + + let mut slots = Vec::new(); + let mut kit_numbers = BTreeMap::new(); + for p in oracle["players"].as_array().unwrap() { + let wire = p["itemData"]["id"].as_i64().unwrap(); + if wire == 0 { + continue; + } + let index = p["index"].as_i64().unwrap(); + let oc = format!("oc-{wire}"); + kit_numbers.insert(oc.clone(), p["kitNumber"].as_i64().unwrap()); + slots.push(ProjectionSlot { + owned_card_id: oc, + index, + is_captain: wire == captain, + is_on_bench: index >= 11, + }); + } + let manager: Vec = serde_json::from_value(oracle["manager"].clone()).unwrap(); + let kicktakers: Vec = serde_json::from_value(oracle["kicktakers"].clone()).unwrap(); + let ext = Fifa17SquadExtensionV1 { + custom: oracle["custom"].as_str().map(str::to_string), + squad_type: oracle["squadType"].as_str().map(str::to_string), + kit_numbers, + manager, + kicktakers, + client_reported: ClientReportedSquadEval { + chemistry: oracle["chemistry"].as_i64(), + rating: oracle["rating"].as_i64(), + star_rating: oracle["starRating"].as_i64(), + }, + }; + let input = SquadProjectionInput { + fifa_squad_id: oracle["id"].as_i64().unwrap(), + name: oracle["squadName"].as_str().unwrap().to_string(), + formation: oracle["formation"].as_str().unwrap().to_string(), + slots, + ext: SquadExtInput::Fresh(ext), + owned: &owned, + }; + let SquadProjection::Projected(projected) = project_squad(&input, &ident, &NoEntities).unwrap() + else { + panic!("expected Projected"); + }; + + // CANONICAL + DERIVED: identity and placement per slot match the read. + assert_eq!(occupied(&projected), occupied(&oracle), "player+kit per index"); + assert_eq!(projected["captain"], oracle["captain"]); + assert_eq!(projected["formation"], oracle["formation"]); + for (pp, op) in projected["players"].as_array().unwrap().iter().zip(oracle["players"].as_array().unwrap()) { + assert_eq!(pp["itemData"]["id"], op["itemData"]["id"], "wire id per slot"); + assert_eq!(pp["itemData"]["resourceId"], op["itemData"]["resourceId"], "asset id per slot"); + assert_eq!(pp["itemData"]["rating"], op["itemData"]["rating"]); + assert_eq!(pp["itemData"]["preferredPosition"], op["itemData"]["preferredPosition"]); + } + // EXTENSION + SHADOW: sourced from the read, so they round-trip identically. + assert_eq!(projected["custom"], oracle["custom"]); + assert_eq!(projected["manager"], oracle["manager"]); + assert_eq!(projected["kicktakers"], oracle["kicktakers"]); + assert_eq!(projected["squadType"], oracle["squadType"]); + assert_eq!(projected["chemistry"], oracle["chemistry"]); + assert_eq!(projected["rating"], oracle["rating"]); + assert_eq!(projected["starRating"], oracle["starRating"]); +} + +#[test] +fn formation_change_reindexes_without_deriving_slots_and_kit_follows_player() { + let (owned, ident) = oracle_tables(); + let swap = project_put(&parse_squad_put(PUT_SWAP.as_bytes()).unwrap(), &owned, &ident); + let f433 = project_put(&parse_squad_put(PUT_F433.as_bytes()).unwrap(), &owned, &ident); + + assert_eq!(f433["formation"], "f433"); + assert_eq!(swap["formation"], "f442"); + + // Same 11 starters (same set of wire ids), reindexed. + let set = |v: &Value| { + let mut ids: Vec = occupied(v).values().map(|(id, _)| *id).collect(); + ids.sort(); + ids + }; + assert_eq!(set(&swap), set(&f433), "same 11 players survive the formation change"); + + // The captain (wire 100000001) moved from index 8 (f442) to index 10 (f433) — + // proof indices are round-tripped, not derived from the formation. + let idx_of = |v: &Value, wire: i64| -> i64 { + occupied(v).into_iter().find(|(_, (id, _))| *id == wire).unwrap().0 + }; + assert_eq!(idx_of(&swap, 100000001), 8); + assert_eq!(idx_of(&f433, 100000001), 10); + + // kit follows the PLAYER, not the slot: captain keeps kit 8 across the reindex. + let kit_of = |v: &Value, wire: i64| -> i64 { + occupied(v).into_iter().find(|(_, (id, _))| *id == wire).unwrap().1 .1 + }; + assert_eq!(kit_of(&swap, 100000001), 8); + assert_eq!(kit_of(&f433, 100000001), 8, "kit stayed with the player despite the reindex"); + assert_eq!(f433["captain"], 100000001, "captain still the same player"); +} + +#[test] +fn one_projector_serves_every_endpoint_no_divergence() { + let (owned, ident) = oracle_tables(); + let projected = project_put(&parse_squad_put(PUT_SWAP.as_bytes()).unwrap(), &owned, &ident); + + // userMassInfo.squad = the projected object + session envelope. + let ummi = user_mass_info_squad(projected.clone(), 33068179); + assert_eq!(ummi["personaId"], 33068179); + assert_eq!(ummi["changed"], 0); + assert!(ummi["actives"].is_array()); + assert_eq!(ummi["players"], projected["players"], "same projected body"); + assert_eq!(ummi["formation"], projected["formation"]); + + // squad/list = a summary SUBSET of the SAME object, not a second projection. + let list = squad_list(&projected); + let entry = &list["squad"][0]; + for k in ["id", "squadName", "formation", "squadType", "rating", "chemistry"] { + assert_eq!(entry[k], projected[k], "summary field {k} derived from the one projection"); + } + // The summary carries only those six keys — no divergent squad shape. + assert_eq!(entry.as_object().unwrap().len(), 6); +}