diff --git a/openfut-adapter-fifa17/src/fut/club_response.rs b/openfut-adapter-fifa17/src/fut/club_response.rs index 634315b..ad8485a 100644 --- a/openfut-adapter-fifa17/src/fut/club_response.rs +++ b/openfut-adapter-fifa17/src/fut/club_response.rs @@ -11,7 +11,7 @@ use serde_json::{json, Value}; use crate::fut::content_taxonomy::ContentKind; use crate::fut::entities::ReverseEntityResolver; -use crate::fut::item::{shape_item, shape_kit_item, shape_staff_item}; +use crate::fut::item::{shape_item, shape_kit_item, shape_staff_item, STAFF_CONTRACT}; // Re-exported so existing `club_response::{…}` callers keep working; the types // are now defined once in `fut::item`. pub use crate::fut::item::{ @@ -19,14 +19,6 @@ pub use crate::fut::item::{ ShapeStats, }; -/// Contracts remaining on an owned staff card. -/// -/// Staff consume contracts exactly as players do (`rec+0x8c`), and the client -/// refuses to start a match when the manager's has run out. Core does not model -/// staff contracts, so this mirrors the constant `shape_item` already emits for -/// players rather than inventing a second, different default. -pub const STAFF_CONTRACT: i64 = 7; - /// Active club-level kit roles, keyed by Core owned-instance id. #[derive(Debug, Clone, Copy, Default)] pub struct ActiveKitAssignments<'a> { diff --git a/openfut-adapter-fifa17/src/fut/item.rs b/openfut-adapter-fifa17/src/fut/item.rs index 51228ef..b2cc222 100644 --- a/openfut-adapter-fifa17/src/fut/item.rs +++ b/openfut-adapter-fifa17/src/fut/item.rs @@ -223,6 +223,14 @@ pub fn shape_kit_item(id: Fifa17KitIdentity, item_state: &str) -> Value { }) } +/// Contracts remaining on an owned staff card. +/// +/// Staff consume contracts exactly as players do (`rec+0x8c`), and the client +/// refuses to start a match when the manager's has run out. Core does not model +/// staff contracts, so this mirrors the constant [`shape_item`] already emits +/// for players rather than inventing a second, different default. +pub const STAFF_CONTRACT: i64 = 7; + /// Build one FIFA 17 staff item (manager or coach). /// /// The key set is deliberately minimal and is taken field-by-field from the diff --git a/openfut-adapter-fifa17/src/fut/squad_projection.rs b/openfut-adapter-fifa17/src/fut/squad_projection.rs index c4b9a15..cf14933 100644 --- a/openfut-adapter-fifa17/src/fut/squad_projection.rs +++ b/openfut-adapter-fifa17/src/fut/squad_projection.rs @@ -36,7 +36,9 @@ 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::item::{ + shape_item, shape_staff_item, CoreOwnedItem, ItemIdentityResolver, STAFF_CONTRACT, +}; use crate::fut::squad::FIFA17_SQUAD_SLOTS; use crate::fut::squad_ext::Fifa17SquadExtensionV1; @@ -176,15 +178,33 @@ pub fn project_squad( } } - // Manager: the ownership-backed assignment, resolved to its FIFA wire ref. - // Emitted as the STATIC_REVERSED `[{id, dream}]` shape (the same shape the - // client sends on save). An owned manager with no resolvable FIFA identity - // is omitted (non-fatal, like /club dropping an unrenderable card) rather - // than emitted with a fabricated id. NOTE: a richer manager itemData - // (contract/leagueId/nation) is INFERRED-only from the offline-season - // diagnosis and deliberately NOT invented here pending wire reversal. - let manager = match input.manager.as_ref().and_then(|m| ident.resolve(m)) { - Some(id) => json!([{ "id": id.item_id, "dream": false }]), + // 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)) { + Some(id) => json!([{ + "id": id.item_id, + "itemData": shape_staff_item(id, STAFF_CONTRACT), + "dream": false, + }]), None => json!([]), }; let squad = json!({ @@ -235,14 +255,22 @@ pub fn squad_list(projected: &Value) -> Value { mod tests { use super::*; use crate::fut::entities::Fifa17Entities; - use crate::fut::item::Fifa17Identity; + use crate::fut::item::{Fifa17Identity, Fifa17StaffIdentity}; // A resolver that mints a distinct wire id per owned item and a fixed asset. - struct TableIdentity(HashMap); + // `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 { @@ -298,15 +326,18 @@ mod tests { 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, - }, - )])); + 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"); @@ -334,7 +365,7 @@ mod tests { 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()); + let ident = TableIdentity(HashMap::new(), HashMap::new()); assert_eq!( project_squad(&input, &ident, &ent()).unwrap(), SquadProjection::Stale @@ -345,7 +376,7 @@ mod tests { fn missing_is_explicit_never_fabricated() { let owned = HashMap::new(); let input = one_slot_input(&owned, SquadExtInput::Missing); - let ident = TableIdentity(HashMap::new()); + let ident = TableIdentity(HashMap::new(), HashMap::new()); assert_eq!( project_squad(&input, &ident, &ent()).unwrap(), SquadProjection::Missing @@ -356,7 +387,7 @@ mod tests { 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 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()), @@ -371,26 +402,29 @@ mod tests { 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, - }, - ), - ])); + 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); @@ -445,8 +479,8 @@ mod tests { 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([ - ( + let ident = TableIdentity( + HashMap::from([( "oc1".to_string(), Fifa17Identity { item_id: 100000042, @@ -454,26 +488,47 @@ mod tests { resource_id: 20801, rareflag: 1, }, - ), - ( + )]), + HashMap::from([( "oc-mgr".to_string(), - Fifa17Identity { + Fifa17StaffIdentity { item_id: 100000427, - asset_id: 5001, - resource_id: 5001, - rareflag: 1, + 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())); input.manager = Some(owned_item("oc-mgr", "fifa17_mgr")); 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, "dream": false }]), - "manager is the ownership-backed wire ref, resolved from the owned item" + json!([{ + "id": 100000427, + "itemData": { + "id": 100000427, + "resourceId": 1_000_509, + "cardsubtypeid": 4, + "itemType": "staff", + "nation": 45, + "leagueId": 53, + "teamid": 241, + "contract": STAFF_CONTRACT, + "itemState": "free", + "owners": 1, + "untradeable": false, + }, + "dream": false, + }]), + "manager is the ownership-backed wire ref WITH its item" ); } @@ -481,15 +536,18 @@ mod tests { 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, - }, - )])); + 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 { diff --git a/openfut-adapter-fifa17/tests/squad_projection.rs b/openfut-adapter-fifa17/tests/squad_projection.rs index 3a2e680..03f6384 100644 --- a/openfut-adapter-fifa17/tests/squad_projection.rs +++ b/openfut-adapter-fifa17/tests/squad_projection.rs @@ -24,7 +24,9 @@ use std::collections::HashMap; -use openfut_adapter_fifa17::fut::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver}; +use openfut_adapter_fifa17::fut::item::{ + CoreOwnedItem, Fifa17Identity, 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::{ @@ -50,11 +52,19 @@ impl SquadWireResolver for OcResolver { } /// owned_card_id → FIFA identity, so two copies of one definition stay distinct. -struct TableIdentity(HashMap); +/// `staff` is a second table because a manager resolves through the STAFF +/// identity, which carries the chemistry fields a player identity cannot hold. +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() + } } /// Neutral entity resolver — badge/flag ids are covered by `fut::item` tests; the @@ -116,7 +126,7 @@ fn oracle_tables() -> (HashMap, TableIdentity) { }, ); } - (owned, TableIdentity(ident)) + (owned, TableIdentity(ident, HashMap::new())) } /// The full pipeline: parse a captured PUT, build the canonical + extension, then @@ -298,17 +308,22 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() { is_on_bench: index >= 11, }); } - // The manager is now ownership-backed: register its owned instance + identity - // and pass it as the assignment, not as an opaque extension field. + // The manager is ownership-backed: register its owned instance + STAFF + // identity and pass it as the assignment, not as an opaque extension field. + // A real managercards row is used (1000509 Luis Enrique, nation 45, LaLiga + // 53, Barcelona 241) so the projected item is a shape the client could + // actually merge. let mgr_wire = oracle["manager"][0]["id"].as_i64().unwrap(); let mgr_oc = format!("oc-{mgr_wire}"); - ident.0.insert( + ident.1.insert( mgr_oc.clone(), - Fifa17Identity { + Fifa17StaffIdentity { item_id: mgr_wire as u32, - asset_id: 5001, - resource_id: 5001, - rareflag: 1, + resource_id: 1_000_509, + subtype: 4, + nation: 45, + league_id: 53, + team_id: 241, }, ); let manager_item = CoreOwnedItem { @@ -378,7 +393,23 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() { } // EXTENSION + SHADOW: sourced from the read, so they round-trip identically. assert_eq!(projected["custom"], oracle["custom"]); - assert_eq!(projected["manager"], oracle["manager"]); + // The manager REF round-trips; the item now rides with it. The capture this + // oracle came from carried a bare `{id, dream}`, but its manager was the + // dangling one every retail capture has, so it never showed that a populated + // ref renders on its own — and in practice it did not. + assert_eq!( + projected["manager"][0]["id"], oracle["manager"][0]["id"], + "the manager wire ref itself must still round-trip" + ); + assert_eq!( + projected["manager"][0]["dream"], + oracle["manager"][0]["dream"] + ); + let mgr_item = &projected["manager"][0]["itemData"]; + assert_eq!(mgr_item["id"], oracle["manager"][0]["id"]); + assert_eq!(mgr_item["cardsubtypeid"], 4); + assert_eq!(mgr_item["resourceId"], 1_000_509); + assert_eq!(mgr_item["contract"], STAFF_CONTRACT); assert_eq!(projected["kicktakers"], oracle["kicktakers"]); assert_eq!(projected["squadType"], oracle["squadType"]); assert_eq!(projected["chemistry"], oracle["chemistry"]);