fix(market): add FIFA 17 tradeOwner/sellerId, answer trade/status, route plain DELETE
Three defects behind "selecting my own Transfer List listing opens no dialog".
Pressing the card emits NO HTTP at all, so the gate is a field in what we already
return -- the client decides locally from the auction record.
1. OWNERSHIP FIELDS (FIFA17-HISTORICAL). FIFA 17 auctionInfo carries `tradeOwner`
(bool), `sellerId` and `offers`; we emitted none of them. `tradeOwner` is the
purpose-built "this auction is mine" flag, and without it the Transfer List has
nothing to key owner actions (Remove / Re-list) on. `sellerId` now carries the
configured persona so it agrees with `tradeOwner` and `sellerName` instead of
telling three different stories. Persona is threaded from config, never baked in.
2. `GET …/trade/status` ANSWERED EMPTY (CONFIRMED from our own live logs). The
Transfer List polls this continuously to refresh live auction state. The tail has
no numeric id, so it fell through `t.starts_with("trade")` into the buy/view arm,
where `trade_id_from_path` fails and the reply is `{"auctionInfo": []}`. The
client asked for the state of its own listings and was repeatedly told there was
none. Now a real handler: `tradeIds` filter, or the whole active pile unfiltered;
unknown ids are absent rather than an error, so a poll never fails closed.
3. PLAIN `DELETE …/trade/<id>` WAS A SILENT NO-OP. Contemporaneous FIFA 17 clients
cancel via `DELETE /ut/game/<sku>/trade/<id>`; only the oracle's
`/ut/delete/game/…` spelling mapped to MarketCancel, so the plain form landed in
the buy/view arm and "cancelled" nothing while returning 200. Both spellings now
map to MarketCancel. Kept the oracle spelling: the differential exercises it.
Why the differential missed all of this: our record's key set was IDENTICAL to the
oracle's, so parity was green. The oracle omits the ownership fields too, because
its own remove flow was never driven by a real client either. The differential now
asserts we COVER every oracle key and that our extra keys are EXACTLY
{offers, sellerId, tradeOwner} -- so an unexplained new divergence still fails,
while the deliberate superset is pinned.
Deliberately NOT changed (no evidence): itemState stays "listFS", expires stays
3600 seconds-remaining, bidState stays "none" for active/unbid, counts stays
count=1, and no FIFA 18+ price fields were added.
332 tests pass, 0 failed, clippy clean. Deployed and verified live: tradeOwner=true
sellerId=33068179 sellerName='CAGE' offers=0 on /tradePile AND /trade/status
(filtered and unfiltered).
This commit is contained in:
@@ -351,9 +351,17 @@ pub enum EconomyRoute {
|
||||
/// leaves every count at its constructor default (0) and the Transfer List
|
||||
/// screen shows no active sale even while the hub tile shows one.
|
||||
MarketCounts,
|
||||
/// `GET …/trade/status` — live auction-state refresh, polled continuously by
|
||||
/// the Transfer List. MUST be classified before [`Self::MarketBuy`]: `status`
|
||||
/// is not a numeric trade id, so the buy/view arm answers every poll with an
|
||||
/// empty `auctionInfo` and the screen never learns its own auctions' state.
|
||||
MarketStatus,
|
||||
/// `…/trade/<id>` — view / buy-now.
|
||||
MarketBuy,
|
||||
/// `DELETE /ut/delete/game/<sku>/trade/<id>` — cancel a listing.
|
||||
/// Cancel a listing. Both `DELETE /ut/delete/game/<sku>/trade/<id>` (the
|
||||
/// oracle's spelling) and plain `DELETE /ut/game/<sku>/trade/<id>` (what
|
||||
/// contemporaneous FIFA 17 clients use) map here — a DELETE on the plain
|
||||
/// spelling otherwise fell into the buy/view arm and silently did nothing.
|
||||
MarketCancel,
|
||||
}
|
||||
|
||||
@@ -411,6 +419,14 @@ fn is_tradepile_counts_tail(tail: &str) -> bool {
|
||||
tail.eq_ignore_ascii_case(COUNTS)
|
||||
}
|
||||
|
||||
/// `trade/status` exactly (case-insensitive) — the live auction-state poll. MUST
|
||||
/// be classified before the generic `trade…` buy/view arm: `status` is not a
|
||||
/// numeric trade id, so that arm degrades every poll to an empty `auctionInfo`.
|
||||
fn is_trade_status_tail(tail: &str) -> bool {
|
||||
const STATUS: &str = "trade/status";
|
||||
tail.eq_ignore_ascii_case(STATUS)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -452,6 +468,11 @@ pub fn classify_economy(method: &str, path: &str) -> Option<EconomyRoute> {
|
||||
// `tradePile/counts`, but the tally is a different deserializer.
|
||||
Some(t) if get && is_tradepile_counts_tail(t) => Some(EconomyRoute::MarketCounts),
|
||||
Some(t) if get && is_tradepile_tail(t) => Some(EconomyRoute::MarketQuery),
|
||||
// BOTH must precede the buy/view arm, which swallows any `trade…` tail:
|
||||
// `trade/status` has no numeric id (so it answered polls with an empty
|
||||
// auctionInfo), and a plain DELETE fell in as a no-op "view".
|
||||
Some(t) if get && is_trade_status_tail(t) => Some(EconomyRoute::MarketStatus),
|
||||
Some(t) if delete && t.starts_with("trade") => Some(EconomyRoute::MarketCancel),
|
||||
Some(t) if t.starts_with("trade") => Some(EconomyRoute::MarketBuy),
|
||||
_ => None,
|
||||
}
|
||||
@@ -2190,6 +2211,9 @@ impl Server {
|
||||
let svc = self.economy.as_ref()?;
|
||||
let path = target.split('?').next().unwrap_or(target);
|
||||
let route = classify_economy(method, path)?;
|
||||
// Seller identity for auction records: our own auctions must carry the
|
||||
// configured persona, never a baked-in literal.
|
||||
let persona = self.persona_id;
|
||||
use crate::economy_store::{
|
||||
handle_pack_open, handle_quick_sell_body, handle_quick_sell_path, handle_store_buy,
|
||||
CoreItemLookup, QuickSellDeps, StoreDeps,
|
||||
@@ -2306,16 +2330,27 @@ impl Server {
|
||||
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
||||
let m = method.to_string();
|
||||
bridge.block_on(async move {
|
||||
crate::market::handle_market_list(&m, resolved, econ.as_ref(), market.as_ref())
|
||||
.await
|
||||
crate::market::handle_market_list(
|
||||
&m,
|
||||
resolved,
|
||||
econ.as_ref(),
|
||||
market.as_ref(),
|
||||
persona,
|
||||
)
|
||||
.await
|
||||
})
|
||||
}
|
||||
EconomyRoute::MarketQuery => {
|
||||
let (bridge, market, econ) =
|
||||
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
||||
bridge.block_on(async move {
|
||||
crate::market::handle_market_query("active", econ.as_ref(), market.as_ref())
|
||||
.await
|
||||
crate::market::handle_market_query(
|
||||
"active",
|
||||
econ.as_ref(),
|
||||
market.as_ref(),
|
||||
persona,
|
||||
)
|
||||
.await
|
||||
})
|
||||
}
|
||||
EconomyRoute::MarketCounts => {
|
||||
@@ -2323,13 +2358,35 @@ impl Server {
|
||||
bridge
|
||||
.block_on(async move { crate::market::handle_market_counts(market.as_ref()).await })
|
||||
}
|
||||
EconomyRoute::MarketStatus => {
|
||||
let (bridge, market, econ) =
|
||||
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
||||
// The raw query carries `tradeIds`; `path` is already stripped.
|
||||
let q = target.split_once('?').map(|(_, q)| q.to_string());
|
||||
bridge.block_on(async move {
|
||||
crate::market::handle_market_status(
|
||||
q.as_deref(),
|
||||
econ.as_ref(),
|
||||
market.as_ref(),
|
||||
persona,
|
||||
)
|
||||
.await
|
||||
})
|
||||
}
|
||||
EconomyRoute::MarketBuy => {
|
||||
let (bridge, market, econ) =
|
||||
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
||||
let (m, p, body) = (method.to_string(), path.to_string(), body.to_vec());
|
||||
bridge.block_on(async move {
|
||||
crate::market::handle_market_buy(&m, &p, &body, econ.as_ref(), market.as_ref())
|
||||
.await
|
||||
crate::market::handle_market_buy(
|
||||
&m,
|
||||
&p,
|
||||
&body,
|
||||
econ.as_ref(),
|
||||
market.as_ref(),
|
||||
persona,
|
||||
)
|
||||
.await
|
||||
})
|
||||
}
|
||||
EconomyRoute::MarketCancel => {
|
||||
@@ -3964,6 +4021,18 @@ mod tests {
|
||||
("GET", "/ut/game/fifa17/tradePile/counts", Some(MarketCounts)),
|
||||
("GET", "/ut/game/fifa17/tradepile/counts", Some(MarketCounts)),
|
||||
("POST", "/ut/game/fifa17/trade/900000001", Some(MarketBuy)),
|
||||
// The live-state poll MUST NOT land in the buy/view arm: `status` is
|
||||
// not a trade id, so that arm answers every poll with an empty set.
|
||||
("GET", "/ut/game/fifa17/trade/status", Some(MarketStatus)),
|
||||
("GET", "/ut/game/fifa17/TRADE/STATUS", Some(MarketStatus)),
|
||||
// Cancel: the oracle's `/ut/delete/game/…` spelling AND the plain
|
||||
// `DELETE /ut/game/…/trade/<id>` used by FIFA 17 clients. The plain
|
||||
// form previously fell into the buy/view arm and silently no-oped.
|
||||
(
|
||||
"DELETE",
|
||||
"/ut/game/fifa17/trade/900000001",
|
||||
Some(MarketCancel),
|
||||
),
|
||||
(
|
||||
"DELETE",
|
||||
"/ut/delete/game/fifa17/trade/900000001",
|
||||
|
||||
Reference in New Issue
Block a user