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.
This commit is contained in:
@@ -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
|
||||
})
|
||||
|
||||
+146
-88
@@ -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<String> {
|
||||
/// `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<String>,
|
||||
/// 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<i64>,
|
||||
}
|
||||
|
||||
/// Resolve a `/auctionhouse` POST (FutISStart) body to a [`ResolvedListing`],
|
||||
@@ -249,6 +253,10 @@ pub fn resolve_market_list<E: ReverseEntityResolver>(
|
||||
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<ResolvedListing>,
|
||||
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<Value> = 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<Value> = 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<Value> = 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::<i64>().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())
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<i64>,
|
||||
}
|
||||
|
||||
/// 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::<i64>()
|
||||
.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<String> = sqlx::query("PRAGMA table_info(listings)")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(db)?
|
||||
.iter()
|
||||
.any(|r| r.get::<String, _>("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::<String, _>("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<i64>,
|
||||
) -> Result<Listing, MarketError> {
|
||||
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();
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user