//! Shape OpenFUT Core's semantic owned inventory into the FIFA 17 `/club` //! response envelope `{"itemData":[ , … ]}`. //! //! This module owns only the **`/club` envelope**; the per-item shape lives in //! the shared [`crate::fut::item`] primitive so `/club` and squad projection //! emit byte-identical items. Items whose real FIFA asset id is unknown are //! **dropped and counted** here (a collection may omit an unrenderable card); //! squad projection, which cannot omit a starter, refuses instead. use serde_json::{json, Value}; use crate::fut::content_taxonomy::ContentKind; use crate::fut::entities::ReverseEntityResolver; use crate::fut::item::{shape_item, shape_kit_item}; // 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, Fifa17KitIdentity, ItemIdentityResolver, ShapeStats, }; /// 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 { 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; } } } (json!({ "itemData": out }), stats) } #[cfg(test)] mod tests { use super::*; use crate::fut::entities::Fifa17Entities; use std::collections::HashMap; fn entities() -> Fifa17Entities { Fifa17Entities::from_maps( HashMap::from([(13, "Premier League".to_string())]), HashMap::from([(52, "Argentina".to_string())]), HashMap::from([(5, "Chelsea".to_string())]), ) } fn item( owned: &str, card: &str, rating: u8, pos: &str, nation: &str, league: &str, club: &str, ) -> CoreOwnedItem { CoreOwnedItem { owned_card_id: owned.into(), card_id: card.into(), rating, position: pos.into(), nation: nation.into(), league: league.into(), club: club.into(), attributes: [90, 88, 70, 85, 40, 78], } } /// Test resolver: card_id -> real asset id, item_id from a table. Stands in /// for the (unresolved-in-production) Core-card→asset mapping. struct MapIdentity(HashMap); impl ItemIdentityResolver for MapIdentity { fn resolve(&self, it: &CoreOwnedItem) -> Option { self.0.get(&it.card_id).copied() } } #[test] fn shapes_item_with_full_field_set_and_reverse_ids() { let ent = entities(); let ident = MapIdentity(HashMap::from([( "card_ch_1".to_string(), Fifa17Identity { item_id: 100000001, asset_id: 20801, resource_id: 20801, rareflag: 1, }, )])); let items = vec![item( "oc1", "card_ch_1", 86, "CDM", "Argentina", "Premier League", "Chelsea", )]; let (body, stats) = shape_club_response(&items, &ent, &ident); assert_eq!(stats.emitted, 1); assert_eq!(stats.dropped_no_asset, 0); let it = &body["itemData"][0]; assert_eq!(it["id"], 100000001); assert_eq!(it["resourceId"], 20801); assert_eq!(it["assetId"], 20801); assert_eq!( it["definitionId"], 20801, "version byte 0 => resourceId==assetId==definitionId" ); assert_eq!(it["rating"], 86); assert_eq!(it["preferredPosition"], "CDM"); assert_eq!(it["leagueId"], 13); assert_eq!(it["teamid"], 5); assert_eq!(it["nation"], 52); assert_eq!(it["itemType"], "player"); assert_eq!(it["rareflag"], 1); assert_eq!(it["contract"], 7); assert_eq!(it["fitness"], 99); assert_eq!(it["attributeList"].as_array().unwrap().len(), 6); assert_eq!(it["attributeList"][0], json!({"index":0,"value":90})); } #[test] fn drops_items_without_a_real_asset_id_never_faking() { let ent = entities(); // Empty identity map == the current synthetic-catalogue reality. let ident = MapIdentity(HashMap::new()); let items = vec![item( "oc1", "card_pl_001", 84, "ST", "England", "Premier League", "Northgate United", )]; let (body, stats) = shape_club_response(&items, &ent, &ident); assert_eq!(stats.emitted, 0); assert_eq!(stats.dropped_no_asset, 1); assert_eq!( body["itemData"].as_array().unwrap().len(), 0, "no fabricated ids emitted" ); } #[test] fn unresolved_entity_names_become_neutral_zero_not_dropped() { let ent = entities(); let ident = MapIdentity(HashMap::from([( "card_x".to_string(), Fifa17Identity { item_id: 100000002, asset_id: 158023, resource_id: 158023, rareflag: 1, }, )])); // Synthetic club "Northgate United" has no FIFA team id. let items = vec![item( "oc2", "card_x", 84, "ST", "England", "Premier League", "Northgate United", )]; let (body, _) = shape_club_response(&items, &ent, &ident); let it = &body["itemData"][0]; assert_eq!( it["teamid"], 0, "unknown club -> neutral 0, item still emitted" ); assert_eq!(it["leagueId"], 13); assert_eq!(it["nation"], 0, "England not in the test nation map -> 0"); } #[test] fn envelope_is_itemdata_object() { let ent = entities(); let ident = MapIdentity(HashMap::new()); let (body, _) = shape_club_response(&[], &ent, &ident); assert!(body.get("itemData").unwrap().is_array()); assert_eq!( body.as_object().unwrap().len(), 1, "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, 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) .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)), ]), kits: HashMap::new(), 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"); } #[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()); } }