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:
@@ -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