From 71fcf5e2519b3ab575067dedd514671f74b6e409 Mon Sep 17 00:00:00 2001 From: funman300 Date: Sat, 15 Aug 2026 17:56:37 +0000 Subject: [PATCH] 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). --- openfut-adapter-fifa17/src/fut/club_stats.rs | 361 +++++++++++++++++++ openfut-adapter-fifa17/src/fut/mod.rs | 1 + openfut-utas-host/src/lib.rs | 77 +++- openfut-utas-host/tests/host_test.rs | 37 ++ scripts/openfut-reachability.py | 9 +- 5 files changed, 477 insertions(+), 8 deletions(-) create mode 100644 openfut-adapter-fifa17/src/fut/club_stats.rs diff --git a/openfut-adapter-fifa17/src/fut/club_stats.rs b/openfut-adapter-fifa17/src/fut/club_stats.rs new file mode 100644 index 0000000..7e52dfe --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/club_stats.rs @@ -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, +} + +// 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 { + 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 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 = 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 = g.iter().map(|(sid, v)| row(1, 0, *sid, *v)).collect(); + + // ---- per-nation buckets (contextId 3, contextValue = nation id) ---- + let mut by_nation: BTreeMap> = 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) -> 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 { + 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"); + } + } +} diff --git a/openfut-adapter-fifa17/src/fut/mod.rs b/openfut-adapter-fifa17/src/fut/mod.rs index 94fc3b1..0e1c98e 100644 --- a/openfut-adapter-fifa17/src/fut/mod.rs +++ b/openfut-adapter-fifa17/src/fut/mod.rs @@ -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; diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index fb01203..62f512a 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -50,11 +50,12 @@ use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPo use openfut_adapter_fifa17::fut::club_response::{ shape_club_response, CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats, }; +use openfut_adapter_fifa17::fut::club_stats::{club_stats_body, ClubStatInput}; use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind; use openfut_adapter_fifa17::fut::economy_policy::{ match_reward_total, result_from_end_reason, MatchResult, }; -use openfut_adapter_fifa17::fut::entities::Fifa17Entities; +use openfut_adapter_fifa17::fut::entities::{Fifa17Entities, ReverseEntityResolver}; use openfut_adapter_fifa17::fut::non_economy; use openfut_adapter_fifa17::fut::owned_query::{ is_special_rareflag, map_to_core, parse_club_query, MapError, @@ -120,6 +121,10 @@ pub enum Route { /// `GET …/hub` — the FUT hub tile counts (club players + auction/tradePile), /// derived from Core inventory + the durable market store (no Python). Hub, + /// `GET …/club/stats/{year,consumables}` — the MY CLUB stat set, computed + /// Core-accurately in Rust (player tiers, staff/consumable families, nation + /// buckets). club/stats/staff stays a separate empty-set route. + ClubStats, /// Anything else — proxied verbatim to the Python oracle. Passthrough, } @@ -160,6 +165,7 @@ pub fn classify(method: &str, path: &str) -> Route { Some("match/reset") if put => Route::MatchReset, Some(tail) if tail.starts_with("phishing/") => Route::SecurityQuestion, Some("club/stats/staff") if get => Route::ClubStatsStaff, + Some("club/stats/year") | Some("club/stats/consumables") if get => Route::ClubStats, Some("hub") if get => Route::Hub, _ => Route::Passthrough, } @@ -895,6 +901,21 @@ impl Fifa17IdentityResolver { ) .unwrap_or(None) } + + /// The FIFA `cardsubtypeid` for an owned item's definition (0 if unknown / + /// a player), from the catalog — used by club-stats family aggregation. + pub fn subtype_of(&self, item: &CoreOwnedItem) -> i64 { + self.catalog.subtype_of(&item.card_id) + } + + /// The observed FIFA `rareflag` for an owned item's definition (0 if unknown), + /// from the catalog — used by club-stats rare-player counting. + pub fn rareflag_of(&self, item: &CoreOwnedItem) -> i64 { + self.catalog + .lookup(&item.card_id) + .map(|c| c.rareflag) + .unwrap_or(0) + } } impl ItemIdentityResolver for Fifa17IdentityResolver { @@ -2278,6 +2299,7 @@ impl Server { json_status(200, &non_economy::club_stats_staff_body()) } Route::Hub => self.handle_hub(), + Route::ClubStats => self.handle_club_stats(), Route::SecurityQuestion => self.handle_security_question(method, target, headers), Route::Passthrough => { let resp = match self.pass.forward(method, target, headers, body) { @@ -2508,6 +2530,40 @@ impl Server { json_status(200, &body) } + /// `GET …/club/stats/{year,consumables}` — the MY CLUB stat set, computed + /// Core-accurately in Rust (no Python). Player tiers + rare from the Core + /// collection, staff/consumable families from the catalog kind+subtype, nation + /// buckets from the reverse entity resolver. Fail-closed 503 on Core error. + fn handle_club_stats(&self) -> WireResponse { + let owned = match self.core.all_owned() { + Ok(o) => o, + Err(e) => { + eprintln!("utas-host owner=RUST route=club-stats status=503 error=core:{e}"); + return error_response(503, "core_unavailable"); + } + }; + let items: Vec = owned + .iter() + .map(|it| ClubStatInput { + kind: self.resolver.kind_of(it), + subtype: self.resolver.subtype_of(it), + rating: it.rating as i64, + rare: self.resolver.rareflag_of(it) != 0, + nation_id: self.entities.nation_id(&it.nation).map(|n| n as i64), + }) + .collect(); + let players = items + .iter() + .filter(|i| matches!(i.kind, ContentKind::Player)) + .count(); + eprintln!( + "utas-host owner=RUST route=club-stats status=200 owned={} players={}", + items.len(), + players + ); + json_status(200, &club_stats_body(&items)) + } + /// Serve forever on `addr` (thread-per-connection, HTTP/1.1 keep-alive). pub fn serve(&self, addr: &str) -> std::io::Result<()> { let listener = TcpListener::bind(addr)?; @@ -3042,10 +3098,14 @@ mod tests { assert_eq!(classify("get", "/ut/game/fifa18/club"), Route::Club); // method must be GET assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::Passthrough); - // near-misses stay on Python (club/stats/year still proxied; staff is now - // its own Rust arm, tested in non_economy_route_ownership) + // near-misses stay on Python: bare club/stats and the country sub-screen are + // NOT the exact /club route and are not (yet) migrated arms. assert_eq!( - classify("GET", "/ut/game/fifa17/club/stats/year"), + classify("GET", "/ut/game/fifa17/club/stats"), + Route::Passthrough + ); + assert_eq!( + classify("GET", "/ut/game/fifa17/club/stats/country/54"), Route::Passthrough ); assert_eq!( @@ -3545,14 +3605,19 @@ mod tests { Route::ClubStatsStaff, ), ("GET", "/ut/game/fifa17/hub", Route::Hub), + ("GET", "/ut/game/fifa17/club/stats/year", Route::ClubStats), + ( + "GET", + "/ut/game/fifa17/club/stats/consumables", + Route::ClubStats, + ), ]; for (m, p, want) in owned { assert_eq!(classify(m, p), *want, "OWN: {m} {p}"); } // Still Python (not yet migrated) / lookalikes / wrong method. let proxied: &[(&str, &str)] = &[ - ("GET", "/ut/game/fifa17/club/stats/consumables"), - ("GET", "/ut/game/fifa17/club/stats/year"), + ("GET", "/ut/game/fifa17/club/stats/country/54"), ("PUT", "/ut/game/fifa17/clientdata/userHubData"), ("POST", "/openfut/account/sync"), ("GET", "/ut/game/fifa17/settingsfoo"), diff --git a/openfut-utas-host/tests/host_test.rs b/openfut-utas-host/tests/host_test.rs index dcbcf93..33ca265 100644 --- a/openfut-utas-host/tests/host_test.rs +++ b/openfut-utas-host/tests/host_test.rs @@ -1434,3 +1434,40 @@ fn hub_counts_players_from_core_no_python() { ); assert_eq!(rec.lock().len(), 0, "hub never reaches Python"); } + +/// `GET /club/stats/year` is served from Rust with Core-accurate player tier +/// counts (contextId 1 global bucket), never reaching Python. +#[test] +fn club_stats_year_counts_tiers_from_core_no_python() { + let items = vec![ + item("oc1", "card_a", 90, "ST", "Brazil", "La Liga", "Barcelona"), // gold + item("oc2", "card_b", 70, "CM", "Spain", "La Liga", "Real Madrid"), // silver + item("oc3", "card_c", 60, "CB", "France", "Ligue 1", "PSG"), // bronze + ]; + let core = Arc::new(FakeCore::new(items, 3)); + let (py_url, rec) = spawn_mock_python(); + let server = build_server(core, &py_url, None); + + let resp = server.handle("GET", "/ut/game/fifa17/club/stats/year", &[], b""); + assert_eq!(resp.status, 200); + let body: Value = serde_json::from_slice(&resp.body).unwrap(); + let g: 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(); + assert_eq!(g["players"], 3); + assert_eq!(g["playersGold"], 1); + assert_eq!(g["playersSilver"], 1); + assert_eq!(g["playersBronze"], 1); + assert_eq!(g["consumables"], 0); + assert_eq!(g["staff"], 0); + assert_eq!(rec.lock().len(), 0, "club/stats never reaches Python"); +} diff --git a/scripts/openfut-reachability.py b/scripts/openfut-reachability.py index 36d4cc3..34b3239 100755 --- a/scripts/openfut-reachability.py +++ b/scripts/openfut-reachability.py @@ -41,13 +41,14 @@ MIGRATED_NON_ECONOMY = { "match/reset", "phishing", # phishing/{trusteddevice,question,validate} "hub", + "club/stats/global", # year/consumables/staff } # Documented residual Python-owned non-economy domains (expected > 0 until # migrated). Keep in sync with docs/PRODUCTION_AUTHORITY_MATRIX.md. RESIDUAL_PYTHON = { "openfut/account/sync", - "club/stats", # year/consumables still Python; club/stats/staff is Rust + "club/stats/context", # country/league/team nation-bucket sub-screens "clientdata/userHubData", } @@ -62,8 +63,12 @@ def python_domain(path: str) -> str: tail = m.group(1) if m else p.lstrip("/") if tail.startswith("phishing/"): return "phishing" + if tail.startswith("club/stats/country") or tail.startswith( + "club/stats/league" + ) or tail.startswith("club/stats/team"): + return "club/stats/context" if tail.startswith("club/stats"): - return "club/stats" + return "club/stats/global" if tail.startswith("clientdata/"): return "clientdata/userHubData" return tail