fix(fifa17): classify retail v2 economy routes

Live staging (S2) showed the retail FIFA17 client issues the Store family
under /ut/v2/game/<sku>/... (PUT /ut/v2/game/fifa17/store/transaction/0),
which escaped Rust economy authority to Python. Fix classify_economy:
- ut_tail() normalizes both /ut/game/<sku>/ and /ut/v2/game/<sku>/ to the
  same tail (generic sku, never hard-coded fifa17); delete family likewise
  accepts /ut/v2/delete/game/.
- StoreBuy matches store/transaction and store/transaction/<digits> via a
  bounded is_store_transaction_tail (never store/transactions, ...foo, or
  .../<id>/extra), mirroring the Python bare /store/transaction regex.
Adds table-driven ut_tail + is_store_transaction_tail + classify_economy v2
unit tests (lib 74).
This commit is contained in:
OpenFUT Agent
2026-08-13 23:53:44 +00:00
parent d74e86c065
commit df6994c957
+165 -6
View File
@@ -139,11 +139,17 @@ pub fn classify(method: &str, path: &str) -> Route {
}
}
/// The tail after `/ut/game/<title>/` (non-empty title), or `None`.
/// The tail after `/ut/game/<sku>/` or `/ut/v2/game/<sku>/` (non-empty sku), or
/// `None`. Retail FIFA 17 issues the Store family (`store/*`, `purchased`) under
/// the `/ut/v2/game/<sku>/` prefix while other routes use `/ut/game/<sku>/`; both
/// normalize to the same tail so economy classification is prefix-agnostic. The
/// `sku` segment is generic (never hard-coded to `fifa17`).
fn ut_tail(path: &str) -> Option<&str> {
let rest = path.strip_prefix("/ut/game/")?;
let (title, tail) = rest.split_once('/')?;
if title.is_empty() {
let rest = path
.strip_prefix("/ut/game/")
.or_else(|| path.strip_prefix("/ut/v2/game/"))?;
let (sku, tail) = rest.split_once('/')?;
if sku.is_empty() {
None
} else {
Some(tail)
@@ -209,6 +215,22 @@ fn is_item_id_tail(tail: &str) -> bool {
}
}
/// `store/transaction` or `store/transaction/<digits>` — the Store BUY create
/// step. Retail sends a trailing numeric transaction id (observed live:
/// `store/transaction/0`). Mirrors the Python oracle's bare `/store/transaction`
/// route, but bounded to a single all-digit id segment so it never absorbs
/// `store/transactions`, `store/transactionfoo`, or `store/transaction/0/extra`.
fn is_store_transaction_tail(tail: &str) -> bool {
match tail.strip_prefix("store/transaction") {
Some("") => true,
Some(rest) => match rest.strip_prefix('/') {
Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()),
None => false,
},
None => false,
}
}
/// Classify a FIFA17 economy route from method + path, mirroring the Python
/// oracle's route table (`utas_server.py` §1418-1553). Returns `None` for any
/// non-economy path. Path is already query-stripped by the caller.
@@ -219,7 +241,10 @@ pub fn classify_economy(method: &str, path: &str) -> Option<EconomyRoute> {
let delete = method.eq_ignore_ascii_case("DELETE");
// The `/ut/delete/game/<sku>/…` family is NOT `/ut/game/…`-prefixed.
if let Some(rest) = path.strip_prefix("/ut/delete/game/") {
if let Some(rest) = path
.strip_prefix("/ut/delete/game/")
.or_else(|| path.strip_prefix("/ut/v2/delete/game/"))
{
if let Some((_sku, tail)) = rest.split_once('/') {
if tail == "item" && post {
return Some(EconomyRoute::QuickSellBody);
@@ -237,7 +262,7 @@ pub fn classify_economy(method: &str, path: &str) -> Option<EconomyRoute> {
match ut_tail(path) {
Some("user/credits") if get => Some(EconomyRoute::Credits),
Some(t) if get && t.starts_with("store/purchasegroup") => Some(EconomyRoute::PurchaseGroup),
Some("store/transaction") if put => Some(EconomyRoute::StoreBuy),
Some(t) if put && is_store_transaction_tail(t) => Some(EconomyRoute::StoreBuy),
Some("purchased") if post => Some(EconomyRoute::PackOpen),
Some("purchased") if get => Some(EconomyRoute::PackReveal),
Some(t) if delete && is_item_id_tail(t) => Some(EconomyRoute::QuickSellPath),
@@ -3088,4 +3113,138 @@ mod tests {
let mut other = serde_json::json!({"other": 1});
assert_eq!(overlay_empty_mypacks(&mut other, StoreMode::CleanV1), 0);
}
#[test]
fn ut_tail_normalizes_v1_and_v2() {
for (path, want) in [
(
"/ut/game/fifa17/store/purchasegroup",
Some("store/purchasegroup"),
),
(
"/ut/v2/game/fifa17/store/purchasegroup",
Some("store/purchasegroup"),
),
(
"/ut/game/fifa17/store/transaction",
Some("store/transaction"),
),
(
"/ut/v2/game/fifa17/store/transaction/0",
Some("store/transaction/0"),
),
("/ut/game/fifa17/purchased", Some("purchased")),
("/ut/v2/game/fifa17/purchased", Some("purchased")),
("/ut/game/fifa17/user/credits", Some("user/credits")),
// generic sku — helper is not fifa17-string-specific.
(
"/ut/game/fifa23/store/transaction/7",
Some("store/transaction/7"),
),
(
"/ut/v2/game/fifa23/store/purchasegroup",
Some("store/purchasegroup"),
),
// negatives.
("/ut/auth", None),
("/openfut/account/sync", None),
("/ut/game/", None),
("/ut/game/fifa17", None),
("/ut/v2/game/fifa17", None),
("/ut/v2/other/thing", None),
("/ut/delete/game/fifa17/item", None),
] {
assert_eq!(ut_tail(path), want, "ut_tail({path})");
}
}
#[test]
fn is_store_transaction_tail_is_bounded() {
assert!(is_store_transaction_tail("store/transaction"));
assert!(is_store_transaction_tail("store/transaction/0"));
assert!(is_store_transaction_tail("store/transaction/123"));
assert!(!is_store_transaction_tail("store/transactions"));
assert!(!is_store_transaction_tail("store/transactionfoo"));
assert!(!is_store_transaction_tail("store/transaction/0/extra"));
assert!(!is_store_transaction_tail("store/transaction/"));
assert!(!is_store_transaction_tail("store/transaction/abc"));
assert!(!is_store_transaction_tail("store/purchasegroup"));
}
#[test]
fn classify_economy_covers_retail_v2_store_family() {
use EconomyRoute::*;
// The exact live-failure shape now classifies as Rust StoreBuy.
assert_eq!(
classify_economy("PUT", "/ut/v2/game/fifa17/store/transaction/0"),
Some(StoreBuy)
);
assert_eq!(
classify_economy("PUT", "/ut/game/fifa17/store/transaction"),
Some(StoreBuy)
);
assert_eq!(
classify_economy("PUT", "/ut/v2/game/fifa17/store/transaction/123"),
Some(StoreBuy)
);
// purchasegroup + purchased under both prefixes.
assert_eq!(
classify_economy("GET", "/ut/v2/game/fifa17/store/purchasegroup"),
Some(PurchaseGroup)
);
assert_eq!(
classify_economy("GET", "/ut/game/fifa17/store/purchasegroup/all"),
Some(PurchaseGroup)
);
assert_eq!(
classify_economy("POST", "/ut/v2/game/fifa17/purchased"),
Some(PackOpen)
);
assert_eq!(
classify_economy("GET", "/ut/v2/game/fifa17/purchased"),
Some(PackReveal)
);
// v1 non-store economy routes still classify (regression).
assert_eq!(
classify_economy("GET", "/ut/game/fifa17/user/credits"),
Some(Credits)
);
assert_eq!(
classify_economy("PUT", "/ut/game/fifa17/item"),
Some(MoveItems)
);
assert_eq!(
classify_economy("DELETE", "/ut/game/fifa17/item/100000001"),
Some(QuickSellPath)
);
assert_eq!(
classify_economy("GET", "/ut/game/fifa17/tradePile"),
Some(MarketQuery)
);
assert_eq!(
classify_economy("POST", "/ut/delete/game/fifa17/item"),
Some(QuickSellBody)
);
assert_eq!(
classify_economy("POST", "/ut/delete/game/fifa17/match"),
Some(MatchEnd)
);
// delete family under v2 prefix too (defense-in-depth symmetry).
assert_eq!(
classify_economy("POST", "/ut/v2/delete/game/fifa17/item"),
Some(QuickSellBody)
);
// negatives: non-economy stays None (proxied to Python).
assert_eq!(
classify_economy("PUT", "/ut/v2/game/fifa17/store/transaction/0/extra"),
None
);
assert_eq!(
classify_economy("PUT", "/ut/game/fifa17/store/transactions"),
None
);
assert_eq!(classify_economy("GET", "/ut/game/fifa17/hub"), None);
assert_eq!(classify_economy("GET", "/ut/v2/game/fifa17/store"), None);
assert_eq!(classify_economy("POST", "/ut/auth"), None);
}
}