fix(market): returning an item to the club ends its auction; close the panel probe

CLOSES the active-own-auction Actions-panel investigation. Live client plus the RE
corpus plus historical FUT behaviour all agree: an active auction is COMMITTED until
sale or expiry and is not seller-actionable, while an expired unsold item becomes
actionable (relist / return to club). Every observation fits that lifecycle --
active+frozen expires was non-selectable, expired was selectable and relisted fine,
relisting made it active and non-selectable again, and the client never emits a
cancel. Documented with confidence tags, and the dead ends are named so they are not
retried: MAY_BE_REMOVED is a constant 1, and the eight-flag array is the CLUB-CARD
menu with no auction-cancellation flag in it.

Implements the return-to-club transition that closure exposes. A pile move to `club`
now cancels any ACTIVE listing on that item, because the auction that put the card
in the pile has to end with it. Otherwise the pile reads `club` while the row stays
`active`, so the card is filtered out of /club (exclusion keys on active listings)
AND still rendered in the Transfer List: the move appears to do nothing. This is the
same limbo class as the earlier pile-vs-listing bug, found by reasoning about the
transition rather than by another live failure.

Scoped to `active` only: a `reserved` row is mid-sale and a `sold` row is already
gone, so cancelling either would let one card be both sold and returned. Two tests
cover exactly that boundary.

