From 58d1f9426f90465c55efcd399bbdab435137e0a5 Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 17 Aug 2026 18:50:19 +0000 Subject: [PATCH] fix(market): add FIFA 17 tradeOwner/sellerId, answer trade/status, route plain DELETE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/` WAS A SILENT NO-OP. Contemporaneous FIFA 17 clients cancel via `DELETE /ut/game//trade/`; 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). --- openfut-utas-host/src/lib.rs | 83 ++++++- openfut-utas-host/src/market.rs | 219 ++++++++++++++++-- .../tests/economy_differential.rs | 24 +- 3 files changed, 295 insertions(+), 31 deletions(-) diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 471f761..5cc4cb5 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -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/` — view / buy-now. MarketBuy, - /// `DELETE /ut/delete/game//trade/` — cancel a listing. + /// Cancel a listing. Both `DELETE /ut/delete/game//trade/` (the + /// oracle's spelling) and plain `DELETE /ut/game//trade/` (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 { // `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/` 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", diff --git a/openfut-utas-host/src/market.rs b/openfut-utas-host/src/market.rs index f4e516f..653115e 100644 --- a/openfut-utas-host/src/market.rs +++ b/openfut-utas-host/src/market.rs @@ -82,7 +82,7 @@ fn trade_id_from_path(path: &str) -> Option { /// `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, 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 = listings.iter().map(auction_record).collect(); + let auctions: Vec = 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 = 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 = 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 { + 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/` — 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()) diff --git a/openfut-utas-host/tests/economy_differential.rs b/openfut-utas-host/tests/economy_differential.rs index ee94e93..7cf6086 100644 --- a/openfut-utas-host/tests/economy_differential.rs +++ b/openfut-utas-host/tests/economy_differential.rs @@ -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",