diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index e03040f..184341e 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -412,6 +412,18 @@ pub enum EconomyRoute { QuickSellPath, /// `POST /ut/delete/game//item` — bulk quick-sell. QuickSellBody, + /// `PUT …/item/resource/` — CONSUMABLE quick-sell, keyed by the + /// stack's resource id rather than an owned instance, with an EMPTY body. + /// + /// Live-captured 2026-08-22 when a Position Modifier was quick-sold from the + /// consumables screen. The same path serves three verbs: GET is the + /// definition lookup, POST is the apply (`ApplyCardByRes`), PUT is this. + /// + /// Neither stack had ever served it. The Python oracle maps `item/resource` + /// method-agnostically to its definition route, so a PUT there returns 200 + /// with a definition list and sells NOTHING — the client believes it sold a + /// card that it still owns. That is why this must be Rust-owned. + QuickSellResource, /// `PUT …/item` — FutMoveCard pile move. MoveItems, /// `…/match/end` (any verb) — match END, the coin-crediting call. @@ -616,6 +628,13 @@ pub fn classify_economy(method: &str, path: &str) -> Option { 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(t) + if put + && t.strip_prefix("item/resource/") + .is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit())) => + { + Some(EconomyRoute::QuickSellResource) + } Some("item") if put => Some(EconomyRoute::MoveItems), Some(t) if (t == "auctionhouse" || t == "transfermarket") => Some(EconomyRoute::MarketList), // MUST precede the base tradePile arm: that matcher also accepts @@ -3767,6 +3786,47 @@ impl Server { }; handle_quick_sell_path(id, &deps) } + EconomyRoute::QuickSellResource => { + // The stack's resource id names a DEFINITION, so pick the owned + // copy deterministically: Core's own order, i.e. the same first + // copy whose wire id the consumables screen already published as + // the stack's `item`. The player therefore sells the card the + // screen showed them. Selling exactly ONE copy is the + // conservative reading of an empty-body request: the screen + // prices a CARD (per-card `discardValue`), so consuming a whole + // stack on one keypress would pay one card's price for N cards. + let rid = ut_tail(path) + .and_then(|t| t.strip_prefix("item/resource/")) + .and_then(|d| d.parse::().ok())?; + let owned = self.core.all_owned().ok()?; + let wire = owned.iter().find_map(|it| { + self.resolver + .resolve_consumable(it) + .filter(|c| i64::from(c.resource_id) == rid) + .map(|c| i64::from(c.item_id)) + }); + let Some(wire) = wire else { + eprintln!( + "utas-host owner=RUST route=economy quick-sell-resource \ + resource={rid} status=404 outcome=not_owned" + ); + return Some(error_response(404, "not_owned")); + }; + let lookup = CoreItemLookup { + core: self.core.as_ref(), + }; + let deps = QuickSellDeps { + econ: svc.econ.as_ref(), + reverse: self.resolver.as_ref(), + items: &lookup, + assets: self.resolver.as_ref(), + }; + eprintln!( + "utas-host owner=RUST route=economy quick-sell-resource \ + resource={rid} wire={wire} copies_sold=1" + ); + handle_quick_sell_path(wire, &deps) + } EconomyRoute::QuickSellBody => { let lookup = CoreItemLookup { core: self.core.as_ref(), @@ -6399,6 +6459,41 @@ mod tests { .is_empty()); } + /// One path, three verbs. GET is the definition lookup, POST applies the + /// consumable, PUT quick-sells it. All three were live-captured; conflating + /// any two of them sells or consumes the wrong thing. + #[test] + fn item_resource_path_dispatches_on_verb() { + assert_eq!( + classify("GET", "/ut/game/fifa17/item/resource"), + Route::ItemDefs + ); + assert_eq!( + classify("POST", "/ut/game/fifa17/item/resource/5001004"), + Route::ConsumableApplyProbe + ); + // The quick-sell is an ECONOMY route, classified before `classify()` + // ever runs, so it must resolve there and not fall through to Python. + assert_eq!( + classify_economy("PUT", "/ut/game/fifa17/item/resource/5003068"), + Some(EconomyRoute::QuickSellResource) + ); + assert_eq!( + classify_economy("PUT", "/ut/v2/game/fifa17/item/resource/5003068"), + Some(EconomyRoute::QuickSellResource) + ); + // The bare `item` PUT is the pile move and must not be captured. + assert_eq!( + classify_economy("PUT", "/ut/game/fifa17/item"), + Some(EconomyRoute::MoveItems) + ); + // A non-numeric tail is not a resource id. + assert_eq!( + classify_economy("PUT", "/ut/game/fifa17/item/resource/bogus"), + None + ); + } + /// The apply re-uses the definition-lookup PATH with a different VERB, which /// is exactly why it went unclaimed. Lock that boundary. #[test]