From 772f8a615a6a51ca30ab04b827e9d8d2c4b5aaf6 Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 17 Aug 2026 19:06:33 +0000 Subject: [PATCH] fix(market): pin auctionInfo to FIFA 17's twelve atoms, add the real auction clock Corrects the record against the CLIENT BINARY rather than library hearsay, using the project's own reverse-engineering record (fifa17-recon/docs/plan-2026-08-06-transfer-market.md, read out of the on-disk PE). REVERTED (refuted): `tradeOwner`, `sellerId`, `offers`. FIFA 17's auctionInfo deserializer (0x18013e410) reads exactly TWELVE atoms -- bidState, buyNowPrice, currentBid, expires, itemData, sellerEstablished, sellerName, startingBid, coinsProcessed, tradeId, tradeState, watched -- and value-SKIPs everything else at 0x180135ff0. Those three fields were added last commit on the strength of contemporaneous FIFA 17 libraries; the PE says the client never reads them, so they were inert and could not have been the Actions-panel gate. A preservation emulator must not emit fields the client does not consume. New test pins the exact set. ADDED: the auction clock. `expires` is SECONDS REMAINING (never an epoch) and the client renders a LIVE COUNTDOWN it expects to reach 0. We hardcoded 3600, so no auction ever aged or ran out. Now `duration` is taken from the ISStart body (additive `duration_secs` column, defaulting to 3600) and `expires` is derived from created_at + duration - now, clamped at 0. An active listing whose clock has run out projects as `expired`/`none`/`expires: 0` -- FIFA 17's relistable state, per the lifecycle table (active=1 inactive=2 expired=3 closed=4; none=0 outbid=1 highest=2 buyNow=3, both closed vocabularies). Pure projection: no row is mutated, so no sweeper and no race with the economy. ADDED: `duplicateItemIdList: []` on GetTradePile, which shares one deserializer (0x18013e7f0) with ISSearch/ISWatchList over four members and we were omitting one. CONFIRMED by the same source, so kept: `GET ut/{ns}/trade/status?tradeIds=a,b,c` is real (ISVIEWTRADE) and my handler matches it exactly, including the comma list. `ISREMOVETRADE` is `DELETE ut/delete/{ns}/trade/{tradeId}` -- our ORIGINAL spelling was right. The plain-DELETE arm stays because the same source advises dispatching on path and being method-agnostic (HTTP verbs are not statically recoverable). Differential returns to strict key-set parity, with a comment recording WHY parity is not sufficient: a field absent from both sides is invisible to it. 333 tests pass, 0 failed, clippy clean. Verified live: the twelve-atom record, the four-member envelope, and the listing correctly reading expires=0 / expired after aging past its hour. --- openfut-utas-host/src/lib.rs | 7 - openfut-utas-host/src/market.rs | 234 +++++++++++------- openfut-utas-host/src/market_store.rs | 80 +++++- .../tests/economy_differential.rs | 37 ++- 4 files changed, 229 insertions(+), 129 deletions(-) diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 5cc4cb5..0cd7347 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -2211,9 +2211,6 @@ impl Server { let svc = self.economy.as_ref()?; let path = target.split('?').next().unwrap_or(target); let route = classify_economy(method, path)?; - // Seller identity for auction records: our own auctions must carry the - // configured persona, never a baked-in literal. - let persona = self.persona_id; use crate::economy_store::{ handle_pack_open, handle_quick_sell_body, handle_quick_sell_path, handle_store_buy, CoreItemLookup, QuickSellDeps, StoreDeps, @@ -2335,7 +2332,6 @@ impl Server { resolved, econ.as_ref(), market.as_ref(), - persona, ) .await }) @@ -2348,7 +2344,6 @@ impl Server { "active", econ.as_ref(), market.as_ref(), - persona, ) .await }) @@ -2368,7 +2363,6 @@ impl Server { q.as_deref(), econ.as_ref(), market.as_ref(), - persona, ) .await }) @@ -2384,7 +2378,6 @@ impl Server { &body, econ.as_ref(), market.as_ref(), - persona, ) .await }) diff --git a/openfut-utas-host/src/market.rs b/openfut-utas-host/src/market.rs index 653115e..af96b36 100644 --- a/openfut-utas-host/src/market.rs +++ b/openfut-utas-host/src/market.rs @@ -33,7 +33,7 @@ use openfut_adapter_fifa17::fut::squad::SquadWireResolver; use crate::economy_store::OwnedItemLookup; -use crate::market_store::{Listing, MarketError, MarketStore}; +use crate::market_store::{now_secs, Listing, MarketError, MarketStore}; use crate::pile_store::PileStore; use crate::{CoreEconomy, CoreError, WireResponse}; @@ -82,13 +82,31 @@ fn trade_id_from_path(path: &str) -> Option { /// `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, persona_id: i64) -> Value { +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), }; @@ -112,24 +130,17 @@ fn auction_record_as(l: &Listing, item_state: &str, persona_id: i64) -> Value { "untradeable": false, }) }); - // OWNERSHIP. `tradeOwner` is FIFA 17's purpose-built "this auction is mine" - // boolean, and it is the field the Transfer List needs before it will offer - // Remove / Re-list on a row. We omitted it entirely, which is consistent with - // the observed symptom: the listing rendered but selecting it opened nothing. + // 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. // - // Provenance: FIFA17-HISTORICAL (contemporaneous FIFA 17 clients carry - // `tradeOwner`/`sellerId`/`offers` in auctionInfo). NOT confirmed from our own - // capture, and notably ABSENT from the Python oracle too — which is why the - // differential could not catch it: the oracle's own remove flow was never - // exercised against a real client either. - // - // This store has no NPC seller, so an unnamed owner is the player. Comparing - // rather than hardcoding `true` keeps the flag honest if a foreign seller is - // ever introduced. - let own = l - .owner - .as_deref() - .is_none_or(|o| o == non_economy::PERSONA_DISPLAY_NAME); + // 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, @@ -137,21 +148,11 @@ fn auction_record_as(l: &Listing, item_state: &str, persona_id: i64) -> Value { "buyNowPrice": l.buy_now_price, "startingBid": l.start_price, "currentBid": current_bid, - // Bid count. 0 is correct for an active/unbid auction alongside - // `bidState: "none"` (FIFA17-HISTORICAL). - "offers": 0, "bidState": bid_state, - // SECONDS REMAINING, never an absolute epoch (FIFA17-HISTORICAL). - "expires": 3600, - "tradeOwner": own, - // The seller identity must agree with `tradeOwner`: our own auction is sold - // by our own persona. Numeric because every other persona id on this wire - // is numeric; the id itself is never baked in (it comes from config, so it - // keeps matching the persona LSX/Blaze/POW/UTAS agree on). - "sellerId": if own { persona_id } else { 0 }, - // Every listing in this store is the player's OWN, so the seller defaults - // to the player's persona name exactly as the oracle stamps it. EA's house - // name here makes the player's own listing look foreign. + "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() @@ -164,9 +165,9 @@ fn auction_record_as(l: &Listing, item_state: &str, persona_id: i64) -> Value { /// Auction record for a market/search context (`itemState: forSale`), and for the /// closed/sold echoes the buy path returns. -fn auction_record(l: &Listing, persona_id: i64) -> Value { +fn auction_record(l: &Listing) -> Value { let state = if l.state == "active" { "forSale" } else { "free" }; - auction_record_as(l, state, persona_id) + auction_record_as(l, state) } /// Run a BLOCKING closure — the blocking Core client — on a fresh OS thread that @@ -206,6 +207,9 @@ pub struct ResolvedListing { /// 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`], @@ -249,6 +253,10 @@ pub fn resolve_market_list( 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), }) } @@ -265,7 +273,6 @@ pub async fn handle_market_list( resolved: Option, econ: &dyn CoreEconomy, store: &MarketStore, - persona_id: i64, ) -> WireResponse { match method { "POST" => { @@ -291,6 +298,7 @@ pub async fn handle_market_list( r.buy_now, r.seller.as_deref(), r.item_json.as_deref(), + r.duration, ) .await { @@ -313,7 +321,7 @@ pub async fn handle_market_list( }; let auctions: Vec = listings .iter() - .map(|l| auction_record(l, persona_id)) + .map(auction_record) .collect(); ok_json(&json!({ "auctionInfo": auctions, @@ -340,7 +348,6 @@ pub async fn handle_market_query( state: &str, econ: &dyn CoreEconomy, store: &MarketStore, - persona_id: i64, ) -> WireResponse { let listings = match store.query_listings(state).await { Ok(l) => l, @@ -348,12 +355,17 @@ pub async fn handle_market_query( }; let auctions: Vec = listings .iter() - .map(|l| auction_record_as(l, "listFS", persona_id)) + .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": [], })) } @@ -414,7 +426,6 @@ pub async fn handle_market_status( query: Option<&str>, econ: &dyn CoreEconomy, store: &MarketStore, - persona_id: i64, ) -> WireResponse { let ids = trade_ids_from_query(query); let listings = if ids.is_empty() { @@ -433,7 +444,7 @@ pub async fn handle_market_status( }; let auctions: Vec = listings .iter() - .map(|l| auction_record_as(l, "listFS", persona_id)) + .map(|l| auction_record_as(l, "listFS")) .collect(); eprintln!( "utas-host owner=RUST route=market-status requested={} returned={} query={}", @@ -471,7 +482,6 @@ pub async fn handle_market_buy( body: &[u8], econ: &dyn CoreEconomy, store: &MarketStore, - persona_id: i64, ) -> WireResponse { let Some(id) = trade_id_from_path(path) else { return ok_json(&json!({ "auctionInfo": [], "credits": credits_or_zero(econ) })); @@ -480,7 +490,7 @@ pub async fn handle_market_buy( if method != "POST" && method != "PUT" { // GET: view one auction. let rec = match store.get_listing(&id).await { - Ok(l) => vec![auction_record(&l, persona_id)], + Ok(l) => vec![auction_record(&l)], Err(_) => vec![], }; return ok_json(&json!({ "auctionInfo": rec, "credits": credits_or_zero(econ) })); @@ -501,10 +511,9 @@ pub async fn handle_market_buy( // 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, persona_id); + let mut rec = auction_record(&listing); rec["currentBid"] = json!(bid); rec["bidState"] = json!("highest"); - rec["offers"] = json!(1); return ok_json(&json!({ "auctionInfo": [rec], "credits": credits_or_zero(econ) })); } @@ -547,7 +556,7 @@ pub async fn handle_market_buy( 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, persona_id); + let mut rec = auction_record(&listing); rec["tradeState"] = json!("closed"); rec["bidState"] = json!("highest"); rec["currentBid"] = json!(price); @@ -613,10 +622,6 @@ mod tests { use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; - /// Seller persona for auction records under test. Matches the live profile so - /// a `sellerId` assertion is meaningful rather than tautological. - const PERSONA: i64 = 33_068_179; - // ---- temp DB helpers --------------------------------------------------- struct TempDb(String); @@ -806,7 +811,7 @@ mod tests { 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) + .create_listing(id, "169193", None, None, Some(169193), 400, buy_now, None, None, None) .await .unwrap(); } @@ -839,7 +844,7 @@ mod tests { &items, &NoEntities, ); - let resp = handle_market_list("POST", resolved, &econ, &store, PERSONA).await; + 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); @@ -855,13 +860,13 @@ mod tests { 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, PERSONA).await; + 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, PERSONA).await; + 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); @@ -872,7 +877,7 @@ mod tests { 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, PERSONA).await; + let resp = handle_market_list("PUT", None, &econ, &store).await; assert_eq!(resp.status, 200); assert_eq!(parse(&resp), json!({})); } @@ -896,7 +901,7 @@ mod tests { &NoEntities, ); assert!(resolved.is_none(), "unresolved item must not build a listing"); - let resp = handle_market_list("POST", resolved, &econ, &store, PERSONA).await; + 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. @@ -933,7 +938,7 @@ mod tests { &items, &NoEntities, ); - let resp = handle_market_list("POST", resolved, &econ, &store, PERSONA).await; + 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. @@ -952,7 +957,7 @@ mod tests { 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, PERSONA).await; + 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); @@ -992,35 +997,89 @@ mod tests { } #[tokio::test] - async fn own_auction_carries_tradeowner_and_a_consistent_seller_identity() { - // The client renders a listing fine but will not offer Remove / Re-list - // unless it can tell the auction is the player's OWN. `tradeOwner` is the - // purpose-built flag for that; `sellerId`/`sellerName` must agree with it, - // or the row claims to be ours while naming a different seller. - let (store, _d) = store_at("owner").await; + 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 rec = parse(&handle_market_query("active", &econ, &store, PERSONA).await)["auctionInfo"] - [0] - .clone(); - assert_eq!(rec["tradeOwner"], true, "own listing must be flagged"); - assert_eq!(rec["sellerId"], PERSONA, "seller id is the player's persona"); + 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!( - rec["sellerName"], - non_economy::PERSONA_DISPLAY_NAME, - "seller name is the player, never EA's house name" + got, + [ + "bidState", + "buyNowPrice", + "coinsProcessed", + "currentBid", + "expires", + "itemData", + "sellerEstablished", + "sellerName", + "startingBid", + "tradeId", + "tradeState", + "watched", + ], + "auctionInfo must be exactly FIFA 17's twelve atoms" ); - // The unbid-active tuple the client expects alongside those. + // "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_eq!(rec["offers"], 0); - assert_eq!(rec["expires"], 3600, "seconds remaining, never an epoch"); + assert!(rec["expires"].as_i64().unwrap() > 0); + assert_eq!(rec["sellerName"], non_economy::PERSONA_DISPLAY_NAME); - // The browse/search projection carries the same ownership truth. - let browse = parse(&handle_market_list("GET", None, &econ, &store, PERSONA).await); - assert_eq!(browse["auctionInfo"][0]["tradeOwner"], true); - assert_eq!(browse["auctionInfo"][0]["sellerId"], PERSONA); + // 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] @@ -1034,25 +1093,28 @@ mod tests { 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, PERSONA).await); + 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); - assert_eq!(all["auctionInfo"][0]["tradeOwner"], true); + // 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, PERSONA).await, + &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, PERSONA).await); + 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. @@ -1073,7 +1135,6 @@ mod tests { b"{}", &econ, &store, - PERSONA, ) .await; assert_eq!(resp.status, 200); @@ -1095,7 +1156,6 @@ mod tests { b"{}", &econ, &store, - PERSONA, ) .await; assert_eq!(resp.status, 461); @@ -1119,7 +1179,6 @@ mod tests { b"{}", &econ, &store, - PERSONA, ) .await; assert_eq!(resp.status, 503); @@ -1139,7 +1198,6 @@ mod tests { b"{}", &econ, &store, - PERSONA, ) .await; assert_eq!(resp.status, 200); @@ -1161,7 +1219,7 @@ mod tests { let e = econ.clone(); tokio::spawn(async move { let r = - handle_market_buy("POST", "/ut/game/fifa17/trade/900000020", b"{}", &*e, &s, PERSONA) + 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()) diff --git a/openfut-utas-host/src/market_store.rs b/openfut-utas-host/src/market_store.rs index c2db121..9cada8b 100644 --- a/openfut-utas-host/src/market_store.rs +++ b/openfut-utas-host/src/market_store.rs @@ -152,6 +152,46 @@ pub struct Listing { /// stub — a stub leaves the Transfer List with an unrenderable row. `None` /// only for rows written before this column existed (renders as a stub). pub item_json: Option, + /// Listing duration in SECONDS, as sent by the client in the `ISStart` body + /// (`duration`). With `created_at` this is the whole auction clock: FIFA 17 + /// renders a live countdown from `expires` and expects it to reach 0, so a + /// listing has to know when it ends. `None` for rows written before this + /// column existed, which fall back to the default duration. + pub duration_secs: Option, +} + +/// FIFA 17 auction durations, in seconds: 3600, 10800, 21600, 43200, 86400, +/// 259200. One hour is the shortest, and the fallback when a client body omits it +/// or a pre-column row is read. +pub const DEFAULT_DURATION_SECS: i64 = 3600; + +/// Seconds since the unix epoch. +pub fn now_secs() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +impl Listing { + /// SECONDS REMAINING on this auction at `now` (unix seconds), clamped at 0 — + /// the wire semantics of `expires`, which is never an absolute epoch. + /// + /// A closed/sold/cancelled listing reads 0: there is no time left on an + /// auction that has already ended. + pub fn expires_in_secs(&self, now: i64) -> i64 { + if self.state != "active" { + return 0; + } + let created_secs = self + .created_at + .parse::() + .map(|ms| ms / 1000) + .unwrap_or(now); + let duration = self.duration_secs.unwrap_or(DEFAULT_DURATION_SECS); + (created_secs + duration - now).max(0) + } } const CREATE_LISTINGS: &str = "CREATE TABLE IF NOT EXISTS listings ( @@ -165,7 +205,8 @@ const CREATE_LISTINGS: &str = "CREATE TABLE IF NOT EXISTS listings ( owner TEXT, state TEXT NOT NULL CHECK (state IN ('active','reserved','sold','cancelled')), created_at TEXT NOT NULL, - item_json TEXT + item_json TEXT, + duration_secs INTEGER )"; fn now_millis() -> String { @@ -190,6 +231,7 @@ fn row_to_listing(row: &sqlx::sqlite::SqliteRow) -> Listing { state: row.get("state"), created_at: row.get("created_at"), item_json: row.get("item_json"), + duration_secs: row.get("duration_secs"), } } @@ -235,17 +277,23 @@ impl MarketStore { // and `CREATE TABLE IF NOT EXISTS` will not add a column to an existing // file. Add it when absent so an existing market DB keeps working (old // rows read back `None` and render the stub card). - let has_item_json = sqlx::query("PRAGMA table_info(listings)") + let existing: Vec = sqlx::query("PRAGMA table_info(listings)") .fetch_all(&pool) .await .map_err(db)? .iter() - .any(|r| r.get::("name") == "item_json"); - if !has_item_json { - sqlx::query("ALTER TABLE listings ADD COLUMN item_json TEXT") - .execute(&pool) - .await - .map_err(db)?; + .map(|r| r.get::("name")) + .collect(); + for (col, decl) in [ + ("item_json", "TEXT"), + ("duration_secs", "INTEGER"), + ] { + if !existing.iter().any(|c| c == col) { + sqlx::query(&format!("ALTER TABLE listings ADD COLUMN {col} {decl}")) + .execute(&pool) + .await + .map_err(db)?; + } } Ok(MarketStore { pool, @@ -275,6 +323,9 @@ impl MarketStore { owner: Option<&str>, // The shaped FIFA card snapshot (`itemData`) for the auction record. item_json: Option<&str>, + // Listing duration in seconds from the client's `ISStart` body; `None` + // falls back to [`DEFAULT_DURATION_SECS`]. + duration_secs: Option, ) -> Result { let created_at = now_millis(); let mut conn = self.pool.acquire().await.map_err(db)?; @@ -284,8 +335,9 @@ impl MarketStore { .map_err(db)?; let res = sqlx::query( "INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, \ - wire_resource_id, start_price, buy_now_price, owner, state, created_at, item_json) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)", + wire_resource_id, start_price, buy_now_price, owner, state, created_at, item_json, \ + duration_secs) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)", ) .bind(listing_id) .bind(card_id) @@ -297,6 +349,7 @@ impl MarketStore { .bind(owner) .bind(&created_at) .bind(item_json) + .bind(duration_secs) .execute(&mut *conn) .await; match res { @@ -317,6 +370,7 @@ impl MarketStore { state: "active".to_string(), created_at, item_json: item_json.map(str::to_string), + duration_secs, }) } Err(e) => { @@ -530,7 +584,7 @@ mod tests { async fn seed(store: &MarketStore, id: &str) -> Listing { store - .create_listing(id, "card_pl_001", None, None, None, 900, 2500, None, None) + .create_listing(id, "card_pl_001", None, None, None, 900, 2500, None, None, None) .await .unwrap() } @@ -561,7 +615,7 @@ mod tests { seed(&store, "900000001").await; assert!(matches!( store - .create_listing("900000001", "card_pl_002", None, None, None, 1, 2, None, None) + .create_listing("900000001", "card_pl_002", None, None, None, 1, 2, None, None, None) .await, Err(MarketError::Conflict) )); @@ -626,6 +680,7 @@ mod tests { 2500, Some("alice"), None, + None, ) .await .unwrap(); @@ -698,6 +753,7 @@ mod tests { 2500, Some("alice"), Some(r#"{"rating":84}"#), + None, ) .await .unwrap(); diff --git a/openfut-utas-host/tests/economy_differential.rs b/openfut-utas-host/tests/economy_differential.rs index 7cf6086..e43ebd8 100644 --- a/openfut-utas-host/tests/economy_differential.rs +++ b/openfut-utas-host/tests/economy_differential.rs @@ -910,34 +910,21 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) { k.sort(); k }; - // Our record is a deliberate SUPERSET of the oracle's. The oracle omits the - // FIFA 17 ownership fields entirely, which is exactly why parity could not - // catch the Actions-panel bug: the field was missing on BOTH sides, because the - // oracle's own remove flow was never driven by a real client either. So assert - // (a) we cover every key the oracle emits, and (b) the extra keys are precisely - // the ownership set we added on purpose — a NEW unexplained divergence still - // fails here. - let (ok, rk) = (keys(o_rec), keys(r_rec)); - for k in &ok { - assert!(rk.contains(k), "rust tradePile record is missing oracle key `{k}`"); - } - let extra: Vec<&String> = rk.iter().filter(|k| !ok.contains(k)).collect(); + // Both sides emit exactly FIFA 17's twelve auctionInfo atoms, so this is a + // strict key-set equality. NOTE the limit of that: parity here proves we match + // the oracle, NOT that either side is complete -- a field absent from BOTH is + // invisible to this check. That is exactly how the Transfer List Actions-panel + // bug hid, and the client binary's atom table is the authority that settled it + // (see docs/FIFA17_TRANSFER_MARKET_WIRE.md). assert_eq!( - extra, - vec!["offers", "sellerId", "tradeOwner"], - "the ONLY keys we add beyond the oracle are the FIFA 17 ownership fields" - ); - // The ownership story must be internally consistent on our side. - assert_eq!(r_rec["tradeOwner"], true, "own pile listing is owned by us"); - assert_eq!( - r_rec["sellerId"], PERSONA_ID, - "sellerId agrees with tradeOwner" + keys(o_rec), + keys(r_rec), + "tradePile auction-record key set parity" ); for f in [ "sellerName", "bidState", "currentBid", - "expires", "sellerEstablished", "watched", "coinsProcessed", @@ -948,6 +935,12 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) { r_rec["sellerName"], PERSONA_DISPLAY_NAME, "the player's own listing is sold BY the player, never by EA" ); + // `expires` is seconds remaining on a live clock, so it need not equal the + // oracle's constant; it must be a positive 64-bit count for an active auction. + assert!( + r_rec["expires"].as_i64().is_some_and(|e| e > 0), + "an active auction has positive seconds remaining" + ); // itemData must be the full shaped card on both sides; a stub cannot render. assert_eq!( o_rec["itemData"]["itemState"], r_rec["itemData"]["itemState"],