feat(fifa17): own FUT hub tile counts in Rust
Migrate GET /hub from the Python proxy to a Rust handler deriving counts from authoritative state: clubPlayers = owned PLAYER cards in Core (consumables/staff excluded via catalog kind; may be lower than Python's profile count by the deferred Legend instances = DIFFERENT-BY-DESIGN), auction/tradePile counts from the durable market store via the async bridge. Fail-closed 503 on Core error; market read failure degrades cosmetic counts to 0. Adds classify arm, handler, ownership + integration tests; reachability tool marks hub migrated.
This commit is contained in:
@@ -117,6 +117,9 @@ pub enum Route {
|
|||||||
/// `GET …/club/stats/staff` — Rust-owned static `{}` (production oracle body;
|
/// `GET …/club/stats/staff` — Rust-owned static `{}` (production oracle body;
|
||||||
/// FIFA's staff-bonus stat set is deliberately empty).
|
/// FIFA's staff-bonus stat set is deliberately empty).
|
||||||
ClubStatsStaff,
|
ClubStatsStaff,
|
||||||
|
/// `GET …/hub` — the FUT hub tile counts (club players + auction/tradePile),
|
||||||
|
/// derived from Core inventory + the durable market store (no Python).
|
||||||
|
Hub,
|
||||||
/// Anything else — proxied verbatim to the Python oracle.
|
/// Anything else — proxied verbatim to the Python oracle.
|
||||||
Passthrough,
|
Passthrough,
|
||||||
}
|
}
|
||||||
@@ -157,6 +160,7 @@ pub fn classify(method: &str, path: &str) -> Route {
|
|||||||
Some("match/reset") if put => Route::MatchReset,
|
Some("match/reset") if put => Route::MatchReset,
|
||||||
Some(tail) if tail.starts_with("phishing/") => Route::SecurityQuestion,
|
Some(tail) if tail.starts_with("phishing/") => Route::SecurityQuestion,
|
||||||
Some("club/stats/staff") if get => Route::ClubStatsStaff,
|
Some("club/stats/staff") if get => Route::ClubStatsStaff,
|
||||||
|
Some("hub") if get => Route::Hub,
|
||||||
_ => Route::Passthrough,
|
_ => Route::Passthrough,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2273,6 +2277,7 @@ impl Server {
|
|||||||
eprintln!("utas-host owner=RUST route=club-stats-staff status=200");
|
eprintln!("utas-host owner=RUST route=club-stats-staff status=200");
|
||||||
json_status(200, &non_economy::club_stats_staff_body())
|
json_status(200, &non_economy::club_stats_staff_body())
|
||||||
}
|
}
|
||||||
|
Route::Hub => self.handle_hub(),
|
||||||
Route::SecurityQuestion => self.handle_security_question(method, target, headers),
|
Route::SecurityQuestion => self.handle_security_question(method, target, headers),
|
||||||
Route::Passthrough => {
|
Route::Passthrough => {
|
||||||
let resp = match self.pass.forward(method, target, headers, body) {
|
let resp = match self.pass.forward(method, target, headers, body) {
|
||||||
@@ -2457,6 +2462,52 @@ impl Server {
|
|||||||
json_status(status, &body)
|
json_status(status, &body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `GET …/hub` — the FUT hub tile counts, owned in Rust (no Python). Derived
|
||||||
|
/// from authoritative state: `clubPlayers` is the count of owned PLAYER cards
|
||||||
|
/// in Core (consumables/staff excluded via the catalog kind), and the auction
|
||||||
|
/// / tradePile counts are the user's active listings in the durable market
|
||||||
|
/// store. `clubPlayers` may be lower than the Python oracle's profile count by
|
||||||
|
/// exactly the deferred (unnameable Legend) instances — DIFFERENT-BY-DESIGN,
|
||||||
|
/// since deferred cards are not owned in Core. Fail-closed on Core error (503);
|
||||||
|
/// a market-store read failure degrades the cosmetic listing counts to 0.
|
||||||
|
fn handle_hub(&self) -> WireResponse {
|
||||||
|
let club_players = match self.core.all_owned() {
|
||||||
|
Ok(items) => items
|
||||||
|
.iter()
|
||||||
|
.filter(|it| self.resolver.kind_of(it) == ContentKind::Player)
|
||||||
|
.count(),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("utas-host owner=RUST route=hub status=503 error=core:{e}");
|
||||||
|
return error_response(503, "core_unavailable");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let active = match &self.economy {
|
||||||
|
Some(svc) => {
|
||||||
|
let market = svc.market.clone();
|
||||||
|
match svc
|
||||||
|
.bridge
|
||||||
|
.block_on(async move { market.query_listings("active").await })
|
||||||
|
{
|
||||||
|
Ok(l) => l.len(),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("utas-host WARN hub market read failed (counts=0): {e}");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
let body = json!({
|
||||||
|
"clubPlayers": club_players,
|
||||||
|
"auctionCount": active,
|
||||||
|
"tradePile": { "count": active, "selling": active, "sold": 0 },
|
||||||
|
});
|
||||||
|
eprintln!(
|
||||||
|
"utas-host owner=RUST route=hub status=200 clubPlayers={club_players} auctionCount={active}"
|
||||||
|
);
|
||||||
|
json_status(200, &body)
|
||||||
|
}
|
||||||
|
|
||||||
/// Serve forever on `addr` (thread-per-connection, HTTP/1.1 keep-alive).
|
/// Serve forever on `addr` (thread-per-connection, HTTP/1.1 keep-alive).
|
||||||
pub fn serve(&self, addr: &str) -> std::io::Result<()> {
|
pub fn serve(&self, addr: &str) -> std::io::Result<()> {
|
||||||
let listener = TcpListener::bind(addr)?;
|
let listener = TcpListener::bind(addr)?;
|
||||||
@@ -3493,13 +3544,14 @@ mod tests {
|
|||||||
"/ut/game/fifa17/club/stats/staff",
|
"/ut/game/fifa17/club/stats/staff",
|
||||||
Route::ClubStatsStaff,
|
Route::ClubStatsStaff,
|
||||||
),
|
),
|
||||||
|
("GET", "/ut/game/fifa17/hub", Route::Hub),
|
||||||
];
|
];
|
||||||
for (m, p, want) in owned {
|
for (m, p, want) in owned {
|
||||||
assert_eq!(classify(m, p), *want, "OWN: {m} {p}");
|
assert_eq!(classify(m, p), *want, "OWN: {m} {p}");
|
||||||
}
|
}
|
||||||
// Still Python (not yet migrated) / lookalikes / wrong method.
|
// Still Python (not yet migrated) / lookalikes / wrong method.
|
||||||
let proxied: &[(&str, &str)] = &[
|
let proxied: &[(&str, &str)] = &[
|
||||||
("GET", "/ut/game/fifa17/hub"),
|
("GET", "/ut/game/fifa17/club/stats/consumables"),
|
||||||
("GET", "/ut/game/fifa17/club/stats/year"),
|
("GET", "/ut/game/fifa17/club/stats/year"),
|
||||||
("PUT", "/ut/game/fifa17/clientdata/userHubData"),
|
("PUT", "/ut/game/fifa17/clientdata/userHubData"),
|
||||||
("POST", "/openfut/account/sync"),
|
("POST", "/openfut/account/sync"),
|
||||||
|
|||||||
@@ -1408,3 +1408,29 @@ fn non_economy_routes_rust_owned_no_python_no_core() {
|
|||||||
"no Python fallback for migrated non-economy routes"
|
"no Python fallback for migrated non-economy routes"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `GET /hub` is served from Rust: `clubPlayers` counts owned PLAYER cards in
|
||||||
|
/// Core, auction/tradePile counts come from the durable market store (0 with no
|
||||||
|
/// economy wired), and Python is never consulted.
|
||||||
|
#[test]
|
||||||
|
fn hub_counts_players_from_core_no_python() {
|
||||||
|
let items = vec![
|
||||||
|
item("oc1", "card_a", 84, "ST", "Brazil", "La Liga", "Barcelona"),
|
||||||
|
item("oc2", "card_b", 80, "CM", "Spain", "La Liga", "Real Madrid"),
|
||||||
|
item("oc3", "card_c", 77, "CB", "France", "Ligue 1", "PSG"),
|
||||||
|
];
|
||||||
|
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/hub", &[], b"");
|
||||||
|
assert_eq!(resp.status, 200);
|
||||||
|
let body: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||||
|
assert_eq!(body["clubPlayers"], 3, "counts owned player cards");
|
||||||
|
assert_eq!(body["auctionCount"], 0, "no economy wired => 0 listings");
|
||||||
|
assert_eq!(
|
||||||
|
body["tradePile"],
|
||||||
|
serde_json::json!({ "count": 0, "selling": 0, "sold": 0 })
|
||||||
|
);
|
||||||
|
assert_eq!(rec.lock().len(), 0, "hub never reaches Python");
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,14 +40,14 @@ MIGRATED_NON_ECONOMY = {
|
|||||||
"leaderboards/options",
|
"leaderboards/options",
|
||||||
"match/reset",
|
"match/reset",
|
||||||
"phishing", # phishing/{trusteddevice,question,validate}
|
"phishing", # phishing/{trusteddevice,question,validate}
|
||||||
|
"hub",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Documented residual Python-owned non-economy domains (expected > 0 until
|
# Documented residual Python-owned non-economy domains (expected > 0 until
|
||||||
# migrated). Keep in sync with docs/PRODUCTION_AUTHORITY_MATRIX.md.
|
# migrated). Keep in sync with docs/PRODUCTION_AUTHORITY_MATRIX.md.
|
||||||
RESIDUAL_PYTHON = {
|
RESIDUAL_PYTHON = {
|
||||||
"openfut/account/sync",
|
"openfut/account/sync",
|
||||||
"hub",
|
"club/stats", # year/consumables still Python; club/stats/staff is Rust
|
||||||
"club/stats",
|
|
||||||
"clientdata/userHubData",
|
"clientdata/userHubData",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user