feat(fifa17): serve the consumable quick-sell (PUT item/resource/<rid>)
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/<instanceId>).
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.
This commit is contained in:
@@ -412,6 +412,18 @@ pub enum EconomyRoute {
|
||||
QuickSellPath,
|
||||
/// `POST /ut/delete/game/<sku>/item` — bulk quick-sell.
|
||||
QuickSellBody,
|
||||
/// `PUT …/item/resource/<resourceId>` — 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<EconomyRoute> {
|
||||
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::<i64>().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]
|
||||
|
||||
Reference in New Issue
Block a user