diff --git a/docs/PRODUCTION_AUTHORITY_MATRIX.md b/docs/PRODUCTION_AUTHORITY_MATRIX.md index cbef3d3..910edd8 100644 --- a/docs/PRODUCTION_AUTHORITY_MATRIX.md +++ b/docs/PRODUCTION_AUTHORITY_MATRIX.md @@ -55,16 +55,16 @@ Legend: owner R = Rust/Core, P = Python oracle (:8199 proxied via PYTHON_FALLBAC | 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) | +| GET /item/resource, /defid | R | `{itemData:[item_def…]}` — asset=rid&0xffffff; hardcoded Ronaldo (20801) + placeholder ("Player",75,CM,attrs 70), mirroring the oracle's `item_def`. Client renders from its LOCAL DB, so the placeholder is exact parity. Migrated + deployed 2026-08-17 | +| GET /marketdata, /marketdata/pricelimits | R | suggested pricing, constant band 150..15000. `/pricelimits` = bare ARRAY (one per defId); plain `/marketdata` = OBJECT — container type is load-bearing (object-where-array froze a live client). Migrated + deployed 2026-08-17 | ### 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` | | 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≠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 /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) | ## AUXILIARY SERVICES (prod container OPENFUT_SERVERS="blaze roster pow") diff --git a/docs/PYTHON_RETIREMENT_PLAN.md b/docs/PYTHON_RETIREMENT_PLAN.md index 322025f..550c61b 100644 --- a/docs/PYTHON_RETIREMENT_PLAN.md +++ b/docs/PYTHON_RETIREMENT_PLAN.md @@ -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/club rename, non-active squad/, 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 | +| oracle utas_server.py (:8199) NON-ECONOMY | **remaining**: user/club rename, non-active squad/, draft/*, 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, item-defs (item/resource,defid), marketdata (+/pricelimits), and the flag-off empty reads season/tournament/champion/clubUser/user/list → `{}` — all Rust). See PRODUCTION_AUTHORITY_MATRIX | remaining tail is mutation (user/club), no-Core-model (squad/), or unimplemented modes (draft/leaderboards/sbs) | | 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,11 @@ 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}, 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/, draft, marketdata (freeze-risk), and mode-gated - leaderboards + /sbs/* (need the mode logic ported). Migrate the data ones once live shapes are captured. + club/stats/{country,league,team}, watchList, static acks, the flag-off empty reads + (season/tournament/champion/clubUser/user/list → `{}`), item-defs (item/resource,defid), and + marketdata (+/pricelimits, container-type-exact) migrated + deployed. **Remaining on Python**: + user/club rename (mutating; needs a Core write), non-active squad/ (no multi-squad Core model), + and the unimplemented modes draft/* + leaderboards + /sbs/* (need the mode logic ported, not a proxy). 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. diff --git a/openfut-adapter-fifa17/src/fut/non_economy.rs b/openfut-adapter-fifa17/src/fut/non_economy.rs index 0a9dcdc..9bbc589 100644 --- a/openfut-adapter-fifa17/src/fut/non_economy.rs +++ b/openfut-adapter-fifa17/src/fut/non_economy.rs @@ -56,6 +56,78 @@ pub fn feature_off_body() -> Value { json!({}) } +/// One FUT item-definition for a requested `resource_id`, replicating the Python +/// oracle's `item_def`: `assetId = resource_id & 0xffffff`; a single hardcoded +/// card (Ronaldo, asset 20801) and a generic placeholder (`"Player"`, 75, CM, +/// attrs 70) for every other asset. The FIFA client renders the real card from +/// its LOCAL DB from the `(rareflag, resourceId)` pair, so this route only needs +/// a valid-shaped record — the placeholder is exactly what the oracle itself +/// returns for all but the one hardcoded asset. Key order is irrelevant (the +/// client's deserializer is key-addressed and skip-safe). +pub fn item_def(resource_id: i64) -> Value { + let asset = resource_id & 0xff_ffff; + // (name, rating, position, nation, leagueId, teamid, [6 attrs]) + let (name, rating, pos, nation, league, team, attrs): (&str, i64, &str, i64, i64, i64, [i64; 6]) = + if asset == 20801 { + ("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80]) + } else { + ("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70]) + }; + let attribute_list: Vec = attrs + .iter() + .enumerate() + .map(|(i, v)| json!({ "index": i, "value": v })) + .collect(); + json!({ + "id": resource_id, + "resourceId": resource_id, + "definitionId": resource_id, + "assetId": asset, + "cardassetid": asset, + "commodityId": asset, + "cardsubtypeid": 0, + "cardType": 0, + "itemType": "player", + "rareflag": 1, + "rating": rating, + "preferredPosition": pos, + "nation": nation, + "leagueId": league, + "teamid": team, + "playStyle": 250, + "attributeList": attribute_list, + "name": name, + "commonName": name, + "lastName": name, + "itemState": "free", + "untradeable": true, + }) +} + +/// `GET …/item/resource`, `…/defid` — `{itemData:[…]}` with one [`item_def`] per +/// requested id (mirrors the oracle's `defs_route`). No ids → an empty list. +pub fn item_defs_body(ids: &[i64]) -> Value { + json!({ "itemData": ids.iter().map(|&id| item_def(id)).collect::>() }) +} + +/// `GET …/marketdata/pricelimits?defId=a,b,c` — FutGetSuggestedPricing. The root +/// MUST be a BARE ARRAY (one element per defId): returning an object here froze a +/// live client (object-where-array busy loop at the listing screen). Constant +/// band 150..15000 (placeholder pricing; not a freeze concern). +pub fn marketdata_pricelimits_body(def_ids: &[i64]) -> Value { + json!(def_ids + .iter() + .map(|&d| json!({ "defId": d, "minPrice": 150, "maxPrice": 15000 })) + .collect::>()) +} + +/// `GET …/marketdata` (NOT `/pricelimits`) — the price-comparison endpoint, which +/// takes an OBJECT `{minPrice,maxPrice}`. Array-where-object would be the same +/// freeze in reverse, so the container type is load-bearing. Constant band. +pub fn marketdata_object_body() -> Value { + json!({ "minPrice": 150, "maxPrice": 15000 }) +} + /// The phishing/security-question action, parsed from the URL tail. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SecurityAction { diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index bac2f32..ec33e30 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -153,6 +153,17 @@ pub enum Route { /// rename) stays on Python. Turning a feature on later means a real Rust /// handler here — never a Python fallback. FeatureOffEmpty, + /// `GET …/item/resource`, `…/defid` — FUT item-definition lookups. Rust builds + /// `{itemData:[…]}` (one placeholder-or-Ronaldo def per queried id), mirroring + /// the oracle's `defs_route`/`item_def`. The client renders the real card from + /// its LOCAL DB, so a valid-shaped placeholder is exact parity. + ItemDefs, + /// `GET …/marketdata` and `…/marketdata/pricelimits` — suggested pricing. + /// `/pricelimits` MUST be a bare ARRAY (one `{defId,minPrice,maxPrice}` per + /// queried defId); plain `/marketdata` MUST be an OBJECT `{minPrice,maxPrice}`. + /// Container type is load-bearing (object-where-array froze a live client); + /// the handler picks it from the path. Constant band 150..15000. + MarketData, /// Anything else — proxied verbatim to the Python oracle. Passthrough, } @@ -218,6 +229,9 @@ pub fn classify(method: &str, path: &str) -> Route { Some("champion") if get => Route::FeatureOffEmpty, Some("clubUser") if get => Route::FeatureOffEmpty, Some("user/list") if get => Route::FeatureOffEmpty, + Some("item/resource") if get => Route::ItemDefs, + Some("defid") if get => Route::ItemDefs, + Some(t) if get && (t == "marketdata" || t.starts_with("marketdata/")) => Route::MarketData, _ => Route::Passthrough, } } @@ -239,6 +253,53 @@ fn ut_tail(path: &str) -> Option<&str> { } } +/// Every run of ≥ 3 ASCII digits in `s`, parsed as `i64` — the allocation-light +/// equivalent of the oracle's `re.findall(r"\d{3,}", query)` used by `defs_route` +/// to pull ids out of `resourceId=`/`definitionId=`/`idList=a,b,c` queries. +fn extract_long_ints(s: &str) -> Vec { + let bytes = s.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while i < bytes.len() { + if bytes[i].is_ascii_digit() { + let start = i; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + if i - start >= 3 { + if let Ok(v) = s[start..i].parse::() { + out.push(v); + } + } + } else { + i += 1; + } + } + out +} + +/// The comma-separated all-digit values of the `defId=` query parameter (the +/// oracle's `parse_qs(...).get("defId")` + `int(x) for x if x.isdigit()`), used +/// by `/marketdata/pricelimits`. Non-digit tokens are skipped, never faked. +fn extract_defid_param(query: &str) -> Vec { + for pair in query.split('&') { + if let Some(v) = pair.strip_prefix("defId=") { + return v + .split(',') + .filter_map(|t| { + let t = t.trim(); + if !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit()) { + t.parse::().ok() + } else { + None + } + }) + .collect(); + } + } + Vec::new() +} + fn is_exact_club_path(path: &str) -> bool { ut_tail(path) == Some("club") } @@ -2342,6 +2403,8 @@ impl Server { Route::WatchList => self.handle_watchlist(method), Route::User => self.handle_user(), Route::FeatureOffEmpty => self.handle_feature_off_empty(path), + Route::ItemDefs => self.handle_item_defs(target), + Route::MarketData => self.handle_marketdata(path, target), Route::SecurityQuestion => self.handle_security_question(method, target, headers), Route::Passthrough => { let resp = match self.pass.forward(method, target, headers, body) { @@ -2740,6 +2803,38 @@ impl Server { json_status(200, &non_economy::feature_off_body()) } + /// `GET …/item/resource`, `…/defid` — FUT item-definition lookup. Builds + /// `{itemData:[…]}` for every integer id (≥ 3 digits) in the query, mirroring + /// the oracle's `defs_route` (`re.findall(r"\d{3,}")` over the raw query). + fn handle_item_defs(&self, target: &str) -> WireResponse { + let query = target.split_once('?').map(|(_, q)| q).unwrap_or(""); + let ids = extract_long_ints(query); + eprintln!( + "utas-host owner=RUST route=item-defs count={} status=200", + ids.len() + ); + json_status(200, &non_economy::item_defs_body(&ids)) + } + + /// `GET …/marketdata[/pricelimits]` — suggested pricing. `/pricelimits` returns + /// the bare ARRAY (one band per queried defId); any other `/marketdata` returns + /// the OBJECT band. Container type is chosen from the path (load-bearing: the + /// wrong one froze a live client). + fn handle_marketdata(&self, path: &str, target: &str) -> WireResponse { + if path.ends_with("/pricelimits") { + let query = target.split_once('?').map(|(_, q)| q).unwrap_or(""); + let ids = extract_defid_param(query); + eprintln!( + "utas-host owner=RUST route=marketdata kind=pricelimits count={} status=200", + ids.len() + ); + json_status(200, &non_economy::marketdata_pricelimits_body(&ids)) + } else { + eprintln!("utas-host owner=RUST route=marketdata kind=object status=200"); + json_status(200, &non_economy::marketdata_object_body()) + } + } + /// `GET …/club/stats/` — 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 @@ -3864,6 +3959,14 @@ mod tests { ("GET", "/ut/game/fifa17/champion", Route::FeatureOffEmpty), ("GET", "/ut/game/fifa17/clubUser", Route::FeatureOffEmpty), ("GET", "/ut/game/fifa17/user/list", Route::FeatureOffEmpty), + ("GET", "/ut/game/fifa17/item/resource", Route::ItemDefs), + ("GET", "/ut/game/fifa17/defid", Route::ItemDefs), + ( + "GET", + "/ut/game/fifa17/marketdata/pricelimits", + Route::MarketData, + ), + ("GET", "/ut/game/fifa17/marketdata", Route::MarketData), ]; for (m, p, want) in owned { assert_eq!(classify(m, p), *want, "OWN: {m} {p}"); @@ -3876,6 +3979,8 @@ mod tests { ("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 + ("POST", "/ut/game/fifa17/item/resource"), // item-defs are GET-only + ("GET", "/ut/game/fifa17/marketdatafoo"), // not the marketdata route ]; for (m, p) in proxied { assert_eq!(classify(m, p), Route::Passthrough, "PROXY: {m} {p}"); @@ -3885,4 +3990,48 @@ mod tests { assert_eq!(classify_economy(m, p), None, "NON-ECON: {m} {p}"); } } + + #[test] + fn item_defs_shape_matches_oracle() { + // Two ids: the one hardcoded card (asset 20801) + a placeholder. + assert_eq!(extract_long_ints("resourceId=20801"), vec![20801]); + assert_eq!(extract_long_ints("idList=200389,200104&x=12"), vec![200389, 200104]); + assert!(extract_long_ints("foo=ab").is_empty()); + + let body = non_economy::item_defs_body(&[20801, 200389]); + let items = body["itemData"].as_array().unwrap(); + assert_eq!(items.len(), 2); + // Hardcoded Ronaldo. + assert_eq!(items[0]["name"], "Ronaldo"); + assert_eq!(items[0]["rating"], 94); + assert_eq!(items[0]["assetId"], 20801); + assert_eq!(items[0]["resourceId"], 20801); + // Placeholder: assetId = resourceId & 0xffffff, rating 75, "Player". + assert_eq!(items[1]["name"], "Player"); + assert_eq!(items[1]["rating"], 75); + assert_eq!(items[1]["assetId"], 200389); + assert_eq!(items[1]["attributeList"].as_array().unwrap().len(), 6); + assert!(non_economy::item_defs_body(&[])["itemData"] + .as_array() + .unwrap() + .is_empty()); + } + + #[test] + fn marketdata_container_types_are_load_bearing() { + // /pricelimits MUST be a bare ARRAY (object-where-array froze a live client). + assert_eq!(extract_defid_param("defId=200389,200104"), vec![200389, 200104]); + let arr = non_economy::marketdata_pricelimits_body(&[200389, 200104]); + assert!(arr.is_array(), "pricelimits must be a bare array"); + let arr = arr.as_array().unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0]["defId"], 200389); + assert_eq!(arr[0]["minPrice"], 150); + assert_eq!(arr[0]["maxPrice"], 15000); + // plain /marketdata MUST be an OBJECT (array-where-object is the same freeze). + let obj = non_economy::marketdata_object_body(); + assert!(obj.is_object(), "plain marketdata must be an object"); + assert_eq!(obj["minPrice"], 150); + assert_eq!(obj["maxPrice"], 15000); + } }