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:
+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(),
|
||||
|
||||
Reference in New Issue
Block a user