From abe9e663c175386654acf9cca5b46c0645faafac Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 14 Aug 2026 05:26:02 +0000 Subject: [PATCH] feat(fifa17): import consumable + staff content as first-class Core content Close 20 of the 33-record content gap (17 consumables + 3 staff; 13 Legends are unrecoverable from PC data). Verdict A (no Core change): consumables/staff become ordinary Core CardDefinitions (neutral player fields + honest family/role names) and owned instances via the SAME generic import path; a catalog kind lets the adapter exclude them from the player-only /club projection. - adapter fut::content_taxonomy: evidence-based cardsubtypeid->family/label (Ghidra-derived ranges) + staff role map; unknown subtype => defer, never fabricate. - adapter catalog: Fifa17CardIdentity/RawCard gain optional kind+subtype (backward-compat: legacy catalogs load as player); kind_of/subtype_of lookups. - adapter item/club_response: shape_club_response excludes non-player kinds (ShapeStats.excluded_non_player); ItemIdentityResolver::kind_of default=Player. - host Fifa17IdentityResolver overrides kind_of to delegate to the catalog so /club excludes consumables/staff in production. - import: Item gains cardsubtypeid/cardassetid/amount/contract; plan_non_player_definitions (resourceId-grouped, subtype-consistency gated); emit_content writes non-player defs + catalog kind + manifest; apply mints owned instances via owned_item_id. Real profile 33068179: 1962 players + 20 non-player = 1982 owned; 18 non-player defs (16 consumable + 2 staff, dup resourceIds shared); 0 deferred non-player; 0 blockers. --- openfut-adapter-fifa17/src/fut/catalog.rs | 73 +++++ .../src/fut/club_response.rs | 69 +++++ .../src/fut/content_taxonomy.rs | 177 +++++++++++ openfut-adapter-fifa17/src/fut/item.rs | 13 + openfut-adapter-fifa17/src/fut/mod.rs | 1 + openfut-import-fifa17/src/apply.rs | 27 +- openfut-import-fifa17/src/lib.rs | 279 +++++++++++++++++- openfut-import-fifa17/src/main.rs | 4 + openfut-import-fifa17/src/model.rs | 13 + openfut-import-fifa17/src/tests.rs | 256 ++++++++++++++++ openfut-utas-host/src/lib.rs | 9 + 11 files changed, 913 insertions(+), 8 deletions(-) create mode 100644 openfut-adapter-fifa17/src/fut/content_taxonomy.rs diff --git a/openfut-adapter-fifa17/src/fut/catalog.rs b/openfut-adapter-fifa17/src/fut/catalog.rs index 06eee2d..2e21c09 100644 --- a/openfut-adapter-fifa17/src/fut/catalog.rs +++ b/openfut-adapter-fifa17/src/fut/catalog.rs @@ -20,6 +20,8 @@ use std::collections::HashMap; use serde::Deserialize; +use crate::fut::content_taxonomy::ContentKind; + /// The FIFA 17 render identity of a card definition. `version` is the high byte /// of `resource_id`; `asset_id` (the low 24 bits) is the real FIFA player id. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -30,6 +32,12 @@ pub struct Fifa17CardIdentity { /// FIFA wire `rareflag` (rare/special card TYPE). Carried so specials render /// as specials; observed metadata, not a guessed label. pub rareflag: i64, + /// Content class of this definition. A catalog authored before this field + /// existed defaults to [`ContentKind::Player`] (backward compatible). + pub kind: ContentKind, + /// FIFA `cardsubtypeid` for a non-player definition (consumable family / + /// staff role), `0` for a player or when absent. + pub subtype: i64, } /// The FIFA 17 numeric namespace policy for owned-item wire ids. @@ -115,6 +123,14 @@ struct RawCard { /// behaviour; the production catalog carries the observed value. #[serde(default = "default_rareflag")] rareflag: i64, + /// Content class token ("player"|"consumable"|"staff"). Absent → default + /// (empty) → [`ContentKind::Player`], so existing player-only catalogs load + /// unchanged. + #[serde(default)] + kind: String, + /// FIFA `cardsubtypeid` for a non-player entry; absent → `0`. + #[serde(default)] + subtype: i64, } fn default_rareflag() -> i64 { @@ -169,6 +185,8 @@ impl Fifa17CardCatalog { version: rc.version, resource_id, rareflag: rc.rareflag, + kind: ContentKind::from_str(&rc.kind), + subtype: rc.subtype, }, ); } @@ -197,6 +215,21 @@ impl Fifa17CardCatalog { self.by_resource.get(&resource_id).map(String::as_str) } + /// Classify a `card_id` as player/consumable/staff. An unknown definition is + /// [`ContentKind::Player`] — the neutral, backward-compatible default (an + /// un-catalogued id was always treated as a player-shaped card). + pub fn kind_of(&self, card_id: &str) -> ContentKind { + self.by_card + .get(card_id) + .map(|c| c.kind) + .unwrap_or(ContentKind::Player) + } + + /// The FIFA `cardsubtypeid` for a definition, or `0` if unknown / a player. + pub fn subtype_of(&self, card_id: &str) -> i64 { + self.by_card.get(card_id).map(|c| c.subtype).unwrap_or(0) + } + pub fn len(&self) -> usize { self.by_card.len() } @@ -335,4 +368,44 @@ mod tests { assert_eq!(ron.version, 0); assert_eq!(ron.resource_id, 20801); } + + #[test] + fn legacy_catalog_without_kind_loads_as_player() { + // A pre-taxonomy catalog (no `kind`/`subtype`) must load unchanged and + // classify every entry as a player, with subtype 0. + let cat = Fifa17CardCatalog::from_json_str( + r#"{"schema_version":1,"game":"fifa17","cards":{ + "fifa17_20801":{"asset_id":20801}, + "fifa17_176580":{"asset_id":176580,"version":5,"rareflag":3} + }}"#, + ) + .unwrap(); + let base = cat.lookup("fifa17_20801").unwrap(); + assert_eq!(base.kind, ContentKind::Player); + assert_eq!(base.subtype, 0); + assert_eq!(base.rareflag, 1, "absent rareflag still defaults to 1"); + assert_eq!(cat.kind_of("fifa17_20801"), ContentKind::Player); + assert_eq!(cat.kind_of("fifa17_176580"), ContentKind::Player); + // Unknown id -> neutral Player default. + assert_eq!(cat.kind_of("fifa17_missing"), ContentKind::Player); + assert_eq!(cat.subtype_of("fifa17_missing"), 0); + } + + #[test] + fn kind_and_subtype_are_parsed_for_non_player_entries() { + let cat = Fifa17CardCatalog::from_json_str( + r#"{"schema_version":1,"game":"fifa17","cards":{ + "fifa17_20801":{"asset_id":20801,"kind":"player","subtype":0}, + "fifa17_5003012":{"asset_id":5003012,"kind":"consumable","subtype":54,"rareflag":0}, + "fifa17_3000083":{"asset_id":3000083,"kind":"staff","subtype":8,"rareflag":0} + }}"#, + ) + .unwrap(); + assert_eq!(cat.kind_of("fifa17_20801"), ContentKind::Player); + assert_eq!(cat.kind_of("fifa17_5003012"), ContentKind::Consumable); + assert_eq!(cat.subtype_of("fifa17_5003012"), 54); + assert_eq!(cat.kind_of("fifa17_3000083"), ContentKind::Staff); + assert_eq!(cat.subtype_of("fifa17_3000083"), 8); + assert_eq!(cat.lookup("fifa17_5003012").unwrap().rareflag, 0); + } } diff --git a/openfut-adapter-fifa17/src/fut/club_response.rs b/openfut-adapter-fifa17/src/fut/club_response.rs index b85f6d4..ad5b3e9 100644 --- a/openfut-adapter-fifa17/src/fut/club_response.rs +++ b/openfut-adapter-fifa17/src/fut/club_response.rs @@ -9,6 +9,7 @@ use serde_json::{json, Value}; +use crate::fut::content_taxonomy::ContentKind; use crate::fut::entities::ReverseEntityResolver; use crate::fut::item::shape_item; // Re-exported so existing `club_response::{…}` callers keep working; the types @@ -25,6 +26,12 @@ pub fn shape_club_response( let mut out = Vec::with_capacity(items.len()); let mut stats = ShapeStats::default(); for item in items { + // Exclude non-player content (consumables/staff): a `/club` player list + // must never render them as 0-rated players. Counted, never emitted. + if ident.kind_of(item) != ContentKind::Player { + stats.excluded_non_player += 1; + continue; + } match ident.resolve(item) { Some(id) => { out.push(shape_item(item, id, ent)); @@ -193,4 +200,66 @@ mod tests { "only itemData at top level" ); } + + /// A resolver that resolves an asset id for EVERY item (so exclusion is not + /// an artifact of a missing asset) but classifies some card_ids as non-player + /// via an explicit kind table. + struct KindMapIdentity { + ids: HashMap, + kinds: HashMap, + } + impl ItemIdentityResolver for KindMapIdentity { + fn resolve(&self, it: &CoreOwnedItem) -> Option { + self.ids.get(&it.card_id).copied() + } + fn kind_of(&self, it: &CoreOwnedItem) -> ContentKind { + self.kinds + .get(&it.card_id) + .copied() + .unwrap_or(ContentKind::Player) + } + } + + #[test] + fn consumable_and_staff_are_excluded_from_club_players() { + let ent = entities(); + let id = |item_id: u32, asset: u32| Fifa17Identity { + item_id, + asset_id: asset, + resource_id: asset, + rareflag: 1, + }; + let ident = KindMapIdentity { + ids: HashMap::from([ + ("card_player".to_string(), id(100000001, 20801)), + ("card_consumable".to_string(), id(100000002, 5003012)), + ("card_staff".to_string(), id(100000003, 3000083)), + ]), + kinds: HashMap::from([ + ("card_consumable".to_string(), ContentKind::Consumable), + ("card_staff".to_string(), ContentKind::Staff), + ]), + }; + let items = vec![ + item( + "oc1", + "card_player", + 86, + "ST", + "Argentina", + "Premier League", + "Chelsea", + ), + item("oc2", "card_consumable", 0, "", "", "", ""), + item("oc3", "card_staff", 0, "", "", "", ""), + ]; + let (body, stats) = shape_club_response(&items, &ent, &ident); + assert_eq!(stats.emitted, 1, "only the player is emitted"); + assert_eq!(stats.excluded_non_player, 2, "consumable + staff excluded"); + assert_eq!(stats.dropped_no_asset, 0); + let arr = body["itemData"].as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["id"], 100000001, "the player survives"); + assert_eq!(arr[0]["itemType"], "player"); + } } diff --git a/openfut-adapter-fifa17/src/fut/content_taxonomy.rs b/openfut-adapter-fifa17/src/fut/content_taxonomy.rs new file mode 100644 index 0000000..2bef9e2 --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/content_taxonomy.rs @@ -0,0 +1,177 @@ +//! FIFA 17 **non-player content taxonomy** — the evidence-based map from a card +//! `cardsubtypeid` to its functional family (consumables) or role (staff). +//! +//! This is the ONLY place the FIFA-specific `cardsubtypeid` vocabulary lives; it +//! keeps that game concept out of generic Core, exactly as the player-side +//! catalog keeps `resourceId`/`rareflag` out of Core. Nothing here is guessed: +//! +//! * Consumable families and their contiguous `cardsubtypeid` ranges are taken +//! verbatim from `fifa17-recon/tools/fut_consumables.py` +//! (`BY_SUBTYPE`/`CORE_KINDS`, Ghidra-derived from `FUN_18013f4d0` / +//! `FUN_1801bfac0`) and `docs/CARD_TAXONOMY.md` (verified against the `.105` +//! `fcc_*.json` tables). +//! * Staff roles are the `FUN_1800d8330` family selector: 4=manager, 5=headcoach, +//! 6=gkcoach, 7=physio, 8=fitnesscoach. +//! +//! Display **labels are functional, never marketing** (e.g. "Player Chemistry +//! Style", not a promo name). A `cardsubtypeid` outside every documented range +//! resolves to `None` — the caller DEFERS it (mirroring the player NoName gate), +//! never fabricating a family. + +/// The disjoint content classes a FIFA 17 owned card can belong to. Player is +/// the default so a catalog authored before this taxonomy existed (no `kind` +/// field) still classifies every entry as a player, unchanged. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ContentKind { + #[default] + Player, + Consumable, + Staff, +} + +impl ContentKind { + /// The stable wire/catalog token for this kind. + pub fn as_str(&self) -> &'static str { + match self { + ContentKind::Player => "player", + ContentKind::Consumable => "consumable", + ContentKind::Staff => "staff", + } + } + + /// Parse a catalog `kind` token. Unknown or "player" (or an absent field that + /// deserializes to the default) is `Player` — backward compatible. + // Intentionally infallible (every input maps to a kind, unknown → Player), so + // it is NOT `std::str::FromStr` (which is fallible); the name mirrors the + // catalog token vocabulary. + #[allow(clippy::should_implement_trait)] + pub fn from_str(s: &str) -> ContentKind { + match s { + "consumable" => ContentKind::Consumable, + "staff" => ContentKind::Staff, + _ => ContentKind::Player, + } + } +} + +/// The functional family + honest display label for a consumable `cardsubtypeid`, +/// or `None` if the subtype is outside every documented range (→ DEFER). +/// +/// Returns `(family, label)`, both `'static`. `family` is the neutral machine +/// name stored as the CardDefinition family; `label` is the functional +/// human-readable name. +pub fn consumable_family(subtype: i64) -> Option<(&'static str, &'static str)> { + let pair = match subtype { + 51..=57 => ("gk_training", "GK Training"), + 61..=67 => ("player_training", "Player Training"), + 71..=86 => ("manager_formation_mod", "Manager Formation"), + 91..=110 => ("position_mod", "Position Modifier"), + 121..=136 => ("formation_mod", "Formation Modifier"), + 201 => ("player_contract", "Player Contract"), + 202 => ("manager_contract", "Manager Contract"), + 211..=218 => ("healing", "Healing"), + 219 => ("player_fitness", "Player Fitness"), + 220 => ("squad_fitness", "Squad Fitness"), + 250..=268 => ("player_playstyle", "Player Chemistry Style"), + 269..=273 => ("gk_playstyle", "GK Chemistry Style"), + 300..=341 => ("manager_league", "Manager League Modifier"), + _ => return None, + }; + Some(pair) +} + +/// The staff role + honest display label for a staff `cardsubtypeid` (4..=8), or +/// `None` for any other subtype (→ DEFER). Grounded in the `FUN_1800d8330` +/// family selector. +pub fn staff_role(subtype: i64) -> Option<(&'static str, &'static str)> { + let pair = match subtype { + 4 => ("manager", "Manager"), + 5 => ("headcoach", "Head Coach"), + 6 => ("gkcoach", "GK Coach"), + 7 => ("physio", "Physio"), + 8 => ("fitnesscoach", "Fitness Coach"), + _ => return None, + }; + Some(pair) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn content_kind_round_trips_and_defaults_to_player() { + assert_eq!(ContentKind::default(), ContentKind::Player); + for k in [ + ContentKind::Player, + ContentKind::Consumable, + ContentKind::Staff, + ] { + assert_eq!(ContentKind::from_str(k.as_str()), k); + } + // Unknown / absent tokens fall back to Player (backward compatible). + assert_eq!(ContentKind::from_str(""), ContentKind::Player); + assert_eq!(ContentKind::from_str("nonsense"), ContentKind::Player); + assert_eq!(ContentKind::from_str("player"), ContentKind::Player); + } + + #[test] + fn consumable_family_range_boundaries() { + // Each contiguous range: lower boundary, upper boundary, family + label. + let cases: &[(i64, i64, &str, &str)] = &[ + (51, 57, "gk_training", "GK Training"), + (61, 67, "player_training", "Player Training"), + (71, 86, "manager_formation_mod", "Manager Formation"), + (91, 110, "position_mod", "Position Modifier"), + (121, 136, "formation_mod", "Formation Modifier"), + (211, 218, "healing", "Healing"), + (250, 268, "player_playstyle", "Player Chemistry Style"), + (269, 273, "gk_playstyle", "GK Chemistry Style"), + (300, 341, "manager_league", "Manager League Modifier"), + ]; + for &(lo, hi, family, label) in cases { + assert_eq!(consumable_family(lo), Some((family, label)), "lo {lo}"); + assert_eq!(consumable_family(hi), Some((family, label)), "hi {hi}"); + } + // Singleton subtypes. + assert_eq!( + consumable_family(201), + Some(("player_contract", "Player Contract")) + ); + assert_eq!( + consumable_family(202), + Some(("manager_contract", "Manager Contract")) + ); + assert_eq!( + consumable_family(219), + Some(("player_fitness", "Player Fitness")) + ); + assert_eq!( + consumable_family(220), + Some(("squad_fitness", "Squad Fitness")) + ); + } + + #[test] + fn consumable_family_gaps_and_out_of_range_are_none() { + // Just outside range edges, and in documented gaps between ranges. + for s in [ + 0, 50, 58, 60, 68, 70, 87, 90, 111, 120, 137, 200, 203, 210, 221, 249, 274, 299, 342, + 999, + ] { + assert_eq!(consumable_family(s), None, "subtype {s} must be unknown"); + } + } + + #[test] + fn staff_role_each_role_and_unknown_is_none() { + assert_eq!(staff_role(4), Some(("manager", "Manager"))); + assert_eq!(staff_role(5), Some(("headcoach", "Head Coach"))); + assert_eq!(staff_role(6), Some(("gkcoach", "GK Coach"))); + assert_eq!(staff_role(7), Some(("physio", "Physio"))); + assert_eq!(staff_role(8), Some(("fitnesscoach", "Fitness Coach"))); + for s in [0, 1, 2, 3, 9, 10, 201, 300] { + assert_eq!(staff_role(s), None, "staff subtype {s} must be unknown"); + } + } +} diff --git a/openfut-adapter-fifa17/src/fut/item.rs b/openfut-adapter-fifa17/src/fut/item.rs index bd5f34c..e0da033 100644 --- a/openfut-adapter-fifa17/src/fut/item.rs +++ b/openfut-adapter-fifa17/src/fut/item.rs @@ -24,6 +24,7 @@ use serde_json::{json, Value}; +use crate::fut::content_taxonomy::ContentKind; use crate::fut::entities::ReverseEntityResolver; /// One owned item in game-independent terms, as read from Core's inventory. @@ -69,6 +70,15 @@ pub struct Fifa17Identity { /// "no real FIFA asset id known" → the caller must not fabricate one. pub trait ItemIdentityResolver { fn resolve(&self, item: &CoreOwnedItem) -> Option; + + /// Classify a Core item's definition as player/consumable/staff. Defaults to + /// [`ContentKind::Player`] so existing resolvers keep their behaviour; a + /// catalog-backed resolver overrides this to consult its `kind_of`, letting + /// `/club` exclude non-player content (which must never render as a + /// 0-rated player). + fn kind_of(&self, _item: &CoreOwnedItem) -> ContentKind { + ContentKind::Player + } } /// Diagnostics from shaping (safe to log — counts only). @@ -76,6 +86,9 @@ pub trait ItemIdentityResolver { pub struct ShapeStats { pub emitted: usize, pub dropped_no_asset: usize, + /// Consumable/staff items excluded from a player projection (they must never + /// render as a 0-rated player). Counted, never emitted. + pub excluded_non_player: usize, } /// Quick-sell / discard value by rating tier (mirrors Core's quick-sell table; diff --git a/openfut-adapter-fifa17/src/fut/mod.rs b/openfut-adapter-fifa17/src/fut/mod.rs index c6956e9..94fc3b1 100644 --- a/openfut-adapter-fifa17/src/fut/mod.rs +++ b/openfut-adapter-fifa17/src/fut/mod.rs @@ -6,6 +6,7 @@ //! socket — a Rust UTAS host wires it to Core later. pub mod catalog; pub mod club_response; +pub mod content_taxonomy; pub mod economy; pub mod economy_policy; pub mod entities; diff --git a/openfut-import-fifa17/src/apply.rs b/openfut-import-fifa17/src/apply.rs index f517441..5d1f1f3 100644 --- a/openfut-import-fifa17/src/apply.rs +++ b/openfut-import-fifa17/src/apply.rs @@ -181,6 +181,23 @@ pub fn plan_apply( }); } } + // Non-player (consumable/staff) owned instances mint via the IDENTICAL + // generic path: deterministic OwnedItemId per (persona, wire), an identity + // mapping, and a GenericOwned with card_id = fifa17_. + for def in &report.non_player.supported { + for &wire in &def.wire_ids { + let core_id = owned_item_id(persona, wire); + wire_to_owned.insert(wire, core_id.clone()); + owned.push(GenericOwned { + owned_item_id: core_id.clone(), + card_id: def.card_id.clone(), + }); + mappings.push(IdentityMapping { + core_id, + wire_id: wire, + }); + } + } // Canonical squad + opaque extension, built by the SAME adapter code the live // squad-write path uses, over the raw source squad. The resolver maps every @@ -253,8 +270,14 @@ pub fn plan_apply( request, mappings, watermark: report.identity.source_watermark, - supported_instances: report.identity.import_wire_ids.len(), - deferred_instances: report.deferred_instances(), + supported_instances: report.identity.import_wire_ids.len() + + report + .non_player + .supported + .iter() + .map(|d| d.wire_ids.len()) + .sum::(), + deferred_instances: report.deferred_instances() + report.non_player.deferred_instances(), source_fingerprint: snapshot_fingerprint.to_string(), }) } diff --git a/openfut-import-fifa17/src/lib.rs b/openfut-import-fifa17/src/lib.rs index cab834a..134f035 100644 --- a/openfut-import-fifa17/src/lib.rs +++ b/openfut-import-fifa17/src/lib.rs @@ -34,6 +34,7 @@ pub mod apply; pub mod model; use model::{Item, Profile}; +use openfut_adapter_fifa17::fut::content_taxonomy::{consumable_family, staff_role, ContentKind}; // ----------------------------------------------------------------- roster @@ -521,6 +522,157 @@ pub fn plan_definitions( plan } +// ------------------------------------------------------- non-player content + +/// An honest, profile-derived NON-player CardDefinition proposal (consumable or +/// staff), keyed by `fifa17_`. Neutral player fields are supplied at +/// emit time; this carries only the identity + honest functional `name` (the +/// taxonomy label, never a marketing name). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NonPlayerDefinition { + pub card_id: String, + pub resource_id: i64, + /// Base asset id when the source carries one (consumables: `== resource_id`); + /// staff carry no `assetId`, so this is `None`. + pub asset_id: Option, + pub kind: ContentKind, + /// FIFA `cardsubtypeid` (consumable family / staff role selector). + pub subtype: i64, + /// Honest functional label (e.g. "Player Contract", "GK Coach"). + pub name: String, + /// Wire ids of every owned copy of this resourceId (preserved). + pub wire_ids: Vec, +} + +/// A non-player group that cannot be honestly classified (DEFERRED, never +/// fabricated). Mirrors the player NoName gate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeferredNonPlayer { + pub resource_id: i64, + /// The agreed subtype when present; `None` when absent or in conflict. + pub subtype: Option, + pub wire_ids: Vec, + pub reason: String, +} + +#[derive(Debug, Default)] +pub struct NonPlayerPlan { + pub supported: Vec, + pub deferred: Vec, + /// Count of SUPPORTED consumable definitions. + pub consumables: usize, + /// Count of SUPPORTED staff definitions. + pub staff: usize, +} + +impl NonPlayerPlan { + /// Deferred non-player INSTANCES (owned copies) across all deferred groups. + pub fn deferred_instances(&self) -> usize { + self.deferred.iter().map(|d| d.wire_ids.len()).sum() + } +} + +/// Plan the non-player (consumable + staff) CardDefinitions. Groups Consumable +/// and Staff items by `resourceId`; each group must agree on `cardsubtypeid` +/// across copies (a disagreement DEFERS with `subtype_conflict`), then resolves +/// the family (consumable) or role (staff) via the adapter's evidence-based +/// taxonomy. A missing or unknown `cardsubtypeid` DEFERS — never a placeholder. +pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan { + let mut groups: BTreeMap> = BTreeMap::new(); + for it in &profile.items { + if matches!(classify(it), ItemClass::Consumable | ItemClass::Staff) { + groups.entry(it.resource_id).or_default().push(it); + } + } + + let mut plan = NonPlayerPlan::default(); + for (resource_id, items) in groups { + let wire_ids: Vec = items.iter().map(|i| i.id).collect(); + + // Class agreement (a resourceId is either all-consumable or all-staff). + let class = classify(items[0]); + if items.iter().any(|i| classify(i) != class) { + plan.deferred.push(DeferredNonPlayer { + resource_id, + subtype: None, + wire_ids, + reason: "class_conflict".to_string(), + }); + continue; + } + + // Subtype must agree across every owned copy (identity invariant). + let first_subtype = items[0].cardsubtypeid; + if items.iter().any(|i| i.cardsubtypeid != first_subtype) { + plan.deferred.push(DeferredNonPlayer { + resource_id, + subtype: None, + wire_ids, + reason: "subtype_conflict".to_string(), + }); + continue; + } + let Some(subtype) = first_subtype else { + plan.deferred.push(DeferredNonPlayer { + resource_id, + subtype: None, + wire_ids, + reason: "missing_cardsubtypeid".to_string(), + }); + continue; + }; + + let (kind, label) = match class { + ItemClass::Consumable => match consumable_family(subtype) { + Some((_family, label)) => (ContentKind::Consumable, label), + None => { + plan.deferred.push(DeferredNonPlayer { + resource_id, + subtype: Some(subtype), + wire_ids, + reason: "unknown_subtype".to_string(), + }); + continue; + } + }, + ItemClass::Staff => match staff_role(subtype) { + Some((_role, label)) => (ContentKind::Staff, label), + None => { + plan.deferred.push(DeferredNonPlayer { + resource_id, + subtype: Some(subtype), + wire_ids, + reason: "unknown_subtype".to_string(), + }); + continue; + } + }, + _ => unreachable!("only Consumable/Staff were grouped"), + }; + + plan.supported.push(NonPlayerDefinition { + card_id: format!("fifa17_{resource_id}"), + resource_id, + asset_id: items[0].asset_id, + kind, + subtype, + name: label.to_string(), + wire_ids, + }); + } + plan.consumables = plan + .supported + .iter() + .filter(|d| d.kind == ContentKind::Consumable) + .count(); + plan.staff = plan + .supported + .iter() + .filter(|d| d.kind == ContentKind::Staff) + .count(); + plan +} + // --------------------------------------------------------------- identity #[derive(Debug, Default)] @@ -632,6 +784,8 @@ pub struct Report { pub definitions: DefinitionPlan, pub identity: IdentityPlan, pub squad: SquadCoverage, + /// Consumable + staff content (supported definitions + deferred groups). + pub non_player: NonPlayerPlan, /// Unconsumed pack entitlements to seed (from `unopenedPackIds`). pub unopened_pack_ids: Vec, } @@ -720,6 +874,7 @@ pub fn analyze( let identity = plan_identity(profile, &supported_rids); let supported_wire: BTreeSet = identity.import_wire_ids.iter().copied().collect(); let squad = plan_squad(profile, &supported_wire); + let non_player = plan_non_player_definitions(profile); Report { game: "fifa17".to_string(), persona_id: profile.persona_id, @@ -731,6 +886,7 @@ pub fn analyze( definitions, identity, squad, + non_player, unopened_pack_ids: profile.unopened_pack_ids.clone(), } } @@ -740,6 +896,7 @@ impl std::fmt::Display for Report { let d = &self.definitions; let id = &self.identity; let sq = &self.squad; + let np = &self.non_player; writeln!(f, "OpenFUT FIFA17 real-profile import — analysis")?; writeln!(f, "=============================================")?; writeln!( @@ -819,11 +976,30 @@ impl std::fmt::Display for Report { } writeln!( f, - "\nRESULT would_import_players={} deferred_player_instances={} deferred_consumables={} deferred_staff={}", + "\nNON-PLAYER CONTENT (consumable/staff) supported={} (consumables={} staff={}) deferred_groups={} deferred_instances={}", + np.supported.len(), + np.consumables, + np.staff, + np.deferred.len(), + np.deferred_instances() + )?; + for nd in &np.deferred { + writeln!( + f, + " DEFER resourceId={} subtype={:?} copies={} reason={}", + nd.resource_id, + nd.subtype, + nd.wire_ids.len(), + nd.reason + )?; + } + writeln!( + f, + "\nRESULT would_import_players={} would_import_non_players={} deferred_player_instances={} deferred_non_player_instances={}", id.import_wire_ids.len(), + np.supported.iter().map(|d| d.wire_ids.len()).sum::(), self.deferred_instances(), - self.counts.consumables, - self.counts.staff + np.deferred_instances() )?; let blockers = self.blockers(); if blockers.is_empty() { @@ -861,6 +1037,10 @@ pub struct EmitSummary { pub catalog_entries: usize, pub supported_instances: usize, pub deferred_instances: usize, + /// Non-player (consumable/staff) supported definitions written. + pub non_player_definitions: usize, + /// Non-player supported owned INSTANCES (owned copies across those defs). + pub non_player_instances: usize, } /// Emit the PUBLIC content pack + host catalog for supported definitions, and a @@ -880,7 +1060,7 @@ pub fn emit_content( std::fs::create_dir_all(&manifest_dir)?; // ---- PUBLIC: Core CardDefinition[] (matches openfut-core models::card) ---- - let defs: Vec = report + let mut defs: Vec = report .definitions .supported .iter() @@ -904,15 +1084,58 @@ pub fn emit_content( }) }) .collect(); + // Non-player CardDefinitions use NEUTRAL player fields + the honest family/ + // role name; Core stores them like any other definition (no FIFA concept). + for d in &report.non_player.supported { + defs.push(serde_json::json!({ + "id": d.card_id, + "name": d.name, + "overall": 0, + "position": "", + "nation": "", + "league": "", + "club": "", + "pace": 0, + "shooting": 0, + "passing": 0, + "dribbling": 0, + "defending": 0, + "physical": 0, + "rarity": "bronze", + "image_path": serde_json::Value::Null, + })); + } let content_pack = content_dir.join("fifa17-production-cards.json"); write_json_pretty(&content_pack, &defs)?; // ---- PUBLIC: host identity catalog {card_id: {asset_id, version, rareflag}} ---- let mut cards = serde_json::Map::new(); for d in &report.definitions.supported { + // Players carry an explicit kind:"player" + subtype:0 so the adapter can + // classify EVERY catalogued card (not just non-players). cards.insert( d.card_id.clone(), - serde_json::json!({ "asset_id": d.asset_id, "version": d.version, "rareflag": d.rareflag }), + serde_json::json!({ + "asset_id": d.asset_id, + "version": d.version, + "rareflag": d.rareflag, + "kind": "player", + "subtype": 0, + }), + ); + } + for d in &report.non_player.supported { + // asset_id falls back to resource_id (staff carry no assetId); version 0, + // rareflag 0 — a consumable/staff never renders as a special card. + cards.insert( + d.card_id.clone(), + serde_json::json!({ + "asset_id": d.asset_id.unwrap_or(d.resource_id), + "version": 0, + "rareflag": 0, + "kind": d.kind.as_str(), + "subtype": d.subtype, + }), ); } let catalog = serde_json::json!({ @@ -966,8 +1189,44 @@ pub fn emit_content( "distinct_variants": c.distinct.len(), })); } + let non_player_supported: Vec = report + .non_player + .supported + .iter() + .map(|d| { + serde_json::json!({ + "card_id": d.card_id, + "resource_id": d.resource_id, + "asset_id": d.asset_id, + "kind": d.kind.as_str(), + "subtype": d.subtype, + "name": d.name, + "wire_ids": d.wire_ids, + }) + }) + .collect(); + let non_player_deferred: Vec = report + .non_player + .deferred + .iter() + .map(|dd| { + serde_json::json!({ + "resource_id": dd.resource_id, + "subtype": dd.subtype, + "wire_ids": dd.wire_ids, + "reason": dd.reason, + }) + }) + .collect(); let supported_instances = report.identity.import_wire_ids.len(); let deferred_instances = report.deferred_instances(); + let non_player_definitions = report.non_player.supported.len(); + let non_player_instances: usize = report + .non_player + .supported + .iter() + .map(|d| d.wire_ids.len()) + .sum(); let manifest = serde_json::json!({ "generator": "openfut-import-fifa17", "source_kind": "python-profile-observation", @@ -987,6 +1246,12 @@ pub fn emit_content( }, "supported_definitions": supported, "deferred": deferred, + "non_player": { + "supported_definitions": non_player_supported, + "supported_instances": non_player_instances, + "deferred": non_player_deferred, + "deferred_instances": report.non_player.deferred_instances(), + }, }); let manifest_path = manifest_dir.join("fifa17-import-manifest.json"); write_json_pretty(&manifest_path, &manifest)?; @@ -996,9 +1261,11 @@ pub fn emit_content( host_catalog, manifest: manifest_path, definitions: report.definitions.supported.len(), - catalog_entries: report.definitions.supported.len(), + catalog_entries: report.definitions.supported.len() + non_player_definitions, supported_instances, deferred_instances, + non_player_definitions, + non_player_instances, }) } diff --git a/openfut-import-fifa17/src/main.rs b/openfut-import-fifa17/src/main.rs index 90a75e9..147b75b 100644 --- a/openfut-import-fifa17/src/main.rs +++ b/openfut-import-fifa17/src/main.rs @@ -123,6 +123,10 @@ fn run() -> Result { sum.supported_instances, sum.deferred_instances ); + println!( + " non-player : {} definition(s), {} instance(s) (consumable/staff)", + sum.non_player_definitions, sum.non_player_instances + ); } if do_apply { diff --git a/openfut-import-fifa17/src/model.rs b/openfut-import-fifa17/src/model.rs index 2004d9e..58ab5dc 100644 --- a/openfut-import-fifa17/src/model.rs +++ b/openfut-import-fifa17/src/model.rs @@ -63,6 +63,19 @@ pub struct Item { pub league_id: Option, #[serde(rename = "attributeList", default)] pub attribute_list: Option>, + /// FIFA `cardsubtypeid` — the consumable family / staff role selector. Absent + /// for player cards; present for consumables and staff. + #[serde(default)] + pub cardsubtypeid: Option, + /// Consumable ART id (small id), distinct from `resourceId`. Permissive. + #[serde(default)] + pub cardassetid: Option, + /// Consumable stack size (`amount`). Permissive. + #[serde(default)] + pub amount: Option, + /// Staff/contract `contract` count. Permissive. + #[serde(default)] + pub contract: Option, } #[derive(Debug, Clone, Deserialize)] diff --git a/openfut-import-fifa17/src/tests.rs b/openfut-import-fifa17/src/tests.rs index 6a04ef0..2f26b14 100644 --- a/openfut-import-fifa17/src/tests.rs +++ b/openfut-import-fifa17/src/tests.rs @@ -626,3 +626,259 @@ fn apply_fails_gracefully_when_core_binary_missing() { let err = apply_import(&plan, &paths, false).unwrap_err(); assert!(format!("{err:#}").contains("spawn core import"), "{err:#}"); } + +// -------------------------------------------------- non-player content + +use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog; +use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind; + +/// A consumable owned item: itemType="player" but NO attributeList; identity is +/// carried entirely by resourceId (== assetId == carddbid) + cardsubtypeid. +fn consumable(id: i64, resource: i64, subtype: i64) -> String { + format!( + r#"{{"id":{id},"resourceId":{resource},"assetId":{resource},"itemType":"player", + "cardsubtypeid":{subtype},"cardassetid":3,"amount":1,"rating":0,"rareflag":0}}"# + ) +} + +/// A staff owned item: itemType="staff", resourceId only (NO assetId), keyed by +/// cardsubtypeid. +fn staff(id: i64, resource: i64, subtype: i64) -> String { + format!( + r#"{{"id":{id},"resourceId":{resource},"itemType":"staff","cardsubtypeid":{subtype},"contract":10}}"# + ) +} + +#[test] +fn plan_non_player_supports_seventeen_consumables_and_three_staff() { + // The exact record set from the ticket: distinct resourceIds, so each is its + // own definition even where two copies share a subtype (54,54 / 100,100 / + // 202,202 / staff 8,8) — subtype duplication across DISTINCT definitions is + // not a conflict. + let consumable_subtypes = [ + 54, 54, 52, 91, 92, 97, 98, 100, 100, 258, 267, 271, 201, 202, 202, 217, 213, + ]; + let staff_subtypes = [8i64, 8, 6]; + let mut items = Vec::new(); + for (i, &st) in consumable_subtypes.iter().enumerate() { + let i = i as i64; + items.push(consumable(100_000_200 + i, 5_003_001 + i, st)); + } + for (i, &st) in staff_subtypes.iter().enumerate() { + let i = i as i64; + items.push(staff(100_000_300 + i, 3_000_001 + i, st)); + } + let plan = plan_non_player_definitions(&profile(&items, "[]", 100000500)); + assert_eq!( + plan.supported.len(), + 20, + "17 consumable + 3 staff definitions" + ); + assert_eq!(plan.consumables, 17); + assert_eq!(plan.staff, 3); + assert!(plan.deferred.is_empty(), "0 deferred: {:?}", plan.deferred); + + // Honest labels + kinds resolve from the taxonomy (spot checks). + let by_id = |cid: &str| plan.supported.iter().find(|d| d.card_id == cid).unwrap(); + // subtype 201 -> Player Contract (13th consumable, resource 5003013) + let contract = by_id("fifa17_5003013"); + assert_eq!(contract.name, "Player Contract"); + assert_eq!(contract.kind, ContentKind::Consumable); + assert_eq!(contract.subtype, 201); + // subtype 258 -> Player Chemistry Style (10th consumable, resource 5003010) + assert_eq!(by_id("fifa17_5003010").name, "Player Chemistry Style"); + // staff subtype 8 -> Fitness Coach; subtype 6 -> GK Coach + let fitness = by_id("fifa17_3000001"); + assert_eq!(fitness.name, "Fitness Coach"); + assert_eq!(fitness.kind, ContentKind::Staff); + assert_eq!(fitness.asset_id, None, "staff carry no assetId"); + assert_eq!(by_id("fifa17_3000003").name, "GK Coach"); +} + +#[test] +fn unknown_subtype_consumable_defers_never_fabricated() { + let plan = plan_non_player_definitions(&profile( + &[consumable(100000300, 5009999, 999)], + "[]", + 100000500, + )); + assert!(plan.supported.is_empty()); + assert_eq!(plan.deferred.len(), 1); + assert_eq!(plan.deferred[0].reason, "unknown_subtype"); + assert_eq!(plan.deferred[0].subtype, Some(999)); + assert_eq!(plan.deferred[0].wire_ids, vec![100000300]); +} + +#[test] +fn missing_cardsubtypeid_defers() { + // itemType player, no attributeList, no cardsubtypeid -> consumable w/o a + // resolvable family -> DEFER (never a placeholder). + let item = + r#"{"id":100000301,"resourceId":5003050,"assetId":5003050,"itemType":"player","rating":0}"# + .to_string(); + let plan = plan_non_player_definitions(&profile(&[item], "[]", 100000500)); + assert!(plan.supported.is_empty()); + assert_eq!(plan.deferred.len(), 1); + assert_eq!(plan.deferred[0].reason, "missing_cardsubtypeid"); +} + +#[test] +fn conflicting_subtype_across_copies_defers() { + // Two copies of one resourceId that disagree on subtype -> defer, never a + // silent winner. + let plan = plan_non_player_definitions(&profile( + &[ + consumable(100000302, 5003060, 201), + consumable(100000303, 5003060, 202), + ], + "[]", + 100000500, + )); + assert!(plan.supported.is_empty()); + assert_eq!(plan.deferred.len(), 1); + assert_eq!(plan.deferred[0].reason, "subtype_conflict"); + assert_eq!(plan.deferred[0].wire_ids, vec![100000302, 100000303]); +} + +#[test] +fn non_player_deferral_is_not_a_blocker() { + // A non-player deferral (like a player NoName deferral) must NOT block emit. + let items = vec![ + player(100000001, 20801, 20801, 94), + consumable(100000300, 5009999, 999), // unknown subtype -> deferred + ]; + let rep = analyze( + &profile(&items, "[]", 100000500), + &roster(), + &entities(), + &none(), + ); + assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers()); + assert_eq!(rep.non_player.deferred.len(), 1); +} + +#[test] +fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() { + let items = vec![ + player(100000001, 20801, 20801, 94), + consumable(100000201, 5003012, 201), // Player Contract + staff(100000427, 3000083, 8), // Fitness Coach + ]; + let rep = analyze( + &profile(&items, "[]", 100000500), + &roster(), + &entities(), + &none(), + ); + assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers()); + assert_eq!(rep.non_player.supported.len(), 2); + + let dir = tempfile::tempdir().unwrap(); + let sum = emit_content(&rep, dir.path(), "fp").unwrap(); + assert_eq!(sum.definitions, 1, "one player definition"); + assert_eq!(sum.non_player_definitions, 2); + assert_eq!(sum.non_player_instances, 2); + assert_eq!( + sum.catalog_entries, 3, + "player + 2 non-player catalog entries" + ); + + // Content pack: neutral non-player CardDefinition with honest name. + let pack: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&sum.content_pack).unwrap()).unwrap(); + let arr = pack.as_array().unwrap(); + let cons = arr.iter().find(|c| c["id"] == "fifa17_5003012").unwrap(); + assert_eq!(cons["name"], "Player Contract"); + assert_eq!(cons["overall"], 0); + assert_eq!(cons["position"], ""); + assert_eq!(cons["nation"], ""); + assert_eq!(cons["rarity"], "bronze"); + assert!(cons["image_path"].is_null()); + + // Catalog: kind+subtype on player AND non-player; staff asset falls back to + // resourceId; and the emitted catalog LOADS in the adapter with kind_of. + let cat: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&sum.host_catalog).unwrap()).unwrap(); + assert_eq!(cat["cards"]["fifa17_20801"]["kind"], "player"); + assert_eq!(cat["cards"]["fifa17_20801"]["subtype"], 0); + assert_eq!(cat["cards"]["fifa17_5003012"]["kind"], "consumable"); + assert_eq!(cat["cards"]["fifa17_5003012"]["subtype"], 201); + assert_eq!(cat["cards"]["fifa17_5003012"]["rareflag"], 0); + assert_eq!(cat["cards"]["fifa17_3000083"]["kind"], "staff"); + assert_eq!(cat["cards"]["fifa17_3000083"]["subtype"], 8); + assert_eq!(cat["cards"]["fifa17_3000083"]["asset_id"], 3000083); + + let loaded = Fifa17CardCatalog::from_file(&sum.host_catalog).unwrap(); + assert_eq!(loaded.kind_of("fifa17_20801"), ContentKind::Player); + assert_eq!(loaded.kind_of("fifa17_5003012"), ContentKind::Consumable); + assert_eq!(loaded.subtype_of("fifa17_5003012"), 201); + assert_eq!(loaded.kind_of("fifa17_3000083"), ContentKind::Staff); + + // Manifest: private non_player section with preserved wire ids. + let man: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&sum.manifest).unwrap()).unwrap(); + assert_eq!(man["non_player"]["supported_instances"], 2); + let np = man["non_player"]["supported_definitions"] + .as_array() + .unwrap(); + assert_eq!(np.len(), 2); + let cons_man = np + .iter() + .find(|d| d["card_id"] == "fifa17_5003012") + .unwrap(); + assert_eq!(cons_man["wire_ids"], serde_json::json!([100000201])); + assert_eq!(cons_man["kind"], "consumable"); +} + +#[test] +fn plan_apply_mints_non_player_owned_instances() { + let items = vec![ + player(100000001, 20801, 20801, 94), + consumable(100000201, 5003012, 201), + staff(100000427, 3000083, 8), + ]; + let (report, raw) = report_and_raw(&items, "[]", 100000500); + let plan = plan_apply(&report, &raw, "fp").unwrap(); + // 1 player + 2 non-player owned instances, minted via the identical path. + assert_eq!(plan.request.owned.len(), 3); + assert_eq!(plan.mappings.len(), 3); + assert_eq!(plan.supported_instances, 3); + assert_eq!(plan.deferred_instances, 0); + let cards: BTreeSet<&str> = plan + .request + .owned + .iter() + .map(|o| o.card_id.as_str()) + .collect(); + assert!(cards.contains("fifa17_5003012"), "consumable minted"); + assert!(cards.contains("fifa17_3000083"), "staff minted"); + // Deterministic OwnedItemId per (persona, wire) — same rule as players. + let m = plan + .mappings + .iter() + .find(|m| m.wire_id == 100000201) + .unwrap(); + assert_eq!(m.core_id, owned_item_id(33068179, 100000201)); + + // Local preflight passes because the emitted content pack contains the + // non-player card_ids too. + let dir = tempfile::tempdir().unwrap(); + let sum = emit_content(&report, dir.path(), "fp").unwrap(); + let ids = content_card_ids(&sum.content_pack).unwrap(); + local_core_preflight(&plan, &ids).unwrap(); +} + +#[test] +fn deferred_non_player_instances_gate_a_production_apply() { + // A supported player + a deferred (unknown-subtype) consumable: the deferred + // non-player instance blocks a production apply, allowed only for staging. + let items = vec![ + player(100000001, 20801, 20801, 94), + consumable(100000300, 5009999, 999), + ]; + let (report, raw) = report_and_raw(&items, "[]", 100000500); + let plan = plan_apply(&report, &raw, "fp").unwrap(); + assert_eq!(plan.deferred_instances, 1, "the deferred consumable counts"); + assert!(gate_staging(&plan, false).is_err(), "production blocks"); + assert!(gate_staging(&plan, true).unwrap(), "staging opt-in allows"); +} diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 9ea4f58..785c48e 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -50,6 +50,7 @@ use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPo use openfut_adapter_fifa17::fut::club_response::{ shape_club_response, CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats, }; +use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind; use openfut_adapter_fifa17::fut::economy_policy::{ match_reward_total, result_from_end_reason, MatchResult, }; @@ -918,6 +919,14 @@ impl ItemIdentityResolver for Fifa17IdentityResolver { rareflag: ident.rareflag, }) } + + /// Delegate content classification to the catalog so `/club` excludes + /// consumable/staff cards (they must never render as 0-rated players). An + /// unmapped card_id resolves to `Player` (the catalog default) but is already + /// dropped by `resolve` returning `None`, so it is never emitted anyway. + fn kind_of(&self, item: &CoreOwnedItem) -> ContentKind { + self.catalog.kind_of(&item.card_id) + } } /// The same production resolver reverses a wire id to a Core owned-instance id