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",
|
||||
|
||||
+198
-21
@@ -82,7 +82,7 @@ fn trade_id_from_path(path: &str) -> Option<String> {
|
||||
/// `item_state` overrides the card's `itemState`: the seller's own pile uses
|
||||
/// `listFS` (list-for-sale), market search results use `forSale` — the oracle
|
||||
/// distinguishes these, so the caller passes the one its screen needs.
|
||||
fn auction_record_as(l: &Listing, item_state: &str) -> Value {
|
||||
fn auction_record_as(l: &Listing, item_state: &str, persona_id: i64) -> Value {
|
||||
let trade_id: i64 = l.listing_id.parse().unwrap_or(0);
|
||||
// resourceId is the FIFA wire identity the client listed (never the Core
|
||||
// card id). 0 means "no art", a valid int — never a fabricated FIFA asset.
|
||||
@@ -112,6 +112,24 @@ fn auction_record_as(l: &Listing, item_state: &str) -> Value {
|
||||
"untradeable": false,
|
||||
})
|
||||
});
|
||||
// OWNERSHIP. `tradeOwner` is FIFA 17's purpose-built "this auction is mine"
|
||||
// boolean, and it is the field the Transfer List needs before it will offer
|
||||
// Remove / Re-list on a row. We omitted it entirely, which is consistent with
|
||||
// the observed symptom: the listing rendered but selecting it opened nothing.
|
||||
//
|
||||
// Provenance: FIFA17-HISTORICAL (contemporaneous FIFA 17 clients carry
|
||||
// `tradeOwner`/`sellerId`/`offers` in auctionInfo). NOT confirmed from our own
|
||||
// capture, and notably ABSENT from the Python oracle too — which is why the
|
||||
// differential could not catch it: the oracle's own remove flow was never
|
||||
// exercised against a real client either.
|
||||
//
|
||||
// This store has no NPC seller, so an unnamed owner is the player. Comparing
|
||||
// rather than hardcoding `true` keeps the flag honest if a foreign seller is
|
||||
// ever introduced.
|
||||
let own = l
|
||||
.owner
|
||||
.as_deref()
|
||||
.is_none_or(|o| o == non_economy::PERSONA_DISPLAY_NAME);
|
||||
json!({
|
||||
"tradeId": trade_id,
|
||||
"itemData": item_data,
|
||||
@@ -119,13 +137,21 @@ fn auction_record_as(l: &Listing, item_state: &str) -> Value {
|
||||
"buyNowPrice": l.buy_now_price,
|
||||
"startingBid": l.start_price,
|
||||
"currentBid": current_bid,
|
||||
// Bid count. 0 is correct for an active/unbid auction alongside
|
||||
// `bidState: "none"` (FIFA17-HISTORICAL).
|
||||
"offers": 0,
|
||||
"bidState": bid_state,
|
||||
// SECONDS REMAINING, never an absolute epoch (FIFA17-HISTORICAL).
|
||||
"expires": 3600,
|
||||
// Every listing in this store is the player's OWN (there is no NPC seller
|
||||
// in a single-account emulator), so the seller defaults to the player's
|
||||
// persona name exactly as the oracle stamps it. This is what lets the
|
||||
// client offer Remove / Re-list on a transfer-pile row; EA's house name
|
||||
// here silently makes the player's own listing un-actionable.
|
||||
"tradeOwner": own,
|
||||
// The seller identity must agree with `tradeOwner`: our own auction is sold
|
||||
// by our own persona. Numeric because every other persona id on this wire
|
||||
// is numeric; the id itself is never baked in (it comes from config, so it
|
||||
// keeps matching the persona LSX/Blaze/POW/UTAS agree on).
|
||||
"sellerId": if own { persona_id } else { 0 },
|
||||
// Every listing in this store is the player's OWN, so the seller defaults
|
||||
// to the player's persona name exactly as the oracle stamps it. EA's house
|
||||
// name here makes the player's own listing look foreign.
|
||||
"sellerName": l
|
||||
.owner
|
||||
.clone()
|
||||
@@ -138,9 +164,9 @@ fn auction_record_as(l: &Listing, item_state: &str) -> Value {
|
||||
|
||||
/// Auction record for a market/search context (`itemState: forSale`), and for the
|
||||
/// closed/sold echoes the buy path returns.
|
||||
fn auction_record(l: &Listing) -> Value {
|
||||
fn auction_record(l: &Listing, persona_id: i64) -> Value {
|
||||
let state = if l.state == "active" { "forSale" } else { "free" };
|
||||
auction_record_as(l, state)
|
||||
auction_record_as(l, state, persona_id)
|
||||
}
|
||||
|
||||
/// Run a BLOCKING closure — the blocking Core client — on a fresh OS thread that
|
||||
@@ -239,6 +265,7 @@ pub async fn handle_market_list(
|
||||
resolved: Option<ResolvedListing>,
|
||||
econ: &dyn CoreEconomy,
|
||||
store: &MarketStore,
|
||||
persona_id: i64,
|
||||
) -> WireResponse {
|
||||
match method {
|
||||
"POST" => {
|
||||
@@ -284,7 +311,10 @@ pub async fn handle_market_list(
|
||||
Ok(l) => l,
|
||||
Err(_) => return json_body(503, &json!({ "error": "market_store" })),
|
||||
};
|
||||
let auctions: Vec<Value> = listings.iter().map(auction_record).collect();
|
||||
let auctions: Vec<Value> = listings
|
||||
.iter()
|
||||
.map(|l| auction_record(l, persona_id))
|
||||
.collect();
|
||||
ok_json(&json!({
|
||||
"auctionInfo": auctions,
|
||||
"credits": credits_or_zero(econ),
|
||||
@@ -310,6 +340,7 @@ pub async fn handle_market_query(
|
||||
state: &str,
|
||||
econ: &dyn CoreEconomy,
|
||||
store: &MarketStore,
|
||||
persona_id: i64,
|
||||
) -> WireResponse {
|
||||
let listings = match store.query_listings(state).await {
|
||||
Ok(l) => l,
|
||||
@@ -317,7 +348,7 @@ pub async fn handle_market_query(
|
||||
};
|
||||
let auctions: Vec<Value> = listings
|
||||
.iter()
|
||||
.map(|l| auction_record_as(l, "listFS"))
|
||||
.map(|l| auction_record_as(l, "listFS", persona_id))
|
||||
.collect();
|
||||
ok_json(&json!({
|
||||
"auctionInfo": auctions,
|
||||
@@ -366,6 +397,71 @@ pub async fn handle_market_cancel(
|
||||
ok_json(&json!({}))
|
||||
}
|
||||
|
||||
/// `GET …/trade/status` — live auction-state refresh for the rows a screen is
|
||||
/// showing. The Transfer List polls this CONTINUOUSLY while it is open.
|
||||
///
|
||||
/// This route previously fell through to the buy/view arm, where
|
||||
/// `trade_id_from_path("trade/status")` cannot parse an id, so every poll was
|
||||
/// answered with an EMPTY `auctionInfo` — the client kept asking for the state of
|
||||
/// its own listings and was told, repeatedly, that there was none. Observed
|
||||
/// directly in the live logs (`route=economy … path=…/trade/status` on repeat),
|
||||
/// so unlike the `tradeOwner` change this is a CONFIRMED defect, not a candidate.
|
||||
///
|
||||
/// `tradeIds` is a comma-separated filter; unknown ids are simply absent from the
|
||||
/// reply rather than erroring. With no filter we answer with the player's own
|
||||
/// active pile, which is the only auction set this single-account market has.
|
||||
pub async fn handle_market_status(
|
||||
query: Option<&str>,
|
||||
econ: &dyn CoreEconomy,
|
||||
store: &MarketStore,
|
||||
persona_id: i64,
|
||||
) -> WireResponse {
|
||||
let ids = trade_ids_from_query(query);
|
||||
let listings = if ids.is_empty() {
|
||||
match store.query_listings("active").await {
|
||||
Ok(l) => l,
|
||||
Err(_) => return json_body(503, &json!({ "error": "market_store" })),
|
||||
}
|
||||
} else {
|
||||
let mut found = Vec::with_capacity(ids.len());
|
||||
for id in &ids {
|
||||
if let Ok(l) = store.get_listing(id).await {
|
||||
found.push(l);
|
||||
}
|
||||
}
|
||||
found
|
||||
};
|
||||
let auctions: Vec<Value> = listings
|
||||
.iter()
|
||||
.map(|l| auction_record_as(l, "listFS", persona_id))
|
||||
.collect();
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=market-status requested={} returned={} query={}",
|
||||
ids.len(),
|
||||
auctions.len(),
|
||||
query.unwrap_or("")
|
||||
);
|
||||
ok_json(&json!({
|
||||
"auctionInfo": auctions,
|
||||
"credits": credits_or_zero(econ),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Parse `tradeIds=1,2,3` (also accepts repeated `tradeIds=`) out of a raw query
|
||||
/// string. Non-numeric entries are skipped rather than failing the whole poll.
|
||||
fn trade_ids_from_query(query: Option<&str>) -> Vec<String> {
|
||||
let Some(q) = query else {
|
||||
return Vec::new();
|
||||
};
|
||||
q.split('&')
|
||||
.filter_map(|kv| kv.split_once('='))
|
||||
.filter(|(k, _)| k.eq_ignore_ascii_case("tradeIds") || k.eq_ignore_ascii_case("tradeId"))
|
||||
.flat_map(|(_, v)| v.split(','))
|
||||
.filter(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `/trade/<id>` — view (GET) or buy-now / bid (POST/PUT). Buy-now is the
|
||||
/// synthetic-seller path: reserve (CAS) → Core `purchase_item` mint+debit →
|
||||
/// complete the sale; any Core failure rolls the reservation back.
|
||||
@@ -375,6 +471,7 @@ pub async fn handle_market_buy(
|
||||
body: &[u8],
|
||||
econ: &dyn CoreEconomy,
|
||||
store: &MarketStore,
|
||||
persona_id: i64,
|
||||
) -> WireResponse {
|
||||
let Some(id) = trade_id_from_path(path) else {
|
||||
return ok_json(&json!({ "auctionInfo": [], "credits": credits_or_zero(econ) }));
|
||||
@@ -383,7 +480,7 @@ pub async fn handle_market_buy(
|
||||
if method != "POST" && method != "PUT" {
|
||||
// GET: view one auction.
|
||||
let rec = match store.get_listing(&id).await {
|
||||
Ok(l) => vec![auction_record(&l)],
|
||||
Ok(l) => vec![auction_record(&l, persona_id)],
|
||||
Err(_) => vec![],
|
||||
};
|
||||
return ok_json(&json!({ "auctionInfo": rec, "credits": credits_or_zero(econ) }));
|
||||
@@ -404,9 +501,10 @@ pub async fn handle_market_buy(
|
||||
// A simple bid below buy-now: we are the sole bidder — echo the raised bid,
|
||||
// no coin movement, no reservation.
|
||||
if bid < listing.buy_now_price {
|
||||
let mut rec = auction_record(&listing);
|
||||
let mut rec = auction_record(&listing, persona_id);
|
||||
rec["currentBid"] = json!(bid);
|
||||
rec["bidState"] = json!("highest");
|
||||
rec["offers"] = json!(1);
|
||||
return ok_json(&json!({ "auctionInfo": [rec], "credits": credits_or_zero(econ) }));
|
||||
}
|
||||
|
||||
@@ -449,7 +547,7 @@ pub async fn handle_market_buy(
|
||||
if let Err(e) = store.complete_sale(&id).await {
|
||||
eprintln!("utas-host WARN market complete_sale({id}) after mint failed: {e}");
|
||||
}
|
||||
let mut rec = auction_record(&listing);
|
||||
let mut rec = auction_record(&listing, persona_id);
|
||||
rec["tradeState"] = json!("closed");
|
||||
rec["bidState"] = json!("highest");
|
||||
rec["currentBid"] = json!(price);
|
||||
@@ -515,6 +613,10 @@ mod tests {
|
||||
use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Seller persona for auction records under test. Matches the live profile so
|
||||
/// a `sellerId` assertion is meaningful rather than tautological.
|
||||
const PERSONA: i64 = 33_068_179;
|
||||
|
||||
// ---- temp DB helpers ---------------------------------------------------
|
||||
|
||||
struct TempDb(String);
|
||||
@@ -737,7 +839,7 @@ mod tests {
|
||||
&items,
|
||||
&NoEntities,
|
||||
);
|
||||
let resp = handle_market_list("POST", resolved, &econ, &store).await;
|
||||
let resp = handle_market_list("POST", resolved, &econ, &store, PERSONA).await;
|
||||
assert_eq!(resp.status, 200);
|
||||
let trade_id = parse(&resp)["id"].as_i64().unwrap();
|
||||
assert_eq!(trade_id, TRADE_ID_BASE + 100004617);
|
||||
@@ -753,13 +855,13 @@ mod tests {
|
||||
assert_eq!(snap["preferredPosition"], "ST");
|
||||
assert_eq!(snap["attributeList"].as_array().unwrap().len(), 6);
|
||||
// tradePile embeds that full card and stamps the seller-pile state.
|
||||
let pile = handle_market_query("active", &econ, &store).await;
|
||||
let pile = handle_market_query("active", &econ, &store, PERSONA).await;
|
||||
let rec = parse(&pile)["auctionInfo"][0].clone();
|
||||
assert_eq!(rec["itemData"]["itemState"], "listFS");
|
||||
assert_eq!(rec["itemData"]["rating"], 84);
|
||||
assert_eq!(rec["itemData"]["id"], 100004617i64);
|
||||
assert_eq!(rec["itemData"]["resourceId"], 169193);
|
||||
let browse = handle_market_list("GET", None, &econ, &store).await;
|
||||
let browse = handle_market_list("GET", None, &econ, &store, PERSONA).await;
|
||||
let b = parse(&browse);
|
||||
assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(b["credits"], 10_000);
|
||||
@@ -770,7 +872,7 @@ mod tests {
|
||||
async fn list_put_is_ack() {
|
||||
let (store, _d) = store_at("put").await;
|
||||
let econ = CountingEconomy::with_balance(0);
|
||||
let resp = handle_market_list("PUT", None, &econ, &store).await;
|
||||
let resp = handle_market_list("PUT", None, &econ, &store, PERSONA).await;
|
||||
assert_eq!(resp.status, 200);
|
||||
assert_eq!(parse(&resp), json!({}));
|
||||
}
|
||||
@@ -794,7 +896,7 @@ mod tests {
|
||||
&NoEntities,
|
||||
);
|
||||
assert!(resolved.is_none(), "unresolved item must not build a listing");
|
||||
let resp = handle_market_list("POST", resolved, &econ, &store).await;
|
||||
let resp = handle_market_list("POST", resolved, &econ, &store, PERSONA).await;
|
||||
assert_eq!(resp.status, 200);
|
||||
assert_eq!(parse(&resp)["id"].as_i64().unwrap(), TRADE_ID_BASE);
|
||||
// Nothing persisted at the would-be trade id: not buyable.
|
||||
@@ -831,7 +933,7 @@ mod tests {
|
||||
&items,
|
||||
&NoEntities,
|
||||
);
|
||||
let resp = handle_market_list("POST", resolved, &econ, &store).await;
|
||||
let resp = handle_market_list("POST", resolved, &econ, &store, PERSONA).await;
|
||||
trade_id = parse(&resp)["id"].as_i64().unwrap();
|
||||
}
|
||||
// Reopen from the same file: both identities survive.
|
||||
@@ -850,7 +952,7 @@ mod tests {
|
||||
let (store, _d) = store_at("query").await;
|
||||
seed_listing(&store, "900000005", 2500).await;
|
||||
let econ = CountingEconomy::with_balance(50);
|
||||
let resp = handle_market_query("active", &econ, &store).await;
|
||||
let resp = handle_market_query("active", &econ, &store, PERSONA).await;
|
||||
let b = parse(&resp);
|
||||
assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(b["auctionInfo"][0]["tradeId"], 900000005i64);
|
||||
@@ -889,6 +991,77 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn own_auction_carries_tradeowner_and_a_consistent_seller_identity() {
|
||||
// The client renders a listing fine but will not offer Remove / Re-list
|
||||
// unless it can tell the auction is the player's OWN. `tradeOwner` is the
|
||||
// purpose-built flag for that; `sellerId`/`sellerName` must agree with it,
|
||||
// or the row claims to be ours while naming a different seller.
|
||||
let (store, _d) = store_at("owner").await;
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
seed_listing(&store, "900000030", 2500).await;
|
||||
|
||||
let rec = parse(&handle_market_query("active", &econ, &store, PERSONA).await)["auctionInfo"]
|
||||
[0]
|
||||
.clone();
|
||||
assert_eq!(rec["tradeOwner"], true, "own listing must be flagged");
|
||||
assert_eq!(rec["sellerId"], PERSONA, "seller id is the player's persona");
|
||||
assert_eq!(
|
||||
rec["sellerName"],
|
||||
non_economy::PERSONA_DISPLAY_NAME,
|
||||
"seller name is the player, never EA's house name"
|
||||
);
|
||||
// The unbid-active tuple the client expects alongside those.
|
||||
assert_eq!(rec["bidState"], "none");
|
||||
assert_eq!(rec["currentBid"], 0);
|
||||
assert_eq!(rec["offers"], 0);
|
||||
assert_eq!(rec["expires"], 3600, "seconds remaining, never an epoch");
|
||||
|
||||
// The browse/search projection carries the same ownership truth.
|
||||
let browse = parse(&handle_market_list("GET", None, &econ, &store, PERSONA).await);
|
||||
assert_eq!(browse["auctionInfo"][0]["tradeOwner"], true);
|
||||
assert_eq!(browse["auctionInfo"][0]["sellerId"], PERSONA);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trade_status_answers_the_poll_instead_of_an_empty_set() {
|
||||
// The Transfer List polls `…/trade/status` continuously to refresh live
|
||||
// auction state. This tail has no numeric id, so it used to fall into the
|
||||
// buy/view arm and every poll was answered with an EMPTY auctionInfo —
|
||||
// observed live, and the screen never learned its own auctions' state.
|
||||
let (store, _d) = store_at("status").await;
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
seed_listing(&store, "900000031", 2500).await;
|
||||
|
||||
// No filter: answer with the player's own active pile.
|
||||
let all = parse(&handle_market_status(None, &econ, &store, PERSONA).await);
|
||||
assert_eq!(
|
||||
all["auctionInfo"].as_array().unwrap().len(),
|
||||
1,
|
||||
"an unfiltered poll must not come back empty while a listing is active"
|
||||
);
|
||||
assert_eq!(all["auctionInfo"][0]["tradeId"], 900_000_031i64);
|
||||
assert_eq!(all["auctionInfo"][0]["tradeOwner"], true);
|
||||
|
||||
// Explicit tradeIds filter returns exactly the requested auction.
|
||||
let one = parse(
|
||||
&handle_market_status(Some("tradeIds=900000031"), &econ, &store, PERSONA).await,
|
||||
);
|
||||
assert_eq!(one["auctionInfo"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(one["auctionInfo"][0]["tradeId"], 900_000_031i64);
|
||||
|
||||
// An unknown id is absent, not an error: the poll must never fail closed.
|
||||
let miss =
|
||||
parse(&handle_market_status(Some("tradeIds=900000099"), &econ, &store, PERSONA).await);
|
||||
assert_eq!(miss["auctionInfo"].as_array().unwrap().len(), 0);
|
||||
|
||||
// Garbage is skipped rather than poisoning the whole poll.
|
||||
assert_eq!(
|
||||
trade_ids_from_query(Some("tradeIds=900000031,abc,,900000032&x=1")),
|
||||
vec!["900000031".to_string(), "900000032".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn buy_now_debits_mints_and_closes() {
|
||||
let (store, _d) = store_at("buy").await;
|
||||
@@ -900,6 +1073,7 @@ mod tests {
|
||||
b"{}",
|
||||
&econ,
|
||||
&store,
|
||||
PERSONA,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status, 200);
|
||||
@@ -921,6 +1095,7 @@ mod tests {
|
||||
b"{}",
|
||||
&econ,
|
||||
&store,
|
||||
PERSONA,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status, 461);
|
||||
@@ -944,6 +1119,7 @@ mod tests {
|
||||
b"{}",
|
||||
&econ,
|
||||
&store,
|
||||
PERSONA,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status, 503);
|
||||
@@ -963,6 +1139,7 @@ mod tests {
|
||||
b"{}",
|
||||
&econ,
|
||||
&store,
|
||||
PERSONA,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status, 200);
|
||||
@@ -984,7 +1161,7 @@ mod tests {
|
||||
let e = econ.clone();
|
||||
tokio::spawn(async move {
|
||||
let r =
|
||||
handle_market_buy("POST", "/ut/game/fifa17/trade/900000020", b"{}", &*e, &s)
|
||||
handle_market_buy("POST", "/ut/game/fifa17/trade/900000020", b"{}", &*e, &s, PERSONA)
|
||||
.await;
|
||||
let body: Value = serde_json::from_slice(&r.body).unwrap();
|
||||
(r.status, body["auctionInfo"].as_array().unwrap().len())
|
||||
|
||||
@@ -910,10 +910,28 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
k.sort();
|
||||
k
|
||||
};
|
||||
// Our record is a deliberate SUPERSET of the oracle's. The oracle omits the
|
||||
// FIFA 17 ownership fields entirely, which is exactly why parity could not
|
||||
// catch the Actions-panel bug: the field was missing on BOTH sides, because the
|
||||
// oracle's own remove flow was never driven by a real client either. So assert
|
||||
// (a) we cover every key the oracle emits, and (b) the extra keys are precisely
|
||||
// the ownership set we added on purpose — a NEW unexplained divergence still
|
||||
// fails here.
|
||||
let (ok, rk) = (keys(o_rec), keys(r_rec));
|
||||
for k in &ok {
|
||||
assert!(rk.contains(k), "rust tradePile record is missing oracle key `{k}`");
|
||||
}
|
||||
let extra: Vec<&String> = rk.iter().filter(|k| !ok.contains(k)).collect();
|
||||
assert_eq!(
|
||||
keys(o_rec),
|
||||
keys(r_rec),
|
||||
"tradePile auction-record key set parity"
|
||||
extra,
|
||||
vec!["offers", "sellerId", "tradeOwner"],
|
||||
"the ONLY keys we add beyond the oracle are the FIFA 17 ownership fields"
|
||||
);
|
||||
// The ownership story must be internally consistent on our side.
|
||||
assert_eq!(r_rec["tradeOwner"], true, "own pile listing is owned by us");
|
||||
assert_eq!(
|
||||
r_rec["sellerId"], PERSONA_ID,
|
||||
"sellerId agrees with tradeOwner"
|
||||
);
|
||||
for f in [
|
||||
"sellerName",
|
||||
|
||||
Reference in New Issue
Block a user