market: stop advertising unlisted pile members as tradeState:"inactive"

RE of the FUT front-end closed the question the Actions-panel investigation left
open, and the answer retracts Q2 rather than completing it.

`tradeState` reaches exactly ONE native branch in CardsDLL — `cmp …,0x4` at
`0x18013e619`, "is it closed?" — and `inactive`(2) and `expired`(3) take the same
edge, producing bit-identical `flagA`/`flagB` (exhaustive 22-site census of
`[reg+0x88]` reads across the PE; confirmed live, both classes read glow=0
inbox=0). The value is then handed to the movie verbatim as the Flash property
`STATE`, and the action gate lives in the APT/ActionScript FUT front-end: the
trade-pile class partitions rows with `getCardsInAuction`/`isInActiveAuction`
(traces `initPile() - IN AUCTION:` / `- NOT IN AUCTION:`) and only auction rows
reach `PreCheckCardOptions` -> `handleTradeCardAction`. A non-auction row renders
and can never be acted on, which is exactly what the operator saw.

So the rows were never usable. "LIVE-CONFIRMED" established that they RENDER,
which is not the same claim, and I treated it as if it were.

The corpus said this before any of it was built —
`plan-2026-08-06-transfer-market.md:731-733`: "`inactive` decodes but no client
path treats it specially; do not emit it." The earlier note explaining that the
warning "was written about the PRESENTATION function" was motivated reasoning.
This also fires the corpus's own pre-registered falsifier E3 (:368-373).

Removed: the `inactive` projection from `GET …/tradePile` and `…/trade/status`,
`UnlistedCandidate`, `resolve_unlisted_pile`, `unlisted_record`,
`Server::resolve_trade_pile`, and the two helpers that existed only to feed them
(`MarketStore::blocking_core_items`, `Fifa17IdentityResolver::wire_for_owned_id`).
Unlisted trade-pile membership is now internal state with no wire expression.

Nothing is stranded: `/club` excludes only items with an ACTIVE listing, so an
unlisted pile member stays visible in the club, which is where the client can act
on it. Verified live after deploy — `/tradePile` total 7 -> 1 with zero `inactive`
rows, `/trade/status` resolving only the real auction, coins unchanged at
29,843,976, and all six former rows present in `/club` (1965 items).

Tests: 126 pass, fmt + clippy clean. Two guards replace the three tests that
pinned the old behaviour: `the_trade_pile_advertises_only_real_auctions` and
`trade_status_answers_only_about_real_auctions`.

NOT fixed here, deliberately: `itemData.itemState: "listFS"` is not a FIFA 17
token (0 occurrences in CardsDLL md5 4de3493131d7d2ff7f8b360c5ac9b655, 0 in
4.26 GiB of process memory, decodes to -1; the real value is `forSale` = 5, and
the Python oracle emits `listFS` too — which is why the differential never caught
it). `CARD_OFFERSTATE` is one of three unresolved action-gate candidates and
every actionable row observed carried -1, so that change ships alone with its own
live A/B.
This commit is contained in:
funman300
2026-08-17 23:14:35 +00:00
parent 3ce69f8951
commit f9ca901a50
6 changed files with 299 additions and 467 deletions
+25 -31
View File
@@ -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::<String, _>("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<HashSet<String>, 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::<Option<String>, _>("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)
));