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:
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -44,7 +44,10 @@ impl ClientDataStore {
|
||||
|
||||
/// The stored blob for `<persona>:<key>`, or `None` if never written.
|
||||
pub fn get(&self, persona: i64, key: &str) -> Option<Value> {
|
||||
self.map.lock().get(&Self::compound_key(persona, key)).cloned()
|
||||
self.map
|
||||
.lock()
|
||||
.get(&Self::compound_key(persona, key))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Store `value` under `<persona>:<key>` and persist the whole map to disk.
|
||||
|
||||
@@ -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<i64> {
|
||||
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<i64>, limit: Option<i64>) -> (
|
||||
let total = items.len() as i64;
|
||||
let off = offset.unwrap_or(0).max(0) as usize;
|
||||
let paged: Vec<Value> = 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<crate::market::UnlistedCandidate> {
|
||||
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/<key>` — 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();
|
||||
|
||||
+112
-343
@@ -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<E: ReverseEntityResolver>(
|
||||
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<E: ReverseEntityResolver>(
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<E, F>(
|
||||
core_ids: &[String],
|
||||
wire_for_core: F,
|
||||
resolver: &dyn ItemIdentityResolver,
|
||||
items: &dyn OwnedItemLookup,
|
||||
ent: &E,
|
||||
) -> Vec<UnlistedCandidate>
|
||||
where
|
||||
E: ReverseEntityResolver,
|
||||
F: Fn(&str) -> Option<i64>,
|
||||
{
|
||||
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::<Value>(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<Value> = listings
|
||||
.iter()
|
||||
.map(auction_record)
|
||||
.collect();
|
||||
let auctions: Vec<Value> = 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<Value> = listings
|
||||
let auctions: Vec<Value> = 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<Value> = listings
|
||||
let auctions: Vec<Value> = 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<Vec<i64>> = if ids.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ids.iter().filter_map(|s| s.parse::<i64>().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<UnlistedCandidate> = ["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(),
|
||||
|
||||
@@ -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)
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user