diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 9463308..4340f05 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -231,6 +231,28 @@ fn is_store_transaction_tail(tail: &str) -> bool { } } +/// `purchased` or `purchased/items` — pack OPEN (POST) / reveal (GET). Retail +/// sends the `/items` sub-path (FutPurchaseItemsServerResponse); the Python +/// oracle's bare `/purchased` regex matches both. Bounded to exactly these two +/// tails (rejects `purchasedfoo`, `purchased/items/extra`). +fn is_purchased_tail(tail: &str) -> bool { + tail == "purchased" || tail == "purchased/items" +} + +/// `tradePile` or `tradePile/counts` — the user's own listings (query) + the +/// listing-count tile. CASE-INSENSITIVE: the FUT hub tile polls lowercase +/// `tradepile`/`tradepile/counts` while the screen uses camelCase `tradePile` +/// (the oracle routes both via `re.I`). Bounded to the `tradepile` family +/// (base tail or a `tradepile/` path); allocation-free. +fn is_tradepile_tail(tail: &str) -> bool { + const BASE: &str = "tradePile"; + match tail.len() { + 9 => tail.eq_ignore_ascii_case(BASE), + n if n > 9 => tail.as_bytes()[9] == b'/' && tail[..9].eq_ignore_ascii_case(BASE), + _ => 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. @@ -263,12 +285,12 @@ pub fn classify_economy(method: &str, path: &str) -> Option { Some("user/credits") if get => Some(EconomyRoute::Credits), Some(t) if get && t.starts_with("store/purchasegroup") => Some(EconomyRoute::PurchaseGroup), 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 post && is_purchased_tail(t) => Some(EconomyRoute::PackOpen), + Some(t) if get && is_purchased_tail(t) => Some(EconomyRoute::PackReveal), Some(t) if delete && is_item_id_tail(t) => Some(EconomyRoute::QuickSellPath), Some("item") if put => Some(EconomyRoute::MoveItems), Some(t) if (t == "auctionhouse" || t == "transfermarket") => Some(EconomyRoute::MarketList), - Some("tradePile") if get => Some(EconomyRoute::MarketQuery), + Some(t) if get && is_tradepile_tail(t) => Some(EconomyRoute::MarketQuery), Some(t) if t.starts_with("trade") => Some(EconomyRoute::MarketBuy), _ => None, } @@ -3247,4 +3269,93 @@ mod tests { assert_eq!(classify_economy("GET", "/ut/v2/game/fifa17/store"), None); assert_eq!(classify_economy("POST", "/ut/auth"), None); } + + /// RETAIL_ROUTE_MATRIX — the audited retail economy route contract. Every + /// economy row MUST classify to its Rust route (Python proxy forbidden); + /// every negative near-miss MUST stay `None` (proxied). Permanent gate against + /// "Python knows route X, Rust forgot route X". + #[test] + fn retail_route_matrix() { + use EconomyRoute::*; + let matrix: &[(&str, &str, Option)] = &[ + // credits + ("GET", "/ut/game/fifa17/user/credits", Some(Credits)), + // purchasegroup (v1 + v2 + /all) + ( + "GET", + "/ut/game/fifa17/store/purchasegroup", + Some(PurchaseGroup), + ), + ( + "GET", + "/ut/game/fifa17/store/purchasegroup/all", + Some(PurchaseGroup), + ), + ( + "GET", + "/ut/v2/game/fifa17/store/purchasegroup/all", + Some(PurchaseGroup), + ), + // store transaction (v2 + trailing id) — round-1 fix + ("PUT", "/ut/game/fifa17/store/transaction", Some(StoreBuy)), + ( + "PUT", + "/ut/v2/game/fifa17/store/transaction/0", + Some(StoreBuy), + ), + // purchased + purchased/items — round-2 fix (POST open, GET reveal) + ("POST", "/ut/game/fifa17/purchased", Some(PackOpen)), + ("POST", "/ut/game/fifa17/purchased/items", Some(PackOpen)), + ("POST", "/ut/v2/game/fifa17/purchased/items", Some(PackOpen)), + ("GET", "/ut/game/fifa17/purchased", Some(PackReveal)), + ("GET", "/ut/game/fifa17/purchased/items", Some(PackReveal)), + // move + ("PUT", "/ut/game/fifa17/item", Some(MoveItems)), + // quick-sell (path + body) + ( + "DELETE", + "/ut/game/fifa17/item/100000001", + Some(QuickSellPath), + ), + ("POST", "/ut/delete/game/fifa17/item", Some(QuickSellBody)), + ( + "POST", + "/ut/v2/delete/game/fifa17/item", + Some(QuickSellBody), + ), + // match end + ("POST", "/ut/delete/game/fifa17/match", Some(MatchEnd)), + // market list / query (case-insensitive tradePile + counts) / buy / cancel + ("POST", "/ut/game/fifa17/auctionhouse", Some(MarketList)), + ("POST", "/ut/game/fifa17/transfermarket", Some(MarketList)), + ("GET", "/ut/game/fifa17/tradePile", Some(MarketQuery)), + ("GET", "/ut/game/fifa17/tradepile", Some(MarketQuery)), + ("GET", "/ut/game/fifa17/tradePile/counts", Some(MarketQuery)), + ("GET", "/ut/game/fifa17/tradepile/counts", Some(MarketQuery)), + ("POST", "/ut/game/fifa17/trade/900000001", Some(MarketBuy)), + ( + "DELETE", + "/ut/delete/game/fifa17/trade/900000001", + Some(MarketCancel), + ), + // ── negatives: must stay None (proxied to Python) ── + ("GET", "/ut/game/fifa17/store", None), + ("GET", "/ut/game/fifa17/store/", None), + ("PUT", "/ut/game/fifa17/store/transactions", None), + ("PUT", "/ut/game/fifa17/store/transaction/0/extra", None), + ("POST", "/ut/game/fifa17/purchasedfoo", None), + ("POST", "/ut/game/fifa17/purchased/items/extra", None), + ("GET", "/ut/game/fifa17/hub", None), + ("GET", "/ut/game/fifa17/marketdata", None), + ("POST", "/ut/auth", None), + ("GET", "/ut/game/fifa17/watchList", None), + ]; + for (m, p, want) in matrix { + assert_eq!( + classify_economy(m, p), + *want, + "RETAIL_ROUTE_MATRIX: {m} {p}" + ); + } + } }