338 tests pass, 0 failed, clippy clean.
This commit is contained in:
funman300
2026-08-17 19:56:06 +00:00
parent 6cc22e5cc5
commit 4e31fb98a2
4 changed files with 206 additions and 8 deletions
+13 -3
View File
@@ -2300,11 +2300,21 @@ impl Server {
}
EconomyRoute::MatchEnd => handle_match_end(svc.econ.as_ref(), body),
EconomyRoute::MoveItems => {
let (bridge, piles, resolver) =
(svc.bridge.clone(), svc.piles.clone(), self.resolver.clone());
let (bridge, piles, resolver, market) = (
svc.bridge.clone(),
svc.piles.clone(),
self.resolver.clone(),
svc.market.clone(),
);
let body = body.to_vec();
bridge.block_on(async move {
crate::market::handle_move_items(&body, resolver.as_ref(), piles.as_ref()).await
crate::market::handle_move_items(
&body,
resolver.as_ref(),
piles.as_ref(),
market.as_ref(),
)
.await
})
}
EconomyRoute::MarketList => {
+105 -5
View File
@@ -620,10 +620,18 @@ pub async fn handle_market_buy(
/// the pile, never an ownership row. Returns the per-item verdict ack
/// (`{"itemData":[{id,pile,success}]}`); an unresolved id is `success:false`,
/// never a fabricated move.
///
/// A move to the `club` pile also ENDS any live auction on that item. This is the
/// return-to-club transition an expired transfer-list item takes, and the auction
/// that put it in the pile has to end with it: otherwise the pile reads `club`
/// while the listing row stays `active`, so the card is filtered out of `/club`
/// AND still rendered in the Transfer List, i.e. the move appears to do nothing.
/// A `reserved` (mid-sale) or `sold` row is never touched.
pub async fn handle_move_items(
body: &[u8],
resolver: &(dyn SquadWireResolver + Sync),
pile_store: &PileStore,
market: &MarketStore,
) -> WireResponse {
let b = parse_body(body);
let Some(items) = b.get("itemData").and_then(Value::as_array) else {
@@ -641,7 +649,23 @@ pub async fn handle_move_items(
.unwrap_or("club")
.to_string();
let success = match resolver.owned_id_for_wire(wire) {
Some(core_id) => pile_store.set(&core_id, &pile).await.is_ok(),
Some(core_id) => {
let moved = pile_store.set(&core_id, &pile).await.is_ok();
if moved && pile == "club" {
match market.cancel_active_for_core_item(&core_id).await {
Ok(n) if n > 0 => eprintln!(
"utas-host owner=RUST route=move-items wire={wire} pile=club auction_cancelled={n}"
),
Ok(_) => {}
// The pile move already succeeded and Core still owns the
// card; report the move honestly and log the stale auction.
Err(e) => eprintln!(
"utas-host WARN move-items wire={wire} pile=club auction cancel failed: {e:?}"
),
}
}
moved
}
None => false,
};
verdicts.push(json!({ "id": wire, "pile": pile, "success": success }));
@@ -1297,14 +1321,88 @@ mod tests {
// ---- move items --------------------------------------------------------
#[tokio::test]
async fn returning_an_expired_listing_to_the_club_ends_its_auction() {
// The return-to-club transition an EXPIRED transfer-list item takes. The
// auction that put the card in the pile has to end with the move: otherwise
// the pile reads `club` while the listing row stays `active`, so the card is
// filtered out of /club (exclusion keys on active listings) AND still
// rendered in the Transfer List — the move appears to do nothing at all.
let db = TempDb::new("moveclub");
let piles = PileStore::open(db.path()).await.unwrap();
let market = MarketStore::open(db.path()).await.unwrap();
let resolver = MapResolver::new(&[(100004617, "core-uuid-7")]);
market
.create_listing(
"900000050",
"169193",
Some("core-uuid-7"),
Some(100004617),
Some(169193),
400,
2500,
None,
None,
None,
)
.await
.unwrap();
let body = json!({ "itemData": [{ "id": 100004617, "pile": "club" }] });
let resp = handle_move_items(body.to_string().as_bytes(), &resolver, &piles, &market).await;
assert_eq!(parse(&resp)["itemData"][0]["success"], true);
assert_eq!(
market.get_listing("900000050").await.unwrap().state,
"cancelled",
"the auction ends with the return to club"
);
assert!(
market.query_listings("active").await.unwrap().is_empty(),
"no active listing remains, so /club stops hiding the card"
);
}
#[tokio::test]
async fn a_pile_move_never_disturbs_a_sale_in_flight() {
// A reserved row is mid-sale and a sold row is already gone. Cancelling
// either on a pile move would let one card be both sold and returned.
let db = TempDb::new("moveinflight");
let piles = PileStore::open(db.path()).await.unwrap();
let market = MarketStore::open(db.path()).await.unwrap();
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)
.await
.unwrap();
}
assert!(market.reserve_listing("900000060").await.unwrap());
market.reserve_listing("900000061").await.unwrap();
market.complete_sale("900000061").await.unwrap();
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,
"reserved",
"an in-flight sale is untouched"
);
assert_eq!(
market.get_listing("900000061").await.unwrap().state,
"sold",
"a completed sale is untouched"
);
}
#[tokio::test]
async fn move_between_club_and_tradepile() {
let db = TempDb::new("move");
let piles = PileStore::open(db.path()).await.unwrap();
let market = MarketStore::open(db.path()).await.unwrap();
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).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);
@@ -1317,7 +1415,7 @@ 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).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(),
@@ -1329,9 +1427,10 @@ mod tests {
async fn move_unknown_wire_is_success_false() {
let db = TempDb::new("moveunk");
let piles = PileStore::open(db.path()).await.unwrap();
let market = MarketStore::open(db.path()).await.unwrap();
let resolver = MapResolver::new(&[]);
let body = json!({ "itemData": [{ "id": 42, "pile": "club" }] });
let resp = handle_move_items(body.to_string().as_bytes(), &resolver, &piles).await;
let resp = handle_move_items(body.to_string().as_bytes(), &resolver, &piles, &market).await;
let b = parse(&resp);
assert_eq!(b["itemData"][0]["success"], false);
assert_eq!(piles.get("core-uuid-7").await.unwrap(), None);
@@ -1345,8 +1444,9 @@ mod tests {
let resolver = MapResolver::new(&[(7, "core-7")]);
{
let piles = PileStore::open(path).await.unwrap();
let market = MarketStore::open(path).await.unwrap();
let body = json!({ "itemData": [{ "id": 7, "pile": "purchased" }] });
handle_move_items(body.to_string().as_bytes(), &resolver, &piles).await;
handle_move_items(body.to_string().as_bytes(), &resolver, &piles, &market).await;
}
let reopened = PileStore::open(path).await.unwrap();
assert_eq!(
+27
View File
@@ -592,6 +592,33 @@ impl MarketStore {
}
self.get_listing(listing_id).await
}
/// Cancel any `active` listing held by a Core owned item, returning how many
/// rows were cancelled (0 when the item has no live auction).
///
/// This is the RETURN-TO-CLUB transition: the client sends a pile move for an
/// expired transfer-list item, and the auction that put it there has to end with
/// it. Without this the pile says `club` while the listing row stays `active`,
/// so the card is still filtered out of `/club` AND still rendered in the
/// Transfer List — the item appears not to move at all.
///
/// Deliberately scoped to `active`: a `reserved` row is mid-sale and a `sold`
/// row is already gone, and cancelling either would let a card be both sold and
/// returned.
pub async fn cancel_active_for_core_item(
&self,
core_item_id: &str,
) -> Result<u64, MarketError> {
Ok(sqlx::query(
"UPDATE listings SET state = 'cancelled' \
WHERE core_item_id = ? AND state = 'active'",
)
.bind(core_item_id)
.execute(&self.pool)
.await
.map_err(db)?
.rows_affected())
}
}
#[cfg(test)]