feat(host): migrate flag-off UTAS reads (season/tournament/champion/clubUser/user-list) to Rust

FUT modes (Seasons/Tournaments/FUT Champions) and the club-identity service are
disabled in this emulator, so these GET reads return {} verbatim from the Python
oracle. Serve them directly from Rust via a new Route::FeatureOffEmpty +
non_economy::feature_off_body() -> {} (byte-identical to the flag-off oracle),
reducing the proxied Python surface.

- The mutating club rename (user/club) stays on Python (needs a Core write).
- Enabling a mode later requires a real Rust handler here, never a Python
  fallback (no split authority).
- classify tests: 5 routes owned + user/club/wrong-method lookalikes stay
  Passthrough; updated the stale clubUser assertion.
- Deployed to prod-host 2026-08-17; verified owner=RUST 200 {} for all five.

Docs: PRODUCTION_AUTHORITY_MATRIX + PYTHON_RETIREMENT_PLAN updated.
This commit is contained in:
funman300
2026-08-17 16:10:53 +00:00
parent e06fd57211
commit 33e9118329
4 changed files with 50 additions and 8 deletions
+3 -2
View File
@@ -53,16 +53,17 @@ Legend: owner R = Rust/Core, P = Python oracle (:8199 proxied via PYTHON_FALLBAC
| GET /club/stats/{year,consumables,staff,country,league,team} | R | Core aggregation; context buckets keyed nation/league/team (owned=1982); staff={} |
| GET /store, /match/keepalive, /captcha, /tfa, /livemessage, /activeMessage | R | unconditional constant acks (byte-identical to the oracle; StaticAck route) |
| GET /watchList (+ PUT/POST/DELETE) | R | empty watch list + authoritative Core credits; add/remove is a no-op ack (oracle persists none) |
| GET /season, /tournament, /champion, /clubUser, /user/list | R | FUT modes + club-identity off → `{}` (FeatureOffEmpty; byte-identical to the flag-off oracle). Migrated + deployed 2026-08-17 |
| POST /ut/.../match/end (DestroyMatch) | R | economy reward (coins credited via Core grant_reward) |
### NON-ECONOMY — still Python (PYTHON_FALLBACK)
| Method/path | Owner | Reason |
|---|---|---|
| GET /item/resource, /defid | P | `item_def(rid)` shapes `{itemData:[…]}` from `PLAYER_DEFS` (asset=rid&0xffffff → name/rating/pos/attrs) + consumable defs, placeholder fallback ("Player",75,attrs 70). Faithful Rust needs those def tables in the host. Shape captured: `docs/evidence/route-shapes-2026-08-17/defs_resource.json` |
| GET /clubUser, /user/list, /user/club | P | club identity (off→{}, flag-gated) + rename (mutating); Core has `clubs` but rename needs a Core write |
| POST /user/club (rename) | P | mutating club rename; Core has `clubs` but rename needs a Core write (deferred). Reads `clubUser`/`user/list` are now Rust. |
| GET /squad/<n> (n≠0, non-active) | P | no multi-squad Core model (Rust owns squad/0, squad/active, /squad/list, PUT) |
| /squad/mode/draft/* | P | FUT Draft mode |
| GET /season, /tournament, /champion, /leaderboards, /sbs/* | P | mode-gated (FUT_MODES/_SBC off by default → `{}`); real behavior needs the mode logic ported |
| GET /leaderboards, /sbs/* | P | mode-gated (FUT_MODES/_SBC off `{}`/content; `/sbs/sets` ships content); real behavior needs the mode logic ported. `season`/`tournament`/`champion` are now Rust. |
| GET /marketdata (+ /marketdata/pricelimits) | P | suggested pricing; freeze-risk (array vs object container); Python-correct, constant band 150..15000 |
| POST /ut/.../match (CREATE), /match/ready (READY), /match (PLAY) | P | match handshake legs; no match ever played in-game (see docs/MATCH_LIFECYCLE.md) |
+5 -5
View File
@@ -8,7 +8,7 @@ rollback-to-python-p2.sh, :p2-rollback image b1b929953f, profile 39bb3e83).
## A. LIVE PRODUCTION REQUIRED (still owns behavior in the live flow)
| Component | Role | Rust status | Retire when |
|---|---|---|---|
| oracle utas_server.py (:8199) NON-ECONOMY | **remaining**: item-defs (item/resource,defid), user-identity (user,clubUser,user/list,user/club), non-active squad/<n>, draft, watchList, marketdata, mode-gated (season/tournament/champion/leaderboards/sbs, off→{}) | **Most non-economy migrated 2026-08-17** (account/sync, auth, userMassInfo, clientdata, hub, club/stats/*, settings, accountinfo, phishing, match/reset, leaderboards/options, static acks — all Rust). See PRODUCTION_AUTHORITY_MATRIX | remaining tail migrated (needs live captures for the data routes; mode-logic port for the gated ones) OR mode-gated ones kept Python |
| oracle utas_server.py (:8199) NON-ECONOMY | **remaining**: item-defs (item/resource,defid), user/club rename, non-active squad/<n>, draft, marketdata, mode-gated leaderboards + /sbs/* | **Most non-economy migrated 2026-08-17** (account/sync, auth, userMassInfo, user, clientdata, hub, club/stats/*, settings, accountinfo, phishing, match/reset, leaderboards/options, watchList, static acks, and the flag-off empty reads season/tournament/champion/clubUser/user/list → `{}` — all Rust). See PRODUCTION_AUTHORITY_MATRIX | remaining tail migrated (needs live captures for the data routes; mode-logic port for the gated ones) OR mode-gated ones kept Python |
| blaze_responder_v3b.py (:42130 Blaze) | FUT Blaze transport | Rust openfut-blaze-host COMPLETE, gate-proven (Gate 10) | container cutover (operator-gated deploy) |
| blaze_responder_v3b.py (:42127 redirector TLS) | first-hop TLS redirect | Rust openfut-redirector-host COMPLETE (OpenSSL) | container cutover + cert consistency |
| roster_server.py (:8081) | roster-update XML | Rust openfut-roster-host COMPLETE (unit-only) | container cutover |
@@ -34,10 +34,10 @@ rollback-to-python-p2.sh, :p2-rollback image b1b929953f, profile 39bb3e83).
## Retirement gating
1. **Mostly DONE (2026-08-17)**: account/sync, ut/auth (Rust SID mint), userMassInfo (full), clientdata,
club/stats/{country,league,team}, and the trivial static acks migrated + deployed. **Remaining on Python**:
item-defs, user-identity (user/clubUser/user-list/user-club), non-active squad/<n>, draft, watchList,
marketdata (no captured wire shape), and mode-gated season/tournament/champion/leaderboards/sbs
(return {} while FUT_MODES/_SBC off). Migrate the data ones once live shapes are captured.
club/stats/{country,league,team}, watchList, static acks, and the flag-off empty reads
(season/tournament/champion/clubUser/user/list`{}`) migrated + deployed. **Remaining on Python**:
item-defs, user/club rename, non-active squad/<n>, draft, marketdata (freeze-risk), and mode-gated
leaderboards + /sbs/* (need the mode logic ported). Migrate the data ones once live shapes are captured.
2. Deploy Rust blaze/roster/redirector via container cutover (operator-gated) — then those Python responders
are class D/E only.
3. POW: build Rust host + reverse bodies (largest blocker) OR keep Python POW as class A indefinitely.
@@ -46,6 +46,16 @@ pub fn club_stats_staff_body() -> Value {
json!({})
}
/// The FUT modes (Seasons / Tournaments / FUT Champions) and the club-identity
/// service are disabled in this emulator, so their read routes (`season`,
/// `tournament`, `champion`, `clubUser`, `user/list`) return an empty object —
/// byte-identical to the Python oracle with `FUT_MODES` / `FUT_CLUB_IDENTITY`
/// off. Enabling a mode later requires a real Rust implementation here, never a
/// Python fallback (which would reintroduce split authority).
pub fn feature_off_body() -> Value {
json!({})
}
/// The phishing/security-question action, parsed from the URL tail.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecurityAction {
+32 -1
View File
@@ -146,6 +146,13 @@ pub enum Route {
/// `GET …/user` — the FUT user profile `{"userInfo": …}`, the same userInfo
/// object `userMassInfo` embeds (shared builder). POST /user (create) stays Python.
User,
/// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and
/// the club-identity service are disabled in this emulator, so these reads
/// return `{}`, byte-identical to the Python oracle with `FUT_MODES` /
/// `FUT_CLUB_IDENTITY` off. The mutating club-identity route (`user/club`
/// rename) stays on Python. Turning a feature on later means a real Rust
/// handler here — never a Python fallback.
FeatureOffEmpty,
/// Anything else — proxied verbatim to the Python oracle.
Passthrough,
}
@@ -206,6 +213,11 @@ pub fn classify(method: &str, path: &str) -> Route {
Some("livemessage") => Route::StaticAck,
Some("activeMessage") => Route::StaticAck,
Some("watchList") => Route::WatchList,
Some("season") if get => Route::FeatureOffEmpty,
Some("tournament") if get => Route::FeatureOffEmpty,
Some("champion") if get => Route::FeatureOffEmpty,
Some("clubUser") if get => Route::FeatureOffEmpty,
Some("user/list") if get => Route::FeatureOffEmpty,
_ => Route::Passthrough,
}
}
@@ -2329,6 +2341,7 @@ impl Server {
Route::StaticAck => self.handle_static_ack(path),
Route::WatchList => self.handle_watchlist(method),
Route::User => self.handle_user(),
Route::FeatureOffEmpty => self.handle_feature_off_empty(path),
Route::SecurityQuestion => self.handle_security_question(method, target, headers),
Route::Passthrough => {
let resp = match self.pass.forward(method, target, headers, body) {
@@ -2717,6 +2730,16 @@ impl Server {
json_status(200, &json!({ "auctionInfo": [], "credits": credits, "total": 0 }))
}
/// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and the
/// club-identity service are off, so return `{}` (parity with the flag-off
/// Python oracle). The mutating `user/club` rename is NOT here — it stays on
/// Python until a real Rust club-identity handler exists.
fn handle_feature_off_empty(&self, path: &str) -> WireResponse {
let tail = ut_tail(path).unwrap_or("");
eprintln!("utas-host owner=RUST route=feature-off-empty tail={tail} status=200");
json_status(200, &non_economy::feature_off_body())
}
/// `GET …/club/stats/<mode>` — 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, and per-context
@@ -3323,9 +3346,10 @@ mod tests {
classify("GET", "/ut/game/fifa17/club/stats/country/54"),
Route::ClubStats
);
// clubUser is now Rust-owned (`{}` while club identity is off).
assert_eq!(
classify("GET", "/ut/game/fifa17/clubUser"),
Route::Passthrough
Route::FeatureOffEmpty
);
assert_eq!(
classify("GET", "/ut/game/fifa17/tradePile"),
@@ -3835,6 +3859,11 @@ mod tests {
Route::ClientData,
),
("POST", "/openfut/account/sync", Route::AccountSync),
("GET", "/ut/game/fifa17/season", Route::FeatureOffEmpty),
("GET", "/ut/game/fifa17/tournament", Route::FeatureOffEmpty),
("GET", "/ut/game/fifa17/champion", Route::FeatureOffEmpty),
("GET", "/ut/game/fifa17/clubUser", Route::FeatureOffEmpty),
("GET", "/ut/game/fifa17/user/list", Route::FeatureOffEmpty),
];
for (m, p, want) in owned {
assert_eq!(classify(m, p), *want, "OWN: {m} {p}");
@@ -3845,6 +3874,8 @@ mod tests {
("POST", "/ut/game/fifa17/match/reset"), // match/reset is PUT-only
("GET", "/ut/game/fifa17/match/reset"),
("PUT", "/ut/game/fifa17/user/accountinfo"),
("POST", "/ut/game/fifa17/season"), // FUT-mode reads are GET-only
("GET", "/ut/game/fifa17/user/club"), // mutating rename stays Python
];
for (m, p) in proxied {
assert_eq!(classify(m, p), Route::Passthrough, "PROXY: {m} {p}");