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