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:
funman300
2026-08-22 17:02:14 +00:00
parent 2a9507cb6a
commit 3c67fea074
+95
View File
@@ -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]