From db743ffd1f4a67b5012db0c9d5b0ce240d3926ac Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 21 Aug 2026 03:17:57 +0000 Subject: [PATCH] feat(fifa17): project owned kits with active home/away designation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the server side of the FUT kit selector. Ownership stays generic in Core (submodule bump: club_kit_assignments + GET/PUT /club/kits); this commit adds the FIFA17 representation, the host projection and importer support. adapter: * ContentKind::Kit ("kit") so kits are classified alongside player/staff/ consumable instead of being mistaken for 0-rated players. * Fifa17CardIdentity carries card_asset_id and team_id; RawCard keeps both optional because the emitted catalog writes null for non-kit definitions. * shape_kit_item emits only the fields the client's kit path reads (id/resourceId/assetId/cardassetid/cardsubtypeid/itemState/owners/ untradeable/teamid) — no attributeList, no itemType. * itemState on the wire is the STRING token activeHomeKit/activeAwayKit; the 101/102 integers are the client's post-deserialisation runtime enum (item+0x5c) and are never emitted. * club_stats S_KITS (0x28) now counts owned kits instead of a hard zero. host: * CoreKitAssignments + CoreAccess::get_active_kits (GET /club/kits), defaulting to no active kits so a Core without the endpoint degrades instead of fabricating a designation. * handle_club classifies type=player|kit, rejects any other type with an empty page and outcome=unsupported_type, and now always fetches unpaginated from Core: kind and transfer-pile membership are host-side concepts Core cannot express, so filtering and pagination must both happen after shaping or pages come back short. importer: * ItemClass::Kit (cardsubtypeid == 9 and resourceId in 6_300_000..=6_400_654), kit counts/balances, and card_asset_id/team_id carried into the emitted catalog and manifest. * a kit group missing cardassetid == 35 or teamid is DEFERRED (missing_kit_render_metadata) rather than defaulted; conflicting render metadata across instances defers as render_metadata_conflict. staging: sold-staging-up.py seeds two owned kits (6300006 home / 6400003 away, team 21) plus both active designations so the projection can be verified over HTTP before involving the client. --- openfut-adapter-fifa17/src/fut/catalog.rs | 22 +- .../src/fut/club_response.rs | 113 ++++++-- openfut-adapter-fifa17/src/fut/club_stats.rs | 34 ++- .../src/fut/content_taxonomy.rs | 6 +- openfut-adapter-fifa17/src/fut/item.rs | 35 +++ openfut-adapter-fifa17/src/fut/owned_query.rs | 5 + openfut-core | 2 +- openfut-import-fifa17/src/lib.rs | 96 +++++-- openfut-import-fifa17/src/model.rs | 3 + openfut-import-fifa17/src/tests.rs | 69 ++++- openfut-utas-host/src/lib.rs | 255 ++++++++++-------- openfut-utas-host/tests/host_test.rs | 72 ++++- scripts/sold-staging-up.py | 73 ++++- 13 files changed, 616 insertions(+), 169 deletions(-) diff --git a/openfut-adapter-fifa17/src/fut/catalog.rs b/openfut-adapter-fifa17/src/fut/catalog.rs index 2e21c09..c43f80a 100644 --- a/openfut-adapter-fifa17/src/fut/catalog.rs +++ b/openfut-adapter-fifa17/src/fut/catalog.rs @@ -38,6 +38,11 @@ pub struct Fifa17CardIdentity { /// FIFA `cardsubtypeid` for a non-player definition (consumable family / /// staff role), `0` for a player or when absent. pub subtype: i64, + /// FIFA card-art class. Players default to `asset_id`; kit definitions carry + /// the verified `fcc_kitcards.cardassetid` value (`35`). + pub card_asset_id: u32, + /// Source team id for a club kit. Zero for content kinds that do not use it. + pub team_id: i64, } /// The FIFA 17 numeric namespace policy for owned-item wire ids. @@ -131,6 +136,12 @@ struct RawCard { /// FIFA `cardsubtypeid` for a non-player entry; absent → `0`. #[serde(default)] subtype: i64, + /// Separate card-art id for non-player definitions; absent → `asset_id`. + #[serde(default)] + card_asset_id: Option, + /// Source team id for a kit definition; absent → `0`. + #[serde(default)] + team_id: Option, } fn default_rareflag() -> i64 { @@ -187,6 +198,8 @@ impl Fifa17CardCatalog { rareflag: rc.rareflag, kind: ContentKind::from_str(&rc.kind), subtype: rc.subtype, + card_asset_id: rc.card_asset_id.unwrap_or(rc.asset_id), + team_id: rc.team_id.unwrap_or(0), }, ); } @@ -397,7 +410,9 @@ mod tests { 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} + "fifa17_3000083":{"asset_id":3000083,"kind":"staff","subtype":8,"rareflag":0}, + "fifa17_6300006":{"asset_id":6300006,"kind":"kit","subtype":9, + "card_asset_id":35,"team_id":21,"rareflag":0} }}"#, ) .unwrap(); @@ -407,5 +422,10 @@ mod tests { 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); + let kit = cat.lookup("fifa17_6300006").unwrap(); + assert_eq!(kit.kind, ContentKind::Kit); + assert_eq!(kit.subtype, 9); + assert_eq!(kit.card_asset_id, 35); + assert_eq!(kit.team_id, 21); } } diff --git a/openfut-adapter-fifa17/src/fut/club_response.rs b/openfut-adapter-fifa17/src/fut/club_response.rs index ad5b3e9..275a0b8 100644 --- a/openfut-adapter-fifa17/src/fut/club_response.rs +++ b/openfut-adapter-fifa17/src/fut/club_response.rs @@ -11,33 +11,65 @@ use serde_json::{json, Value}; use crate::fut::content_taxonomy::ContentKind; use crate::fut::entities::ReverseEntityResolver; -use crate::fut::item::shape_item; +use crate::fut::item::{shape_item, shape_kit_item}; // Re-exported so existing `club_response::{…}` callers keep working; the types // are now defined once in `fut::item`. -pub use crate::fut::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats}; +pub use crate::fut::item::{ + CoreOwnedItem, Fifa17Identity, Fifa17KitIdentity, ItemIdentityResolver, ShapeStats, +}; -/// Shape the whole `/club` response. Items without a resolvable real asset id -/// are dropped (counted in `ShapeStats`), never emitted with a fabricated id. +/// Active club-level kit roles, keyed by Core owned-instance id. +#[derive(Debug, Clone, Copy, Default)] +pub struct ActiveKitAssignments<'a> { + pub home: Option<&'a str>, + pub away: Option<&'a str>, +} + +/// Shape the player portion of `/club` (the historical/default query). pub fn shape_club_response( items: &[CoreOwnedItem], ent: &impl ReverseEntityResolver, ident: &I, +) -> (Value, ShapeStats) { + shape_club_response_with_kits(items, ent, ident, ActiveKitAssignments::default()) +} + +/// Shape `/club` items, including ownership-backed active kit designations. +/// Consumables/staff remain excluded because they use separate wire envelopes. +pub fn shape_club_response_with_kits( + items: &[CoreOwnedItem], + ent: &impl ReverseEntityResolver, + ident: &I, + active_kits: ActiveKitAssignments<'_>, ) -> (Value, ShapeStats) { 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)); - stats.emitted += 1; + match ident.kind_of(item) { + ContentKind::Player => match ident.resolve(item) { + Some(id) => { + out.push(shape_item(item, id, ent)); + stats.emitted += 1; + } + None => stats.dropped_no_asset += 1, + }, + ContentKind::Kit => match ident.resolve_kit(item) { + Some(id) => { + let item_state = if active_kits.home == Some(item.owned_card_id.as_str()) { + "activeHomeKit" + } else if active_kits.away == Some(item.owned_card_id.as_str()) { + "activeAwayKit" + } else { + "free" + }; + out.push(shape_kit_item(id, item_state)); + stats.emitted += 1; + } + None => stats.dropped_no_asset += 1, + }, + ContentKind::Consumable | ContentKind::Staff => { + stats.excluded_non_player += 1; } - None => stats.dropped_no_asset += 1, } } (json!({ "itemData": out }), stats) @@ -207,11 +239,15 @@ mod tests { struct KindMapIdentity { ids: HashMap, kinds: HashMap, + kits: HashMap, } impl ItemIdentityResolver for KindMapIdentity { fn resolve(&self, it: &CoreOwnedItem) -> Option { self.ids.get(&it.card_id).copied() } + fn resolve_kit(&self, it: &CoreOwnedItem) -> Option { + self.kits.get(&it.card_id).copied() + } fn kind_of(&self, it: &CoreOwnedItem) -> ContentKind { self.kinds .get(&it.card_id) @@ -235,6 +271,7 @@ mod tests { ("card_consumable".to_string(), id(100000002, 5003012)), ("card_staff".to_string(), id(100000003, 3000083)), ]), + kits: HashMap::new(), kinds: HashMap::from([ ("card_consumable".to_string(), ContentKind::Consumable), ("card_staff".to_string(), ContentKind::Staff), @@ -262,4 +299,50 @@ mod tests { assert_eq!(arr[0]["id"], 100000001, "the player survives"); assert_eq!(arr[0]["itemType"], "player"); } + + #[test] + fn kits_project_with_owned_active_home_and_away_states() { + let ent = entities(); + let kit = |item_id, resource_id, team_id| Fifa17KitIdentity { + item_id, + asset_id: resource_id, + resource_id, + card_asset_id: 35, + subtype: 9, + team_id, + }; + let ident = KindMapIdentity { + ids: HashMap::new(), + kits: HashMap::from([ + ("kit-home".into(), kit(100000010, 6300006, 21)), + ("kit-away".into(), kit(100000011, 6400003, 21)), + ]), + kinds: HashMap::from([ + ("kit-home".into(), ContentKind::Kit), + ("kit-away".into(), ContentKind::Kit), + ]), + }; + let items = vec![ + item("owned-home", "kit-home", 0, "", "", "", ""), + item("owned-away", "kit-away", 0, "", "", "", ""), + ]; + let (body, stats) = shape_club_response_with_kits( + &items, + &ent, + &ident, + ActiveKitAssignments { + home: Some("owned-home"), + away: Some("owned-away"), + }, + ); + assert_eq!(stats.emitted, 2); + assert_eq!(body["itemData"][0]["resourceId"], 6300006); + assert_eq!(body["itemData"][0]["cardassetid"], 35); + assert_eq!(body["itemData"][0]["cardsubtypeid"], 9); + assert_eq!(body["itemData"][0]["teamid"], 21); + assert_eq!(body["itemData"][0]["itemState"], "activeHomeKit"); + assert_eq!(body["itemData"][1]["itemState"], "activeAwayKit"); + assert!(body["itemData"][0].get("attributeList").is_none()); + assert!(body["itemData"][0].get("itemType").is_none()); + } } diff --git a/openfut-adapter-fifa17/src/fut/club_stats.rs b/openfut-adapter-fifa17/src/fut/club_stats.rs index bccacbe..a9f8cb4 100644 --- a/openfut-adapter-fifa17/src/fut/club_stats.rs +++ b/openfut-adapter-fifa17/src/fut/club_stats.rs @@ -6,7 +6,8 @@ //! (`FUN_18012fd40` atom table). The body is `{"stat":[{contextId,contextValue, //! type,typeValue}, …]}`: //! * a GLOBAL bucket (contextId 1, contextValue 0) with player tier counts, -//! staff-by-family, consumables-by-family, and honest zeros for club items; +//! staff/consumable families, owned-kit count, and honest zeros for other +//! club-item families; //! * per-NATION buckets (contextId 3, contextValue = nation id) with the tier //! counts the MY CLUB summary panel sums into PLAYERS_EMPLOYED. //! @@ -215,12 +216,20 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value { } g.insert(S_CONSUMABLES, cons_total); - // club items: honest zeros (Core holds none; each is read by some panel). + // Club items: kits are Core-owned and counted; unimplemented families stay + // honest zeros. for sid in [ - 0x14, 0x1E, 0x28, 0x29, 0x2A, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, + 0x14, 0x1E, 0x29, 0x2A, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, ] { g.entry(sid).or_insert(0); } + g.insert( + S_KITS, + items + .iter() + .filter(|item| matches!(item.kind, ContentKind::Kit)) + .count() as i64, + ); let mut stat: Vec = g.iter().map(|(sid, v)| row(1, 0, *sid, *v)).collect(); @@ -297,6 +306,17 @@ mod tests { team_id: None, } } + fn kit() -> ClubStatInput { + ClubStatInput { + kind: ContentKind::Kit, + subtype: 9, + rating: 0, + rare: false, + nation_id: None, + league_id: None, + team_id: Some(21), + } + } fn global(body: &Value) -> std::collections::HashMap { body["stat"] @@ -355,6 +375,14 @@ mod tests { assert_eq!(g["consumablesTrainingPlayerPlayStyle"], 1); } + #[test] + fn owned_kits_increment_global_kit_count() { + let items = vec![player(90, false, None), kit(), kit()]; + let g = global(&club_stats_body(&items, ContextField::Nation)); + assert_eq!(g["players"], 1); + assert_eq!(g["kits"], 2); + } + #[test] fn nation_buckets_emitted_and_players_excludes_nonplayers() { let items = vec![ diff --git a/openfut-adapter-fifa17/src/fut/content_taxonomy.rs b/openfut-adapter-fifa17/src/fut/content_taxonomy.rs index 2bef9e2..0a6b262 100644 --- a/openfut-adapter-fifa17/src/fut/content_taxonomy.rs +++ b/openfut-adapter-fifa17/src/fut/content_taxonomy.rs @@ -18,7 +18,7 @@ //! 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 disjoint content classes a FIFA 17 owned item 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)] @@ -27,6 +27,7 @@ pub enum ContentKind { Player, Consumable, Staff, + Kit, } impl ContentKind { @@ -36,6 +37,7 @@ impl ContentKind { ContentKind::Player => "player", ContentKind::Consumable => "consumable", ContentKind::Staff => "staff", + ContentKind::Kit => "kit", } } @@ -49,6 +51,7 @@ impl ContentKind { match s { "consumable" => ContentKind::Consumable, "staff" => ContentKind::Staff, + "kit" => ContentKind::Kit, _ => ContentKind::Player, } } @@ -106,6 +109,7 @@ mod tests { ContentKind::Player, ContentKind::Consumable, ContentKind::Staff, + ContentKind::Kit, ] { assert_eq!(ContentKind::from_str(k.as_str()), k); } diff --git a/openfut-adapter-fifa17/src/fut/item.rs b/openfut-adapter-fifa17/src/fut/item.rs index e95bc80..174b1d2 100644 --- a/openfut-adapter-fifa17/src/fut/item.rs +++ b/openfut-adapter-fifa17/src/fut/item.rs @@ -66,11 +66,29 @@ pub struct Fifa17Identity { pub rareflag: i64, } +/// FIFA-side identity fields needed to render an owned club kit. Unlike player +/// items, kit art and source-team metadata come from `fcc_kitcards`, not Core. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Fifa17KitIdentity { + pub item_id: u32, + pub asset_id: u32, + pub resource_id: u32, + pub card_asset_id: u32, + pub subtype: i64, + pub team_id: i64, +} + /// Supplies the FIFA numeric identity for a Core item. Returning `None` means /// "no real FIFA asset id known" → the caller must not fabricate one. pub trait ItemIdentityResolver { fn resolve(&self, item: &CoreOwnedItem) -> Option; + /// Resolve one owned kit definition. Default `None` preserves existing + /// player-only resolvers; the catalog-backed FIFA17 resolver overrides it. + fn resolve_kit(&self, _item: &CoreOwnedItem) -> Option { + None + } + /// 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 @@ -158,6 +176,23 @@ pub fn shape_item( }) } +/// Build one FIFA 17 club-kit item. `item_state` is the proven wire enum token: +/// `free`, `activeHomeKit`, or `activeAwayKit`; the client deserializes the +/// latter two to runtime values 101 and 102. +pub fn shape_kit_item(id: Fifa17KitIdentity, item_state: &str) -> Value { + json!({ + "id": id.item_id, + "resourceId": id.resource_id, + "assetId": id.asset_id, + "cardassetid": id.card_asset_id, + "cardsubtypeid": id.subtype, + "itemState": item_state, + "owners": 1, + "untradeable": false, + "teamid": id.team_id, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/openfut-adapter-fifa17/src/fut/owned_query.rs b/openfut-adapter-fifa17/src/fut/owned_query.rs index dc89951..3cc33a1 100644 --- a/openfut-adapter-fifa17/src/fut/owned_query.rs +++ b/openfut-adapter-fifa17/src/fut/owned_query.rs @@ -48,6 +48,9 @@ use std::collections::HashMap; /// fields are FIFA entity ids that MUST be resolved before reaching Core. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Fifa17ClubQuery { + /// Requested FIFA item family (`player`, `kit`, …). Adapter-owned: Core has + /// no FIFA content taxonomy, so the host applies this filter locally. + pub item_type: Option, /// Quality filter: `any` (default, always present) or `gold`. pub level: Option, /// "Special" filter (`SP`). Semantics UNKNOWN — never applied. @@ -115,6 +118,7 @@ pub fn parse_club_query(query: &str) -> Fifa17ClubQuery { None => (pair, String::new()), }; match k { + "type" => out.item_type = Some(v), "level" => out.level = Some(v), "rare" => out.rare = Some(v), "position" => out.position = Some(v), @@ -325,6 +329,7 @@ mod tests { assert_eq!( q, Fifa17ClubQuery { + item_type: Some("player".into()), level: Some("gold".into()), rare: None, position: Some("ST".into()), diff --git a/openfut-core b/openfut-core index 2fb8352..f0550e2 160000 --- a/openfut-core +++ b/openfut-core @@ -1 +1 @@ -Subproject commit 2fb835200ffb7c176d07bad0b40078c609dc83d6 +Subproject commit f0550e2ae1e8130dd8b3ceed8393c8d226a4c66b diff --git a/openfut-import-fifa17/src/lib.rs b/openfut-import-fifa17/src/lib.rs index 134f035..2218def 100644 --- a/openfut-import-fifa17/src/lib.rs +++ b/openfut-import-fifa17/src/lib.rs @@ -166,10 +166,14 @@ pub enum ItemClass { PlayerCard, Consumable, Staff, + Kit, Other, } pub fn classify(item: &Item) -> ItemClass { + if item.cardsubtypeid == Some(9) && (6_300_000..=6_400_654).contains(&item.resource_id) { + return ItemClass::Kit; + } match item.item_type.as_str() { "staff" => ItemClass::Staff, "player" => { @@ -189,12 +193,13 @@ pub struct ItemCounts { pub player_cards: usize, pub consumables: usize, pub staff: usize, + pub kits: usize, pub other: usize, } impl ItemCounts { pub fn balances(&self) -> bool { - self.player_cards + self.consumables + self.staff + self.other == self.total + self.player_cards + self.consumables + self.staff + self.kits + self.other == self.total } } @@ -208,6 +213,7 @@ pub fn count_items(profile: &Profile) -> ItemCounts { ItemClass::PlayerCard => c.player_cards += 1, ItemClass::Consumable => c.consumables += 1, ItemClass::Staff => c.staff += 1, + ItemClass::Kit => c.kits += 1, ItemClass::Other => c.other += 1, } } @@ -524,21 +530,23 @@ pub fn plan_definitions( // ------------------------------------------------------- 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). +/// An honest, profile-derived non-player CardDefinition proposal, keyed by +/// `fifa17_`. Neutral player fields are supplied at emit time; FIFA +/// render metadata stays here and in the adapter catalog, never generic Core. #[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`. + /// Base asset id when the source carries one. pub asset_id: Option, pub kind: ContentKind, - /// FIFA `cardsubtypeid` (consumable family / staff role selector). + /// FIFA `cardsubtypeid` (consumable family, staff role, or kit family). pub subtype: i64, - /// Honest functional label (e.g. "Player Contract", "GK Coach"). + /// Non-player card-art id (`35` for kits), when present. + pub card_asset_id: Option, + /// Source team id for a kit definition, when present. + pub team_id: Option, + /// Honest functional label (e.g. "Player Contract", "GK Coach", "Kit"). pub name: String, /// Wire ids of every owned copy of this resourceId (preserved). pub wire_ids: Vec, @@ -563,6 +571,8 @@ pub struct NonPlayerPlan { pub consumables: usize, /// Count of SUPPORTED staff definitions. pub staff: usize, + /// Count of SUPPORTED kit definitions. + pub kits: usize, } impl NonPlayerPlan { @@ -572,15 +582,16 @@ impl NonPlayerPlan { } } -/// 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. +/// Plan non-player CardDefinitions. Consumable, Staff, and Kit groups must agree +/// on their definition-level metadata across every owned copy; disagreement or +/// missing required metadata defers the whole group, never fabricates a value. 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) { + if matches!( + classify(it), + ItemClass::Consumable | ItemClass::Staff | ItemClass::Kit + ) { groups.entry(it.resource_id).or_default().push(it); } } @@ -622,6 +633,21 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan { continue; }; + let card_asset_id = items[0].cardassetid; + let team_id = items[0].teamid; + if items + .iter() + .any(|item| item.cardassetid != card_asset_id || item.teamid != team_id) + { + plan.deferred.push(DeferredNonPlayer { + resource_id, + subtype: Some(subtype), + wire_ids, + reason: "render_metadata_conflict".to_string(), + }); + continue; + } + let (kind, label) = match class { ItemClass::Consumable => match consumable_family(subtype) { Some((_family, label)) => (ContentKind::Consumable, label), @@ -647,15 +673,33 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan { continue; } }, - _ => unreachable!("only Consumable/Staff were grouped"), + ItemClass::Kit if subtype == 9 => { + if card_asset_id != Some(35) || team_id.is_none() { + plan.deferred.push(DeferredNonPlayer { + resource_id, + subtype: Some(subtype), + wire_ids, + reason: "missing_kit_render_metadata".to_string(), + }); + continue; + } + (ContentKind::Kit, "Kit") + } + _ => unreachable!("only Consumable/Staff/Kit were grouped"), }; plan.supported.push(NonPlayerDefinition { card_id: format!("fifa17_{resource_id}"), resource_id, - asset_id: items[0].asset_id, + asset_id: if class == ItemClass::Kit { + Some(resource_id) + } else { + items[0].asset_id + }, kind, subtype, + card_asset_id, + team_id, name: label.to_string(), wire_ids, }); @@ -670,6 +714,11 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan { .iter() .filter(|d| d.kind == ContentKind::Staff) .count(); + plan.kits = plan + .supported + .iter() + .filter(|definition| definition.kind == ContentKind::Kit) + .count(); plan } @@ -815,10 +864,11 @@ impl Report { let mut b = Vec::new(); if !self.counts.balances() { b.push(format!( - "item-type accounting does not balance ({}+{}+{}+{} != {})", + "item-type accounting does not balance ({}+{}+{}+{}+{} != {})", self.counts.player_cards, self.counts.consumables, self.counts.staff, + self.counts.kits, self.counts.other, self.counts.total )); @@ -911,11 +961,12 @@ impl std::fmt::Display for Report { )?; writeln!( f, - "\nSOURCE ITEMS total={} player_cards={} consumables={} staff={} other={} balances={}", + "\nSOURCE ITEMS total={} player_cards={} consumables={} staff={} kits={} other={} balances={}", self.counts.total, self.counts.player_cards, self.counts.consumables, self.counts.staff, + self.counts.kits, self.counts.other, self.counts.balances() )?; @@ -976,10 +1027,11 @@ impl std::fmt::Display for Report { } writeln!( f, - "\nNON-PLAYER CONTENT (consumable/staff) supported={} (consumables={} staff={}) deferred_groups={} deferred_instances={}", + "\nNON-PLAYER CONTENT (consumable/staff/kit) supported={} (consumables={} staff={} kits={}) deferred_groups={} deferred_instances={}", np.supported.len(), np.consumables, np.staff, + np.kits, np.deferred.len(), np.deferred_instances() )?; @@ -1130,6 +1182,8 @@ pub fn emit_content( cards.insert( d.card_id.clone(), serde_json::json!({ + "card_asset_id": d.card_asset_id, + "team_id": d.team_id, "asset_id": d.asset_id.unwrap_or(d.resource_id), "version": 0, "rareflag": 0, @@ -1195,6 +1249,8 @@ pub fn emit_content( .iter() .map(|d| { serde_json::json!({ + "card_asset_id": d.card_asset_id, + "team_id": d.team_id, "card_id": d.card_id, "resource_id": d.resource_id, "asset_id": d.asset_id, diff --git a/openfut-import-fifa17/src/model.rs b/openfut-import-fifa17/src/model.rs index 58ab5dc..f6a3c36 100644 --- a/openfut-import-fifa17/src/model.rs +++ b/openfut-import-fifa17/src/model.rs @@ -76,6 +76,9 @@ pub struct Item { /// Staff/contract `contract` count. Permissive. #[serde(default)] pub contract: Option, + /// Owned-item lifecycle state (`activeHomeKit` / `activeAwayKit` for kits). + #[serde(rename = "itemState", default)] + pub item_state: String, } #[derive(Debug, Clone, Deserialize)] diff --git a/openfut-import-fifa17/src/tests.rs b/openfut-import-fifa17/src/tests.rs index 2f26b14..4a7d16d 100644 --- a/openfut-import-fifa17/src/tests.rs +++ b/openfut-import-fifa17/src/tests.rs @@ -72,11 +72,21 @@ fn classification_balances_across_disjoint_classes() { player(100000002, VER5_176580, 176580, 92), r#"{"id":100000239,"resourceId":5003012,"assetId":5003012,"itemType":"player","rating":85}"#.to_string(), r#"{"id":100000427,"resourceId":3000083,"itemType":"staff"}"#.to_string(), + r#"{"id":100000500,"resourceId":6300006,"assetId":6300006, + "cardsubtypeid":9,"cardassetid":35,"teamid":21,"itemState":"activeHomeKit"}"# + .to_string(), ]; - let c = count_items(&profile(&items, "[]", 100000500)); + let c = count_items(&profile(&items, "[]", 100000501)); assert_eq!( - (c.total, c.player_cards, c.consumables, c.staff, c.other), - (4, 2, 1, 1, 0) + ( + c.total, + c.player_cards, + c.consumables, + c.staff, + c.kits, + c.other + ), + (5, 2, 1, 1, 1, 0) ); assert!(c.balances()); } @@ -649,6 +659,14 @@ fn staff(id: i64, resource: i64, subtype: i64) -> String { ) } +fn kit(id: i64, resource: i64, team_id: i64, item_state: &str) -> String { + format!( + r#"{{"id":{id},"resourceId":{resource},"assetId":{resource}, + "cardsubtypeid":9,"cardassetid":35,"teamid":{team_id}, + "itemState":"{item_state}","owners":1,"untradeable":false}}"# + ) +} + #[test] fn plan_non_player_supports_seventeen_consumables_and_three_staff() { // The exact record set from the ticket: distinct resourceIds, so each is its @@ -676,6 +694,7 @@ fn plan_non_player_supports_seventeen_consumables_and_three_staff() { ); assert_eq!(plan.consumables, 17); assert_eq!(plan.staff, 3); + assert_eq!(plan.kits, 0); assert!(plan.deferred.is_empty(), "0 deferred: {:?}", plan.deferred); // Honest labels + kinds resolve from the taxonomy (spot checks). @@ -695,6 +714,17 @@ fn plan_non_player_supports_seventeen_consumables_and_three_staff() { assert_eq!(by_id("fifa17_3000003").name, "GK Coach"); } +#[test] +fn kit_missing_render_metadata_defers() { + let item = r#"{"id":100000501,"resourceId":6300006,"assetId":6300006, + "cardsubtypeid":9,"itemState":"activeHomeKit"}"# + .to_string(); + let plan = plan_non_player_definitions(&profile(&[item], "[]", 100000600)); + assert!(plan.supported.is_empty()); + assert_eq!(plan.deferred.len(), 1); + assert_eq!(plan.deferred[0].reason, "missing_kit_render_metadata"); +} + #[test] fn unknown_subtype_consumable_defers_never_fabricated() { let plan = plan_non_player_definitions(&profile( @@ -763,6 +793,7 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() { player(100000001, 20801, 20801, 94), consumable(100000201, 5003012, 201), // Player Contract staff(100000427, 3000083, 8), // Fitness Coach + kit(100000500, 6300006, 21, "activeHomeKit"), ]; let rep = analyze( &profile(&items, "[]", 100000500), @@ -771,16 +802,16 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() { &none(), ); assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers()); - assert_eq!(rep.non_player.supported.len(), 2); + assert_eq!(rep.non_player.supported.len(), 3); 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.non_player_definitions, 3); + assert_eq!(sum.non_player_instances, 3); assert_eq!( - sum.catalog_entries, 3, - "player + 2 non-player catalog entries" + sum.catalog_entries, 4, + "player + 3 non-player catalog entries" ); // Content pack: neutral non-player CardDefinition with honest name. @@ -807,21 +838,29 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() { 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); + assert_eq!(cat["cards"]["fifa17_6300006"]["kind"], "kit"); + assert_eq!(cat["cards"]["fifa17_6300006"]["subtype"], 9); + assert_eq!(cat["cards"]["fifa17_6300006"]["card_asset_id"], 35); + assert_eq!(cat["cards"]["fifa17_6300006"]["team_id"], 21); 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); + assert_eq!(loaded.kind_of("fifa17_6300006"), ContentKind::Kit); + let loaded_kit = loaded.lookup("fifa17_6300006").unwrap(); + assert_eq!(loaded_kit.card_asset_id, 35); + assert_eq!(loaded_kit.team_id, 21); // 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); + assert_eq!(man["non_player"]["supported_instances"], 3); let np = man["non_player"]["supported_definitions"] .as_array() .unwrap(); - assert_eq!(np.len(), 2); + assert_eq!(np.len(), 3); let cons_man = np .iter() .find(|d| d["card_id"] == "fifa17_5003012") @@ -836,13 +875,14 @@ fn plan_apply_mints_non_player_owned_instances() { player(100000001, 20801, 20801, 94), consumable(100000201, 5003012, 201), staff(100000427, 3000083, 8), + kit(100000500, 6300006, 21, "activeHomeKit"), ]; 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); + // Player, consumable, staff, and kit instances mint through one generic path. + assert_eq!(plan.request.owned.len(), 4); + assert_eq!(plan.mappings.len(), 4); + assert_eq!(plan.supported_instances, 4); assert_eq!(plan.deferred_instances, 0); let cards: BTreeSet<&str> = plan .request @@ -852,6 +892,7 @@ fn plan_apply_mints_non_player_owned_instances() { .collect(); assert!(cards.contains("fifa17_5003012"), "consumable minted"); assert!(cards.contains("fifa17_3000083"), "staff minted"); + assert!(cards.contains("fifa17_6300006"), "kit minted"); // Deterministic OwnedItemId per (persona, wire) — same rule as players. let m = plan .mappings diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index e9a5355..59ab3e2 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -51,7 +51,8 @@ use std::time::{Instant, SystemTime, UNIX_EPOCH}; use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy}; use openfut_adapter_fifa17::fut::club_response::{ - shape_club_response, CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats, + shape_club_response_with_kits, ActiveKitAssignments, CoreOwnedItem, Fifa17Identity, + Fifa17KitIdentity, ItemIdentityResolver, }; use openfut_adapter_fifa17::fut::club_stats::{club_stats_body, ClubStatInput, ContextField}; use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind; @@ -636,6 +637,13 @@ pub struct CoreReplaceResult { pub slots_written: usize, } +/// Core owned-instance ids assigned to the club's two active kit roles. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CoreKitAssignments { + pub home_owned_card_id: Option, + pub away_owned_card_id: Option, +} + /// How the host reaches Core. The adapter never sees this — the host owns the /// transport, mirroring the architecture rule. Tests inject a fake. #[derive(Debug, Clone, PartialEq, Eq)] @@ -698,6 +706,12 @@ pub trait CoreAccess: Send + Sync { )) } + /// Ownership-backed active home/away kit ids (`GET /club/kits`). A Core + /// without the endpoint projects no active kits rather than fabricating one. + fn get_active_kits(&self) -> Result { + Ok(CoreKitAssignments::default()) + } + fn list_sbcs(&self) -> Result, CoreError> { Err(CoreError::Parse( "Core SBC access is not implemented".into(), @@ -872,6 +886,32 @@ impl CoreAccess for HttpCoreClient { Ok(()) } + fn get_active_kits(&self) -> Result { + let response = self + .client + .get(format!("{}/club/kits", self.base_url)) + .header("X-OpenFUT-Game", &self.game) + .send() + .map_err(|error| CoreError::Http(error.to_string()))?; + let status = response.status().as_u16(); + if !(200..300).contains(&status) { + return Err(CoreError::Status(status)); + } + let body: Value = response + .json() + .map_err(|error| CoreError::Parse(error.to_string()))?; + let owned_id = |slot: &str| { + body.get(slot) + .and_then(|item| item.get("id")) + .and_then(Value::as_str) + .map(str::to_string) + }; + Ok(CoreKitAssignments { + home_owned_card_id: owned_id("home"), + away_owned_card_id: owned_id("away"), + }) + } + fn list_sbcs(&self) -> Result, CoreError> { let response = self .client @@ -1608,6 +1648,24 @@ impl Fifa17IdentityResolver { pub fn definition_identity(&self, card_id: &str) -> Option<(i64, ContentKind)> { self.catalog.lookup(card_id).map(|c| (c.rareflag, c.kind)) } + + fn wire_for(&self, item: &CoreOwnedItem) -> Option { + match self.store.resolve_or_allocate( + Fifa17WireItemIdPolicy::GAME, + Fifa17WireItemIdPolicy::OWNED_ITEM_KIND, + &item.owned_card_id, + Fifa17WireItemIdPolicy::owned_item_base_floor(), + ) { + Ok(wire) => Some(wire as u32), + Err(error) => { + eprintln!( + "utas-host ERROR identity store alloc failed for {}: {error}", + item.owned_card_id + ); + None + } + } + } } impl ItemIdentityResolver for Fifa17IdentityResolver { @@ -1615,36 +1673,32 @@ impl ItemIdentityResolver for Fifa17IdentityResolver { // Definition identity first: an unmapped card is dropped (never faked). let ident = self.catalog.lookup(&item.card_id)?; // Instance identity: stable, persistent, reversible wire id. - let wire = match self.store.resolve_or_allocate( - Fifa17WireItemIdPolicy::GAME, - Fifa17WireItemIdPolicy::OWNED_ITEM_KIND, - &item.owned_card_id, - Fifa17WireItemIdPolicy::owned_item_base_floor(), - ) { - Ok(w) => w, - Err(e) => { - // Infrastructure failure allocating a wire id: drop this item - // (freeze-safe) and log — never emit an unstable/fake id. - eprintln!( - "utas-host ERROR identity store alloc failed for {}: {e}", - item.owned_card_id - ); - return None; - } - }; + let wire = self.wire_for(item)?; Some(Fifa17Identity { - // Wire ids live in 1e8..9e8 (policy) — well within u32. - item_id: wire as u32, + item_id: wire, asset_id: ident.asset_id, resource_id: ident.resource_id, 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 resolve_kit(&self, item: &CoreOwnedItem) -> Option { + let ident = self.catalog.lookup(&item.card_id)?; + if ident.kind != ContentKind::Kit { + return None; + } + Some(Fifa17KitIdentity { + item_id: self.wire_for(item)?, + asset_id: ident.asset_id, + resource_id: ident.resource_id, + card_asset_id: ident.card_asset_id, + subtype: ident.subtype, + team_id: ident.team_id, + }) + } + + /// Delegate content classification to the catalog. Unknown definitions + /// retain the backward-compatible Player default but fail identity resolution. fn kind_of(&self, item: &CoreOwnedItem) -> ContentKind { self.catalog.kind_of(&item.card_id) } @@ -1684,6 +1738,7 @@ pub struct ClubDeps<'a> { /// ACTIVE transfer-market listing. In FIFA a listed card has LEFT the club, so /// it must not also appear here. Empty = show everything Core owns. pub hidden: &'a std::collections::HashSet, + pub active_kits: &'a CoreKitAssignments, } /// Filter already-shaped `/club` items to SPECIALS (`rareflag > 1`) and paginate @@ -1742,10 +1797,27 @@ fn paginate_items(items: &[Value], offset: Option, limit: Option) -> ( /// JSON body (UTAS must never 401/403; an empty result is the safe degrade). pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) { let raw = parse_club_query(query); + let requested_kind = match raw.item_type.as_deref() { + None | Some("player") => ContentKind::Player, + Some("kit") => ContentKind::Kit, + Some(other) => { + return ( + json_response(&json!({ "itemData": [] })), + ClubLog { + outcome: "unsupported_type", + filter: format!("type={other}"), + total: 0, + emitted: 0, + dropped_no_asset: 0, + offset: raw.start.map(|value| value as i64), + limit: raw.count.map(|value| value as i64), + }, + ); + } + }; let core_q = match map_to_core(&raw, deps.entities) { Ok(c) => c, Err(e) => { - // Unknown FIFA id — never a raw-id passthrough, never a guess. return ( json_response(&json!({ "itemData": [] })), ClubLog { @@ -1754,104 +1826,70 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) total: 0, emitted: 0, dropped_no_asset: 0, - offset: raw.start.map(|s| s as i64), - limit: raw.count.map(|c| c as i64), + offset: raw.start.map(|value| value as i64), + limit: raw.count.map(|value| value as i64), }, ); } }; - let pairs = core_q.to_query_pairs(); - let filter = summarize(&pairs); let (offset, limit) = (core_q.offset, core_q.limit); - - // Host-side filters Core cannot express: - // * "Special" (rare=SP): rareflag lives in the FIFA catalog, not Core. - // * listed-card exclusion: transfer-market listings are host-owned state. - // Either way Core must NOT paginate — it would paginate the unfiltered set - // and return short pages. So fetch everything matching the OTHER filters, - // exclude/shape locally, then paginate the filtered set here. When neither - // applies (the common case) the fast Core-paginated path below is unchanged. - if core_q.special || !deps.hidden.is_empty() { - // Log which host-side filter forced local pagination. - let local_desc = match (core_q.special, deps.hidden.len()) { - (true, 0) => format!("{filter},rare=SP"), - (true, n) => format!("{filter},rare=SP,hidden={n}"), - (false, n) => format!("{filter},hidden={n}"), - }; - let mut base = core_q.clone(); - base.offset = None; - base.limit = None; - return match deps.core.query_owned(&base.to_query_pairs()) { - Ok(page) => { - // Exclude hidden instances BEFORE shaping: a card on the transfer - // list is not in the club, so it must not consume a page slot. - let visible: Vec = page - .items - .into_iter() - .filter(|it| !deps.hidden.contains(&it.owned_card_id)) - .collect(); - let (body, stats) = shape_club_response(&visible, deps.entities, deps.assets); - let all = body - .get("itemData") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - let (paged, total) = if core_q.special { - special_filter_page(&all, offset, limit) - } else { - paginate_items(&all, offset, limit) - }; - let emitted = paged.len(); - ( - json_response(&json!({ "itemData": paged })), - ClubLog { - outcome: "ok", - filter: local_desc, - total, - emitted, - dropped_no_asset: stats.dropped_no_asset, - offset, - limit, - }, - ) - } - Err(e) => { - eprintln!("utas-host ERROR /club (local-filter) core query failed: {e}"); - ( - json_response(&json!({ "itemData": [] })), - ClubLog { - outcome: "core_error", - filter: local_desc, - total: 0, - emitted: 0, - dropped_no_asset: 0, - offset, - limit, - }, - ) - } - }; + let mut base = core_q.clone(); + base.offset = None; + base.limit = None; + let mut filter = summarize(&base.to_query_pairs()); + if !filter.is_empty() { + filter.push(','); } - match deps.core.query_owned(&pairs) { + filter.push_str(&format!("type={}", requested_kind.as_str())); + if core_q.special { + filter.push_str(",rare=SP"); + } + if !deps.hidden.is_empty() { + filter.push_str(&format!(",hidden={}", deps.hidden.len())); + } + + match deps.core.query_owned(&base.to_query_pairs()) { Ok(page) => { - let (body, stats): (Value, ShapeStats) = - shape_club_response(&page.items, deps.entities, deps.assets); + // Kind and transfer-pile membership live outside generic Core, so + // filtering and pagination must happen here over the final set. + let visible: Vec = page + .items + .into_iter() + .filter(|item| !deps.hidden.contains(&item.owned_card_id)) + .filter(|item| deps.assets.kind_of(item) == requested_kind) + .collect(); + let active = ActiveKitAssignments { + home: deps.active_kits.home_owned_card_id.as_deref(), + away: deps.active_kits.away_owned_card_id.as_deref(), + }; + let (body, stats) = + shape_club_response_with_kits(&visible, deps.entities, deps.assets, active); + let all = body + .get("itemData") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let (paged, total) = if core_q.special { + special_filter_page(&all, offset, limit) + } else { + paginate_items(&all, offset, limit) + }; + let emitted = paged.len(); ( - json_response(&body), + json_response(&json!({ "itemData": paged })), ClubLog { outcome: "ok", filter, - total: page.total, - emitted: stats.emitted, + total, + emitted, dropped_no_asset: stats.dropped_no_asset, offset, limit, }, ) } - Err(e) => { - // Degrade to a valid empty page; DO NOT fall back to Python. - eprintln!("utas-host ERROR /club core query failed: {e}"); + Err(error) => { + eprintln!("utas-host ERROR /club core query failed: {error}"); ( json_response(&json!({ "itemData": [] })), ClubLog { @@ -3382,11 +3420,16 @@ impl Server { Route::Club => { let query = target.split_once('?').map(|(_, q)| q).unwrap_or(""); let hidden = self.club_hidden_ids(); + let active_kits = self.core.get_active_kits().unwrap_or_else(|error| { + eprintln!("utas-host WARN active kit read unavailable: {error}"); + CoreKitAssignments::default() + }); let deps = ClubDeps { core: self.core.as_ref(), entities: self.entities.as_ref(), assets: self.resolver.as_ref(), hidden: &hidden, + active_kits: &active_kits, }; let (resp, log) = handle_club(query, &deps); eprintln!( diff --git a/openfut-utas-host/tests/host_test.rs b/openfut-utas-host/tests/host_test.rs index 29f7818..fb87b5b 100644 --- a/openfut-utas-host/tests/host_test.rs +++ b/openfut-utas-host/tests/host_test.rs @@ -15,9 +15,9 @@ use openfut_identity::JsonIdentityStore; use openfut_utas_host::account_store::AccountStore; use openfut_utas_host::{ classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list, - handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState, CorePage, - CoreReplaceRequest, CoreReplaceResult, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver, - HttpCoreClient, PassClient, Route, Server, SquadDeps, + handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState, + CoreKitAssignments, CorePage, CoreReplaceRequest, CoreReplaceResult, CoreSquadRead, + CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, PassClient, Route, Server, SquadDeps, }; use parking_lot::Mutex; use serde_json::Value; @@ -338,11 +338,13 @@ fn club_excludes_listed_items_and_paginates_the_visible_set() { // oc2 has an active transfer-market listing. let hidden: std::collections::HashSet = ["oc2".to_string()].into_iter().collect(); + let active_kits = CoreKitAssignments::default(); let deps = ClubDeps { core: core.as_ref(), entities: &ents, assets: resolver.as_ref(), hidden: &hidden, + active_kits: &active_kits, }; let (resp, log) = handle_club("", &deps); let v: Value = serde_json::from_slice(&resp.body).unwrap(); @@ -370,13 +372,14 @@ fn club_excludes_listed_items_and_paginates_the_visible_set() { ); assert_eq!(log2.total, 2); - // Nothing hidden → the fast Core-paginated path, all three visible. + // Nothing hidden → all three player items remain visible. let none: std::collections::HashSet = std::collections::HashSet::new(); let deps_all = ClubDeps { core: core.as_ref(), entities: &ents, assets: resolver.as_ref(), hidden: &none, + active_kits: &active_kits, }; let (resp3, log3) = handle_club("", &deps_all); let v3: Value = serde_json::from_slice(&resp3.body).unwrap(); @@ -384,6 +387,67 @@ fn club_excludes_listed_items_and_paginates_the_visible_set() { assert_eq!(log3.total, 3); } +#[test] +fn club_projects_only_owned_kits_with_active_designations() { + let core = Arc::new(FakeCore::new( + vec![ + item( + "player", + "card_player", + 90, + "ST", + "Argentina", + "Premier League", + "Chelsea", + ), + item("home", "kit_home", 0, "", "", "", ""), + item("away", "kit_away", 0, "", "", "", ""), + ], + 3, + )); + let catalog = Fifa17CardCatalog::from_json_str( + r#"{"schema_version":1,"game":"fifa17","cards":{ + "card_player":{"asset_id":20801}, + "kit_home":{"asset_id":6300006,"kind":"kit","subtype":9, + "card_asset_id":35,"team_id":21,"rareflag":0}, + "kit_away":{"asset_id":6400003,"kind":"kit","subtype":9, + "card_asset_id":35,"team_id":21,"rareflag":0} + }}"#, + ) + .unwrap(); + let resolver = Arc::new(Fifa17IdentityResolver::new( + catalog, + Arc::new(JsonIdentityStore::open(unique_store_path()).unwrap()), + )); + let ents = entities(); + let hidden = std::collections::HashSet::new(); + let active_kits = CoreKitAssignments { + home_owned_card_id: Some("home".into()), + away_owned_card_id: Some("away".into()), + }; + let deps = ClubDeps { + core: core.as_ref(), + entities: &ents, + assets: resolver.as_ref(), + hidden: &hidden, + active_kits: &active_kits, + }; + + let (kit_response, kit_log) = handle_club("type=kit", &deps); + let kits: Value = serde_json::from_slice(&kit_response.body).unwrap(); + assert_eq!(kit_log.total, 2); + assert_eq!(kits["itemData"][0]["itemState"], "activeHomeKit"); + assert_eq!(kits["itemData"][1]["itemState"], "activeAwayKit"); + assert_eq!(kits["itemData"][0]["cardassetid"], 35); + assert_eq!(kits["itemData"][0]["teamid"], 21); + + let (player_response, player_log) = handle_club("type=player", &deps); + let players: Value = serde_json::from_slice(&player_response.body).unwrap(); + assert_eq!(player_log.total, 1); + assert_eq!(players["itemData"].as_array().unwrap().len(), 1); + assert_eq!(players["itemData"][0]["itemType"], "player"); +} + // ── /club served from Core ───────────────────────────────────────────────── #[test] diff --git a/scripts/sold-staging-up.py b/scripts/sold-staging-up.py index 6d98582..cf1818b 100755 --- a/scripts/sold-staging-up.py +++ b/scripts/sold-staging-up.py @@ -149,6 +149,14 @@ SELLER_SQUAD_CARDS = [ DISPOSABLE_ITEM = "owned-a-disposable" DISPOSABLE_CARD = "fifa17_232273" # Nelson Atiagli LB 51, rareflag 1 +# Two authoritative modern kit-card definitions for one real source team. These +# are staging fixtures derived from fcc_kitcards, not synthetic FIFA identities. +KIT_TEAM_ID = 21 +STAGING_KITS = [ + ("home", "owned-a-kit-home", "fifa17_6300006", 6_300_006), + ("away", "owned-a-kit-away", "fifa17_6400003", 6_400_003), +] + READY_TIMEOUT_S = 60.0 @@ -439,6 +447,47 @@ def materialise(lay: Layout) -> None: shutil.copy2(safe_path(src), safe_path(dst)) ok(f"FIFA17 content copied from {CONTENT_SRC} (outside production state)") + with open(safe_path(lay.cards)) as fh: + definitions = json.load(fh) + with open(safe_path(lay.catalog)) as fh: + catalog = json.load(fh) + existing = {definition["id"] for definition in definitions} + for _slot, _owned_id, card_id, resource_id in STAGING_KITS: + if card_id not in existing: + definitions.append({ + "id": card_id, + "name": "Kit", + "overall": 0, + "position": "", + "nation": "", + "league": "", + "club": "", + "pace": 0, + "shooting": 0, + "passing": 0, + "dribbling": 0, + "defending": 0, + "physical": 0, + "rarity": "bronze", + "image_path": None, + }) + catalog["cards"][card_id] = { + "asset_id": resource_id, + "version": 0, + "rareflag": 0, + "kind": "kit", + "subtype": 9, + "card_asset_id": 35, + "team_id": KIT_TEAM_ID, + } + with open(safe_path(lay.cards), "w") as fh: + json.dump(definitions, fh, indent=2) + fh.write("\n") + with open(safe_path(lay.catalog), "w") as fh: + json.dump(catalog, fh, indent=2) + fh.write("\n") + ok("added ownership-backed home/away kit fixtures from fcc_kitcards") + def assert_seed_cards_resolvable(lay: Layout) -> None: """Core's content preflight rejects any owned card whose card_id is not a loaded @@ -448,7 +497,11 @@ def assert_seed_cards_resolvable(lay: Layout) -> None: pack_ids = {c["id"] for c in json.load(fh)} with open(safe_path(lay.catalog)) as fh: catalog_ids = set(json.load(fh)["cards"]) - wanted = [c for _, c in SELLER_SQUAD_CARDS] + [DISPOSABLE_CARD] + wanted = ( + [card for _, card in SELLER_SQUAD_CARDS] + + [DISPOSABLE_CARD] + + [card for _, _, card, _ in STAGING_KITS] + ) missing_pack = sorted(set(wanted) - pack_ids) missing_cat = sorted(set(wanted) - catalog_ids) if missing_pack or missing_cat: @@ -587,7 +640,11 @@ def seed_core_db(lay: Layout) -> None: "INSERT INTO owned_cards (id, club_id, card_id, is_loan, " "acquired_at) VALUES (?, ?, ?, 0, ?)", [(item, SELLER_CLUB, card, TS) for item, card in SELLER_SQUAD_CARDS] - + [(DISPOSABLE_ITEM, SELLER_CLUB, DISPOSABLE_CARD, TS)], + + [(DISPOSABLE_ITEM, SELLER_CLUB, DISPOSABLE_CARD, TS)] + + [ + (owned_id, SELLER_CLUB, card_id, TS) + for _slot, owned_id, card_id, _resource_id in STAGING_KITS + ], ) conn.execute( "INSERT INTO squads (id, club_id, name, formation, created_at, " @@ -602,12 +659,20 @@ def seed_core_db(lay: Layout) -> None: for idx, (item, _) in enumerate(SELLER_SQUAD_CARDS) ], ) + conn.executemany( + "INSERT INTO club_kit_assignments (club_id, slot, owned_card_id, updated_at) " + "VALUES (?, ?, ?, ?)", + [ + (SELLER_CLUB, slot, owned_id, TS) + for slot, owned_id, _card_id, _resource_id in STAGING_KITS + ], + ) finally: conn.close() ok( f"seeded Seller A ({PERSONA_NAME}, persona {PERSONA_ID}, {SELLER_COINS} coins, " - f"{len(SELLER_SQUAD_CARDS)} starters + 1 disposable) and Buyer B " - f"({BUYER_COINS} coins)" + f"{len(SELLER_SQUAD_CARDS)} starters + 1 disposable + " + f"{len(STAGING_KITS)} active kits) and Buyer B ({BUYER_COINS} coins)" )