diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 1ebc831..fb01203 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -117,6 +117,9 @@ pub enum Route { /// `GET …/club/stats/staff` — Rust-owned static `{}` (production oracle body; /// FIFA's staff-bonus stat set is deliberately empty). 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. Passthrough, } @@ -157,6 +160,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("hub") if get => Route::Hub, _ => Route::Passthrough, } } @@ -2273,6 +2277,7 @@ impl Server { eprintln!("utas-host owner=RUST route=club-stats-staff status=200"); json_status(200, &non_economy::club_stats_staff_body()) } + Route::Hub => self.handle_hub(), Route::SecurityQuestion => self.handle_security_question(method, target, headers), Route::Passthrough => { let resp = match self.pass.forward(method, target, headers, body) { @@ -2457,6 +2462,52 @@ impl Server { 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). pub fn serve(&self, addr: &str) -> std::io::Result<()> { let listener = TcpListener::bind(addr)?; @@ -3493,13 +3544,14 @@ mod tests { "/ut/game/fifa17/club/stats/staff", Route::ClubStatsStaff, ), + ("GET", "/ut/game/fifa17/hub", Route::Hub), ]; 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/hub"), + ("GET", "/ut/game/fifa17/club/stats/consumables"), ("GET", "/ut/game/fifa17/club/stats/year"), ("PUT", "/ut/game/fifa17/clientdata/userHubData"), ("POST", "/openfut/account/sync"), diff --git a/openfut-utas-host/tests/host_test.rs b/openfut-utas-host/tests/host_test.rs index aceea44..dcbcf93 100644 --- a/openfut-utas-host/tests/host_test.rs +++ b/openfut-utas-host/tests/host_test.rs @@ -1408,3 +1408,29 @@ fn non_economy_routes_rust_owned_no_python_no_core() { "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"); +} diff --git a/scripts/openfut-reachability.py b/scripts/openfut-reachability.py index 0383926..36d4cc3 100755 --- a/scripts/openfut-reachability.py +++ b/scripts/openfut-reachability.py @@ -40,14 +40,14 @@ MIGRATED_NON_ECONOMY = { "leaderboards/options", "match/reset", "phishing", # phishing/{trusteddevice,question,validate} + "hub", } # 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", - "hub", - "club/stats", + "club/stats", # year/consumables still Python; club/stats/staff is Rust "clientdata/userHubData", }