diff --git a/openfut-adapter-fifa17/src/fut/squad_projection.rs b/openfut-adapter-fifa17/src/fut/squad_projection.rs index 0bf7473..3ef3273 100644 --- a/openfut-adapter-fifa17/src/fut/squad_projection.rs +++ b/openfut-adapter-fifa17/src/fut/squad_projection.rs @@ -35,11 +35,14 @@ use std::collections::HashMap; use serde_json::{json, Value}; +use crate::fut::club_response::ActiveKitAssignments; 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, + shape_club_item, shape_item, shape_staff_item, CoreOwnedItem, ItemIdentityResolver, + STAFF_CONTRACT, }; +use crate::fut::item_state; use crate::fut::squad::FIFA17_SQUAD_SLOTS; use crate::fut::squad_ext::Fifa17SquadExtensionV1; @@ -235,15 +238,75 @@ pub fn project_squad( Ok(SquadProjection::Projected(squad)) } +/// The active club items for `squad.actives`, in slot order. +/// +/// ## Why this exists, and why it is NOT `[]` +/// +/// `actives` is the ONLY carrier that makes a club item resident in FIFA 17. +/// The squad parser's arm for atom 11 (`actives`) computes the address of the +/// i-th element of the client's five-element club-item array and hands it to the +/// item deserializer as the out-handle: +/// +/// ```text +/// cmp edi,0x5 ; at most five entries are read +/// jge +/// mov rax,QWORD PTR [r13+0x108] ; the club-item array +/// lea rcx,[rax+rcx*8] ; &array[edi] (edi * 24) +/// call 0x18013fe00 ; the item deserializer, writing that slot +/// ``` +/// +/// That deserializer inserts the record into the client's resident item map +/// (keyed by wire instance id, and its only gate is a non-zero id) and binds the +/// slot handle to it. So each element must be a FULL item object, exactly like +/// `squad.manager[].itemData` — an id reference alone installs nothing, because +/// the manager installer looks its id up in that same map and does nothing when +/// it misses. +/// +/// An empty array makes the client read the array-end token immediately and +/// parse nothing, which leaves all five slots null. Every later consumer then +/// resolves to the client's static not-found sentinel, whose item pointer is +/// NULL — which is exactly why the pre-match kit selector had no kits. +/// +/// Elements are shaped by the shared [`shape_club_item`], the same primitive +/// `/club?type=kit` uses, so the two routes cannot drift. Ordering is positional +/// on the wire but not semantic: both client consumers (the activate path and +/// the store lookup) search the five slots by content — itemState, or +/// cardtype/cardsubtypeid — never by index. +/// +/// A designated kit whose owned row or FIFA kit identity cannot be resolved is +/// omitted rather than emitted with a fabricated id, matching `/club`. +pub fn squad_actives( + owned: &HashMap, + ident: &I, + active_kits: ActiveKitAssignments<'_>, +) -> Value { + let mut out = Vec::new(); + for (owned_card_id, state) in [ + (active_kits.home, item_state::ACTIVE_HOME_KIT), + (active_kits.away, item_state::ACTIVE_AWAY_KIT), + ] { + let Some(owned_card_id) = owned_card_id else { + continue; + }; + let Some(item) = owned.get(owned_card_id) else { + continue; + }; + if let Some(id) = ident.resolve_kit(item) { + out.push(shape_club_item(id, state)); + } + } + Value::Array(out) +} + /// 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 { +/// the session-envelope fields the projector does not own: `personaId`, the +/// observed constant `changed: 0`, and `actives` from [`squad_actives`]. +pub fn user_mass_info_squad(projected: Value, persona_id: i64, actives: Value) -> 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!([])); + map.insert("actives".into(), actives); } obj } diff --git a/openfut-adapter-fifa17/tests/squad_projection.rs b/openfut-adapter-fifa17/tests/squad_projection.rs index 9c6c39f..b8d1e3f 100644 --- a/openfut-adapter-fifa17/tests/squad_projection.rs +++ b/openfut-adapter-fifa17/tests/squad_projection.rs @@ -24,16 +24,18 @@ use std::collections::HashMap; +use openfut_adapter_fifa17::fut::club_response::ActiveKitAssignments; use openfut_adapter_fifa17::fut::item::{ - CoreOwnedItem, Fifa17Identity, Fifa17StaffIdentity, ItemIdentityResolver, STAFF_CONTRACT, + CoreOwnedItem, Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver, + STAFF_CONTRACT, }; 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, + project_squad, squad_actives, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput, SquadProjection, SquadProjectionInput, }; -use serde_json::Value; +use serde_json::{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"); @@ -503,11 +505,17 @@ fn one_projector_serves_every_endpoint_no_divergence() { &ident, ); - // userMassInfo.squad = the projected object + session envelope. - let ummi = user_mass_info_squad(projected.clone(), 33068179); + // userMassInfo.squad = the projected object + session envelope. `actives` is + // supplied by the caller now, so the envelope must carry it through verbatim + // rather than hardcoding an empty array. + let actives = json!([{ "id": 100004874, "itemState": "activeHomeKit" }]); + let ummi = user_mass_info_squad(projected.clone(), 33068179, actives.clone()); assert_eq!(ummi["personaId"], 33068179); assert_eq!(ummi["changed"], 0); - assert!(ummi["actives"].is_array()); + assert_eq!( + ummi["actives"], actives, + "the envelope must pass actives through, not replace it" + ); assert_eq!(ummi["players"], projected["players"], "same projected body"); assert_eq!(ummi["formation"], projected["formation"]); @@ -530,3 +538,153 @@ fn one_projector_serves_every_endpoint_no_divergence() { // The summary carries only those six keys — no divergent squad shape. assert_eq!(entry.as_object().unwrap().len(), 6); } + +// ---- squad.actives: the only carrier that makes a club item resident -------- + +/// A resolver that can answer `resolve_kit`, which the default trait method +/// cannot (it returns `None` for player-only resolvers). +struct KitIdentity(HashMap); +impl ItemIdentityResolver for KitIdentity { + fn resolve(&self, _it: &CoreOwnedItem) -> Option { + None + } + fn resolve_kit(&self, it: &CoreOwnedItem) -> Option { + self.0.get(&it.owned_card_id).copied() + } +} + +fn kit_owned(owned_card_id: &str) -> CoreOwnedItem { + CoreOwnedItem { + owned_card_id: owned_card_id.to_string(), + card_id: format!("def-{owned_card_id}"), + rating: 0, + position: String::new(), + nation: String::new(), + league: String::new(), + club: String::new(), + attributes: [0; 6], + contract_matches: None, + source_rating: None, + core_content_kind: Some("kit".to_string()), + } +} + +fn home_away_fixture() -> (HashMap, KitIdentity) { + let owned = HashMap::from([ + ("oc-home".to_string(), kit_owned("oc-home")), + ("oc-away".to_string(), kit_owned("oc-away")), + ]); + // Real `fcc_kitcards` rows for team 21: 6300006 is the home card (category 2, + // assetid 14) and 6400003 the away card (category 3, assetid 15). + let ident = KitIdentity(HashMap::from([ + ( + "oc-home".to_string(), + Fifa17KitIdentity { + item_id: 100004874, + asset_id: 14, + resource_id: 6300006, + card_asset_id: 35, + subtype: 9, + team_id: 21, + category: 2, + year: 0, + }, + ), + ( + "oc-away".to_string(), + Fifa17KitIdentity { + item_id: 100004873, + asset_id: 15, + resource_id: 6400003, + card_asset_id: 35, + subtype: 9, + team_id: 21, + category: 3, + year: 0, + }, + ), + ])); + (owned, ident) +} + +/// The client's squad parser reads at most five `actives` entries and parses each +/// one straight into a slot of its five-element club-item array, so each element +/// must be a full item object carrying a non-zero `id` — an id reference alone +/// installs nothing. +#[test] +fn squad_actives_emits_full_items_for_the_designated_kits() { + let (owned, ident) = home_away_fixture(); + let actives = squad_actives( + &owned, + &ident, + ActiveKitAssignments { + home: Some("oc-home"), + away: Some("oc-away"), + }, + ); + let arr = actives.as_array().expect("actives is an array"); + assert_eq!(arr.len(), 2, "one entry per designated kit"); + + assert_eq!(arr[0]["id"], 100004874); + assert_eq!(arr[0]["itemState"], "activeHomeKit"); + assert_eq!(arr[0]["resourceId"], 6300006); + assert_eq!(arr[1]["id"], 100004873); + assert_eq!(arr[1]["itemState"], "activeAwayKit"); + assert_eq!(arr[1]["resourceId"], 6400003); + + for entry in arr { + assert_eq!(entry["itemType"], "kit"); + assert_eq!( + entry["cardsubtypeid"], 9, + "cardsubtypeid 9 derives cardtype 7" + ); + assert_eq!(entry["teamid"], 21, "the clone path keys kit art on teamid"); + assert_ne!(entry["id"], 0, "a zero id is never made resident"); + } +} + +/// Undesignated slots contribute nothing, and an unresolvable designation is +/// omitted rather than emitted with a fabricated id — the same policy `/club` +/// applies when a card has no FIFA identity. +#[test] +fn squad_actives_omits_absent_and_unresolvable_designations() { + let (owned, ident) = home_away_fixture(); + + let home_only = squad_actives( + &owned, + &ident, + ActiveKitAssignments { + home: Some("oc-home"), + away: None, + }, + ); + assert_eq!(home_only.as_array().unwrap().len(), 1); + assert_eq!(home_only[0]["itemState"], "activeHomeKit"); + + // Designated but not present in the owned collection. + let dangling = squad_actives( + &owned, + &ident, + ActiveKitAssignments { + home: Some("oc-missing"), + away: None, + }, + ); + assert_eq!(dangling.as_array().unwrap().len(), 0); + + // Present and designated, but with no resolvable FIFA kit identity. + let unresolvable = squad_actives( + &HashMap::from([("oc-x".to_string(), kit_owned("oc-x"))]), + &ident, + ActiveKitAssignments { + home: Some("oc-x"), + away: None, + }, + ); + assert_eq!(unresolvable.as_array().unwrap().len(), 0); + + // Nothing designated at all is an empty array, which is what left every + // club-item slot null before this projector existed. + let none = squad_actives(&owned, &ident, ActiveKitAssignments::default()); + assert_eq!(none.as_array().unwrap().len(), 0); +} diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 436e0ea..0762c48 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -81,7 +81,7 @@ use openfut_adapter_fifa17::fut::squad_ext::{ build_squad_write, Fifa17SquadExtensionV1, SquadBuildError, EXT_NAMESPACE, EXT_SCHEMA_VERSION, }; use openfut_adapter_fifa17::fut::squad_projection::{ - project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput, + project_squad, squad_actives, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput, SquadProjection, SquadProjectionInput, }; use openfut_adapter_fifa17::fut::store_catalog::{ @@ -2663,8 +2663,10 @@ pub struct SquadLog { /// The active squad projected from Core, with the freshness policy applied. enum HostProjection { - /// Fresh: the projected FIFA squad object (before any endpoint envelope). - Squad(Value), + /// Fresh: the projected FIFA squad object (before any endpoint envelope), + /// plus the active club items for its `actives` array. They travel together + /// because both are derived from the SAME bounded Core fetch. + Squad { squad: Value, actives: Value }, /// Stored extension is stale vs the canonical squad — NEVER applied. Stale, /// No extension stored — nothing fabricated. @@ -2747,8 +2749,28 @@ fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection { owned: &owned_by_id, manager, }; + // The active club designations Core already owns (`/club/active-items`), shaped + // into the `actives` array the client needs to make a club item resident. A + // transport error here is non-fatal and yields no actives: the squad still + // renders, exactly as it did before this array was populated. It is reported, + // because silently empty actives is precisely the failure that left the + // pre-match kit selector blank. + let actives = match deps.core.get_active_kits() { + Ok(assignments) => squad_actives( + &owned_by_id, + deps.resolver, + ActiveKitAssignments { + home: assignments.home_owned_card_id.as_deref(), + away: assignments.away_owned_card_id.as_deref(), + }, + ), + Err(error) => { + eprintln!("utas-host WARN active club items unavailable, squad.actives empty: {error}"); + json!([]) + } + }; match project_squad(&input, deps.resolver, deps.entities) { - Ok(SquadProjection::Projected(v)) => HostProjection::Squad(v), + Ok(SquadProjection::Projected(squad)) => HostProjection::Squad { squad, actives }, Ok(SquadProjection::Stale) => HostProjection::Stale, Ok(SquadProjection::Missing) => HostProjection::Missing, Err(e) => HostProjection::Error(e.to_string()), @@ -2917,8 +2939,8 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ /// to an empty list, NEVER served from Python and NEVER projected from stale ext. pub fn handle_squad_list(deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) { match project_active_squad(deps) { - HostProjection::Squad(v) => ( - json_response(&squad_list(&v)), + HostProjection::Squad { squad, .. } => ( + json_response(&squad_list(&squad)), SquadLog { outcome: "ok", detail: String::new(), @@ -2955,8 +2977,8 @@ pub fn handle_squad_list(deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) { /// (never 401/403, never a Python fallback that could mask split authority). pub fn handle_squad_active(deps: &SquadDeps<'_>, persona_id: i64) -> (WireResponse, SquadLog) { match project_active_squad(deps) { - HostProjection::Squad(v) => ( - json_response(&user_mass_info_squad(v, persona_id)), + HostProjection::Squad { squad, actives } => ( + json_response(&user_mass_info_squad(squad, persona_id, actives)), SquadLog { outcome: "ok", detail: String::new(), @@ -3081,8 +3103,8 @@ pub fn handle_user_mass_info( }) .unwrap_or(0); let (squad_val, log) = match project_active_squad(deps) { - HostProjection::Squad(v) => ( - user_mass_info_squad(v, persona), + HostProjection::Squad { squad, actives } => ( + user_mass_info_squad(squad, persona, actives), SquadLog { outcome: "ok", detail: String::new(), @@ -4556,7 +4578,9 @@ impl Server { }; let deps = self.squad_deps(); let (squad, squad_outcome) = match project_active_squad(&deps) { - HostProjection::Squad(v) => (user_mass_info_squad(v, self.persona_id), "ok"), + HostProjection::Squad { squad, actives } => { + (user_mass_info_squad(squad, self.persona_id, actives), "ok") + } HostProjection::Stale => (empty_squad_overlay(self.persona_id), "stale_integrity"), HostProjection::Missing => (empty_squad_overlay(self.persona_id), "missing_integrity"), HostProjection::Error(_) => (empty_squad_overlay(self.persona_id), "core_error"),