From d37a9d5b5ed8259dc1e363765908884b2c70f8f0 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 20 Aug 2026 16:43:39 +0000 Subject: [PATCH] feat(fifa17): ownership-backed squad manager, not opaque round-trip Move the squad manager from an opaque, unvalidated wire ref in the squad extension to a resolved, ownership-backed assignment (Core migration 0023 squad_managers). - squad::to_proposed reverse-resolves the manager wire ref to a Core owned_card_id on ProposedSquad; an unresolvable manager is reported as an unresolved wire id (a live save is refused rather than assigning a manager the club does not own). - squad_ext: drop the opaque manager field from Fifa17SquadExtensionV1 (clean cutover) and the now-unused SquadEntityRef->WireItemRef From. - squad_projection: project the manager as the STATIC_REVERSED [{id,dream}] wire ref resolved from the owned assignment; absent -> [] (never faked). Richer manager itemData (contract/league/nation) is INFERRED-only and left out pending wire reversal. - import(apply): drop a historical dangling manager ref rather than failing the whole import (live PUTs still refuse an unresolved manager). --- openfut-adapter-fifa17/src/fut/squad.rs | 38 +++++++-- openfut-adapter-fifa17/src/fut/squad_ext.rs | 51 ++++++------ .../src/fut/squad_projection.rs | 82 ++++++++++++++++++- openfut-import-fifa17/src/apply.rs | 10 ++- 4 files changed, 147 insertions(+), 34 deletions(-) diff --git a/openfut-adapter-fifa17/src/fut/squad.rs b/openfut-adapter-fifa17/src/fut/squad.rs index 8d16b7f..350db85 100644 --- a/openfut-adapter-fifa17/src/fut/squad.rs +++ b/openfut-adapter-fifa17/src/fut/squad.rs @@ -141,6 +141,13 @@ pub struct ProposedSquad { /// never used to derive slot layout. pub formation: Option, pub slots: Vec, + /// The owned instance assigned as the squad's **manager**, reverse-resolved + /// from the wire `manager` ref to a Core `owned_card_id` (so the assignment + /// is ownership-backed, never a dangling wire id). `None` when the save + /// carries no manager. A manager wire id the resolver cannot map is reported + /// in `unresolved_wire_ids` — a save is refused rather than assigning a + /// manager the club does not own. + pub manager_owned_card_id: Option, /// 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. @@ -180,12 +187,14 @@ pub fn parse_squad_put(body: &[u8]) -> Result { /// Resolve a parsed save into a **canonical** [`ProposedSquad`]: drop empty /// (`id == 0`) slots, reverse-map each occupied slot's wire id to a Core -/// `owned_card_id`, flag the captain, and derive the bench split from the fixed -/// 23-slot array. FIFA-only state (`custom`, manager, kicktakers, kit numbers, -/// squadType) and client-reported evaluation are NOT canonical — they are built -/// separately into [`crate::fut::squad_ext::Fifa17SquadExtensionV1`]. The -/// formation token is carried verbatim (never mapped). Unresolvable occupied ids -/// are reported, never guessed or dropped. +/// `owned_card_id`, flag the captain, derive the bench split from the fixed +/// 23-slot array, and reverse-resolve the manager ref to an owned instance +/// (the manager assignment is ownership-backed canonical state, migration 0023). +/// The remaining FIFA-only state (`custom`, kicktakers, kit numbers, squadType) +/// and client-reported evaluation are NOT canonical — they are built separately +/// into [`crate::fut::squad_ext::Fifa17SquadExtensionV1`]. The formation token is +/// carried verbatim (never mapped). 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(); @@ -205,11 +214,22 @@ pub fn to_proposed(put: &Fifa17SquadPut, resolver: &dyn SquadWireResolver) -> Pr None => unresolved.push(p.item_data.id), } } + // Manager: the first non-zero manager ref, reverse-resolved to an owned + // instance. An unresolvable manager is an unresolved wire id (refused), not a + // silently dropped assignment — you cannot manage with a card you don't own. + let mut manager_owned_card_id = None; + if let Some(wire) = put.manager.iter().map(|m| m.id).find(|&id| id != 0) { + match resolver.owned_id_for_wire(wire) { + Some(owned) => manager_owned_card_id = Some(owned), + None => unresolved.push(wire), + } + } ProposedSquad { squad_id: put.id, name: put.squad_name.clone(), formation: put.formation.clone(), slots, + manager_owned_card_id, unresolved_wire_ids: unresolved, } } @@ -237,9 +257,11 @@ mod tests { } fn full_resolver() -> MapResolver { // indices 0..=10 (11 starters); the rest of the 23 slots are id==0 (empty). + // 100000427 is the fixture's manager ref — the host resolves it like any + // other owned instance, so the manager assignment is ownership-backed. let ids = [ 100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009, - 100000001, 100000002, 100000025, + 100000001, 100000002, 100000025, 100000427, ]; MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect()) } @@ -283,6 +305,8 @@ mod tests { assert_eq!(caps[0].owned_card_id, "oc-100000001"); assert_eq!(caps[0].index, 8); assert_eq!(caps[0].kit_number, 8); + // The manager ref is reverse-resolved to an owned instance (canonical). + assert_eq!(sq.manager_owned_card_id.as_deref(), Some("oc-100000427")); } #[test] diff --git a/openfut-adapter-fifa17/src/fut/squad_ext.rs b/openfut-adapter-fifa17/src/fut/squad_ext.rs index c034c3f..2224806 100644 --- a/openfut-adapter-fifa17/src/fut/squad_ext.rs +++ b/openfut-adapter-fifa17/src/fut/squad_ext.rs @@ -12,7 +12,7 @@ //! | `custom` | opaque 33-int string; meaning UNKNOWN, round-tripped verbatim | //! | `squad_type` | an observed FIFA wire token; no matching generic Core concept | //! | `kit_numbers` | keyed by **`owned_card_id`** — evidence: kit follows the player | -//! | `manager` | a FIFA manager item ref; not a squad player, semantics opaque | +//! | ~~manager~~ | MOVED to ownership-backed canonical Core state (migration 0023 `squad_managers`); resolved to an `owned_card_id`, no longer opaque here | //! | `kicktakers` | role→item refs; relationship to captain UNKNOWN, kept opaque | //! | `client_reported` | chemistry/rating/starRating — client shadow, NOT authority | //! @@ -30,7 +30,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -use crate::fut::squad::{ClientReportedSquadEval, Fifa17SquadPut, ProposedSquad, SquadEntityRef}; +use crate::fut::squad::{ClientReportedSquadEval, Fifa17SquadPut, ProposedSquad}; /// Opaque scope key Core files this extension under (`game_entity_ext.namespace`). pub const EXT_NAMESPACE: &str = "fifa17.squad"; @@ -49,15 +49,6 @@ pub struct WireItemRef { pub dream: bool, } -impl From<&SquadEntityRef> for WireItemRef { - fn from(r: &SquadEntityRef) -> Self { - WireItemRef { - id: r.id, - dream: r.dream, - } - } -} - /// A kicktaker slot preserved verbatim. `index` is the role slot (0..=4 observed); /// `item` is the referenced FIFA wire item. The role→player meaning and any /// relationship to the captain are UNKNOWN, so this is stored opaquely and never @@ -83,9 +74,9 @@ pub struct Fifa17SquadExtensionV1 { /// proves the kit number follows the player across swaps and formation change. #[serde(default)] pub kit_numbers: BTreeMap, - /// Manager item ref(s), opaque. Not a squad player; not shaped as an item. - #[serde(default)] - pub manager: Vec, + // NOTE: the squad manager is NO LONGER carried here. It is ownership-backed + // canonical Core state (migration 0023 `squad_managers`), resolved to an + // `owned_card_id` on the ProposedSquad — never a dangling opaque wire ref. /// Kicktaker role refs, opaque (see [`KicktakerRef`]). #[serde(default)] pub kicktakers: Vec, @@ -132,7 +123,6 @@ impl Fifa17SquadExtensionV1 { custom: put.custom.clone(), squad_type: put.squad_type.clone(), kit_numbers, - manager: put.manager.iter().map(WireItemRef::from).collect(), kicktakers: put .kicktakers .iter() @@ -256,6 +246,9 @@ mod tests { let ids = [ 100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009, 100000001, 100000002, 100000025, + // The f442 fixture's manager ref — the host resolves it like any other + // owned instance, so the ownership-backed manager assignment is present. + 100000427, ]; MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect()) } @@ -308,22 +301,32 @@ mod tests { } #[test] - fn manager_and_kicktakers_preserved_opaquely() { - let ext = built().extension; + fn manager_is_canonical_and_kicktakers_stay_opaque() { + let build = built(); + // Manager is now ownership-backed canonical state: the wire ref resolved + // to a Core owned_card_id on the ProposedSquad, not an opaque ext blob. assert_eq!( - ext.manager, - vec![WireItemRef { - id: 100000427, - dream: false - }] + build.canonical.manager_owned_card_id.as_deref(), + Some("oc-100000427") ); + // Kicktakers remain opaque in the extension. + let ext = build.extension; assert_eq!(ext.kicktakers.len(), 5); - // All five reference the same wire id in this capture; carried verbatim, - // NEVER normalized to the captain even though they coincide here. assert!(ext.kicktakers.iter().all(|k| k.item.id == 100000001)); assert_eq!(ext.kicktakers[0].index, 0); } + #[test] + fn build_refuses_an_unowned_manager() { + // Manager wire id present but NOT resolvable → refused, never assigned a + // manager the club does not own. + let put = parse_squad_put(PUT_F442.as_bytes()).unwrap(); + let mut ids = full_resolver().0; + ids.remove(&100000427); + let err = build_squad_write(&put, &MapResolver(ids)).unwrap_err(); + assert_eq!(err, SquadBuildError::UnresolvedWireIds(vec![100000427])); + } + #[test] fn unknown_schema_version_is_rejected_not_coerced() { let payload = built().extension.to_payload(); diff --git a/openfut-adapter-fifa17/src/fut/squad_projection.rs b/openfut-adapter-fifa17/src/fut/squad_projection.rs index f45da7a..c4b9a15 100644 --- a/openfut-adapter-fifa17/src/fut/squad_projection.rs +++ b/openfut-adapter-fifa17/src/fut/squad_projection.rs @@ -77,6 +77,11 @@ pub struct SquadProjectionInput<'a> { /// 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. @@ -171,6 +176,17 @@ 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 }]), + None => json!([]), + }; let squad = json!({ "id": input.fifa_squad_id, "squadName": input.name, @@ -180,7 +196,7 @@ pub fn project_squad( "starRating": ext.client_reported.star_rating, "rating": ext.client_reported.rating, "captain": captain_wire, - "manager": ext.manager, + "manager": manager, "custom": ext.custom, "players": players, "kicktakers": ext.kicktakers, @@ -262,6 +278,7 @@ mod tests { }], ext, owned, + manager: None, } } @@ -272,7 +289,6 @@ mod tests { custom: Some("[1,2,3]".into()), squad_type: Some("REGULAR_SQUAD".into()), kit_numbers: kit, - manager: vec![], kicktakers: vec![], client_reported: Default::default(), } @@ -403,6 +419,7 @@ mod tests { ], ext: SquadExtInput::Fresh(ext), owned: owned_ref, + manager: None, }; let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else { panic!(); @@ -423,4 +440,65 @@ mod tests { "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, + }, + ), + ( + "oc-mgr".to_string(), + Fifa17Identity { + item_id: 100000427, + asset_id: 5001, + resource_id: 5001, + rareflag: 1, + }, + ), + ])); + 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"); + }; + assert_eq!( + v["manager"], + json!([{ "id": 100000427, "dream": false }]), + "manager is the ownership-backed wire ref, resolved from the owned 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, + }, + )])); + // 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" + ); + } } diff --git a/openfut-import-fifa17/src/apply.rs b/openfut-import-fifa17/src/apply.rs index 5d1f1f3..ce224fb 100644 --- a/openfut-import-fifa17/src/apply.rs +++ b/openfut-import-fifa17/src/apply.rs @@ -208,7 +208,15 @@ pub fn plan_apply( .and_then(|s| s.get(0)) .context("report says a squad is present but profile has no squads[0]")?; let body = serde_json::to_vec(raw_squad).context("re-serialize source squad")?; - let put = parse_squad_put(&body).map_err(|e| anyhow::anyhow!("parse source squad: {e}"))?; + let mut put = + parse_squad_put(&body).map_err(|e| anyhow::anyhow!("parse source squad: {e}"))?; + // A HISTORICAL profile may reference a manager whose owned instance is + // not imported (unsupported/deferred, or a dangling id with no owned + // item at all). Drop such a manager ref here rather than failing the + // whole import — the manager assignment is only imported when its owned + // instance is. (A LIVE squad PUT still refuses an unresolved manager, + // because the client is actively assigning one it must own.) + put.manager.retain(|m| wire_to_owned.contains_key(&m.id)); let build = build_squad_write(&put, &MapResolver(&wire_to_owned)) .map_err(|e| anyhow::anyhow!("build squad write: {e}"))?; let formation = build