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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user