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
+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();