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:
funman300
2026-08-17 19:06:33 +00:00
parent bf9ae20367
commit 772f8a615a
4 changed files with 229 additions and 129 deletions
-7
View File
@@ -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
View File
@@ -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())
+68 -12
View File
@@ -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();
+15 -22
View File
@@ -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"],