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:
@@ -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 => {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user