From 3c67fea074f499844a0b2379beb6c9d00d725252 Mon Sep 17 00:00:00 2001 From: funman300 Date: Sat, 22 Aug 2026 17:02:14 +0000 Subject: [PATCH] feat(fifa17): serve the consumable quick-sell (PUT item/resource/) Fixing the display was only half of it. Quick-selling a consumable from the repaired screen produced "There was a problem communicating with the FIFA Ultimate Team servers", because the client's consumable quick-sell is a route neither stack had ever served: PUT /ut/game/fifa17/item/resource/5003068 body_len=0 Live-captured on staging. That path now carries three verbs -- GET is the definition lookup, POST applies the consumable (ApplyCardByRes), PUT quick-sells it -- and it is keyed by the stack's RESOURCE id, not an owned instance, unlike the player quick-sell (DELETE item/). This had to be Rust-owned rather than proxied: the Python oracle maps item/resource method-agnostically to its definition route, so on production -- where the oracle is alive -- a PUT would return 200 with a definition list and sell nothing, and the client would show a successful sale of a card the player still owns. Implementation reuses the retail-proven quick-sell path verbatim (handle_quick_sell_path), so pricing comes from the same ItemIdentityResolver::discard_value that stamps the number on the stack. Display and payout are the same call; they cannot drift. Two decisions, both documented in the code as decisions rather than discoveries: * ONE copy per request. The request carries no quantity, and the screen prices a CARD, so consuming a whole stack on one keypress would pay one card's price for N cards. Selling one is the conservative reading. * The copy sold is Core's first matching owned instance -- the same one whose wire id the consumables screen already published as the stack's `item`, so the player sells the card they were shown. Verified against the running staging host: displays 38 -> PUT -> coins +38, owned -1, consumables -1, stack 2 -> 1 replay sold the one remaining copy (+38, -1), no double credit exhausted -> 404 not_owned, coins +0, owned +0 (no phantom payment) 123 host tests (+1 locking all three verbs on the shared path, and that the bare `item` PUT stays the pile move), clippy -D warnings clean, fmt clean. --- openfut-utas-host/src/lib.rs | 95 ++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) 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]