feat(host): migrate item-defs + marketdata UTAS reads to Rust

Two more real client-hit reads move off the Python proxy:

- GET /item/resource, /defid (Route::ItemDefs): build {itemData:[item_def…]}
  for every >=3-digit id in the query, replicating the oracle's item_def
  (assetId = resourceId & 0xffffff; hardcoded Ronaldo asset 20801 + a generic
  "Player" 75 CM placeholder). The client renders the real card from its local
  DB, so the placeholder is exact parity.
- GET /marketdata (+ /marketdata/pricelimits) (Route::MarketData): suggested
  pricing, constant band 150..15000. /pricelimits returns a BARE ARRAY (one
  {defId,minPrice,maxPrice} per queried defId); plain /marketdata returns an
  OBJECT {minPrice,maxPrice}. The container type is load-bearing — object-where-
  array froze a live client at the listing screen, so the handler picks it from
  the path.

Adds extract_long_ints / extract_defid_param query parsers, shape+parser unit
tests (incl. the freeze-critical container-type assertions), and classify-table
coverage. Deployed to prod-host 2026-08-17; verified owner=RUST 200 for all four
(Ronaldo/placeholder resolve, pricelimits=array, marketdata=object).

Docs: PRODUCTION_AUTHORITY_MATRIX + PYTHON_RETIREMENT_PLAN updated. Remaining
Python tail is now only mutation (user/club), no-Core-model (squad/<n>), and
unimplemented modes (draft/leaderboards/sbs).
This commit is contained in:
funman300
2026-08-17 16:21:17 +00:00
parent 33e9118329
commit 1aa84afa9a
4 changed files with 229 additions and 7 deletions
+2 -2
View File
@@ -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> (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")
+6 -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/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 |
| oracle utas_server.py (:8199) NON-ECONOMY | **remaining**: user/club rename, non-active squad/<n>, 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/<n>), 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/<n>, 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/<n> (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.
@@ -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<Value> = 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::<Vec<_>>() })
}
/// `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::<Vec<_>>())
}
/// `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 {
+149
View File
@@ -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<i64> {
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::<i64>() {
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<i64> {
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::<i64>().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/<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
@@ -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);
}
}