//! 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::contract_cards::PACK_FRESH_CONTRACT_MATCHES; use crate::fut::entities::ReverseEntityResolver; use crate::fut::item::{ shape_item, shape_staff_item, CoreOwnedItem, ItemIdentityResolver, STAFF_CONTRACT, }; 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, /// The owned instance assigned as this squad's **manager** (Core's /// ownership-backed `squad_managers` assignment, migration 0023), or `None`. /// Projected as the FIFA `manager` wire ref resolved from ownership — never a /// dangling wire id, and never fabricated when absent. pub manager: Option, } /// 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, ident.discard_value(item), item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES), ), "kitNumber": kit, })); } None => players.push(json!({ "index": index, "itemData": { "id": 0, "dream": false }, "kitNumber": 0, })), } } // Manager: the ownership-backed assignment, resolved to its FIFA wire ref // AND carrying its item, as `[{id, itemData, dream}]`. // // The bare `[{id, dream}]` form is NOT sufficient, which cost a real // debugging round: the operator picked a manager in the hub, the save // persisted (Core `squad_managers` row written, `outcome=ok`, no unresolved // ref), and the pre-match squad still showed no manager. Every retail // capture that shows the bare form has `id: 0` — an EMPTY manager — so none // of them ever demonstrated that a POPULATED ref resolves without its item. // // The squad response is self-contained for players: `players[].itemData` // carries the whole card rather than an id the client resolves out of band. // The manager is the same kind of slot in the same object, and the one // implementation that ever drove a working manager (the Python oracle's // squad) emits `id` BESIDE `itemData` exactly like this. Note the element // shape differs from a player slot: `{index, itemData, kitNumber}` there, // `{id, itemData, dream}` here. // // An owned manager with no resolvable FIFA staff identity is omitted // (non-fatal, like /club dropping an unrenderable card) rather than emitted // with a fabricated id. let manager = match input .manager .as_ref() .and_then(|m| ident.resolve_staff(m).map(|id| (m, id))) { Some((mgr, id)) => json!([{ "id": id.item_id, "itemData": shape_staff_item(id, mgr.contract_matches.unwrap_or(STAFF_CONTRACT)), "dream": false, }]), None => json!([]), }; 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": 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, Fifa17StaffIdentity}; // A resolver that mints a distinct wire id per owned item and a fixed asset. // `staff` is separate because a manager resolves through the STAFF identity, // which carries the chemistry fields a player identity has no room for. struct TableIdentity( HashMap, HashMap, ); impl ItemIdentityResolver for TableIdentity { fn resolve(&self, it: &CoreOwnedItem) -> Option { self.0.get(&it.owned_card_id).copied() } fn resolve_staff(&self, it: &CoreOwnedItem) -> Option { self.1.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], contract_matches: None, source_rating: None, core_content_kind: None, } } 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, manager: None, } } 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, 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, resource_id: 20801, rareflag: 1, }, )]), HashMap::new(), ); 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(), 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(), 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(), 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, resource_id: 101490, rareflag: 1, }, ), ( "oc-b".to_string(), Fifa17Identity { item_id: 100000031, asset_id: 101490, resource_id: 101490, rareflag: 1, }, ), ]), HashMap::new(), ); 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, manager: None, }; 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" ); } #[test] fn manager_projected_from_ownership_as_wire_ref() { 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, resource_id: 20801, rareflag: 1, }, )]), HashMap::from([( "oc-mgr".to_string(), Fifa17StaffIdentity { item_id: 100000427, resource_id: 1_000_509, subtype: 4, nation: 45, league_id: 53, team_id: 241, }, )]), ); let mut input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext())); // A manager mid-way through its contracts: the projection must report // Core's persisted count, not the pack-fresh constant, or the pre-match // screen contradicts the club screen. let mut manager = owned_item("oc-mgr", "fifa17_mgr"); manager.contract_matches = Some(12); input.manager = Some(manager); let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else { panic!("expected Projected"); }; // The item must ride ALONG with the ref: a bare `{id, dream}` left the // pre-match squad with no manager even though the assignment had been // saved, because nothing in the response described the card. assert_eq!( v["manager"], json!([{ "id": 100000427, "itemData": { "id": 100000427, "resourceId": 1_000_509, "cardsubtypeid": 4, "itemType": "staff", "nation": 45, "leagueId": 53, "teamid": 241, "contract": 12, "itemState": "free", "owners": 1, "untradeable": false, }, "dream": false, }]), "manager is the ownership-backed wire ref WITH its item" ); } #[test] fn absent_manager_projects_empty_array_never_fabricated() { 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, resource_id: 20801, rareflag: 1, }, )]), HashMap::new(), ); // one_slot_input leaves manager: None. 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["manager"], json!([]), "no manager assignment => empty array, nothing fabricated" ); } }