feat(fifa17): own club/stats/{year,consumables} in Rust (Core-accurate)

Migrate the MY CLUB stat set from the Python proxy to a Rust handler computing
Core-accurate counts: player tiers + rare from the collection, staff/consumable
families from catalog kind+subtype, per-nation buckets via the reverse entity
resolver. Faithful port of fut_club_stats.py (VOCAB + global_counts +
context_rows). Unlike the oracle (stale profile + synthetic consumable shelf),
this reflects the real imported content (incl. the content-gap consumables/staff).
Fail-closed 503 on Core error. club/stats/country|league|team sub-screens remain
Python (documented). Adds adapter club_stats module (5 tests), host handler +
classify arm + resolver subtype_of/rareflag_of, ownership + integration tests;
reachability tool splits club/stats global(migrated) vs context(residual).
This commit is contained in:
funman300
2026-08-15 17:56:37 +00:00
parent 979e71fbea
commit 71fcf5e251
5 changed files with 477 additions and 8 deletions
@@ -0,0 +1,361 @@
//! 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-by-family, consumables-by-family, and honest zeros for club items;
//! * 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` come from the FIFA catalog; `nation_id` from the
/// reverse entity resolver (None = unresolved nation, bucket skipped).
#[derive(Debug, Clone)]
pub struct ClubStatInput {
pub kind: ContentKind,
pub subtype: i64,
pub rating: i64,
pub rare: bool,
pub nation_id: Option<i64>,
}
// 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_BADGES: i64 = 0x2D;
/// cardsubtypeid (staff family) -> stat id (STAFF_SUBTYPE_STAT).
fn staff_stat(subtype: i64) -> Option<i64> {
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<i64> {
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 club/stats/{year,consumables} — the
/// global bucket plus per-nation buckets.
pub fn club_stats_body(items: &[ClubStatInput]) -> Value {
// ---- global bucket (contextId 1, contextValue 0), sorted by stat id ----
let mut g: BTreeMap<i64, i64> = 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: honest zeros (Core holds none; each is read by some panel).
for sid in [
0x14, 0x1E, 0x28, 0x29, 0x2A, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
] {
g.entry(sid).or_insert(0);
}
let mut stat: Vec<Value> = g.iter().map(|(sid, v)| row(1, 0, *sid, *v)).collect();
// ---- per-nation buckets (contextId 3, contextValue = nation id) ----
let mut by_nation: BTreeMap<i64, Vec<&ClubStatInput>> = BTreeMap::new();
for p in &players {
if let Some(nid) = p.nation_id {
by_nation.entry(nid).or_default().push(p);
}
}
for (nid, sel) in &by_nation {
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;
// Order mirrors the oracle context_rows: gold, silver, bronze, rare, kits, badges.
stat.push(row(3, *nid, S_GOLD, gold));
stat.push(row(3, *nid, S_SILVER, silver));
stat.push(row(3, *nid, S_BRONZE, bronze));
stat.push(row(3, *nid, S_RARE, rare));
stat.push(row(3, *nid, S_KITS, 0));
stat.push(row(3, *nid, S_BADGES, 0));
}
json!({ "stat": stat })
}
#[cfg(test)]
mod tests {
use super::*;
fn player(rating: i64, rare: bool, nation: Option<i64>) -> ClubStatInput {
ClubStatInput {
kind: ContentKind::Player,
subtype: 0,
rating,
rare,
nation_id: nation,
}
}
fn staff(subtype: i64) -> ClubStatInput {
ClubStatInput {
kind: ContentKind::Staff,
subtype,
rating: 0,
rare: false,
nation_id: None,
}
}
fn consumable(subtype: i64) -> ClubStatInput {
ClubStatInput {
kind: ContentKind::Consumable,
subtype,
rating: 0,
rare: false,
nation_id: None,
}
}
fn global(body: &Value) -> std::collections::HashMap<String, i64> {
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));
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));
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));
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 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);
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)]));
for atom in [
"stadia",
"balls",
"kits",
"badges",
"trophies",
"leagueLogos",
] {
assert_eq!(g[atom], 0, "{atom} present as honest zero");
}
}
}
+1
View File
@@ -6,6 +6,7 @@
//! socket — a Rust UTAS host wires it to Core later.
pub mod catalog;
pub mod club_response;
pub mod club_stats;
pub mod content_taxonomy;
pub mod economy;
pub mod economy_policy;