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
+61
View File
@@ -269,3 +269,64 @@ perfectly while still being behaviourally wrong, because FIFA expects an **evolv
server-side state machine** rather than a static object that looks like one. The
frozen `expires` is the canonical example: every field was the right name, type and
vocabulary, and the feature was still broken.
---
## INVESTIGATION CLOSED — active own auction is not seller-actionable
**Do not reopen without new direct FIFA17.exe evidence contradicting the lifecycle
below.** The correct FIFA 17 lifecycle is:
```text
ACTIVE AUCTION tradeState=active, expires>0/counting down
-> seller CANNOT withdraw it through Transfer List actions
-> the item is not seller-actionable while the auction runs
EXPIRED UNSOLD tradeState=expired, expires=0
-> the item becomes actionable again
-> relist / return-to-club / other expired-item actions
```
### Confidence tags
```text
CONFIRMED — live FIFA17.exe:
active listing with ticking expires is NON-selectable
expired listing IS selectable
relisting makes it active and therefore non-selectable again
no cancel request is ever emitted by the client
expires must advance with the wall clock
no button prompt is offered on the Transfer List for an active auction
CONFIRMED — local RE corpus:
every CardsDLL-side prerequisite passes (IS_TRADING_ENABLED=1,
TRADE_PILE_SIZE=100, watch-list=50, item+0x49 tradeable)
MAY_BE_REMOVED is a CONSTANT 1 — it cannot be the gate and the server
cannot move it
the eight-flag array FUN_18003e370 publishes is the CLUB-CARD Actions menu
and contains NO transfer-auction cancellation flag
the auction parser is limited to the known twelve atoms
HISTORICAL FUT / FIFA17-era:
active auctions are committed until sale or expiry
expired Transfer List items expose relist/return actions
FIFA 17 trading guidance tells players to relist once auctions expire
UNKNOWN, and no longer required for backend fidelity:
the exact Flash/ActionScript branch that makes active cards non-selectable
```
### Explicitly out of scope now
Do NOT add auction fields, change `itemState`, revisit `tradeOwner`, probe
`MAY_BE_REMOVED`, add an active-auction cancel feature, or disassemble Flash in
order to make active auctions selectable. Three of those were already tried and
refuted; the rest are ruled out above.
### Return-to-club transition (implemented)
A pile move to `club` now ENDS any `active` auction on that item. Without it 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 silently appears to do nothing. `reserved` (mid-sale) and `sold`
rows are never touched, so a card can never be both sold and returned.
+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)]