//! FIFA 17 transfer-market + item-move handlers, Core-backed and durable. //! //! These implement the market and FutMoveCard routes against two authorities: //! Core owns coins + item ownership (via [`CoreEconomy`]); the host owns the //! durable *listing* lifecycle ([`MarketStore`]) and *pile* location //! ([`PileStore`]). They are fail-closed like the rest of the economy cluster: //! a Core failure yields a controlled response and NEVER a Python fallback that //! would reintroduce a second writer. //! //! ## Synthetic-seller model //! //! A buy-now debits the buyer and MINTS the won card into their club //! (`CoreEconomy::purchase_item`) — there is no real counterparty and no seller //! credit, matching the single-player oracle. The buy is race-safe: the listing //! is reserved with an atomic compare-and-swap BEFORE any coin movement, so two //! concurrent buyers resolve to exactly one debit and one sold listing; the //! loser sees a closed (empty) auction. On any Core failure the reservation is //! rolled back so the listing becomes buyable again (no coins lost, no phantom //! sale). //! //! ## Piles are not ownership //! //! `handle_move_items` records only the pile keyed by the Core owned-instance id //! (reverse-resolved from the FIFA wire id). Core stays the sole ownership //! authority — the move never mints, transfers, or duplicates an inventory row. use serde_json::{json, Value}; use openfut_adapter_fifa17::fut::entities::ReverseEntityResolver; use openfut_adapter_fifa17::fut::item::{shape_item, ItemIdentityResolver}; use openfut_adapter_fifa17::fut::non_economy; use openfut_adapter_fifa17::fut::squad::SquadWireResolver; use crate::economy_store::OwnedItemLookup; use crate::market_store::{now_secs, Listing, MarketError, MarketStore}; use crate::pile_store::PileStore; use crate::{CoreEconomy, CoreError, WireResponse}; /// FIFA trade-id numbering base (mirrors the oracle's `_TRADE_ID_BASE`). const TRADE_ID_BASE: i64 = 900_000_000; fn json_body(status: u16, body: &Value) -> WireResponse { let bytes = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec()); WireResponse { status, headers: vec![("Content-Type".to_string(), "application/json".to_string())], body: bytes, } } fn ok_json(body: &Value) -> WireResponse { json_body(200, body) } fn parse_body(body: &[u8]) -> Value { serde_json::from_slice(body).unwrap_or_else(|_| json!({})) } /// Extract the numeric trade id that follows `/trade/` in a path, as a string /// (the listing id space is numeric-string). fn trade_id_from_path(path: &str) -> Option { let tail = path.split("/trade/").nth(1)?; let digits: String = tail.chars().take_while(|c| c.is_ascii_digit()).collect(); if digits.is_empty() { None } else { Some(digits) } } /// Shape one listing into the FIFA auction record (0x18013e410 fields), sourced /// from durable listing state rather than a hardcoded sample pool. /// /// `itemData` MUST be a full card object — the same proven-safe shape that renders /// club/squad cards (the Python oracle's tradePile reuses its club card verbatim). /// A 4-field stub gives the client's card view-model nothing to draw, so the /// Transfer List shows a row with no visible card. The full card comes from the /// snapshot persisted at listing time; a row written before snapshots existed /// degrades to the stub (honest, not fabricated). /// /// `item_state` overrides the card's `itemState`: the seller's own pile uses /// `listFS` (list-for-sale), market search results use `forSale` — the oracle /// distinguishes these, so the caller passes the one its screen needs. fn auction_record_as(l: &Listing, item_state: &str) -> Value { let trade_id: i64 = l.listing_id.parse().unwrap_or(0); // resourceId is the FIFA wire identity the client listed (never the Core // card id). 0 means "no art", a valid int — never a fabricated FIFA asset. let resource = l.wire_resource_id.or(l.wire_item_id).unwrap_or(0); let item_id = l.wire_item_id.unwrap_or(trade_id); // `expires` is SECONDS REMAINING (a 64-bit int), never an epoch, and the // client renders a LIVE COUNTDOWN from it and expects it to reach 0. A frozen // constant is therefore wrong on the wire even though it renders: the auction // never appears to age. Derived from the stored creation time plus the // client-supplied listing duration. let expires = l.expires_in_secs(now_secs()); // An unsold auction whose clock has run out reads `expired`/`none` with // `expires: 0` — that is FIFA 17's relistable state. Both vocabularies are // closed sets read out of the client: `tradeState` decodes through a table // walk (`active=1 inactive=2 expired=3 closed=4`, anything else -1) and // `bidState` through a strcmp ladder (`none=0 outbid=1 highest=2 buyNow=3`, // anything else silently `none`). NEVER invent a state string — an // unrecognised one is swallowed as `none` and produces a plausible-looking // but wrong UI. There is no `won`, `lost` or `sold`. // // This is a pure PROJECTION: no row is mutated, so nothing here can race the // economy or need a background sweeper. let (trade_state, bid_state, current_bid) = match l.state.as_str() { "active" if expires == 0 => ("expired", "none", 0), "active" => ("active", "none", 0), _ => ("closed", "highest", l.buy_now_price), }; let item_data = l .item_json .as_deref() .and_then(|s| serde_json::from_str::(s).ok()) .filter(Value::is_object) .map(|mut card| { // Keep the wire identity and presentation state authoritative here. card["id"] = json!(item_id); card["itemState"] = json!(item_state); card["untradeable"] = json!(false); card }) .unwrap_or_else(|| { json!({ "id": item_id, "resourceId": resource, "itemState": item_state, "untradeable": false, }) }); // EXACTLY the twelve fields FIFA 17's auctionInfo deserializer (0x18013e410) // reads. Everything else falls through to its value-SKIP at 0x180135ff0, so an // extra key is not "harmless richness" — it is dead weight that misleads the // next reader about what the client consumes. // // In particular `tradeOwner` / `sellerId` / `offers` are NOT read by FIFA 17. // They were added here on the strength of contemporaneous FIFA 17 libraries // and are refuted by the PE's own atom table (see // docs/FIFA17_TRANSFER_MARKET_WIRE.md): the client cannot be told "this // auction is yours" through the record at all, so ownership is NOT the gate on // the Transfer List Actions panel. json!({ "tradeId": trade_id, "itemData": item_data, "tradeState": trade_state, "buyNowPrice": l.buy_now_price, "startingBid": l.start_price, "currentBid": current_bid, "bidState": bid_state, "expires": expires, // Bounded copy, max 30 chars. The oracle stamps the player's persona here // and `fut_account.py` annotates that property as "UTAS sellerName", so // EA's house name would make the player's own listing look foreign. "sellerName": l .owner .clone() .unwrap_or_else(|| non_economy::PERSONA_DISPLAY_NAME.to_string()), "sellerEstablished": 1, "watched": false, "coinsProcessed": 0, }) } /// 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" }; auction_record_as(l, state) } /// Run a BLOCKING closure — the blocking Core client — on a fresh OS thread that /// has NO Tokio runtime entered, then block until it finishes. The market /// handlers are `async` and driven on the shared runtime by the host bridge, but /// `CoreEconomy` is a `reqwest::blocking` client: calling it while a runtime is /// entered panics (`reqwest::blocking::wait::enter`). Hopping to a plain thread /// keeps the Core call off the runtime, so blocking is legal. Cheap relative to /// the network round-trip it guards. fn off_runtime(f: F) -> T where F: FnOnce() -> T + Send, T: Send, { std::thread::scope(|s| s.spawn(f).join().expect("off-runtime Core call panicked")) } /// Current Core balance as the `credits` field, or 0 if Core is unreachable /// (used only to decorate an already-decided response; the transactional path /// never trusts a fabricated balance). fn credits_or_zero(econ: &dyn CoreEconomy) -> i64 { off_runtime(|| econ.balance()).unwrap_or(0) } /// A FutISStart POST resolved to a persistable listing — all owned/`Send` data, /// so it can cross the async persist boundary. Built by [`resolve_market_list`] /// on the caller's thread from Core inventory (the request body carries only the /// wire item id, never the resourceId or Core card_id). pub struct ResolvedListing { pub item_id: i64, pub core_id: String, pub card_id: String, pub resource_id: Option, pub start: i64, pub buy_now: i64, pub seller: Option, /// The full shaped FIFA card (`itemData`) snapshot for the auction record. /// `None` only when the item has no resolvable FIFA identity (never faked). pub item_json: Option, /// Listing duration in seconds from the client's body. `None` when the client /// omits it, which falls back to the store's default. pub duration: Option, } /// Resolve a `/auctionhouse` POST (FutISStart) body to a [`ResolvedListing`], /// SERVER-SIDE: the client's body carries only the wire item id, so the owned /// card's Core `card_id` (minted on a synthetic buy) and FIFA `resourceId` (the /// auction record) come from Core inventory. Returns `None` when the body names /// no item OR the id does not reverse-resolve to an owned card — either way the /// caller acks a fresh trade id and persists nothing (Core stays the ownership /// authority; a phantom listing would mint a card the buy preflight rejects). /// Pure identity + a single Core inventory read; run it OFF the async runtime. pub fn resolve_market_list( body: &[u8], reverse: &dyn SquadWireResolver, resolver: &dyn ItemIdentityResolver, items: &dyn OwnedItemLookup, ent: &E, ) -> Option { let b = parse_body(body); let item_data = b.get("itemData"); let item_id = item_data .and_then(|d| d.get("id")) .and_then(Value::as_i64) .or_else(|| b.get("itemId").and_then(Value::as_i64))?; let core_id = reverse.owned_id_for_wire(item_id)?; let owned = items.owned_item(&core_id)?; // Shape the FULL card ONCE, here, with the same shaper `/club` and the squad // projection use — so the auction record renders identically to the club card. // A listing is a snapshot; storing it avoids re-resolving on every tradePile // poll and keeps the (non-Send) resolvers off the async persist path. let identity = resolver.resolve(&owned); let resource_id = identity.map(|id| id.resource_id as i64); let item_json = identity .map(|id| shape_item(&owned, id, ent)) .and_then(|card| serde_json::to_string(&card).ok()); Some(ResolvedListing { item_id, core_id, card_id: owned.card_id, 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), 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 // countdown never moved and an auction could never run out. duration: b.get("duration").and_then(Value::as_i64).filter(|d| *d > 0), }) } /// `/auctionhouse` — search (GET), list-for-sale (POST FutISStart), relist (PUT). /// /// * GET returns the durable active auctions plus the FutGetAuctionCount ints, /// in the oracle's `_market_body` shape. /// * POST persists the pre-resolved listing (see [`resolve_market_list`]) and /// returns `{"id": tradeId}`; an unresolved item acks a fresh id, persisting /// nothing. /// * PUT (relist-all) is an ack `{}`. pub async fn handle_market_list( method: &str, resolved: Option, econ: &dyn CoreEconomy, store: &MarketStore, ) -> WireResponse { match method { "POST" => { let Some(r) = resolved else { // No item, or the id did not resolve to an owned card: fresh-id ack. eprintln!( "utas-host owner=RUST route=market-list POST listed=false reason=unresolved" ); return ok_json(&json!({ "id": TRADE_ID_BASE })); }; // Trade-id space is offset from the wire item id, so each owned item // maps to a unique, stable auction id (no modular wraparound). let trade_id = TRADE_ID_BASE + r.item_id; let listing_id = trade_id.to_string(); match store .create_listing( &listing_id, &r.card_id, Some(&r.core_id), Some(r.item_id), r.resource_id, r.start, r.buy_now, r.seller.as_deref(), r.item_json.as_deref(), r.duration, ) .await { Ok(_) | Err(MarketError::Conflict) => { eprintln!( "utas-host owner=RUST route=market-list POST item_id={} listed=true trade_id={trade_id}", r.item_id ); ok_json(&json!({ "id": trade_id })) } Err(_) => json_body(503, &json!({ "error": "market_store" })), } } "PUT" => ok_json(&json!({})), _ => { // GET: browse the durable active auctions. let listings = match store.query_listings("active").await { Ok(l) => l, Err(_) => return json_body(503, &json!({ "error": "market_store" })), }; let auctions: Vec = listings .iter() .map(auction_record) .collect(); ok_json(&json!({ "auctionInfo": auctions, "credits": credits_or_zero(econ), "total": auctions.len(), "duplicateItemIdList": [], "count": 0, "maxAuctionsAllowed": 100, "offered": 0, "selling": auctions.len(), "sold": 0, })) } } } /// Query listings in a given `state` (e.g. the user's own sale pile is the /// `active` set). Returns the oracle's tradePile shape. /// /// 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. pub async fn handle_market_query( state: &str, econ: &dyn CoreEconomy, store: &MarketStore, ) -> WireResponse { let listings = match store.query_listings(state).await { Ok(l) => l, Err(_) => return json_body(503, &json!({ "error": "market_store" })), }; let auctions: Vec = listings .iter() .map(|l| auction_record_as(l, "listFS")) .collect(); // 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 // omitting `duplicateItemIdList`; `[]` is the safe, recommended value. ok_json(&json!({ "auctionInfo": auctions, "credits": credits_or_zero(econ), "total": auctions.len(), "duplicateItemIdList": [], })) } /// `GET …/tradePile/counts` — FutGetAuctionCount (the auction TALLY), a DISTINCT /// deserializer from `/tradePile`. It reads exactly five SCALAR INTS — `count`, /// `maxAuctionsAllowed`, `offered`, `selling`, `sold` — and skips anything else, /// so answering it with the `auctionInfo` listing body leaves every count at its /// constructor default (0): the hub tile shows a listing while the Transfer List /// screen shows none. All five being ints means there is no container-type /// freeze risk. They are the only inputs to IS_MAX_AUCTIONS, so /// `maxAuctionsAllowed = 100` with `selling < 100` keeps the listing cap open. /// A store read failure degrades to zeros (cosmetic tally, never fail-closed). pub async fn handle_market_counts(store: &MarketStore) -> WireResponse { let n = store .query_listings("active") .await .map(|l| l.len() as i64) .unwrap_or(0); ok_json(&json!({ "count": n, "maxAuctionsAllowed": 100, "offered": 0, "selling": n, "sold": 0, })) } /// `DELETE /ut/delete/game//trade/` — remove a listing from the sale /// pile. Cancels the `active` listing once; the oracle always acks `{}`, so a /// missing/already-closed listing is not surfaced as an error to the client /// (the sale pile simply no longer shows it). pub async fn handle_market_cancel( path: &str, owner: Option<&str>, store: &MarketStore, ) -> WireResponse { if let Some(id) = trade_id_from_path(path) { // Idempotent from the client's view: NotFound / already-closed still acks. let _ = store.cancel_listing(&id, owner).await; } ok_json(&json!({})) } /// `GET …/trade/status` — live auction-state refresh for the rows a screen is /// showing. The Transfer List polls this CONTINUOUSLY while it is open. /// /// This route previously fell through to the buy/view arm, where /// `trade_id_from_path("trade/status")` cannot parse an id, so every poll was /// answered with an EMPTY `auctionInfo` — the client kept asking for the state of /// its own listings and was told, repeatedly, that there was none. Observed /// directly in the live logs (`route=economy … path=…/trade/status` on repeat), /// so unlike the `tradeOwner` change this is a CONFIRMED defect, not a candidate. /// /// `tradeIds` is a comma-separated filter; unknown ids are simply absent from the /// reply rather than erroring. With no filter we answer with the player's own /// active pile, which is the only auction set this single-account market has. pub async fn handle_market_status( query: Option<&str>, econ: &dyn CoreEconomy, store: &MarketStore, ) -> WireResponse { let ids = trade_ids_from_query(query); let listings = if ids.is_empty() { match store.query_listings("active").await { Ok(l) => l, Err(_) => return json_body(503, &json!({ "error": "market_store" })), } } else { let mut found = Vec::with_capacity(ids.len()); for id in &ids { if let Ok(l) = store.get_listing(id).await { found.push(l); } } found }; let auctions: Vec = listings .iter() .map(|l| auction_record_as(l, "listFS")) .collect(); eprintln!( "utas-host owner=RUST route=market-status requested={} returned={} query={}", ids.len(), auctions.len(), query.unwrap_or("") ); ok_json(&json!({ "auctionInfo": auctions, "credits": credits_or_zero(econ), })) } /// Parse `tradeIds=1,2,3` (also accepts repeated `tradeIds=`) out of a raw query /// string. Non-numeric entries are skipped rather than failing the whole poll. fn trade_ids_from_query(query: Option<&str>) -> Vec { let Some(q) = query else { return Vec::new(); }; q.split('&') .filter_map(|kv| kv.split_once('=')) .filter(|(k, _)| k.eq_ignore_ascii_case("tradeIds") || k.eq_ignore_ascii_case("tradeId")) .flat_map(|(_, v)| v.split(',')) .filter(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())) .map(str::to_string) .collect() } /// `/trade/` — view (GET) or buy-now / bid (POST/PUT). Buy-now is the /// synthetic-seller path: reserve (CAS) → Core `purchase_item` mint+debit → /// complete the sale; any Core failure rolls the reservation back. pub async fn handle_market_buy( method: &str, path: &str, body: &[u8], econ: &dyn CoreEconomy, store: &MarketStore, ) -> WireResponse { let Some(id) = trade_id_from_path(path) else { return ok_json(&json!({ "auctionInfo": [], "credits": credits_or_zero(econ) })); }; if method != "POST" && method != "PUT" { // GET: view one auction. let rec = match store.get_listing(&id).await { Ok(l) => vec![auction_record(&l)], Err(_) => vec![], }; return ok_json(&json!({ "auctionInfo": rec, "credits": credits_or_zero(econ) })); } let listing = match store.get_listing(&id).await { Ok(l) => l, // Unknown/closed auction: empty body (client treats as gone), never an error. Err(_) => return ok_json(&json!({ "auctionInfo": [], "credits": credits_or_zero(econ) })), }; let b = parse_body(body); let bid = b .get("bid") .and_then(Value::as_i64) .unwrap_or(listing.buy_now_price); // A simple bid below buy-now: we are the sole bidder — echo the raised bid, // no coin movement, no reservation. if bid < listing.buy_now_price { let mut rec = auction_record(&listing); rec["currentBid"] = json!(bid); rec["bidState"] = json!("highest"); return ok_json(&json!({ "auctionInfo": [rec], "credits": credits_or_zero(econ) })); } // BUY NOW. Reserve first (atomic CAS): only one concurrent buyer wins. match store.reserve_listing(&id).await { Ok(true) => {} // Lost the race / already reserved / sold / cancelled: closed auction. Ok(false) | Err(MarketError::NotFound) => { return ok_json(&json!({ "auctionInfo": [], "credits": credits_or_zero(econ) })) } Err(_) => return json_body(503, &json!({ "error": "market_store" })), } let price = listing.buy_now_price; // Pre-check funds so an affordability failure is a clean 461, distinct from a // Core transport failure (503). Core's purchase is still the atomic authority. let balance = match off_runtime(|| econ.balance()) { Ok(b) => b, Err(_) => { let _ = store.rollback_reservation(&id).await; return json_body(503, &json!({ "error": "core_unreachable" })); } }; if balance < price { let _ = store.rollback_reservation(&id).await; return json_body( 461, &json!({ "reason": "insufficient_coins", "credits": balance }), ); } // Mint the won card into the buyer's club. A listing sells at most once (the // CAS guarantees it), so a deterministic minted id is safe and idempotent. let minted_item_id = format!("market-buy:{id}"); match off_runtime(|| econ.purchase_item(price, &minted_item_id, &listing.card_id)) { Ok(new_balance) => { // Core has taken the coins and minted the item; finalise the listing. // If completing fails, Core is still authoritative — report success. if let Err(e) = store.complete_sale(&id).await { eprintln!("utas-host WARN market complete_sale({id}) after mint failed: {e}"); } let mut rec = auction_record(&listing); rec["tradeState"] = json!("closed"); rec["bidState"] = json!("highest"); rec["currentBid"] = json!(price); rec["itemData"]["itemState"] = json!("free"); ok_json(&json!({ "auctionInfo": [rec], "credits": new_balance })) } // Insufficient funds surfaced by Core (concurrent debit) -> 461. Err(CoreError::Status(400)) => { let _ = store.rollback_reservation(&id).await; json_body( 461, &json!({ "reason": "insufficient_coins", "credits": balance }), ) } // Any other Core failure: fail closed, restore the listing. Err(_) => { let _ = store.rollback_reservation(&id).await; json_body(503, &json!({ "error": "core_unreachable" })) } } } /// `PUT /ut/game//item` — FutMoveCard. Move each requested owned item to its /// target pile via [`PileStore`], reverse-resolving the FIFA wire id to the Core /// owned-instance id. Core remains the ownership authority — this records ONLY /// 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. pub async fn handle_move_items( body: &[u8], resolver: &(dyn SquadWireResolver + Sync), pile_store: &PileStore, ) -> WireResponse { let b = parse_body(body); let Some(items) = b.get("itemData").and_then(Value::as_array) else { return ok_json(&json!({ "itemData": [] })); }; let mut verdicts = Vec::with_capacity(items.len()); for item in items { let Some(wire) = item.get("id").and_then(Value::as_i64) else { continue; }; let pile = item .get("pile") .and_then(Value::as_str) .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(), None => false, }; verdicts.push(json!({ "id": wire, "pile": pile, "success": success })); } ok_json(&json!({ "itemData": verdicts })) } #[cfg(test)] mod tests { use super::*; use crate::{EconomyEntitlement, EconomyGrantItem, EconomyPurchase}; use std::collections::HashMap; use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; // ---- temp DB helpers --------------------------------------------------- struct TempDb(String); impl TempDb { fn new(tag: &str) -> Self { static N: AtomicU64 = AtomicU64::new(0); let n = N.fetch_add(1, Ordering::SeqCst); let path = std::env::temp_dir() .join(format!("ofut-market-h-{tag}-{}-{n}.db", std::process::id())); TempDb(path.to_string_lossy().into_owned()) } fn path(&self) -> &str { &self.0 } } impl Drop for TempDb { fn drop(&mut self) { for suffix in ["", "-wal", "-shm"] { let _ = std::fs::remove_file(format!("{}{suffix}", self.0)); } } } // ---- CoreEconomy double that debits coins and counts purchase calls ---- struct CountingEconomy { balance: AtomicI64, purchase_calls: AtomicUsize, fail: bool, } impl CountingEconomy { fn with_balance(balance: i64) -> Self { CountingEconomy { balance: AtomicI64::new(balance), purchase_calls: AtomicUsize::new(0), fail: false, } } fn failing() -> Self { CountingEconomy { balance: AtomicI64::new(0), purchase_calls: AtomicUsize::new(0), fail: true, } } } impl CoreEconomy for CountingEconomy { fn balance(&self) -> Result { if self.fail { Err(CoreError::Status(500)) } else { Ok(self.balance.load(Ordering::SeqCst)) } } fn entitlements(&self) -> Result, CoreError> { Ok(vec![]) } fn purchase_entitlement( &self, _cost: i64, _definition_id: &str, ) -> Result { Err(CoreError::Status(500)) } fn redeem_entitlement( &self, _entitlement_id: &str, _items: &[EconomyGrantItem], ) -> Result { Err(CoreError::Status(500)) } fn sell_item(&self, _item_id: &str, _price: i64) -> Result { Err(CoreError::Status(500)) } fn grant_reward(&self, _amount: i64) -> Result { Err(CoreError::Status(500)) } fn purchase_item( &self, cost: i64, _item_id: &str, _card_id: &str, ) -> Result { self.purchase_calls.fetch_add(1, Ordering::SeqCst); if self.fail { return Err(CoreError::Status(500)); } // Atomic debit: reject (and do NOT debit) if it would go negative, // mirroring Core's BadRequest(400) on insufficient funds. let mut cur = self.balance.load(Ordering::SeqCst); loop { if cur < cost { return Err(CoreError::Status(400)); } match self.balance.compare_exchange( cur, cur - cost, Ordering::SeqCst, Ordering::SeqCst, ) { Ok(_) => return Ok(cur - cost), Err(actual) => cur = actual, } } } fn purchase_items( &self, _cost: i64, _items: &[EconomyGrantItem], ) -> Result { Err(CoreError::Status(500)) } } // ---- SquadWireResolver double ----------------------------------------- struct MapResolver(HashMap); impl MapResolver { fn new(pairs: &[(i64, &str)]) -> Self { MapResolver(pairs.iter().map(|(w, c)| (*w, c.to_string())).collect()) } } impl SquadWireResolver for MapResolver { fn owned_id_for_wire(&self, wire: i64) -> Option { self.0.get(&wire).cloned() } } // ---- ItemIdentityResolver + OwnedItemLookup doubles ------------------- use openfut_adapter_fifa17::fut::item::{CoreOwnedItem, Fifa17Identity}; /// Owned-inventory double: core id -> CoreOwnedItem. struct FakeItems(HashMap); impl OwnedItemLookup for FakeItems { fn owned_item(&self, core_id: &str) -> Option { self.0.get(core_id).cloned() } } /// Identity double: resolves every owned item to a fixed FIFA resourceId. struct FixedIdentity(u32); impl ItemIdentityResolver for FixedIdentity { fn resolve(&self, _item: &CoreOwnedItem) -> Option { Some(Fifa17Identity { item_id: 0, asset_id: self.0, resource_id: self.0, rareflag: 1, }) } } /// A CoreOwnedItem with the given core id + card id (other fields dummy). fn owned(core_id: &str, card_id: &str) -> CoreOwnedItem { CoreOwnedItem { owned_card_id: core_id.to_string(), card_id: card_id.to_string(), rating: 84, position: "ST".to_string(), nation: String::new(), league: String::new(), club: String::new(), attributes: [80, 80, 80, 80, 80, 80], } } /// Entity double: no reverse entity mappings, so shaped cards carry ids 0 /// (a valid int — the shaper never fabricates an entity id). struct NoEntities; impl ReverseEntityResolver for NoEntities { fn league_id(&self, _name: &str) -> Option { None } fn nation_id(&self, _name: &str) -> Option { None } fn team_id(&self, _name: &str) -> Option { None } } async fn store_at(tag: &str) -> (MarketStore, TempDb) { let db = TempDb::new(tag); let store = MarketStore::open(db.path()).await.unwrap(); (store, db) } 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) .await .unwrap(); } fn parse(resp: &WireResponse) -> Value { serde_json::from_slice(&resp.body).unwrap() } // ---- listing / auctionhouse ------------------------------------------- #[tokio::test] async fn list_post_persists_and_returns_trade_id() { let (store, _d) = store_at("post").await; let econ = CountingEconomy::with_balance(10_000); // The client body carries only the wire item id; the server resolves the // owned card's card_id + resourceId from Core inventory. let body = json!({ "itemData": { "id": 100004617 }, "startingBid": 300, "buyNowPrice": 2500 }); let reverse = MapResolver::new(&[(100004617, "core-1")]); let items = FakeItems( [("core-1".to_string(), owned("core-1", "169193"))] .into_iter() .collect(), ); let ident = FixedIdentity(169193); let resolved = resolve_market_list( body.to_string().as_bytes(), &reverse, &ident, &items, &NoEntities, ); let resp = handle_market_list("POST", resolved, &econ, &store).await; assert_eq!(resp.status, 200); let trade_id = parse(&resp)["id"].as_i64().unwrap(); assert_eq!(trade_id, TRADE_ID_BASE + 100004617); // Persisted with the resolved Core card_id + wire resourceId, browsable. let listed = store.get_listing(&trade_id.to_string()).await.unwrap(); assert_eq!(listed.buy_now_price, 2500); assert_eq!(listed.card_id, "169193"); assert_eq!(listed.wire_resource_id, Some(169193)); // The listing carries the FULL shaped card snapshot, not a 4-field stub: // a stub leaves the Transfer List with an unrenderable row (the live bug). let snap: Value = serde_json::from_str(listed.item_json.as_deref().unwrap()).unwrap(); assert_eq!(snap["rating"], 84); 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 rec = parse(&pile)["auctionInfo"][0].clone(); assert_eq!(rec["itemData"]["itemState"], "listFS"); assert_eq!(rec["itemData"]["rating"], 84); assert_eq!(rec["itemData"]["id"], 100004617i64); assert_eq!(rec["itemData"]["resourceId"], 169193); let browse = handle_market_list("GET", None, &econ, &store).await; let b = parse(&browse); assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1); assert_eq!(b["credits"], 10_000); assert_eq!(b["maxAuctionsAllowed"], 100); } #[tokio::test] async fn list_put_is_ack() { let (store, _d) = store_at("put").await; let econ = CountingEconomy::with_balance(0); let resp = handle_market_list("PUT", None, &econ, &store).await; assert_eq!(resp.status, 200); assert_eq!(parse(&resp), json!({})); } #[tokio::test] async fn list_unresolved_item_fails_closed_no_listing() { // A wire id that does not resolve to an owned card must NOT create a listing // (a synthetic buy would otherwise mint a card Core cannot resolve). Acks. let (store, _d) = store_at("deny").await; let econ = CountingEconomy::with_balance(10_000); let body = json!({ "itemData": { "id": 100004617 }, "startingBid": 300, "buyNowPrice": 2500 }); let reverse = MapResolver::new(&[]); // maps nothing -> unresolved let items = FakeItems(HashMap::new()); let ident = FixedIdentity(0); let resolved = resolve_market_list( body.to_string().as_bytes(), &reverse, &ident, &items, &NoEntities, ); 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); // Nothing persisted at the would-be trade id: not buyable. assert!(matches!( store .get_listing(&(TRADE_ID_BASE + 100004617).to_string()) .await, Err(MarketError::NotFound) )); } #[tokio::test] async fn list_persists_core_card_and_wire_resource_across_reopen() { // The listing carries BOTH the authoritative Core card_id (for the mint) // and the FIFA wire resourceId (for the auction record), durably. let db = TempDb::new("reopen-map"); let trade_id; { let store = MarketStore::open(db.path()).await.unwrap(); let econ = CountingEconomy::with_balance(10_000); let body = json!({ "itemData": { "id": 100004900 }, "startingBid": 300, "buyNowPrice": 2500 }); let reverse = MapResolver::new(&[(100004900, "core-9")]); let items = FakeItems( [("core-9".to_string(), owned("core-9", "card_pl_042"))] .into_iter() .collect(), ); let ident = FixedIdentity(20801); let resolved = resolve_market_list( body.to_string().as_bytes(), &reverse, &ident, &items, &NoEntities, ); let resp = handle_market_list("POST", resolved, &econ, &store).await; trade_id = parse(&resp)["id"].as_i64().unwrap(); } // Reopen from the same file: both identities survive. let reopened = MarketStore::open(db.path()).await.unwrap(); let l = reopened.get_listing(&trade_id.to_string()).await.unwrap(); assert_eq!(l.card_id, "card_pl_042", "Core card_id minted on buy"); assert_eq!( l.wire_resource_id, Some(20801), "wire resourceId for the record" ); } #[tokio::test] async fn query_returns_active_pile() { 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 b = parse(&resp); assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1); assert_eq!(b["auctionInfo"][0]["tradeId"], 900000005i64); assert_eq!(b["credits"], 50); } #[tokio::test] async fn counts_is_the_five_int_tally_not_the_listing_body() { // FutGetAuctionCount is a DISTINCT deserializer from /tradePile: five // scalar ints, no auctionInfo. Answering it with the listing body leaves // every count at its constructor default, so the Transfer List screen // shows no active sale even while the hub tile reports one (live bug). let (store, _d) = store_at("counts").await; let b = parse(&handle_market_counts(&store).await); assert_eq!(b["count"], 0); assert_eq!(b["selling"], 0); seed_listing(&store, "900000007", 2500).await; let resp = handle_market_counts(&store).await; assert_eq!(resp.status, 200); let b = parse(&resp); assert_eq!(b["count"], 1, "tally counts the active listing"); assert_eq!(b["selling"], 1); assert_eq!(b["sold"], 0); assert_eq!(b["offered"], 0); assert_eq!( b["maxAuctionsAllowed"], 100, "cap stays open for IS_MAX_AUCTIONS" ); assert!( b.get("auctionInfo").is_none(), "the tally must NOT carry the listing body" ); for k in ["count", "maxAuctionsAllowed", "offered", "selling", "sold"] { assert!(b[k].is_i64(), "{k} must be a scalar int"); } } #[tokio::test] async fn auction_record_carries_exactly_the_twelve_fields_fifa17_reads() { // FIFA 17's auctionInfo deserializer (0x18013e410) reads TWELVE atoms and // value-SKIPs everything else. Emitting extras is not harmless richness: it // misleads the next reader about what the client consumes, and it is how // `tradeOwner`/`sellerId`/`offers` got added on library hearsay and then had // to be removed. Pin the set. let (store, _d) = store_at("atoms").await; let econ = CountingEconomy::with_balance(10_000); seed_listing(&store, "900000030", 2500).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(); got.sort_unstable(); assert_eq!( got, [ "bidState", "buyNowPrice", "coinsProcessed", "currentBid", "expires", "itemData", "sellerEstablished", "sellerName", "startingBid", "tradeId", "tradeState", "watched", ], "auctionInfo must be exactly FIFA 17's twelve atoms" ); // "listed by user" is active / none / currentBid 0 / expires > 0. Both // vocabularies are closed sets; an unrecognised bidState is swallowed as // `none` and renders a plausible but wrong UI. assert_eq!(rec["tradeState"], "active"); assert_eq!(rec["bidState"], "none"); assert_eq!(rec["currentBid"], 0); assert!(rec["expires"].as_i64().unwrap() > 0); assert_eq!(rec["sellerName"], non_economy::PERSONA_DISPLAY_NAME); // 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(); env.sort_unstable(); assert_eq!( env, ["auctionInfo", "credits", "duplicateItemIdList", "total"], "GetTradePile envelope is the shared IS-list body" ); } #[tokio::test] async fn expires_counts_down_and_an_unsold_auction_reads_expired() { // `expires` is SECONDS REMAINING and the client renders a live countdown // that it expects to reach 0. A frozen constant means the auction never // ages and can never run out. use crate::market_store::DEFAULT_DURATION_SECS; let (store, _d) = store_at("clock").await; seed_listing(&store, "900000040", 2500).await; let l = store.get_listing("900000040").await.unwrap(); let created = l.created_at.parse::().unwrap() / 1000; assert_eq!( l.expires_in_secs(created), DEFAULT_DURATION_SECS, "a fresh listing has its whole duration left" ); assert_eq!( l.expires_in_secs(created + 600), DEFAULT_DURATION_SECS - 600, "the clock actually advances" ); assert_eq!( l.expires_in_secs(created + DEFAULT_DURATION_SECS + 5), 0, "expiry clamps at 0, never negative" ); // A closed listing has no time left regardless of when it was created. let mut sold = l.clone(); sold.state = "sold".into(); assert_eq!(sold.expires_in_secs(created), 0); } #[tokio::test] async fn trade_status_answers_the_poll_instead_of_an_empty_set() { // The Transfer List polls `…/trade/status` continuously to refresh live // auction state. This tail has no numeric id, so it used to fall into the // buy/view arm and every poll was answered with an EMPTY auctionInfo — // observed live, and the screen never learned its own auctions' state. let (store, _d) = store_at("status").await; let econ = CountingEconomy::with_balance(10_000); 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); assert_eq!( all["auctionInfo"].as_array().unwrap().len(), 1, "an unfiltered poll must not come back empty while a listing is active" ); assert_eq!(all["auctionInfo"][0]["tradeId"], 900_000_031i64); // ISViewTrade's body is the auction list plus credits — no `total` and no // `duplicateItemIdList`, unlike the shared IS-list body. assert_eq!(all["auctionInfo"][0]["tradeState"], "active"); 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, ); 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); assert_eq!(miss["auctionInfo"].as_array().unwrap().len(), 0); // Garbage is skipped rather than poisoning the whole poll. assert_eq!( trade_ids_from_query(Some("tradeIds=900000031,abc,,900000032&x=1")), vec!["900000031".to_string(), "900000032".to_string()] ); } #[tokio::test] async fn buy_now_debits_mints_and_closes() { let (store, _d) = store_at("buy").await; seed_listing(&store, "900000010", 2500).await; let econ = CountingEconomy::with_balance(10_000); let resp = handle_market_buy( "POST", "/ut/game/fifa17/trade/900000010", b"{}", &econ, &store, ) .await; assert_eq!(resp.status, 200); let b = parse(&resp); assert_eq!(b["auctionInfo"][0]["tradeState"], "closed"); assert_eq!(b["credits"], 7500); assert_eq!(econ.purchase_calls.load(Ordering::SeqCst), 1); assert_eq!(store.get_listing("900000010").await.unwrap().state, "sold"); } #[tokio::test] async fn buy_now_insufficient_is_461_and_no_debit() { let (store, _d) = store_at("poor").await; seed_listing(&store, "900000011", 2500).await; let econ = CountingEconomy::with_balance(100); let resp = handle_market_buy( "POST", "/ut/game/fifa17/trade/900000011", b"{}", &econ, &store, ) .await; assert_eq!(resp.status, 461); assert_eq!(parse(&resp)["reason"], "insufficient_coins"); // Reservation rolled back -> still buyable, no debit happened. assert_eq!( store.get_listing("900000011").await.unwrap().state, "active" ); assert_eq!(econ.balance().unwrap(), 100); } #[tokio::test] async fn buy_core_failure_rolls_back_and_503() { let (store, _d) = store_at("coredown").await; seed_listing(&store, "900000012", 2500).await; let econ = CountingEconomy::failing(); let resp = handle_market_buy( "POST", "/ut/game/fifa17/trade/900000012", b"{}", &econ, &store, ) .await; assert_eq!(resp.status, 503); assert_eq!( store.get_listing("900000012").await.unwrap().state, "active" ); } #[tokio::test] async fn buy_unknown_auction_is_empty_ok() { let (store, _d) = store_at("gone").await; let econ = CountingEconomy::with_balance(10_000); let resp = handle_market_buy( "POST", "/ut/game/fifa17/trade/900099999", b"{}", &econ, &store, ) .await; assert_eq!(resp.status, 200); assert!(parse(&resp)["auctionInfo"].as_array().unwrap().is_empty()); assert_eq!(econ.purchase_calls.load(Ordering::SeqCst), 0); } /// Two buyers hit the SAME listing concurrently: exactly one sale, exactly /// one debit; the loser sees a closed (empty) auction. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn two_buyers_exactly_one_sale_one_debit() { let (store, _d) = store_at("race").await; seed_listing(&store, "900000020", 2500).await; let store = Arc::new(store); let econ = Arc::new(CountingEconomy::with_balance(10_000)); let mk = || { let s = store.clone(); let e = econ.clone(); tokio::spawn(async move { let r = handle_market_buy("POST", "/ut/game/fifa17/trade/900000020", b"{}", &*e, &s) .await; let body: Value = serde_json::from_slice(&r.body).unwrap(); (r.status, body["auctionInfo"].as_array().unwrap().len()) }) }; let (a, b) = (mk(), mk()); let (ra, rb) = (a.await.unwrap(), b.await.unwrap()); // Exactly one buy produced a (closed) auction record; the other is empty. let winners = [ra, rb].iter().filter(|(_, n)| *n == 1).count(); assert_eq!(winners, 1, "exactly one buyer wins: {ra:?} {rb:?}"); assert_eq!( econ.purchase_calls.load(Ordering::SeqCst), 1, "exactly one Core debit" ); assert_eq!(econ.balance().unwrap(), 7500, "debited exactly once"); assert_eq!(store.get_listing("900000020").await.unwrap().state, "sold"); } // ---- cancel ------------------------------------------------------------ #[tokio::test] async fn cancel_acks_and_cancels() { let (store, _d) = store_at("cancel").await; seed_listing(&store, "900000030", 2500).await; let resp = handle_market_cancel("/ut/delete/game/fifa17/trade/900000030", None, &store).await; assert_eq!(resp.status, 200); assert_eq!(parse(&resp), json!({})); assert_eq!( store.get_listing("900000030").await.unwrap().state, "cancelled" ); // A cancel of a nonexistent trade still acks (client-idempotent). let resp2 = handle_market_cancel("/ut/delete/game/fifa17/trade/900099999", None, &store).await; assert_eq!(resp2.status, 200); } // ---- move items -------------------------------------------------------- #[tokio::test] async fn move_between_club_and_tradepile() { let db = TempDb::new("move"); let piles = PileStore::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; assert_eq!(resp.status, 200); let b = parse(&resp); assert_eq!(b["itemData"][0]["success"], true); assert_eq!(b["itemData"][0]["pile"], "trade"); assert_eq!(b["itemData"][0]["id"], 100004617i64); assert_eq!( piles.get("core-uuid-7").await.unwrap().as_deref(), Some("trade") ); // 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; assert_eq!(parse(&resp2)["itemData"][0]["success"], true); assert_eq!( piles.get("core-uuid-7").await.unwrap().as_deref(), Some("club") ); } #[tokio::test] async fn move_unknown_wire_is_success_false() { let db = TempDb::new("moveunk"); let piles = PileStore::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 b = parse(&resp); assert_eq!(b["itemData"][0]["success"], false); assert_eq!(piles.get("core-uuid-7").await.unwrap(), None); } /// Pile state persists across a store reopen (durable, not in-memory). #[tokio::test] async fn move_persists_across_reopen() { let db = TempDb::new("movepersist"); let path = db.path(); let resolver = MapResolver::new(&[(7, "core-7")]); { let piles = PileStore::open(path).await.unwrap(); let body = json!({ "itemData": [{ "id": 7, "pile": "purchased" }] }); handle_move_items(body.to_string().as_bytes(), &resolver, &piles).await; } let reopened = PileStore::open(path).await.unwrap(); assert_eq!( reopened.get("core-7").await.unwrap().as_deref(), Some("purchased") ); } }