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:
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user