//! 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::contract_cards::PACK_FRESH_CONTRACT_MATCHES; use crate::fut::entities::ReverseEntityResolver; use crate::fut::item::{shape_club_item, shape_item, shape_staff_item, STAFF_CONTRACT}; use crate::fut::item_state; // Re-exported so existing `club_response::{…}` callers keep working; the types // are now defined once in `fut::item`. pub use crate::fut::item::{ CoreOwnedItem, Fifa17ConsumableIdentity, Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, 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. /// /// This envelope carries the two families whose record shape it can carry: /// players and kits, plus the staff family (manager + the four coach families). /// Consumables have their own route and their own STACK envelope, and the /// club-customisation families are counted and withheld — see each arm. 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, ident.discard_value(item), item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES), )); stats.emitted += 1; } None => stats.dropped_no_asset += 1, }, // Kit, badge and stadium are ONE cardtype-7 record with one // client-side resolver; only the equipped designation differs. ContentKind::Kit | ContentKind::Badge | ContentKind::Stadium => { match ident.resolve_kit(item) { Some(id) => { let state = if active_kits.home == Some(item.owned_card_id.as_str()) { item_state::ACTIVE_HOME_KIT } else if active_kits.away == Some(item.owned_card_id.as_str()) { item_state::ACTIVE_AWAY_KIT } else { item_state::FREE }; out.push(shape_club_item(id, state)); stats.emitted += 1; } None => stats.dropped_no_asset += 1, } } // A manager is a staff card: both Core kinds resolve through the one // staff record shape, discriminated on the wire by `cardsubtypeid` // (the same set as `ContentKind::is_staff_family`, spelled out here // because a guard arm would not prove exhaustiveness). ContentKind::Manager | ContentKind::Staff => match ident.resolve_staff(item) { Some(id) => { out.push(shape_staff_item( id, item.contract_matches.unwrap_or(STAFF_CONTRACT), )); stats.emitted += 1; } None => stats.dropped_no_asset += 1, }, // Consumables have their OWN route and their own envelope: // `GET club/consumables/`, whose element is a stack // wrapper, not an item (see [`crate::fut::consumables`]). A bare // consumable item in THIS envelope is accepted by the client and // silently discarded, so emitting one here would be a 200 that does // nothing — the worst failure shape in this project. Counted. ContentKind::Consumable => { stats.excluded_non_player += 1; } // The cardtype-9 families. Unlike kits/badges/stadia these have NO // database name resolver at all, so the displayed name can only come // from `localizedName` on the wire. That offset is confirmed // (`+0xd9`), but "the parser reads it" is NOT "sending it is safe", // and this project pays for that distinction with a client freeze. // Counted and withheld rather than guessed: ownership stays // authoritative in Core either way, and club/stats still counts the // families so the screen's own numbers are right. ContentKind::Ball | ContentKind::Misc => { stats.excluded_non_player += 1; } } } (json!({ "itemData": out }), stats) } #[cfg(test)] mod tests { use super::*; use crate::fut::contract_cards::CONTRACT_MATCH_CAP; 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], // Untracked by default, so these fixtures exercise the pack-fresh // fallback; a test that cares sets it explicitly. contract_matches: None, source_rating: None, core_content_kind: None, } } /// 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, staff: 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 resolve_staff(&self, it: &CoreOwnedItem) -> Option { self.staff.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 consumables_are_excluded_but_staff_is_shaped() { 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)), ]), kits: HashMap::new(), staff: HashMap::from([( "card_staff".to_string(), Fifa17StaffIdentity { item_id: 100000003, resource_id: 3000083, subtype: 8, nation: 0, league_id: 0, team_id: 0, }, )]), 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, 2, "the player and the staff card are emitted" ); assert_eq!( stats.excluded_non_player, 1, "only the consumable is excluded; staff has a wire envelope of its own" ); assert_eq!(stats.dropped_no_asset, 0); let arr = body["itemData"].as_array().unwrap(); assert_eq!(arr.len(), 2); assert_eq!(arr[0]["id"], 100000001, "the player survives"); assert_eq!(arr[0]["itemType"], "player"); let coach = &arr[1]; assert_eq!(coach["id"], 100000003); assert_eq!(coach["resourceId"], 3000083); assert_eq!(coach["cardsubtypeid"], 8); assert_eq!(coach["itemType"], "staff"); assert_eq!( coach["contract"], STAFF_CONTRACT, "this fixture is UNTRACKED (contract_matches None), so the wire shows \ the pack-fresh fallback — not because the shaper hardcodes it" ); assert!( coach.get("nation").is_none() && coach.get("leagueId").is_none() && coach.get("teamid").is_none(), "a COACH has no nation/league/team column in the client's tables, so \ those keys must be absent rather than invented as zeroes" ); assert!( coach.get("attributeList").is_none() && coach.get("preferredPosition").is_none(), "both survive the client's merge and are read by the card view-model" ); } #[test] fn manager_carries_the_chemistry_fields_only_the_server_can_supply() { let ent = entities(); let ident = KindMapIdentity { ids: HashMap::new(), kits: HashMap::new(), staff: HashMap::from([( "card_manager".to_string(), Fifa17StaffIdentity { item_id: 100004871, resource_id: 1000509, subtype: 4, nation: 45, league_id: 53, team_id: 241, }, )]), kinds: HashMap::from([("card_manager".to_string(), ContentKind::Staff)]), }; let items = vec![item("oc-mgr", "card_manager", 0, "", "", "", "")]; let (body, stats) = shape_club_response(&items, &ent, &ident); assert_eq!(stats.emitted, 1); let mgr = &body["itemData"][0]; assert_eq!( mgr["cardsubtypeid"], 4, "subtype alone selects managercards" ); assert_eq!( mgr["resourceId"], 1000509, "the merge key is read RAW: it must equal the carddbid with no version byte" ); // rec+0xde / rec+0xe0 / rec+0x94 — the merge never writes these, so an // omission here is an unrecoverable blank flag and zero chemistry. assert_eq!(mgr["nation"], 45); assert_eq!(mgr["leagueId"], 53); assert_eq!(mgr["teamid"], 241); assert_eq!( mgr["contract"], STAFF_CONTRACT, "untracked fixture => pack-fresh fallback" ); assert_eq!(mgr["itemState"], "free"); assert_eq!(mgr["owners"], 1); let keys: Vec<&String> = mgr.as_object().unwrap().keys().collect(); assert_eq!( keys.len(), 11, "exactly the 11 justified keys, no more: {keys:?}" ); } /// `/club` is the screen a contract apply is judged on: if the envelope keeps /// reporting the pack-fresh count, a committed apply is invisible and the /// operator sees a 200 that did nothing. Both families must carry the number /// Core persisted. #[test] fn club_reports_the_contract_core_persisted_for_players_and_staff() { let ent = entities(); let ident = KindMapIdentity { ids: HashMap::from([( "card_player".to_string(), Fifa17Identity { item_id: 100000001, asset_id: 20801, resource_id: 20801, rareflag: 1, }, )]), kits: HashMap::new(), staff: HashMap::from([( "card_manager".to_string(), Fifa17StaffIdentity { item_id: 100004871, resource_id: 1000509, subtype: 4, nation: 45, league_id: 53, team_id: 241, }, )]), kinds: HashMap::from([ ("card_player".to_string(), ContentKind::Player), ("card_manager".to_string(), ContentKind::Staff), ]), }; // A player mid-way through its contracts, a fully topped-up manager, and // one untracked player that must fall back. let mut played = item( "oc-played", "card_player", 86, "ST", "Argentina", "Premier League", "Chelsea", ); played.contract_matches = Some(3); let mut manager = item("oc-mgr", "card_manager", 0, "", "", "", ""); manager.contract_matches = Some(CONTRACT_MATCH_CAP); let untracked = item( "oc-fresh", "card_player", 86, "ST", "Argentina", "Premier League", "Chelsea", ); let (body, stats) = shape_club_response(&[played, manager, untracked], &ent, &ident); assert_eq!(stats.emitted, 3); let arr = body["itemData"].as_array().unwrap(); assert_eq!(arr[0]["contract"], 3, "the player's persisted count"); assert_eq!( arr[1]["contract"], CONTRACT_MATCH_CAP, "staff read the same persisted field, not STAFF_CONTRACT" ); assert_eq!( arr[2]["contract"], PACK_FRESH_CONTRACT_MATCHES, "only an untracked instance falls back" ); } #[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(), staff: 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()); } /// Kit, badge and stadium are one cardtype-7 record and MUST all project. /// Ball and league logo are cardtype 9, have no database name resolver, and /// stay withheld until `localizedName` is established as safe to send. /// Counting a family in club/stats while never shaping it is the divergence /// this test pins: the wire set and the withheld set are both asserted. #[test] fn cardtype7_club_items_project_and_cardtype9_stay_withheld() { let ent = entities(); let kit_id = |item_id, resource, subtype, art| Fifa17KitIdentity { item_id, asset_id: resource, resource_id: resource, card_asset_id: art, subtype, team_id: 21, }; let ident = KindMapIdentity { ids: HashMap::new(), kinds: HashMap::from([ ("c_kit".to_string(), ContentKind::Kit), ("c_badge".to_string(), ContentKind::Badge), ("c_stadium".to_string(), ContentKind::Stadium), ("c_ball".to_string(), ContentKind::Ball), ("c_logo".to_string(), ContentKind::Misc), ]), kits: HashMap::from([ ("c_kit".to_string(), kit_id(1, 6_300_006, 9, 35)), ("c_badge".to_string(), kit_id(2, 6_000_005, 11, 39)), ("c_stadium".to_string(), kit_id(3, 6_200_000, 10, 36)), // Resolvable on purpose: withholding must be a decision about the // FAMILY, not an accident of a missing identity. ("c_ball".to_string(), kit_id(4, 8_120_194, 30, 37)), ("c_logo".to_string(), kit_id(5, 8_010_015, 31, 40)), ]), staff: HashMap::new(), }; let items: Vec = ["c_kit", "c_badge", "c_stadium", "c_ball", "c_logo"] .iter() .map(|c| item(&format!("oc_{c}"), c, 0, "", "", "", "")) .collect(); let (body, stats) = shape_club_response_with_kits( &items, &ent, &ident, ActiveKitAssignments { home: None, away: None, }, ); let arr = body["itemData"].as_array().unwrap(); assert_eq!(stats.emitted, 3, "kit + badge + stadium"); assert_eq!(stats.excluded_non_player, 2, "ball + league logo withheld"); assert_eq!(stats.dropped_no_asset, 0, "withholding is not a drop"); let subtypes: Vec = arr .iter() .map(|i| i["cardsubtypeid"].as_i64().unwrap()) .collect(); assert_eq!(subtypes, vec![9, 11, 10]); // teamid only where the caption resolves TeamName_Abbr15_. assert_eq!(arr[0]["teamid"], 21, "kit"); assert_eq!(arr[1]["teamid"], 21, "badge"); assert!( arr[2].get("teamid").is_none(), "stadium caption reads assetId" ); for it in arr { assert!( item_state::is_recovered(it["itemState"].as_str().unwrap()), "every emitted state must be a recovered token" ); } } }