fix(fifa17): classify full retail economy route shapes

Round-2 live staging (candidate 47ced22) showed the CONFIRMED retail Store BUY
uses POST /ut/game/fifa17/purchased/items (reveal GET .../purchased/items),
which the exact-tail 'purchased' match missed -> Python (Core coins unchanged).
Comprehensive audited fix in classify_economy:
- is_purchased_tail: 'purchased' AND 'purchased/items' (POST->PackOpen,
  GET->PackReveal); bounded (rejects purchasedfoo, purchased/items/extra).
- is_tradepile_tail: 'tradePile' family CASE-INSENSITIVE incl 'tradePile/counts'
  (hub tile polls lowercase; oracle routes via re.I); allocation-free.
- (kept) v1/v2 prefix normalization + store/transaction[/<digits>].
Adds retail_route_matrix unit test = the machine-auditable route contract gate
(all economy shapes + negative near-misses). Host lib 75.
This commit is contained in:
OpenFUT Agent
2026-08-14 00:50:03 +00:00
parent 47ced228de
commit a6416a3f1d
+114 -3
View File
@@ -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/<sub>` 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<EconomyRoute> {
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<EconomyRoute>)] = &[
// 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}"
);
}
}
}