diff --git a/docs/FIFA17_TRANSFER_MARKET_WIRE.md b/docs/FIFA17_TRANSFER_MARKET_WIRE.md index 93ac838..c62c242 100644 --- a/docs/FIFA17_TRANSFER_MARKET_WIRE.md +++ b/docs/FIFA17_TRANSFER_MARKET_WIRE.md @@ -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. diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 0cd7347..b9ce947 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -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 => { diff --git a/openfut-utas-host/src/market.rs b/openfut-utas-host/src/market.rs index ef24f9d..31ea198 100644 --- a/openfut-utas-host/src/market.rs +++ b/openfut-utas-host/src/market.rs @@ -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!( diff --git a/openfut-utas-host/src/market_store.rs b/openfut-utas-host/src/market_store.rs index 9b513ba..db51f36 100644 --- a/openfut-utas-host/src/market_store.rs +++ b/openfut-utas-host/src/market_store.rs @@ -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 { + 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)]