diff --git a/docs/FIFA17_TRANSFER_MARKET_WIRE.md b/docs/FIFA17_TRANSFER_MARKET_WIRE.md index c749dfa..c54f235 100644 --- a/docs/FIFA17_TRANSFER_MARKET_WIRE.md +++ b/docs/FIFA17_TRANSFER_MARKET_WIRE.md @@ -399,7 +399,7 @@ Tests: `returning_an_expired_listing_to_the_club_ends_its_auction`, --- -## Q2 representation — narrowed to ONE candidate by elimination (2026-08-17) +## Q2 representation — narrowed to ONE candidate by elimination (2026-08-17) — **SUPERSEDED, see the final section** Re-entry discriminator result: **CONFIRMED BUG.** After fully leaving and re-entering FUT, the active auction reconstructs correctly under LISTED ITEMS, but the unlisted @@ -469,7 +469,7 @@ test, not the immediate post-mutation response. --- -## Q2 — LIVE-CONFIRMED: `tradeState: "inactive"` is the unlisted representation +## Q2 — LIVE-CONFIRMED: `tradeState: "inactive"` is the unlisted representation — **SUPERSEDED, see the final section** Operator-observed in the real FIFA 17 client, 2026-08-17, with the bounded one-item probe (`OPENFUT_FIFA17_UNLISTED_PROBE`). Fixture: @@ -534,7 +534,7 @@ mixed) has been observed — it remains an open question whether FIFA 17 expects --- -## Q2 status downgrade + the "Expired" differential EXPLAINED (2026-08-17) +## Q2 status downgrade + the "Expired" differential EXPLAINED (2026-08-17) — **SUPERSEDED, see the final section** Phase C was a PARTIAL pass. Corrected conclusions: @@ -588,3 +588,70 @@ touched and neither is implicated. If the actions still do not appear, those rem the next candidates — and `itemState`'s vocabulary should be dumped statically with `fifa17-recon/tools/vocab_dump.py` (already validated against the tradeState table) rather than guessed. + +--- + +## Q2 — RETRACTED, and CLOSED as unrepresentable (2026-08-17, RE of the FUT front-end) + +**Everything in the two Q2 sections above is superseded.** The `tradeState:"inactive"` +projection has been removed from `GET …/tradePile` and `…/trade/status`, and the +`UnlistedCandidate` machinery deleted. Live after the fix: `total` 7 → 1, only the real +auction, `inactive` rows 0, coins unchanged, and all six former rows still present in +`/club` (invariant intact). + +### Why the earlier conclusion was wrong + +It was `LIVE-CONFIRMED` that the row **renders**. That was never in doubt, and it is +not the same claim as the row being usable. The rows were never actionable, and could +not have been: + +| Fact | Confidence | +|---|---| +| `tradeState` reaches ONE native branch, `cmp …,0x4` at `0x18013e619` ("is it `closed`?"). `inactive`(2) and `expired`(3) take the same edge → bit-identical `flagA`/`flagB`. Exhaustive 22-site census of `[reg+0x88]` reads across the PE. | CONFIRMED | +| `tradeState` is handed to the movie verbatim as the Flash property `STATE` (publisher `0x1801bf030`); the action gate is in the APT/ActionScript FUT front-end, not CardsDLL. | CONFIRMED | +| The trade-pile movie class partitions rows with `getCardsInAuction`/`isInActiveAuction` (traces `initPile() - IN AUCTION:` / `- NOT IN AUCTION:`), and only auction rows reach `PreCheckCardOptions` → `handleTradeCardAction`. | CONFIRMED (interned AS symbols + trace strings, live) | +| A client rule excludes non-auction pile rows from the action path (Category C). | STRONGLY SUPPORTED | +| The gate term is `STATE == 2`. | **NOT PROVEN** — `STATE`, `CARD_OFFERSTATE` and the `RESERVEDPRICE`/`MAX_CREDITS` pair all differ together in every row we can observe | +| In the shipped game, unlisted pile rows were a CLIENT-SIDE transient built by the movie from a `TO_TRADEPILE` Flash message (`FUN_1801be6a0`), never server-delivered. | STRONGLY SUPPORTED | + +This fires the corpus's own pre-registered falsifier E3 +(`plan-2026-08-06-transfer-market.md:368-373`): the native predicates all pass +(`IS_TRADING_ENABLED=1`, `TRADE_PILE_SIZE=100`, `tradeable=1`) and an `expired` row in +the same section IS actionable, so "the Flash layer has its own gate and CardsDLL is no +longer the right place to look" is now an observation. + +**The corpus said so before any of this was written**, at +`plan-2026-08-06-transfer-market.md:731-733`: "`inactive` decodes but no client path +treats it specially; **do not emit it**." The projection was built anyway, and the +earlier reasoning above (that the warning "was written about the PRESENTATION +function") was motivated rather than evidenced. + +**Consequence for the product:** a server cannot put an unlisted item on the Transfer +List in an actionable state. `/tradePile` carries only twelve-atom auction records and +`pile` has no deserializer arm, so anything rendered there must be a real auction. +Unlisted trade-pile membership is now internal state with no wire expression; those +cards live in `/club`, which is where the client can act on them. + +### Open: `itemData.itemState: "listFS"` is not a FIFA 17 token + +Independent of the gate, and CONFIRMED: `listFS` occurs **0 times** in +`CardsDLL_Win64_retail.dll` (md5 `4de3493131d7d2ff7f8b360c5ac9b655`) and 0 times in +4.26 GiB of live process memory; it decodes to **−1** through `FUN_180166660`, observed +live on our listed row. FIFA 17's value for a listed item is `forSale` (5), from the +12-row table at `0x180229cc0`. We emit `listFS` on `/tradePile` and `/trade/status`, and +so does the Python oracle (`utas_server.py:3921`) — which is exactly why the +differential never caught it: **oracle parity is necessary but not sufficient.** + +Deliberately NOT fixed in the same change: `CARD_OFFERSTATE` (itemState) is one of the +three unresolved gate candidates, and every actionable row observed so far carried +`itemState = −1`. Flipping it to `forSale` could perturb the one flow that currently +works (expired → Return to Club), so it ships alone and gets its own live A/B. + +### Proving the gate (not yet done) + +Disassemble the AVM1 action buffer of `tradepile.isInActiveAuction` (and +`PreCheckCardOptions`): either walk from the interned symbol at `0xb84d7498` to its APT +function object in the live process, or extract +`external/ion_fut/screens/trading/tradepile` from `ui.sb` + the `cas` archives offline. +Nothing else in reach separates `STATE` from `CARD_OFFERSTATE` from the price pair, +because all three differ together in every observable row. diff --git a/docs/evidence/market-lifecycle-2026-08-17/inactive-projection-removed.json b/docs/evidence/market-lifecycle-2026-08-17/inactive-projection-removed.json new file mode 100644 index 0000000..8e0346b --- /dev/null +++ b/docs/evidence/market-lifecycle-2026-08-17/inactive-projection-removed.json @@ -0,0 +1,34 @@ +{ + "date": "2026-08-17", + "change": "removed tradeState:'inactive' projection from /tradePile and /trade/status", + "why": "RE of the FUT front-end: the action gate is in the APT/ActionScript layer; only rows classified as auctions (getCardsInAuction/isInActiveAuction) reach PreCheckCardOptions -> handleTradeCardAction, so an 'inactive' row renders and can never be acted on. The corpus already said 'do not emit it' (plan-2026-08-06-transfer-market.md:731-733).", + "before": { + "total": 7, + "by_state": { + "expired": 1, + "inactive": 6 + }, + "credits": 29843976, + "trade_status_returned": 7 + }, + "after": { + "total": 1, + "by_state": { + "expired": 1 + }, + "credits": 29843976, + "trade_status_returned": 1 + }, + "club_invariant": { + "formerly_inactive_members_visible_in_club": 6, + "of": 6, + "stranded": 0, + "club_items": 1965 + }, + "not_fixed_here": { + "itemState": "listFS", + "finding": "0 occurrences in CardsDLL (md5 4de3493131d7d2ff7f8b360c5ac9b655) and 0 in 4.26 GiB of process memory; decodes to -1. FIFA 17 value is forSale (5).", + "reason_deferred": "CARD_OFFERSTATE is one of three unresolved action-gate candidates; every actionable row observed carried itemState -1, so changing it could perturb the working expired -> Return-to-Club flow. Ships alone with its own live A/B." + }, + "gate_status": "Category C, STRONGLY SUPPORTED. Gate term NOT PROVEN: STATE, CARD_OFFERSTATE and RESERVEDPRICE/MAX_CREDITS all differ together in every observable row. Proof needs AVM1 disassembly of tradepile.isInActiveAuction." +} diff --git a/openfut-utas-host/src/clientdata_store.rs b/openfut-utas-host/src/clientdata_store.rs index 7625a0a..53da77c 100644 --- a/openfut-utas-host/src/clientdata_store.rs +++ b/openfut-utas-host/src/clientdata_store.rs @@ -44,7 +44,10 @@ impl ClientDataStore { /// The stored blob for `:`, or `None` if never written. pub fn get(&self, persona: i64, key: &str) -> Option { - self.map.lock().get(&Self::compound_key(persona, key)).cloned() + self.map + .lock() + .get(&Self::compound_key(persona, key)) + .cloned() } /// Store `value` under `:` and persist the whole map to disk. diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index af26972..b1a10f6 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -1052,21 +1052,6 @@ impl Fifa17IdentityResolver { .unwrap_or(None) } - /// The wire item id ALREADY mapped to a Core owned instance, or `None`. - /// - /// NEVER allocates, unlike the resolver's forward path: enumerating the trade - /// pile must not mint identities for items the client has never seen, or a - /// read would quietly consume wire ids. - pub fn wire_for_owned_id(&self, core_id: &str) -> Option { - self.store - .external_for( - Fifa17WireItemIdPolicy::GAME, - Fifa17WireItemIdPolicy::OWNED_ITEM_KIND, - core_id, - ) - .unwrap_or(None) - } - /// The FIFA `cardsubtypeid` for an owned item's definition (0 if unknown / /// a player), from the catalog — used by club-stats family aggregation. pub fn subtype_of(&self, item: &CoreOwnedItem) -> i64 { @@ -1133,7 +1118,6 @@ impl SquadWireResolver for Fifa17IdentityResolver { } } - // ───────────────────────────── /club handler ──────────────────────────────── /// Safe, structured summary of a handled `/club` request (no auth/session/device @@ -1200,7 +1184,12 @@ fn paginate_items(items: &[Value], offset: Option, limit: Option) -> ( let total = items.len() as i64; let off = offset.unwrap_or(0).max(0) as usize; let paged: Vec = match limit { - Some(l) => items.iter().skip(off).take(l.max(0) as usize).cloned().collect(), + Some(l) => items + .iter() + .skip(off) + .take(l.max(0) as usize) + .cloned() + .collect(), None => items.iter().skip(off).cloned().collect(), }; (paged, total) @@ -2208,39 +2197,6 @@ impl Server { self } - /// Resolve every Core instance in the `trade` pile to a shaped unlisted - /// candidate, for the routes that must agree about those ids. - /// - /// Three phases in this order for a reason: the pile read is async, the - /// identity/Core resolvers are NOT `Send` so they cannot cross an await, and the - /// caller's response build is async again. - /// - /// Both `/tradePile` and `/trade/status` use this. They MUST: the client polls - /// `/trade/status` for the row it is showing, and answering there with an empty - /// body while `/tradePile` advertises the id makes the client degrade the row to - /// "Expired" with no actions. A tradeId has to resolve on every route that can be - /// asked about it. - fn resolve_trade_pile(&self, svc: &EconomyServices) -> Vec { - let piles = svc.piles.clone(); - let trade_ids = svc - .bridge - .block_on(async move { piles.list_by_pile("trade").await }) - .unwrap_or_default(); - if trade_ids.is_empty() { - return Vec::new(); - } - let lookup = crate::economy_store::CoreItemLookup { - core: self.core.as_ref(), - }; - crate::market::resolve_unlisted_pile( - &trade_ids, - |core_id| self.resolver.wire_for_owned_id(core_id), - self.resolver.as_ref(), - &lookup, - self.entities.as_ref(), - ) - } - /// Dispatch a FIFA17 economy route to its Rust handler, or `None` if the path /// is not an economy route (or no economy services are wired). This is the /// handler-wiring entry point exercised by the integration harness; it is @@ -2385,48 +2341,34 @@ 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()) + .await }) } EconomyRoute::MarketQuery => { let (bridge, market, econ) = (svc.bridge.clone(), svc.market.clone(), svc.econ.clone()); - let unlisted = self.resolve_trade_pile(svc); bridge.block_on(async move { - crate::market::handle_market_query( - "active", - econ.as_ref(), - market.as_ref(), - &unlisted, - ) - .await + crate::market::handle_market_query("active", econ.as_ref(), market.as_ref()) + .await }) } EconomyRoute::MarketCounts => { let (bridge, market) = (svc.bridge.clone(), svc.market.clone()); - bridge - .block_on(async move { crate::market::handle_market_counts(market.as_ref()).await }) + 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()); - // ISViewTrade is asked about the same ids /tradePile advertises, so it - // needs the same pile enumeration or an unlisted row degrades. - let unlisted = self.resolve_trade_pile(svc); bridge.block_on(async move { crate::market::handle_market_status( q.as_deref(), econ.as_ref(), market.as_ref(), - &unlisted, ) .await }) @@ -2436,14 +2378,8 @@ impl Server { (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()) + .await }) } EconomyRoute::MarketCancel => { @@ -2673,7 +2609,10 @@ impl Server { coins, ents.len() ); - json_status(200, &non_economy::account_sync_body(&req, coins, ents.len())) + json_status( + 200, + &non_economy::account_sync_body(&req, coins, ents.len()), + ) } /// `GET/PUT/POST …/clientdata/` — opaque per-user client blob store. GET @@ -2737,7 +2676,10 @@ impl Server { json_status(200, &body) } Err(e) => { - eprintln!("utas-host owner=RUST route=userMassInfo status={} error=core", e.status); + eprintln!( + "utas-host owner=RUST route=userMassInfo status={} error=core", + e.status + ); e } } @@ -2753,7 +2695,10 @@ impl Server { json_status(200, &json!({ "userInfo": user_info })) } Err(e) => { - eprintln!("utas-host owner=RUST route=user status={} error=core", e.status); + eprintln!( + "utas-host owner=RUST route=user status={} error=core", + e.status + ); e } } @@ -2896,7 +2841,10 @@ impl Server { }; let (bridge, market) = (svc.bridge.clone(), svc.market.clone()); match bridge.block_on(async move { market.query_listings("active").await }) { - Ok(listings) => listings.into_iter().filter_map(|l| l.core_item_id).collect(), + Ok(listings) => listings + .into_iter() + .filter_map(|l| l.core_item_id) + .collect(), Err(e) => { eprintln!("utas-host WARN market read failed (club shows all): {e}"); std::collections::HashSet::new() @@ -2994,7 +2942,10 @@ impl Server { .and_then(|s| s.econ.balance().ok()) .unwrap_or(0); eprintln!("utas-host owner=RUST route=watchlist method=GET status=200 credits={credits}"); - json_status(200, &json!({ "auctionInfo": [], "credits": credits, "total": 0 })) + json_status( + 200, + &json!({ "auctionInfo": [], "credits": credits, "total": 0 }), + ) } /// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and the @@ -4075,8 +4026,16 @@ mod tests { ("GET", "/ut/game/fifa17/tradePile", Some(MarketQuery)), ("GET", "/ut/game/fifa17/tradepile", Some(MarketQuery)), // The tally is a DISTINCT deserializer from the listing list. - ("GET", "/ut/game/fifa17/tradePile/counts", Some(MarketCounts)), - ("GET", "/ut/game/fifa17/tradepile/counts", Some(MarketCounts)), + ( + "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. @@ -4197,7 +4156,7 @@ mod tests { ("POST", "/ut/game/fifa17/season"), // FUT-mode reads are GET-only ("GET", "/ut/game/fifa17/user/club"), // mutating rename stays Python ("POST", "/ut/game/fifa17/item/resource"), // item-defs are GET-only - ("GET", "/ut/game/fifa17/marketdatafoo"), // not the marketdata route + ("GET", "/ut/game/fifa17/marketdatafoo"), // not the marketdata route ]; for (m, p) in proxied { assert_eq!(classify(m, p), Route::Passthrough, "PROXY: {m} {p}"); @@ -4212,7 +4171,10 @@ mod tests { fn item_defs_shape_matches_oracle() { // Two ids: the one hardcoded card (asset 20801) + a placeholder. assert_eq!(extract_long_ints("resourceId=20801"), vec![20801]); - assert_eq!(extract_long_ints("idList=200389,200104&x=12"), vec![200389, 200104]); + assert_eq!( + extract_long_ints("idList=200389,200104&x=12"), + vec![200389, 200104] + ); assert!(extract_long_ints("foo=ab").is_empty()); let body = non_economy::item_defs_body(&[20801, 200389]); @@ -4237,7 +4199,10 @@ mod tests { #[test] fn marketdata_container_types_are_load_bearing() { // /pricelimits MUST be a bare ARRAY (object-where-array froze a live client). - assert_eq!(extract_defid_param("defId=200389,200104"), vec![200389, 200104]); + assert_eq!( + extract_defid_param("defId=200389,200104"), + vec![200389, 200104] + ); let arr = non_economy::marketdata_pricelimits_body(&[200389, 200104]); assert!(arr.is_array(), "pricelimits must be a bare array"); let arr = arr.as_array().unwrap(); diff --git a/openfut-utas-host/src/market.rs b/openfut-utas-host/src/market.rs index db86c05..3430220 100644 --- a/openfut-utas-host/src/market.rs +++ b/openfut-utas-host/src/market.rs @@ -166,7 +166,11 @@ 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 { - let state = if l.state == "active" { "forSale" } else { "free" }; + let state = if l.state == "active" { + "forSale" + } else { + "free" + }; auction_record_as(l, state) } @@ -251,7 +255,10 @@ pub fn resolve_market_list( resource_id, start: b.get("startingBid").and_then(Value::as_i64).unwrap_or(150), buy_now: b.get("buyNowPrice").and_then(Value::as_i64).unwrap_or(0), - seller: b.get("sellerName").and_then(Value::as_str).map(str::to_string), + seller: b + .get("sellerName") + .and_then(Value::as_str) + .map(str::to_string), item_json, // FIFA 17's ISStart body carries the listing duration in seconds. We // previously dropped it and reported a frozen `expires`, so the client's @@ -260,107 +267,6 @@ pub fn resolve_market_list( }) } -/// A single unlisted trade-pile item to expose on `/tradePile` as a NON-ACTIVE -/// auction record — the bounded Q2 experiment (see -/// docs/FIFA17_TRANSFER_MARKET_WIRE.md). -/// -/// Why this shape is forced rather than chosen: the CardsDLL route table has exactly -/// ONE trade-pile route, it carries only twelve-atom auction records, `pile` has no -/// deserializer arm (membership comes from the owning list), and of those atoms only -/// `tradeState` can express lifecycle. Its vocabulary is a four-row `{const char*, -/// int}` table dumped from the PE — `active=1 inactive=2 expired=3 closed=4` — so -/// `"inactive"` is the client's own token for "in the pile, not on the market". -pub struct UnlistedCandidate { - /// FIFA wire item id. - pub item_id: i64, - /// Core owned-instance id, used to confirm the pile and the absence of an auction. - pub core_id: String, - /// The full shaped card, so the row renders like any other. - pub item_json: Option, -} - -/// Resolve every Core owned instance sitting in the trade pile to an -/// [`UnlistedCandidate`]. Pure identity plus one Core inventory read each, so it runs -/// OFF the async runtime exactly like [`resolve_market_list`] (the trait-object -/// resolvers are not `Send`). -/// -/// `wire_for_core` MUST be a non-allocating lookup: enumerating a pile is a READ, and -/// minting a wire id for an item the client has never seen would silently consume -/// identities. An item with no existing mapping, no Core record, or no resolvable -/// FIFA identity is skipped rather than faked. -pub fn resolve_unlisted_pile( - core_ids: &[String], - wire_for_core: F, - resolver: &dyn ItemIdentityResolver, - items: &dyn OwnedItemLookup, - ent: &E, -) -> Vec -where - E: ReverseEntityResolver, - F: Fn(&str) -> Option, -{ - let mut out = Vec::with_capacity(core_ids.len()); - for core_id in core_ids { - let Some(item_id) = wire_for_core(core_id) else { - continue; - }; - let Some(owned) = items.owned_item(core_id) else { - continue; - }; - let Some(item_json) = resolver - .resolve(&owned) - .map(|id| shape_item(&owned, id, ent)) - .and_then(|card| serde_json::to_string(&card).ok()) - else { - continue; - }; - out.push(UnlistedCandidate { - item_id, - core_id: core_id.clone(), - item_json: Some(item_json), - }); - } - out -} - -/// The auction record for an item sitting in the trade pile with NO auction. -/// -/// `tradeState: "inactive"` is the whole point; everything else is deliberately the -/// zero/neutral value so the row cannot render a countdown or a price. `tradeId` -/// reuses the item's stable auction id, because the client keys its record store on -/// `tradeId` and re-parents `itemData` onto an existing record — so listing this -/// item later UPDATES this row rather than creating a duplicate ghost. -fn unlisted_record(c: &UnlistedCandidate) -> Value { - let item_data = c - .item_json - .as_deref() - .and_then(|s| serde_json::from_str::(s).ok()) - .filter(Value::is_object) - .map(|mut card| { - card["id"] = json!(c.item_id); - // `free` is what /club emits for an unheld card; there is no evidence of a - // distinct itemState for a pile-resident item, so do not invent one. - card["itemState"] = json!("free"); - card["untradeable"] = json!(false); - card - }) - .unwrap_or_else(|| json!({ "id": c.item_id, "itemState": "free", "untradeable": false })); - json!({ - "tradeId": TRADE_ID_BASE + c.item_id, - "itemData": item_data, - "tradeState": "inactive", - "buyNowPrice": 0, - "startingBid": 0, - "currentBid": 0, - "bidState": "none", - "expires": 0, - "sellerName": non_economy::PERSONA_DISPLAY_NAME, - "sellerEstablished": 1, - "watched": false, - "coinsProcessed": 0, - }) -} - /// `/auctionhouse` — search (GET), list-for-sale (POST FutISStart), relist (PUT). /// /// * GET returns the durable active auctions plus the FutGetAuctionCount ints, @@ -455,10 +361,7 @@ 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(auction_record).collect(); ok_json(&json!({ "auctionInfo": auctions, "credits": credits_or_zero(econ), @@ -480,44 +383,31 @@ pub async fn handle_market_list( /// This is the SELLER's own pile, so each card carries `itemState: "listFS"` /// (list-for-sale) — the state the oracle stamps on a tradePile card, distinct /// from the `forSale` used for market search results. +/// +/// ONLY real auctions appear here. An item sitting in the trade pile with no +/// auction is deliberately absent: `plan-2026-08-06-transfer-market.md:731-733` +/// says of the `tradeState` vocabulary "`inactive` decodes but no client path +/// treats it specially; do not emit it", and RE of the FUT front-end confirmed +/// why — the trade-pile movie admits only rows it classifies as auctions +/// (`getCardsInAuction`/`isInActiveAuction`) into the action path, so an +/// `inactive` row renders and can never be acted on. In the shipped game those +/// rows were a client-side transient built by the movie itself from a +/// `TO_TRADEPILE` Flash message, never server-delivered. Nothing is stranded: +/// `/club` excludes only items with an ACTIVE listing, so an unlisted pile member +/// stays visible in the club. pub async fn handle_market_query( state: &str, econ: &dyn CoreEconomy, store: &MarketStore, - unlisted: &[UnlistedCandidate], ) -> WireResponse { let listings = match store.query_listings(state).await { Ok(l) => l, Err(_) => return json_body(503, &json!({ "error": "market_store" })), }; - let mut auctions: Vec = listings + let auctions: Vec = listings .iter() .map(|l| auction_record_as(l, "listFS")) .collect(); - - // Unlisted trade-pile members, as LIVE-CONFIRMED `tradeState: "inactive"` rows. - // The caller enumerates them FROM the pile, so membership is established; what - // must still be excluded is any item whose listing state forbids advertising it as - // an owned unlisted card. A pile row OUTLIVES its auction, so after a sale the - // seller's `trade` row is stale — filtering on the active set alone would - // re-advertise a SOLD card as an unlisted item (caught by the differential). - let blocked = store.blocking_core_items().await.unwrap_or_default(); - let mut inactive = 0usize; - for c in unlisted { - if blocked.contains(&c.core_id) { - continue; - } - auctions.push(unlisted_record(c)); - inactive += 1; - } - if !unlisted.is_empty() { - eprintln!( - "utas-host owner=RUST route=market-query active={} inactive={inactive} pile_candidates={}", - listings.len(), - unlisted.len() - ); - } - // GetTradePile shares one deserializer (0x18013e7f0) with ISSearch and // ISWatchList, over exactly four members: `auctionInfo` (array), `credits` // (int), `duplicateItemIdList` (array of objects) and `total` (int). We were @@ -587,7 +477,6 @@ pub async fn handle_market_status( query: Option<&str>, econ: &dyn CoreEconomy, store: &MarketStore, - unlisted: &[UnlistedCandidate], ) -> WireResponse { let ids = trade_ids_from_query(query); let listings = if ids.is_empty() { @@ -604,33 +493,10 @@ pub async fn handle_market_status( } found }; - let mut auctions: Vec = listings + let auctions: Vec = listings .iter() .map(|l| auction_record_as(l, "listFS")) .collect(); - - // ISViewTrade MUST resolve the SAME tradeIds `/tradePile` advertises. The client - // polls the row it is displaying and, for an unlisted pile member, there is no - // listing row to find — so answering from the market store alone returned an empty - // body and the client DEGRADED the row (Time Remaining rendered "Expired", and no - // actions were offered). Observed live: `requested=1 returned=0` on repeat for the - // selected row. A tradeId must resolve on every route that can be asked about it. - let blocked = store.blocking_core_items().await.unwrap_or_default(); - let wanted: Option> = if ids.is_empty() { - None - } else { - Some(ids.iter().filter_map(|s| s.parse::().ok()).collect()) - }; - for c in unlisted { - if blocked.contains(&c.core_id) { - continue; - } - let trade_id = TRADE_ID_BASE + c.item_id; - let asked = wanted.as_ref().is_none_or(|w| w.contains(&trade_id)); - if asked && !auctions.iter().any(|a| a["tradeId"] == json!(trade_id)) { - auctions.push(unlisted_record(c)); - } - } eprintln!( "utas-host owner=RUST route=market-status requested={} returned={} query={}", ids.len(), @@ -1019,61 +885,28 @@ mod tests { } #[tokio::test] - async fn unlisted_candidate_is_a_non_active_pile_row() { - // Q2: an item in the trade pile with NO auction. The route table has exactly - // one trade-pile route and it carries only auction records, `pile` has no - // deserializer arm, and of the twelve atoms only `tradeState` expresses - // lifecycle — whose PE string table is `active/inactive/expired/closed`. So - // the row must be `inactive`, with no timer and no prices. - let (store, _d) = store_at("unlisted").await; + async fn the_trade_pile_advertises_only_real_auctions() { + // Regression guard for a wrong answer we shipped: an item sitting in the + // trade pile with no auction was advertised as a synthetic + // `tradeState:"inactive"` record. The corpus already forbade it + // (plan-2026-08-06-transfer-market.md:731-733, "`inactive` decodes but no + // client path treats it specially; do not emit it"), and RE of the FUT + // front-end explained why: the trade-pile movie admits only rows it + // classifies as auctions into the action path, so such a row renders and + // can never be acted on. Only auctions belong in this body. + let (store, _d) = store_at("pileonlyauctions").await; let econ = CountingEconomy::with_balance(10_000); - let c = UnlistedCandidate { - item_id: 100_000_059, - core_id: "core-unlisted".into(), - item_json: Some(r#"{"rating":93,"preferredPosition":"RW"}"#.into()), - }; - // No candidates supplied -> nothing extra is advertised. - let body = parse(&handle_market_query("active", &econ, &store, &[]).await); - assert_eq!( - body["auctionInfo"].as_array().unwrap().len(), - 0, - "an empty candidate set adds nothing" - ); + // Nothing listed: an empty pile, whatever the item store holds. + let body = parse(&handle_market_query("active", &econ, &store).await); + assert_eq!(body["auctionInfo"].as_array().unwrap().len(), 0); + assert_eq!(body["total"], 0); - let body = parse(&handle_market_query("active", &econ, &store, std::slice::from_ref(&c)).await); - let recs = body["auctionInfo"].as_array().unwrap(); - assert_eq!(recs.len(), 1, "now in the pile, so it is exposed"); - let r = &recs[0]; - assert_eq!(r["tradeState"], "inactive", "the PE's token for not-on-market"); - assert_eq!(r["expires"], 0, "no countdown on an item that is not listed"); - assert_eq!(r["buyNowPrice"], 0); - assert_eq!(r["startingBid"], 0); - assert_eq!(r["currentBid"], 0); - assert_eq!(r["bidState"], "none"); - assert_eq!(r["itemData"]["id"], 100_000_059i64); - assert_eq!(r["itemData"]["itemState"], "free"); - assert_eq!(r["itemData"]["rating"], 93, "the full card still renders"); - // Stable tradeId: the client keys its record store on it, so listing this - // item later must UPDATE this row rather than add a duplicate ghost. - assert_eq!(r["tradeId"], TRADE_ID_BASE + 100_000_059); - // Still exactly the twelve atoms. - let mut keys: Vec<&str> = r.as_object().unwrap().keys().map(String::as_str).collect(); - keys.sort_unstable(); - assert_eq!(keys.len(), 12, "no extra atoms on the inactive row"); - } - - #[tokio::test] - async fn unlisted_candidate_never_duplicates_a_real_auction() { - // If the item already owns a listing, advertising it as `inactive` too would - // put two records with the same tradeId in one body. - let (store, _d) = store_at("unlistedduped").await; - let econ = CountingEconomy::with_balance(10_000); store .create_listing( &(TRADE_ID_BASE + 100_000_059).to_string(), "169193", - Some("core-unlisted"), + Some("core-listed"), Some(100_000_059), Some(169193), 150, @@ -1084,94 +917,31 @@ mod tests { ) .await .unwrap(); - let c = UnlistedCandidate { - item_id: 100_000_059, - core_id: "core-unlisted".into(), - item_json: None, - }; - let body = parse(&handle_market_query("active", &econ, &store, std::slice::from_ref(&c)).await); + + let body = parse(&handle_market_query("active", &econ, &store).await); let recs = body["auctionInfo"].as_array().unwrap(); - assert_eq!(recs.len(), 1, "the real auction only"); + assert_eq!(recs.len(), 1, "the real auction, and nothing synthetic"); assert_eq!(recs[0]["tradeState"], "active"); - } - - #[tokio::test] - async fn a_sold_or_in_flight_card_is_never_advertised_as_an_unlisted_member() { - // A pile row OUTLIVES its auction: after a sale the seller's `trade` row is - // stale. Filtering only on ACTIVE listings therefore re-advertised a SOLD card - // as an owned unlisted item — caught by the differential, not by unit tests, - // because it needs the full list -> sell -> poll sequence. - let (store, _d) = store_at("soldpile").await; - let econ = CountingEconomy::with_balance(10_000); - let mk = |id: &str, core: &str| { - let store = store.clone(); - let (id, core) = (id.to_string(), core.to_string()); - async move { - store - .create_listing( - &id, - "169193", - Some(&core), - None, - None, - 150, - 2500, - None, - None, - None, - ) - .await - .unwrap(); - } - }; - mk("900000070", "core-sold").await; - mk("900000071", "core-inflight").await; - mk("900000072", "core-cancelled").await; - store.reserve_listing("900000070").await.unwrap(); - store.complete_sale("900000070").await.unwrap(); - store.reserve_listing("900000071").await.unwrap(); - store.cancel_listing("900000072", None).await.unwrap(); - - let cands: Vec = ["core-sold", "core-inflight", "core-cancelled"] - .iter() - .enumerate() - .map(|(i, core)| UnlistedCandidate { - item_id: 100_000_200 + i as i64, - core_id: (*core).to_string(), - item_json: None, - }) - .collect(); - - let body = parse(&handle_market_query("active", &econ, &store, &cands).await); - let recs = body["auctionInfo"].as_array().unwrap(); - let states: Vec<&str> = recs - .iter() - .map(|r| r["tradeState"].as_str().unwrap()) - .collect(); - assert_eq!( - states, - vec!["inactive"], - "only the CANCELLED card returns to the pile as unlisted; sold and \ - in-flight cards must not be advertised as owned" - ); - assert_eq!( - recs[0]["itemData"]["id"], 100_000_202i64, - "and it is specifically the cancelled one" - ); - - // The store-level rule, asserted directly. - let blocked = store.blocking_core_items().await.unwrap(); - assert!(blocked.contains("core-sold")); - assert!(blocked.contains("core-inflight")); assert!( - !blocked.contains("core-cancelled"), - "a cancelled listing means the card is back in the pile" + recs.iter().all(|r| r["tradeState"] != "inactive"), + "`inactive` must never appear on the wire: {recs:#?}" ); } async fn seed_listing(store: &MarketStore, id: &str, buy_now: i64) { store - .create_listing(id, "169193", None, None, Some(169193), 400, buy_now, None, None, None) + .create_listing( + id, + "169193", + None, + None, + Some(169193), + 400, + buy_now, + None, + None, + None, + ) .await .unwrap(); } @@ -1220,7 +990,7 @@ 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).await; let rec = parse(&pile)["auctionInfo"][0].clone(); assert_eq!(rec["itemData"]["itemState"], "listFS"); assert_eq!(rec["itemData"]["rating"], 84); @@ -1260,7 +1030,10 @@ mod tests { &items, &NoEntities, ); - assert!(resolved.is_none(), "unresolved item must not build a listing"); + assert!( + resolved.is_none(), + "unresolved item must not build a listing" + ); let resp = handle_market_list("POST", resolved, &econ, &store).await; assert_eq!(resp.status, 200); assert_eq!(parse(&resp)["id"].as_i64().unwrap(), TRADE_ID_BASE); @@ -1317,7 +1090,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).await; let b = parse(&resp); assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1); assert_eq!(b["auctionInfo"][0]["tradeId"], 900000005i64); @@ -1367,9 +1140,14 @@ mod tests { let econ = CountingEconomy::with_balance(10_000); seed_listing(&store, "900000030", 2500).await; - let body = parse(&handle_market_query("active", &econ, &store, &[]).await); + let body = parse(&handle_market_query("active", &econ, &store).await); let rec = body["auctionInfo"][0].clone(); - let mut got: Vec<&str> = rec.as_object().unwrap().keys().map(String::as_str).collect(); + let mut got: Vec<&str> = rec + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); got.sort_unstable(); assert_eq!( got, @@ -1400,7 +1178,12 @@ mod tests { // GetTradePile shares the IS-list body: four members, including the // `duplicateItemIdList` we used to omit. - let mut env: Vec<&str> = body.as_object().unwrap().keys().map(String::as_str).collect(); + let mut env: Vec<&str> = body + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); env.sort_unstable(); assert_eq!( env, @@ -1453,7 +1236,7 @@ mod tests { 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, &[]).await); + let all = parse(&handle_market_status(None, &econ, &store).await); assert_eq!( all["auctionInfo"].as_array().unwrap().len(), 1, @@ -1466,15 +1249,12 @@ mod tests { assert!(all["credits"].is_i64()); // Explicit tradeIds filter returns exactly the requested auction. - let one = parse( - &handle_market_status(Some("tradeIds=900000031"), &econ, &store, &[]).await, - ); + let one = parse(&handle_market_status(Some("tradeIds=900000031"), &econ, &store).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, &[]).await); + let miss = parse(&handle_market_status(Some("tradeIds=900000099"), &econ, &store).await); assert_eq!(miss["auctionInfo"].as_array().unwrap().len(), 0); // Garbage is skipped rather than poisoning the whole poll. @@ -1485,50 +1265,24 @@ mod tests { } #[tokio::test] - async fn trade_status_resolves_the_unlisted_ids_tradepile_advertises() { - // A tradeId must resolve on EVERY route that can be asked about it. The client - // polls /trade/status for the row it is displaying; when /tradePile advertised - // an unlisted id but /trade/status answered with an empty body, the client - // DEGRADED the row — Time Remaining rendered "Expired" and no actions were - // offered. Observed live as `requested=1 returned=0` on repeat for the selected - // row, while the same id was present in /tradePile. - let (store, _d) = store_at("statusunlisted").await; + async fn trade_status_answers_only_about_real_auctions() { + // The companion to `the_trade_pile_advertises_only_real_auctions`: since the + // pile no longer advertises synthetic ids, ISVIEWTRADE has none to resolve. + // An id it knows nothing about is simply absent from the reply rather than + // being answered with an invented record. + let (store, _d) = store_at("statusonlyauctions").await; let econ = CountingEconomy::with_balance(10_000); - let c = UnlistedCandidate { - item_id: 100_000_122, - core_id: "core-pile".into(), - item_json: Some(r#"{"rating":90,"preferredPosition":"CM"}"#.into()), - }; - let cands = std::slice::from_ref(&c); let trade_id = TRADE_ID_BASE + 100_000_122; - // Asked for explicitly: it must come back, not be silently absent. - let one = parse( - &handle_market_status(Some(&format!("tradeIds={trade_id}")), &econ, &store, cands) - .await, - ); - let recs = one["auctionInfo"].as_array().unwrap(); - assert_eq!(recs.len(), 1, "the polled unlisted id must resolve"); - assert_eq!(recs[0]["tradeId"], trade_id); - assert_eq!(recs[0]["tradeState"], "inactive"); - assert_eq!(recs[0]["expires"], 0); - - // Unfiltered poll: still present. - let all = parse(&handle_market_status(None, &econ, &store, cands).await); - assert_eq!(all["auctionInfo"].as_array().unwrap().len(), 1); - - // A DIFFERENT id was asked for: the unlisted row must not be volunteered. - let other = parse( - &handle_market_status(Some("tradeIds=900000999"), &econ, &store, cands).await, + let unknown = parse( + &handle_market_status(Some(&format!("tradeIds={trade_id}")), &econ, &store).await, ); assert_eq!( - other["auctionInfo"].as_array().unwrap().len(), + unknown["auctionInfo"].as_array().unwrap().len(), 0, - "only the ids actually asked about are answered" + "an id with no auction resolves to nothing, not to a synthetic row" ); - // Once the item owns a real auction, the auction wins and the id is not - // duplicated by an inactive row. store .create_listing( &trade_id.to_string(), @@ -1544,12 +1298,13 @@ mod tests { ) .await .unwrap(); - let listed = parse( - &handle_market_status(Some(&format!("tradeIds={trade_id}")), &econ, &store, cands) - .await, + + let one = parse( + &handle_market_status(Some(&format!("tradeIds={trade_id}")), &econ, &store).await, ); - let recs = listed["auctionInfo"].as_array().unwrap(); - assert_eq!(recs.len(), 1, "exactly one record for one tradeId"); + let recs = one["auctionInfo"].as_array().unwrap(); + assert_eq!(recs.len(), 1, "now there is a real auction to answer with"); + assert_eq!(recs[0]["tradeId"], trade_id); assert_eq!(recs[0]["tradeState"], "active"); } @@ -1742,7 +1497,18 @@ mod tests { let resolver = MapResolver::new(&[(1, "core-a"), (2, "core-b")]); for (id, core) in [("900000060", "core-a"), ("900000061", "core-b")] { market - .create_listing(id, "169193", Some(core), None, None, 400, 2500, None, None, None) + .create_listing( + id, + "169193", + Some(core), + None, + None, + 400, + 2500, + None, + None, + None, + ) .await .unwrap(); } @@ -1750,7 +1516,8 @@ mod tests { market.reserve_listing("900000061").await.unwrap(); market.complete_sale("900000061").await.unwrap(); - let body = json!({ "itemData": [{ "id": 1, "pile": "club" }, { "id": 2, "pile": "club" }] }); + let body = + json!({ "itemData": [{ "id": 1, "pile": "club" }, { "id": 2, "pile": "club" }] }); handle_move_items(body.to_string().as_bytes(), &resolver, &piles, &market).await; assert_eq!( market.get_listing("900000060").await.unwrap().state, @@ -1772,7 +1539,8 @@ mod tests { let resolver = MapResolver::new(&[(100004617, "core-uuid-7")]); let to_trade = json!({ "itemData": [{ "id": 100004617, "pile": "trade" }] }); - let resp = handle_move_items(to_trade.to_string().as_bytes(), &resolver, &piles, &market).await; + let resp = + handle_move_items(to_trade.to_string().as_bytes(), &resolver, &piles, &market).await; assert_eq!(resp.status, 200); let b = parse(&resp); assert_eq!(b["itemData"][0]["success"], true); @@ -1785,7 +1553,8 @@ mod tests { // Move back to the club. let to_club = json!({ "itemData": [{ "id": 100004617, "pile": "club" }] }); - let resp2 = handle_move_items(to_club.to_string().as_bytes(), &resolver, &piles, &market).await; + let resp2 = + handle_move_items(to_club.to_string().as_bytes(), &resolver, &piles, &market).await; assert_eq!(parse(&resp2)["itemData"][0]["success"], true); assert_eq!( piles.get("core-uuid-7").await.unwrap().as_deref(), diff --git a/openfut-utas-host/src/market_store.rs b/openfut-utas-host/src/market_store.rs index 479ed86..28539ec 100644 --- a/openfut-utas-host/src/market_store.rs +++ b/openfut-utas-host/src/market_store.rs @@ -29,7 +29,6 @@ //! `CHECK` constraint even though it is a transient intermediate — omitting it //! would make [`MarketStore::reserve_listing`] fail the constraint. -use std::collections::HashSet; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -285,10 +284,7 @@ impl MarketStore { .iter() .map(|r| r.get::("name")) .collect(); - for (col, decl) in [ - ("item_json", "TEXT"), - ("duration_secs", "INTEGER"), - ] { + for (col, decl) in [("item_json", "TEXT"), ("duration_secs", "INTEGER")] { if !existing.iter().any(|c| c == col) { sqlx::query(&format!("ALTER TABLE listings ADD COLUMN {col} {decl}")) .execute(&pool) @@ -620,30 +616,6 @@ impl MarketStore { .map_err(db)? .rows_affected()) } - - /// Core owned-instance ids whose listing state FORBIDS advertising them as an - /// unlisted pile member: `active` (a real auction is shown instead), `reserved` - /// (a sale is in flight) and `sold` (the card is gone). - /// - /// `cancelled` is deliberately absent: a cancelled listing means the card came - /// back to the pile and IS an unlisted member again. - /// - /// Needed because a pile row outlives its auction — after a sale the seller's - /// `trade` row is stale, and filtering on the active set alone would re-advertise - /// a sold card as an owned unlisted item. - pub async fn blocking_core_items(&self) -> Result, MarketError> { - let rows = sqlx::query( - "SELECT DISTINCT core_item_id FROM listings \ - WHERE core_item_id IS NOT NULL AND state IN ('active','reserved','sold')", - ) - .fetch_all(&self.pool) - .await - .map_err(db)?; - Ok(rows - .iter() - .filter_map(|r| r.get::, _>("core_item_id")) - .collect()) - } } #[cfg(test)] @@ -769,7 +741,18 @@ mod tests { async fn seed(store: &MarketStore, id: &str) -> Listing { store - .create_listing(id, "card_pl_001", None, None, None, 900, 2500, None, None, None) + .create_listing( + id, + "card_pl_001", + None, + None, + None, + 900, + 2500, + None, + None, + None, + ) .await .unwrap() } @@ -800,7 +783,18 @@ mod tests { seed(&store, "900000001").await; assert!(matches!( store - .create_listing("900000001", "card_pl_002", None, None, None, 1, 2, None, None, None) + .create_listing( + "900000001", + "card_pl_002", + None, + None, + None, + 1, + 2, + None, + None, + None + ) .await, Err(MarketError::Conflict) ));