//! FIFA17 MY CLUB stat set (`GET …/club/stats/{year,consumables}`), computed //! from OpenFUT Core's authoritative owned inventory. //! //! Faithful port of the Python oracle's `fut_club_stats.py` (`global_counts` + //! `context_rows` + `stats_body`), which is itself censused from CardsDLL //! (`FUN_18012fd40` atom table). The body is `{"stat":[{contextId,contextValue, //! type,typeValue}, …]}`: //! * a GLOBAL bucket (contextId 1, contextValue 0) with player tier counts, //! 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. //! //! Unlike the oracle (which counts its own stale profile + a synthetic consumable //! shelf), this counts Core — so staff and consumable families reflect the real //! imported content. Unrecognized atoms are inert in the client, so this is a //! low-risk cosmetic surface; the tier/staff/consumable atoms are the ones the //! screen reads and they are Core-accurate here. use std::collections::BTreeMap; use serde_json::{json, Value}; use crate::fut::content_taxonomy::{consumable_family, ContentKind}; /// One owned item, already classified from the catalog + entity tables by the /// host. `subtype`/`rare`/`asset_id` come from the FIFA catalog; `nation_id`/ /// `league_id`/`team_id` from the reverse entity resolver for players and from /// the kit table for kits (None = unresolved, bucket skipped). #[derive(Debug, Clone)] pub struct ClubStatInput { pub kind: ContentKind, pub subtype: i64, pub rating: i64, pub rare: bool, /// Base FIFA asset id. For a kit this is the `fcc_kitcards.assetid` family /// discriminator, which is what splits the home/away kit counters. pub asset_id: i64, pub nation_id: Option, pub league_id: Option, pub team_id: Option, } /// Which entity the per-context (`contextId 3`) buckets are keyed by — the FIFA /// `MY CLUB` sub-screen selector (`fut_club_stats.py::context_rows`): /// * `Nation` — the default screen (year/consumables/club/newcards): nation buckets. /// * `League` — URL `club/stats/country/`: league (leagueId) buckets, tier stats. /// * `Team` — URL `club/stats/league/`: team (teamid) buckets, players/kits/badge. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ContextField { Nation, League, Team, } // Stat ids (CardsDLL atom table, fut_club_stats.py VOCAB). const S_PLAYERS: i64 = 0x01; const S_BRONZE: i64 = 0x02; const S_SILVER: i64 = 0x03; const S_GOLD: i64 = 0x04; const S_RARE: i64 = 0x05; const S_STAFF: i64 = 0x0A; const S_CONSUMABLES: i64 = 0x3C; const S_KITS: i64 = 0x28; const S_KITS_HOME: i64 = 0x29; const S_KITS_AWAY: i64 = 0x2A; const S_BADGES: i64 = 0x2D; /// First `carddbid` of the AWAY kit family. `fcc_kitcards` is split into a /// `63xxxxx` home family and a `64xxxxx` away family, and the table's own /// `assetid` column agrees exactly: across all 1482 rows, assetid 14 covers /// precisely the 828 `63xxxxx` ids and assetid 15 precisely the 654 `64xxxxx` /// ids, with no exceptions either way. A kit's catalog `asset_id` IS its /// carddbid, so the id itself is the family key -- the `assetid` column is not /// carried on the wire and would be a second source of truth for the same fact. const KIT_AWAY_FLOOR: i64 = 6_400_000; /// Which kit family an owned kit belongs to. Only meaningful for /// [`ContentKind::Kit`]; the caller filters first. fn is_home_kit(kit: &ClubStatInput) -> bool { kit.asset_id < KIT_AWAY_FLOOR } /// cardsubtypeid (staff family) -> stat id (STAFF_SUBTYPE_STAT). fn staff_stat(subtype: i64) -> Option { match subtype { 4 => Some(0x0B), // manager 5 => Some(0x0C), // head coach 6 => Some(0x0D), // GK coach 7 => Some(0x0E), // physio 8 => Some(0x0F), // fitness coach _ => None, } } /// consumable family `kind` -> stat id (CONSUMABLE_KIND_STAT). fn consumable_stat(kind: &str) -> Option { Some(match kind { "player_contract" => 0x42, "manager_contract" => 0x47, "healing" => 0x41, "player_fitness" => 0x44, "squad_fitness" => 0x4A, "gk_training" => 0x46, "player_training" => 0x43, "position_mod" => 0x45, "player_playstyle" => 0x4B, "gk_playstyle" => 0x4C, "manager_league" => 0x4D, "manager_formation_mod" | "formation_mod" => 0x48, _ => return None, }) } /// The JSON `type` atom name for a stat id (VOCAB). Only the ids this module /// emits are mapped; an unmapped id would panic (guards a transcription slip). fn vocab(stat_id: i64) -> &'static str { match stat_id { 0x01 => "players", 0x02 => "playersBronze", 0x03 => "playersSilver", 0x04 => "playersGold", 0x05 => "rarePlayers", 0x0A => "staff", 0x0B => "staffManager", 0x0C => "staffHeadCoach", 0x0D => "staffGKCoach", 0x0E => "staffPhysio", 0x0F => "staffFitnessCoach", 0x14 => "stadia", 0x1E => "balls", 0x28 => "kits", 0x29 => "kitsHome", 0x2A => "kitsAway", 0x2D => "badges", 0x2E => "badgeDBid", 0x2F => "leagueLogos", 0x32 => "trophies", 0x33 => "trophiesOffline", 0x34 => "trophiesOnline", 0x35 => "trophiesFeaturedOffline", 0x36 => "trophiesFeaturedOnline", 0x37 => "trophiesSeasonOffline", 0x38 => "trophiesSeasonOnline", 0x3C => "consumables", 0x41 => "consumablesHealing", 0x42 => "consumablesContractPlayer", 0x43 => "consumablesTrainingPlayer", 0x44 => "consumablesFitnessPlayer", 0x45 => "consumablesPosition", 0x46 => "consumablesTrainingGk", 0x47 => "consumablesContractManager", 0x48 => "consumablesFormationManager", 0x49 => "consumablesTrainingManager", 0x4A => "consumablesFitnessTeam", 0x4B => "consumablesTrainingPlayerPlayStyle", 0x4C => "consumablesTrainingGkPlayStyle", 0x4D => "consumablesTrainingManagerLeagueModifier", other => panic!("club_stats: unmapped stat id {other:#x}"), } } fn row(context_id: i64, context_value: i64, stat_id: i64, value: i64) -> Value { json!({ "contextId": context_id, "contextValue": context_value, "type": vocab(stat_id), "typeValue": value, }) } fn is_player(i: &ClubStatInput) -> bool { matches!(i.kind, ContentKind::Player) } /// Build the full `{"stat":[…]}` body for a club/stats screen — the global bucket /// (identical for every mode) plus per-context buckets keyed by `ctx` /// (nation / league / team), mirroring `fut_club_stats.py::stats_body`. pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value { // ---- global bucket (contextId 1, contextValue 0), sorted by stat id ---- let mut g: BTreeMap = BTreeMap::new(); let players: Vec<&ClubStatInput> = items.iter().filter(|i| is_player(i)).collect(); g.insert(S_PLAYERS, players.len() as i64); g.insert( S_GOLD, players.iter().filter(|i| i.rating >= 75).count() as i64, ); g.insert( S_SILVER, players .iter() .filter(|i| (65..75).contains(&i.rating)) .count() as i64, ); g.insert( S_BRONZE, players .iter() .filter(|i| i.rating > 0 && i.rating < 65) .count() as i64, ); g.insert(S_RARE, players.iter().filter(|i| i.rare).count() as i64); // staff per family + total for sid in [0x0B, 0x0C, 0x0D, 0x0E, 0x0F] { g.insert(sid, 0); } let mut staff_total = 0i64; for it in items .iter() .filter(|i| matches!(i.kind, ContentKind::Staff)) { if let Some(sid) = staff_stat(it.subtype) { *g.get_mut(&sid).unwrap() += 1; staff_total += 1; } } g.insert(S_STAFF, staff_total); // consumables per family + total for sid in [ 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, ] { g.insert(sid, 0); } let mut cons_total = 0i64; for it in items .iter() .filter(|i| matches!(i.kind, ContentKind::Consumable)) { cons_total += 1; if let Some((kind, _label)) = consumable_family(it.subtype) { if let Some(sid) = consumable_stat(kind) { *g.entry(sid).or_insert(0) += 1; } } } g.insert(S_CONSUMABLES, cons_total); // Club items: kits are Core-owned and counted (total plus the home/away // family split); unimplemented families stay honest zeros. for sid in [ 0x14, 0x1E, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, ] { g.entry(sid).or_insert(0); } let kits: Vec<&ClubStatInput> = items .iter() .filter(|item| matches!(item.kind, ContentKind::Kit)) .collect(); let home = kits.iter().filter(|kit| is_home_kit(kit)).count() as i64; g.insert(S_KITS, kits.len() as i64); g.insert(S_KITS_HOME, home); g.insert(S_KITS_AWAY, kits.len() as i64 - home); let mut stat: Vec = g.iter().map(|(sid, v)| row(1, 0, *sid, *v)).collect(); // ---- per-context buckets (contextId 3, contextValue = entity id) ---- // Nation/League read the tier set (gold/silver/bronze/rare/kits/badges); // Team (the league screen) reads players/kits/badgeDBid. Mirrors context_rows. let mut by_ctx: BTreeMap> = BTreeMap::new(); for p in &players { let id = match ctx { ContextField::Nation => p.nation_id, ContextField::League => p.league_id, ContextField::Team => p.team_id, }; if let Some(id) = id { by_ctx.entry(id).or_default().push(p); } } // A kit belongs to the team that wears it and has no nation/league of its // own, so it only buckets on the team screen -- and it buckets there even if // the club owns no player from that team, which is the normal case for a kit // won from a pack. let mut kits_by_team: BTreeMap = BTreeMap::new(); if ctx == ContextField::Team { for kit in &kits { if let Some(id) = kit.team_id { *kits_by_team.entry(id).or_insert(0) += 1; by_ctx.entry(id).or_default(); } } } for (cid, sel) in &by_ctx { if ctx == ContextField::Team { stat.push(row(3, *cid, S_PLAYERS, sel.len() as i64)); stat.push(row( 3, *cid, S_KITS, kits_by_team.get(cid).copied().unwrap_or(0), )); stat.push(row(3, *cid, 0x2E, 0)); // badgeDBid } else { let gold = sel.iter().filter(|i| i.rating >= 75).count() as i64; let silver = sel.iter().filter(|i| (65..75).contains(&i.rating)).count() as i64; let bronze = sel.iter().filter(|i| i.rating > 0 && i.rating < 65).count() as i64; let rare = sel.iter().filter(|i| i.rare).count() as i64; stat.push(row(3, *cid, S_GOLD, gold)); stat.push(row(3, *cid, S_SILVER, silver)); stat.push(row(3, *cid, S_BRONZE, bronze)); stat.push(row(3, *cid, S_RARE, rare)); stat.push(row(3, *cid, S_KITS, 0)); stat.push(row(3, *cid, S_BADGES, 0)); } } json!({ "stat": stat }) } #[cfg(test)] mod tests { use super::*; fn player(rating: i64, rare: bool, nation: Option) -> ClubStatInput { ClubStatInput { kind: ContentKind::Player, subtype: 0, rating, rare, asset_id: 158023, nation_id: nation, league_id: None, team_id: None, } } fn staff(subtype: i64) -> ClubStatInput { ClubStatInput { kind: ContentKind::Staff, subtype, rating: 0, rare: false, asset_id: 0, nation_id: None, league_id: None, team_id: None, } } fn consumable(subtype: i64) -> ClubStatInput { ClubStatInput { kind: ContentKind::Consumable, subtype, rating: 0, rare: false, asset_id: 0, nation_id: None, league_id: None, team_id: None, } } /// A kit worn by `team`. `carddbid` is the real `fcc_kitcards` id, which is /// also the catalog `asset_id` and therefore the home/away family key. fn kit_of(carddbid: i64, team: i64) -> ClubStatInput { ClubStatInput { kind: ContentKind::Kit, subtype: 9, rating: 0, rare: false, asset_id: carddbid, nation_id: None, league_id: None, team_id: Some(team), } } /// Real team-21 kits from `fcc_kitcards`: 6300006 is its home kit and /// 6400003 its away kit. const HOME_KIT: i64 = 6_300_006; const AWAY_KIT: i64 = 6_400_003; fn kit() -> ClubStatInput { kit_of(HOME_KIT, 21) } fn global(body: &Value) -> std::collections::HashMap { body["stat"] .as_array() .unwrap() .iter() .filter(|r| r["contextId"] == 1) .map(|r| { ( r["type"].as_str().unwrap().to_string(), r["typeValue"].as_i64().unwrap(), ) }) .collect() } #[test] fn tiers_and_rare_counted() { let items = vec![ player(90, true, Some(52)), player(70, true, Some(52)), player(60, false, Some(21)), ]; let g = global(&club_stats_body(&items, ContextField::Nation)); assert_eq!(g["players"], 3); assert_eq!(g["playersGold"], 1); assert_eq!(g["playersSilver"], 1); assert_eq!(g["playersBronze"], 1); assert_eq!(g["rarePlayers"], 2); } #[test] fn staff_by_family() { let items = vec![staff(6), staff(8), staff(8)]; // 1 gk coach, 2 fitness let g = global(&club_stats_body(&items, ContextField::Nation)); assert_eq!(g["staffGKCoach"], 1); assert_eq!(g["staffFitnessCoach"], 2); assert_eq!(g["staff"], 3); assert_eq!(g["staffManager"], 0); } #[test] fn consumables_by_family() { // 54 gk_training, 201 player_contract, 217 healing, 258 player_playstyle let items = vec![ consumable(54), consumable(201), consumable(217), consumable(258), ]; let g = global(&club_stats_body(&items, ContextField::Nation)); assert_eq!(g["consumables"], 4); assert_eq!(g["consumablesTrainingGk"], 1); assert_eq!(g["consumablesContractPlayer"], 1); assert_eq!(g["consumablesHealing"], 1); 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); } /// `kits` is the total and `kitsHome`/`kitsAway` are its family split, the /// same total/subset shape as players/playersGold and staff/staffManager. #[test] fn kit_counts_split_by_home_and_away_family() { let items = vec![ kit_of(HOME_KIT, 21), kit_of(6_300_010, 38), kit_of(AWAY_KIT, 21), ]; let g = global(&club_stats_body(&items, ContextField::Nation)); assert_eq!(g["kits"], 3); assert_eq!(g["kitsHome"], 2); assert_eq!(g["kitsAway"], 1); } /// A kit buckets onto the team that wears it -- including a team the club /// owns no player from, which is the normal case for a kit won from a pack. #[test] fn kits_bucket_onto_their_own_team_on_the_team_screen() { let mut with_team = player(90, false, None); with_team.team_id = Some(21); let items = vec![ with_team, kit_of(HOME_KIT, 21), kit_of(AWAY_KIT, 21), kit_of(6_300_010, 38), ]; let body = club_stats_body(&items, ContextField::Team); let kits_for = |team: i64| { body["stat"] .as_array() .unwrap() .iter() .find(|r| r["contextId"] == 3 && r["contextValue"] == team && r["type"] == "kits") .map(|r| r["typeValue"].as_i64().unwrap()) }; assert_eq!(kits_for(21), Some(2)); // Team 38 has no players, so only the kit creates its bucket. assert_eq!(kits_for(38), Some(1)); // A nation/league screen has no team context, so kits stay out of it. let nation = club_stats_body(&items, ContextField::Nation); assert!(nation["stat"] .as_array() .unwrap() .iter() .filter(|r| r["contextId"] == 3 && r["type"] == "kits") .all(|r| r["typeValue"] == 0)); } #[test] fn nation_buckets_emitted_and_players_excludes_nonplayers() { let items = vec![ player(90, true, Some(52)), player(80, false, Some(52)), staff(8), ]; let body = club_stats_body(&items, ContextField::Nation); let g = global(&body); assert_eq!(g["players"], 2, "staff not counted as player"); let buckets: Vec<&Value> = body["stat"] .as_array() .unwrap() .iter() .filter(|r| r["contextId"] == 3 && r["contextValue"] == 52) .collect(); // gold, silver, bronze, rare, kits, badges assert_eq!(buckets.len(), 6); let gold = buckets.iter().find(|r| r["type"] == "playersGold").unwrap(); assert_eq!(gold["typeValue"], 2); } #[test] fn honest_zero_club_items_present() { let g = global(&club_stats_body( &[player(90, false, None)], ContextField::Nation, )); for atom in [ "stadia", "balls", "kits", "badges", "trophies", "leagueLogos", ] { assert_eq!(g[atom], 0, "{atom} present as honest zero"); } } #[test] fn league_and_team_context_modes() { let mut a = player(90, true, Some(52)); a.league_id = Some(13); a.team_id = Some(240); let mut b = player(60, false, Some(52)); b.league_id = Some(13); b.team_id = Some(9); let items = vec![a, b]; // country screen -> league (leagueId) buckets, tier set (6 rows). let body = club_stats_body(&items, ContextField::League); let league_rows: Vec<&Value> = body["stat"] .as_array() .unwrap() .iter() .filter(|r| r["contextId"] == 3 && r["contextValue"] == 13) .collect(); assert_eq!(league_rows.len(), 6); let gold = league_rows .iter() .find(|r| r["type"] == "playersGold") .unwrap(); assert_eq!(gold["typeValue"], 1); // league screen -> team (teamid) buckets: players/kits/badgeDBid (3 rows). let body = club_stats_body(&items, ContextField::Team); let team_rows: Vec<&Value> = body["stat"] .as_array() .unwrap() .iter() .filter(|r| r["contextId"] == 3 && r["contextValue"] == 240) .collect(); assert_eq!(team_rows.len(), 3); let players = team_rows.iter().find(|r| r["type"] == "players").unwrap(); assert_eq!(players["typeValue"], 1); assert!(team_rows.iter().any(|r| r["type"] == "badgeDBid")); } }