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
+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);
}
}