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:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ClubStatInput> = 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"),
|
||||
|
||||
@@ -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<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();
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user