From afadb13de49f04c4c797b1fbe885efcd47f42afa Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 17 Aug 2026 20:50:51 +0000 Subject: [PATCH] =?UTF-8?q?feat(market):=20PHASE=20C=20=E2=80=94=20expose?= =?UTF-8?q?=20every=20unlisted=20trade-pile=20item=20as=20tradeState=20"in?= =?UTF-8?q?active"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Q2 is LIVE-CONFIRMED (operator saw the inactive row under TRANSFER LIST with Start Price 0 and no Buy Now / Current Bid / timer, active rows still separate under LISTED ITEMS, and the state survived a full FUT exit/re-entry). Promoting from the bounded one-item probe to the real behaviour: the env gate is gone and /tradePile now enumerates the whole trade pile. Mechanism: read the pile (async), resolve each member to a shaped card (sync, because the identity/Core resolvers are not `Send`), then build the response (async). The core->wire lookup is `wire_for_owned_id`, which uses the identity store's NON-allocating `external_for` -- enumerating a pile is a READ and must never mint a wire id for an item the client has not seen. Items with no mapping, no Core record or no resolvable FIFA identity are skipped, never faked. Includes a bug the DIFFERENTIAL caught and unit tests did not: a pile row OUTLIVES its auction, so after a sale the seller's `trade` row is stale, and filtering only on ACTIVE listings re-advertised a SOLD card as an owned unlisted item. Suppression is now by listing state via `blocking_core_items()` -- active (real auction shown instead), reserved (sale in flight) and sold (card gone) -- while `cancelled` is deliberately NOT suppressed, because a cancelled listing means the card came back to the pile. New test covers all three plus the store-level rule. counts semantics deliberately unchanged: `count`/`selling` still track auctions only. 341 tests pass, 0 failed, clippy clean. Deployed: the 6 previously stranded pile items now render, alongside the 1 active listing, with Ronaldo correctly in /club and out of the pile. Body preserved as phase-c-full-pile-exposed.json. --- .../phase-c-full-pile-exposed.json | 434 ++++++++++++++++++ openfut-utas-host/src/lib.rs | 64 ++- openfut-utas-host/src/market.rs | 202 +++++--- openfut-utas-host/src/market_store.rs | 25 + 4 files changed, 643 insertions(+), 82 deletions(-) create mode 100644 docs/evidence/market-lifecycle-2026-08-17/phase-c-full-pile-exposed.json diff --git a/docs/evidence/market-lifecycle-2026-08-17/phase-c-full-pile-exposed.json b/docs/evidence/market-lifecycle-2026-08-17/phase-c-full-pile-exposed.json new file mode 100644 index 0000000..00780cc --- /dev/null +++ b/docs/evidence/market-lifecycle-2026-08-17/phase-c-full-pile-exposed.json @@ -0,0 +1,434 @@ +{ + "auctionInfo": [ + { + "bidState": "none", + "buyNowPrice": 15000, + "coinsProcessed": 0, + "currentBid": 0, + "expires": 1474, + "itemData": { + "assetId": 158023, + "attributeList": [ + { + "index": 0, + "value": 89 + }, + { + "index": 1, + "value": 90 + }, + { + "index": 2, + "value": 86 + }, + { + "index": 3, + "value": 96 + }, + { + "index": 4, + "value": 26 + }, + { + "index": 5, + "value": 61 + } + ], + "cardassetid": 158023, + "cardsubtypeid": 0, + "contract": 7, + "definitionId": 158023, + "discardValue": 1500, + "fitness": 99, + "id": 100000155, + "itemState": "listFS", + "itemType": "player", + "leagueId": 53, + "nation": 52, + "owners": 1, + "playStyle": 250, + "preferredPosition": "RW", + "rareflag": 1, + "rating": 93, + "resourceId": 158023, + "teamid": 241, + "untradeable": false + }, + "sellerEstablished": 1, + "sellerName": "CAGE", + "startingBid": 150, + "tradeId": 1000000155, + "tradeState": "active", + "watched": false + }, + { + "bidState": "none", + "buyNowPrice": 0, + "coinsProcessed": 0, + "currentBid": 0, + "expires": 0, + "itemData": { + "assetId": 172610, + "attributeList": [ + { + "index": 0, + "value": 69 + }, + { + "index": 1, + "value": 33 + }, + { + "index": 2, + "value": 47 + }, + { + "index": 3, + "value": 57 + }, + { + "index": 4, + "value": 80 + }, + { + "index": 5, + "value": 76 + } + ], + "cardassetid": 172610, + "cardsubtypeid": 0, + "contract": 7, + "definitionId": 172610, + "discardValue": 900, + "fitness": 99, + "id": 100004867, + "itemState": "free", + "itemType": "player", + "leagueId": 31, + "nation": 54, + "owners": 1, + "playStyle": 250, + "preferredPosition": "CB", + "rareflag": 1, + "rating": 80, + "resourceId": 172610, + "teamid": 54, + "untradeable": false + }, + "sellerEstablished": 1, + "sellerName": "CAGE", + "startingBid": 0, + "tradeId": 1000004867, + "tradeState": "inactive", + "watched": false + }, + { + "bidState": "none", + "buyNowPrice": 0, + "coinsProcessed": 0, + "currentBid": 0, + "expires": 0, + "itemData": { + "assetId": 163824, + "attributeList": [ + { + "index": 0, + "value": 70 + }, + { + "index": 1, + "value": 43 + }, + { + "index": 2, + "value": 56 + }, + { + "index": 3, + "value": 62 + }, + { + "index": 4, + "value": 82 + }, + { + "index": 5, + "value": 82 + } + ], + "cardassetid": 163824, + "cardsubtypeid": 0, + "contract": 7, + "definitionId": 163824, + "discardValue": 900, + "fitness": 99, + "id": 100004866, + "itemState": "free", + "itemType": "player", + "leagueId": 13, + "nation": 50, + "owners": 1, + "playStyle": 250, + "preferredPosition": "CB", + "rareflag": 1, + "rating": 83, + "resourceId": 163824, + "teamid": 7, + "untradeable": false + }, + "sellerEstablished": 1, + "sellerName": "CAGE", + "startingBid": 0, + "tradeId": 1000004866, + "tradeState": "inactive", + "watched": false + }, + { + "bidState": "none", + "buyNowPrice": 0, + "coinsProcessed": 0, + "currentBid": 0, + "expires": 0, + "itemData": { + "assetId": 177003, + "attributeList": [ + { + "index": 0, + "value": 74 + }, + { + "index": 1, + "value": 75 + }, + { + "index": 2, + "value": 86 + }, + { + "index": 3, + "value": 89 + }, + { + "index": 4, + "value": 72 + }, + { + "index": 5, + "value": 66 + } + ], + "cardassetid": 177003, + "cardsubtypeid": 0, + "contract": 7, + "definitionId": 177003, + "discardValue": 1500, + "fitness": 99, + "id": 100000122, + "itemState": "free", + "itemType": "player", + "leagueId": 53, + "nation": 10, + "owners": 1, + "playStyle": 250, + "preferredPosition": "CM", + "rareflag": 1, + "rating": 89, + "resourceId": 177003, + "teamid": 243, + "untradeable": false + }, + "sellerEstablished": 1, + "sellerName": "CAGE", + "startingBid": 0, + "tradeId": 1000000122, + "tradeState": "inactive", + "watched": false + }, + { + "bidState": "none", + "buyNowPrice": 0, + "coinsProcessed": 0, + "currentBid": 0, + "expires": 0, + "itemData": { + "assetId": 177003, + "attributeList": [ + { + "index": 0, + "value": 74 + }, + { + "index": 1, + "value": 75 + }, + { + "index": 2, + "value": 86 + }, + { + "index": 3, + "value": 89 + }, + { + "index": 4, + "value": 72 + }, + { + "index": 5, + "value": 66 + } + ], + "cardassetid": 177003, + "cardsubtypeid": 0, + "contract": 7, + "definitionId": 177003, + "discardValue": 1500, + "fitness": 99, + "id": 100000127, + "itemState": "free", + "itemType": "player", + "leagueId": 53, + "nation": 10, + "owners": 1, + "playStyle": 250, + "preferredPosition": "CM", + "rareflag": 1, + "rating": 89, + "resourceId": 177003, + "teamid": 243, + "untradeable": false + }, + "sellerEstablished": 1, + "sellerName": "CAGE", + "startingBid": 0, + "tradeId": 1000000127, + "tradeState": "inactive", + "watched": false + }, + { + "bidState": "none", + "buyNowPrice": 0, + "coinsProcessed": 0, + "currentBid": 0, + "expires": 0, + "itemData": { + "assetId": 158023, + "attributeList": [ + { + "index": 0, + "value": 89 + }, + { + "index": 1, + "value": 90 + }, + { + "index": 2, + "value": 86 + }, + { + "index": 3, + "value": 96 + }, + { + "index": 4, + "value": 26 + }, + { + "index": 5, + "value": 61 + } + ], + "cardassetid": 158023, + "cardsubtypeid": 0, + "contract": 7, + "definitionId": 158023, + "discardValue": 1500, + "fitness": 99, + "id": 100000059, + "itemState": "free", + "itemType": "player", + "leagueId": 53, + "nation": 52, + "owners": 1, + "playStyle": 250, + "preferredPosition": "RW", + "rareflag": 1, + "rating": 93, + "resourceId": 158023, + "teamid": 241, + "untradeable": false + }, + "sellerEstablished": 1, + "sellerName": "CAGE", + "startingBid": 0, + "tradeId": 1000000059, + "tradeState": "inactive", + "watched": false + }, + { + "bidState": "none", + "buyNowPrice": 0, + "coinsProcessed": 0, + "currentBid": 0, + "expires": 0, + "itemData": { + "assetId": 158023, + "attributeList": [ + { + "index": 0, + "value": 89 + }, + { + "index": 1, + "value": 90 + }, + { + "index": 2, + "value": 86 + }, + { + "index": 3, + "value": 96 + }, + { + "index": 4, + "value": 26 + }, + { + "index": 5, + "value": 61 + } + ], + "cardassetid": 158023, + "cardsubtypeid": 0, + "contract": 7, + "definitionId": 158023, + "discardValue": 1500, + "fitness": 99, + "id": 100000079, + "itemState": "free", + "itemType": "player", + "leagueId": 53, + "nation": 52, + "owners": 1, + "playStyle": 250, + "preferredPosition": "RW", + "rareflag": 1, + "rating": 93, + "resourceId": 158023, + "teamid": 241, + "untradeable": false + }, + "sellerEstablished": 1, + "sellerName": "CAGE", + "startingBid": 0, + "tradeId": 1000000079, + "tradeState": "inactive", + "watched": false + } + ], + "credits": 29843976, + "duplicateItemIdList": [], + "total": 7 +} diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index cd9bec6..f2df7de 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -1052,6 +1052,21 @@ 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 { @@ -2347,39 +2362,44 @@ impl Server { }) } EconomyRoute::MarketQuery => { - // BOUNDED Q2 EXPERIMENT (off unless the env names one wire item id): - // expose a SINGLE unlisted trade-pile item as a non-active auction - // record. Resolved synchronously here because the identity/Core - // resolvers are not `Send`, exactly like the market-list path. - let unlisted = std::env::var("OPENFUT_FIFA17_UNLISTED_PROBE") - .ok() - .and_then(|v| v.trim().parse::().ok()) - .filter(|id| *id > 0) - .and_then(|id| { - let lookup = CoreItemLookup { - core: self.core.as_ref(), - }; - crate::market::resolve_unlisted_candidate( - id, - self.resolver.as_ref(), - self.resolver.as_ref(), - &lookup, - self.entities.as_ref(), - ) - }); + // Unlisted trade-pile members are exposed as `tradeState: "inactive"` + // rows (LIVE-CONFIRMED; see docs/FIFA17_TRANSFER_MARKET_WIRE.md). + // + // Three steps, 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 response build is async again. let (bridge, market, econ, piles) = ( svc.bridge.clone(), svc.market.clone(), svc.econ.clone(), svc.piles.clone(), ); + let trade_ids = { + let p = piles.clone(); + bridge + .block_on(async move { p.list_by_pile("trade").await }) + .unwrap_or_default() + }; + let unlisted = if trade_ids.is_empty() { + Vec::new() + } else { + let lookup = 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(), + ) + }; bridge.block_on(async move { crate::market::handle_market_query( "active", econ.as_ref(), market.as_ref(), - piles.as_ref(), - unlisted.as_ref(), + &unlisted, ) .await }) diff --git a/openfut-utas-host/src/market.rs b/openfut-utas-host/src/market.rs index 64afcf9..5a5039a 100644 --- a/openfut-utas-host/src/market.rs +++ b/openfut-utas-host/src/market.rs @@ -279,27 +279,48 @@ pub struct UnlistedCandidate { pub item_json: Option, } -/// Resolve ONE wire item id to an [`UnlistedCandidate`]. Pure identity plus a single -/// Core inventory read, so it runs OFF the async runtime exactly like -/// [`resolve_market_list`] (the trait-object resolvers are not `Send`). -pub fn resolve_unlisted_candidate( - item_id: i64, - reverse: &dyn SquadWireResolver, +/// 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, -) -> Option { - let core_id = reverse.owned_id_for_wire(item_id)?; - let owned = items.owned_item(&core_id)?; - let item_json = resolver - .resolve(&owned) - .map(|id| shape_item(&owned, id, ent)) - .and_then(|card| serde_json::to_string(&card).ok()); - Some(UnlistedCandidate { - item_id, - core_id, - item_json, - }) +) -> 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. @@ -463,8 +484,7 @@ pub async fn handle_market_query( state: &str, econ: &dyn CoreEconomy, store: &MarketStore, - piles: &PileStore, - unlisted: Option<&UnlistedCandidate>, + unlisted: &[UnlistedCandidate], ) -> WireResponse { let listings = match store.query_listings(state).await { Ok(l) => l, @@ -475,27 +495,27 @@ pub async fn handle_market_query( .map(|l| auction_record_as(l, "listFS")) .collect(); - // BOUNDED Q2 EXPERIMENT: expose ONE unlisted trade-pile item as a non-active - // record. Every precondition is re-checked here so the row can never contradict - // the auction state: the item must actually be in the `trade` pile, and it must - // NOT already own a listing (otherwise it would duplicate a real auction). - if let Some(c) = unlisted { - let in_trade_pile = matches!(piles.get(&c.core_id).await, Ok(Some(p)) if p == "trade"); - let already_listed = listings - .iter() - .any(|l| l.core_item_id.as_deref() == Some(c.core_id.as_str())); - if in_trade_pile && !already_listed { - auctions.push(unlisted_record(c)); - eprintln!( - "utas-host owner=RUST route=market-query unlisted_candidate item_id={} state=inactive", - c.item_id - ); - } else { - eprintln!( - "utas-host owner=RUST route=market-query unlisted_candidate item_id={} skipped in_pile={in_trade_pile} listed={already_listed}", - c.item_id - ); + // 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 @@ -974,14 +994,6 @@ mod tests { (store, db) } - /// An empty pile store on its own temp file, for tradePile tests that do not - /// exercise the unlisted candidate. - async fn empty_piles() -> (PileStore, TempDb) { - let db = TempDb::new("piles"); - let piles = PileStore::open(db.path()).await.unwrap(); - (piles, db) - } - #[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 @@ -990,7 +1002,6 @@ mod tests { // 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; - let (piles, _pd) = empty_piles().await; let econ = CountingEconomy::with_balance(10_000); let c = UnlistedCandidate { item_id: 100_000_059, @@ -998,16 +1009,15 @@ mod tests { item_json: Some(r#"{"rating":93,"preferredPosition":"RW"}"#.into()), }; - // Not in the trade pile yet -> deliberately NOT exposed. - let body = parse(&handle_market_query("active", &econ, &store, &piles, Some(&c)).await); + // 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 item that is not in the trade pile must never be advertised as one" + "an empty candidate set adds nothing" ); - piles.set("core-unlisted", "trade").await.unwrap(); - let body = parse(&handle_market_query("active", &econ, &store, &piles, Some(&c)).await); + 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]; @@ -1034,7 +1044,6 @@ mod tests { // 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 (piles, _pd) = empty_piles().await; let econ = CountingEconomy::with_balance(10_000); store .create_listing( @@ -1051,18 +1060,91 @@ mod tests { ) .await .unwrap(); - piles.set("core-unlisted", "trade").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, &piles, Some(&c)).await); + 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, "the real auction only"); 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" + ); + } + 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) @@ -1114,7 +1196,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, &empty_piles().await.0, None).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); @@ -1211,7 +1293,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, &empty_piles().await.0, None).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); @@ -1261,7 +1343,7 @@ mod tests { let econ = CountingEconomy::with_balance(10_000); seed_listing(&store, "900000030", 2500).await; - let body = parse(&handle_market_query("active", &econ, &store, &empty_piles().await.0, None).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(); got.sort_unstable(); diff --git a/openfut-utas-host/src/market_store.rs b/openfut-utas-host/src/market_store.rs index db51f36..479ed86 100644 --- a/openfut-utas-host/src/market_store.rs +++ b/openfut-utas-host/src/market_store.rs @@ -29,6 +29,7 @@ //! `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; @@ -619,6 +620,30 @@ 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)]