diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs
index 7b1e705..9463308 100644
--- a/openfut-utas-host/src/lib.rs
+++ b/openfut-utas-host/src/lib.rs
@@ -139,11 +139,17 @@ pub fn classify(method: &str, path: &str) -> Route {
}
}
-/// The tail after `/ut/game/
/` (non-empty title), or `None`.
+/// The tail after `/ut/game//` or `/ut/v2/game//` (non-empty sku), or
+/// `None`. Retail FIFA 17 issues the Store family (`store/*`, `purchased`) under
+/// the `/ut/v2/game//` prefix while other routes use `/ut/game//`; 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/` — 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 {
let delete = method.eq_ignore_ascii_case("DELETE");
// The `/ut/delete/game//…` 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 {
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);
+ }
}