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 svc = self.economy.as_ref()?;
|
||||||
let path = target.split('?').next().unwrap_or(target);
|
let path = target.split('?').next().unwrap_or(target);
|
||||||
let route = classify_economy(method, path)?;
|
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::{
|
use crate::economy_store::{
|
||||||
handle_pack_open, handle_quick_sell_body, handle_quick_sell_path, handle_store_buy,
|
handle_pack_open, handle_quick_sell_body, handle_quick_sell_path, handle_store_buy,
|
||||||
CoreItemLookup, QuickSellDeps, StoreDeps,
|
CoreItemLookup, QuickSellDeps, StoreDeps,
|
||||||
@@ -2335,7 +2332,6 @@ impl Server {
|
|||||||
resolved,
|
resolved,
|
||||||
econ.as_ref(),
|
econ.as_ref(),
|
||||||
market.as_ref(),
|
market.as_ref(),
|
||||||
persona,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
@@ -2348,7 +2344,6 @@ impl Server {
|
|||||||
"active",
|
"active",
|
||||||
econ.as_ref(),
|
econ.as_ref(),
|
||||||
market.as_ref(),
|
market.as_ref(),
|
||||||
persona,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
@@ -2368,7 +2363,6 @@ impl Server {
|
|||||||
q.as_deref(),
|
q.as_deref(),
|
||||||
econ.as_ref(),
|
econ.as_ref(),
|
||||||
market.as_ref(),
|
market.as_ref(),
|
||||||
persona,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
@@ -2384,7 +2378,6 @@ impl Server {
|
|||||||
&body,
|
&body,
|
||||||
econ.as_ref(),
|
econ.as_ref(),
|
||||||
market.as_ref(),
|
market.as_ref(),
|
||||||
persona,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
|
|||||||
+146
-88
@@ -33,7 +33,7 @@ use openfut_adapter_fifa17::fut::squad::SquadWireResolver;
|
|||||||
|
|
||||||
use crate::economy_store::OwnedItemLookup;
|
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::pile_store::PileStore;
|
||||||
use crate::{CoreEconomy, CoreError, WireResponse};
|
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
|
/// `item_state` overrides the card's `itemState`: the seller's own pile uses
|
||||||
/// `listFS` (list-for-sale), market search results use `forSale` — the oracle
|
/// `listFS` (list-for-sale), market search results use `forSale` — the oracle
|
||||||
/// distinguishes these, so the caller passes the one its screen needs.
|
/// 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);
|
let trade_id: i64 = l.listing_id.parse().unwrap_or(0);
|
||||||
// resourceId is the FIFA wire identity the client listed (never the Core
|
// 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.
|
// 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 resource = l.wire_resource_id.or(l.wire_item_id).unwrap_or(0);
|
||||||
let item_id = l.wire_item_id.unwrap_or(trade_id);
|
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() {
|
let (trade_state, bid_state, current_bid) = match l.state.as_str() {
|
||||||
|
"active" if expires == 0 => ("expired", "none", 0),
|
||||||
"active" => ("active", "none", 0),
|
"active" => ("active", "none", 0),
|
||||||
_ => ("closed", "highest", l.buy_now_price),
|
_ => ("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,
|
"untradeable": false,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
// OWNERSHIP. `tradeOwner` is FIFA 17's purpose-built "this auction is mine"
|
// EXACTLY the twelve fields FIFA 17's auctionInfo deserializer (0x18013e410)
|
||||||
// boolean, and it is the field the Transfer List needs before it will offer
|
// reads. Everything else falls through to its value-SKIP at 0x180135ff0, so an
|
||||||
// Remove / Re-list on a row. We omitted it entirely, which is consistent with
|
// extra key is not "harmless richness" — it is dead weight that misleads the
|
||||||
// the observed symptom: the listing rendered but selecting it opened nothing.
|
// next reader about what the client consumes.
|
||||||
//
|
//
|
||||||
// Provenance: FIFA17-HISTORICAL (contemporaneous FIFA 17 clients carry
|
// In particular `tradeOwner` / `sellerId` / `offers` are NOT read by FIFA 17.
|
||||||
// `tradeOwner`/`sellerId`/`offers` in auctionInfo). NOT confirmed from our own
|
// They were added here on the strength of contemporaneous FIFA 17 libraries
|
||||||
// capture, and notably ABSENT from the Python oracle too — which is why the
|
// and are refuted by the PE's own atom table (see
|
||||||
// differential could not catch it: the oracle's own remove flow was never
|
// docs/FIFA17_TRANSFER_MARKET_WIRE.md): the client cannot be told "this
|
||||||
// exercised against a real client either.
|
// auction is yours" through the record at all, so ownership is NOT the gate on
|
||||||
//
|
// the Transfer List Actions panel.
|
||||||
// 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);
|
|
||||||
json!({
|
json!({
|
||||||
"tradeId": trade_id,
|
"tradeId": trade_id,
|
||||||
"itemData": item_data,
|
"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,
|
"buyNowPrice": l.buy_now_price,
|
||||||
"startingBid": l.start_price,
|
"startingBid": l.start_price,
|
||||||
"currentBid": current_bid,
|
"currentBid": current_bid,
|
||||||
// Bid count. 0 is correct for an active/unbid auction alongside
|
|
||||||
// `bidState: "none"` (FIFA17-HISTORICAL).
|
|
||||||
"offers": 0,
|
|
||||||
"bidState": bid_state,
|
"bidState": bid_state,
|
||||||
// SECONDS REMAINING, never an absolute epoch (FIFA17-HISTORICAL).
|
"expires": expires,
|
||||||
"expires": 3600,
|
// Bounded copy, max 30 chars. The oracle stamps the player's persona here
|
||||||
"tradeOwner": own,
|
// and `fut_account.py` annotates that property as "UTAS sellerName", so
|
||||||
// The seller identity must agree with `tradeOwner`: our own auction is sold
|
// EA's house name would make the player's own listing look foreign.
|
||||||
// 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.
|
|
||||||
"sellerName": l
|
"sellerName": l
|
||||||
.owner
|
.owner
|
||||||
.clone()
|
.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
|
/// Auction record for a market/search context (`itemState: forSale`), and for the
|
||||||
/// closed/sold echoes the buy path returns.
|
/// 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" };
|
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
|
/// 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.
|
/// The full shaped FIFA card (`itemData`) snapshot for the auction record.
|
||||||
/// `None` only when the item has no resolvable FIFA identity (never faked).
|
/// `None` only when the item has no resolvable FIFA identity (never faked).
|
||||||
pub item_json: Option<String>,
|
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`],
|
/// 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),
|
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),
|
seller: b.get("sellerName").and_then(Value::as_str).map(str::to_string),
|
||||||
item_json,
|
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>,
|
resolved: Option<ResolvedListing>,
|
||||||
econ: &dyn CoreEconomy,
|
econ: &dyn CoreEconomy,
|
||||||
store: &MarketStore,
|
store: &MarketStore,
|
||||||
persona_id: i64,
|
|
||||||
) -> WireResponse {
|
) -> WireResponse {
|
||||||
match method {
|
match method {
|
||||||
"POST" => {
|
"POST" => {
|
||||||
@@ -291,6 +298,7 @@ pub async fn handle_market_list(
|
|||||||
r.buy_now,
|
r.buy_now,
|
||||||
r.seller.as_deref(),
|
r.seller.as_deref(),
|
||||||
r.item_json.as_deref(),
|
r.item_json.as_deref(),
|
||||||
|
r.duration,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -313,7 +321,7 @@ pub async fn handle_market_list(
|
|||||||
};
|
};
|
||||||
let auctions: Vec<Value> = listings
|
let auctions: Vec<Value> = listings
|
||||||
.iter()
|
.iter()
|
||||||
.map(|l| auction_record(l, persona_id))
|
.map(auction_record)
|
||||||
.collect();
|
.collect();
|
||||||
ok_json(&json!({
|
ok_json(&json!({
|
||||||
"auctionInfo": auctions,
|
"auctionInfo": auctions,
|
||||||
@@ -340,7 +348,6 @@ pub async fn handle_market_query(
|
|||||||
state: &str,
|
state: &str,
|
||||||
econ: &dyn CoreEconomy,
|
econ: &dyn CoreEconomy,
|
||||||
store: &MarketStore,
|
store: &MarketStore,
|
||||||
persona_id: i64,
|
|
||||||
) -> WireResponse {
|
) -> WireResponse {
|
||||||
let listings = match store.query_listings(state).await {
|
let listings = match store.query_listings(state).await {
|
||||||
Ok(l) => l,
|
Ok(l) => l,
|
||||||
@@ -348,12 +355,17 @@ pub async fn handle_market_query(
|
|||||||
};
|
};
|
||||||
let auctions: Vec<Value> = listings
|
let auctions: Vec<Value> = listings
|
||||||
.iter()
|
.iter()
|
||||||
.map(|l| auction_record_as(l, "listFS", persona_id))
|
.map(|l| auction_record_as(l, "listFS"))
|
||||||
.collect();
|
.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!({
|
ok_json(&json!({
|
||||||
"auctionInfo": auctions,
|
"auctionInfo": auctions,
|
||||||
"credits": credits_or_zero(econ),
|
"credits": credits_or_zero(econ),
|
||||||
"total": auctions.len(),
|
"total": auctions.len(),
|
||||||
|
"duplicateItemIdList": [],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,7 +426,6 @@ pub async fn handle_market_status(
|
|||||||
query: Option<&str>,
|
query: Option<&str>,
|
||||||
econ: &dyn CoreEconomy,
|
econ: &dyn CoreEconomy,
|
||||||
store: &MarketStore,
|
store: &MarketStore,
|
||||||
persona_id: i64,
|
|
||||||
) -> WireResponse {
|
) -> WireResponse {
|
||||||
let ids = trade_ids_from_query(query);
|
let ids = trade_ids_from_query(query);
|
||||||
let listings = if ids.is_empty() {
|
let listings = if ids.is_empty() {
|
||||||
@@ -433,7 +444,7 @@ pub async fn handle_market_status(
|
|||||||
};
|
};
|
||||||
let auctions: Vec<Value> = listings
|
let auctions: Vec<Value> = listings
|
||||||
.iter()
|
.iter()
|
||||||
.map(|l| auction_record_as(l, "listFS", persona_id))
|
.map(|l| auction_record_as(l, "listFS"))
|
||||||
.collect();
|
.collect();
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"utas-host owner=RUST route=market-status requested={} returned={} query={}",
|
"utas-host owner=RUST route=market-status requested={} returned={} query={}",
|
||||||
@@ -471,7 +482,6 @@ pub async fn handle_market_buy(
|
|||||||
body: &[u8],
|
body: &[u8],
|
||||||
econ: &dyn CoreEconomy,
|
econ: &dyn CoreEconomy,
|
||||||
store: &MarketStore,
|
store: &MarketStore,
|
||||||
persona_id: i64,
|
|
||||||
) -> WireResponse {
|
) -> WireResponse {
|
||||||
let Some(id) = trade_id_from_path(path) else {
|
let Some(id) = trade_id_from_path(path) else {
|
||||||
return ok_json(&json!({ "auctionInfo": [], "credits": credits_or_zero(econ) }));
|
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" {
|
if method != "POST" && method != "PUT" {
|
||||||
// GET: view one auction.
|
// GET: view one auction.
|
||||||
let rec = match store.get_listing(&id).await {
|
let rec = match store.get_listing(&id).await {
|
||||||
Ok(l) => vec![auction_record(&l, persona_id)],
|
Ok(l) => vec![auction_record(&l)],
|
||||||
Err(_) => vec![],
|
Err(_) => vec![],
|
||||||
};
|
};
|
||||||
return ok_json(&json!({ "auctionInfo": rec, "credits": credits_or_zero(econ) }));
|
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,
|
// A simple bid below buy-now: we are the sole bidder — echo the raised bid,
|
||||||
// no coin movement, no reservation.
|
// no coin movement, no reservation.
|
||||||
if bid < listing.buy_now_price {
|
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["currentBid"] = json!(bid);
|
||||||
rec["bidState"] = json!("highest");
|
rec["bidState"] = json!("highest");
|
||||||
rec["offers"] = json!(1);
|
|
||||||
return ok_json(&json!({ "auctionInfo": [rec], "credits": credits_or_zero(econ) }));
|
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 {
|
if let Err(e) = store.complete_sale(&id).await {
|
||||||
eprintln!("utas-host WARN market complete_sale({id}) after mint failed: {e}");
|
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["tradeState"] = json!("closed");
|
||||||
rec["bidState"] = json!("highest");
|
rec["bidState"] = json!("highest");
|
||||||
rec["currentBid"] = json!(price);
|
rec["currentBid"] = json!(price);
|
||||||
@@ -613,10 +622,6 @@ mod tests {
|
|||||||
use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
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 ---------------------------------------------------
|
// ---- temp DB helpers ---------------------------------------------------
|
||||||
|
|
||||||
struct TempDb(String);
|
struct TempDb(String);
|
||||||
@@ -806,7 +811,7 @@ mod tests {
|
|||||||
|
|
||||||
async fn seed_listing(store: &MarketStore, id: &str, buy_now: i64) {
|
async fn seed_listing(store: &MarketStore, id: &str, buy_now: i64) {
|
||||||
store
|
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
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
@@ -839,7 +844,7 @@ mod tests {
|
|||||||
&items,
|
&items,
|
||||||
&NoEntities,
|
&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);
|
assert_eq!(resp.status, 200);
|
||||||
let trade_id = parse(&resp)["id"].as_i64().unwrap();
|
let trade_id = parse(&resp)["id"].as_i64().unwrap();
|
||||||
assert_eq!(trade_id, TRADE_ID_BASE + 100004617);
|
assert_eq!(trade_id, TRADE_ID_BASE + 100004617);
|
||||||
@@ -855,13 +860,13 @@ mod tests {
|
|||||||
assert_eq!(snap["preferredPosition"], "ST");
|
assert_eq!(snap["preferredPosition"], "ST");
|
||||||
assert_eq!(snap["attributeList"].as_array().unwrap().len(), 6);
|
assert_eq!(snap["attributeList"].as_array().unwrap().len(), 6);
|
||||||
// tradePile embeds that full card and stamps the seller-pile state.
|
// 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();
|
let rec = parse(&pile)["auctionInfo"][0].clone();
|
||||||
assert_eq!(rec["itemData"]["itemState"], "listFS");
|
assert_eq!(rec["itemData"]["itemState"], "listFS");
|
||||||
assert_eq!(rec["itemData"]["rating"], 84);
|
assert_eq!(rec["itemData"]["rating"], 84);
|
||||||
assert_eq!(rec["itemData"]["id"], 100004617i64);
|
assert_eq!(rec["itemData"]["id"], 100004617i64);
|
||||||
assert_eq!(rec["itemData"]["resourceId"], 169193);
|
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);
|
let b = parse(&browse);
|
||||||
assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1);
|
assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1);
|
||||||
assert_eq!(b["credits"], 10_000);
|
assert_eq!(b["credits"], 10_000);
|
||||||
@@ -872,7 +877,7 @@ mod tests {
|
|||||||
async fn list_put_is_ack() {
|
async fn list_put_is_ack() {
|
||||||
let (store, _d) = store_at("put").await;
|
let (store, _d) = store_at("put").await;
|
||||||
let econ = CountingEconomy::with_balance(0);
|
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!(resp.status, 200);
|
||||||
assert_eq!(parse(&resp), json!({}));
|
assert_eq!(parse(&resp), json!({}));
|
||||||
}
|
}
|
||||||
@@ -896,7 +901,7 @@ mod tests {
|
|||||||
&NoEntities,
|
&NoEntities,
|
||||||
);
|
);
|
||||||
assert!(resolved.is_none(), "unresolved item must not build a listing");
|
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!(resp.status, 200);
|
||||||
assert_eq!(parse(&resp)["id"].as_i64().unwrap(), TRADE_ID_BASE);
|
assert_eq!(parse(&resp)["id"].as_i64().unwrap(), TRADE_ID_BASE);
|
||||||
// Nothing persisted at the would-be trade id: not buyable.
|
// Nothing persisted at the would-be trade id: not buyable.
|
||||||
@@ -933,7 +938,7 @@ mod tests {
|
|||||||
&items,
|
&items,
|
||||||
&NoEntities,
|
&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();
|
trade_id = parse(&resp)["id"].as_i64().unwrap();
|
||||||
}
|
}
|
||||||
// Reopen from the same file: both identities survive.
|
// Reopen from the same file: both identities survive.
|
||||||
@@ -952,7 +957,7 @@ mod tests {
|
|||||||
let (store, _d) = store_at("query").await;
|
let (store, _d) = store_at("query").await;
|
||||||
seed_listing(&store, "900000005", 2500).await;
|
seed_listing(&store, "900000005", 2500).await;
|
||||||
let econ = CountingEconomy::with_balance(50);
|
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);
|
let b = parse(&resp);
|
||||||
assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1);
|
assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1);
|
||||||
assert_eq!(b["auctionInfo"][0]["tradeId"], 900000005i64);
|
assert_eq!(b["auctionInfo"][0]["tradeId"], 900000005i64);
|
||||||
@@ -992,35 +997,89 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn own_auction_carries_tradeowner_and_a_consistent_seller_identity() {
|
async fn auction_record_carries_exactly_the_twelve_fields_fifa17_reads() {
|
||||||
// The client renders a listing fine but will not offer Remove / Re-list
|
// FIFA 17's auctionInfo deserializer (0x18013e410) reads TWELVE atoms and
|
||||||
// unless it can tell the auction is the player's OWN. `tradeOwner` is the
|
// value-SKIPs everything else. Emitting extras is not harmless richness: it
|
||||||
// purpose-built flag for that; `sellerId`/`sellerName` must agree with it,
|
// misleads the next reader about what the client consumes, and it is how
|
||||||
// or the row claims to be ours while naming a different seller.
|
// `tradeOwner`/`sellerId`/`offers` got added on library hearsay and then had
|
||||||
let (store, _d) = store_at("owner").await;
|
// to be removed. Pin the set.
|
||||||
|
let (store, _d) = store_at("atoms").await;
|
||||||
let econ = CountingEconomy::with_balance(10_000);
|
let econ = CountingEconomy::with_balance(10_000);
|
||||||
seed_listing(&store, "900000030", 2500).await;
|
seed_listing(&store, "900000030", 2500).await;
|
||||||
|
|
||||||
let rec = parse(&handle_market_query("active", &econ, &store, PERSONA).await)["auctionInfo"]
|
let body = parse(&handle_market_query("active", &econ, &store).await);
|
||||||
[0]
|
let rec = body["auctionInfo"][0].clone();
|
||||||
.clone();
|
let mut got: Vec<&str> = rec.as_object().unwrap().keys().map(String::as_str).collect();
|
||||||
assert_eq!(rec["tradeOwner"], true, "own listing must be flagged");
|
got.sort_unstable();
|
||||||
assert_eq!(rec["sellerId"], PERSONA, "seller id is the player's persona");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rec["sellerName"],
|
got,
|
||||||
non_economy::PERSONA_DISPLAY_NAME,
|
[
|
||||||
"seller name is the player, never EA's house name"
|
"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["bidState"], "none");
|
||||||
assert_eq!(rec["currentBid"], 0);
|
assert_eq!(rec["currentBid"], 0);
|
||||||
assert_eq!(rec["offers"], 0);
|
assert!(rec["expires"].as_i64().unwrap() > 0);
|
||||||
assert_eq!(rec["expires"], 3600, "seconds remaining, never an epoch");
|
assert_eq!(rec["sellerName"], non_economy::PERSONA_DISPLAY_NAME);
|
||||||
|
|
||||||
// The browse/search projection carries the same ownership truth.
|
// GetTradePile shares the IS-list body: four members, including the
|
||||||
let browse = parse(&handle_market_list("GET", None, &econ, &store, PERSONA).await);
|
// `duplicateItemIdList` we used to omit.
|
||||||
assert_eq!(browse["auctionInfo"][0]["tradeOwner"], true);
|
let mut env: Vec<&str> = body.as_object().unwrap().keys().map(String::as_str).collect();
|
||||||
assert_eq!(browse["auctionInfo"][0]["sellerId"], PERSONA);
|
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]
|
#[tokio::test]
|
||||||
@@ -1034,25 +1093,28 @@ mod tests {
|
|||||||
seed_listing(&store, "900000031", 2500).await;
|
seed_listing(&store, "900000031", 2500).await;
|
||||||
|
|
||||||
// No filter: answer with the player's own active pile.
|
// 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!(
|
assert_eq!(
|
||||||
all["auctionInfo"].as_array().unwrap().len(),
|
all["auctionInfo"].as_array().unwrap().len(),
|
||||||
1,
|
1,
|
||||||
"an unfiltered poll must not come back empty while a listing is active"
|
"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]["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.
|
// Explicit tradeIds filter returns exactly the requested auction.
|
||||||
let one = parse(
|
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"].as_array().unwrap().len(), 1);
|
||||||
assert_eq!(one["auctionInfo"][0]["tradeId"], 900_000_031i64);
|
assert_eq!(one["auctionInfo"][0]["tradeId"], 900_000_031i64);
|
||||||
|
|
||||||
// An unknown id is absent, not an error: the poll must never fail closed.
|
// An unknown id is absent, not an error: the poll must never fail closed.
|
||||||
let miss =
|
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);
|
assert_eq!(miss["auctionInfo"].as_array().unwrap().len(), 0);
|
||||||
|
|
||||||
// Garbage is skipped rather than poisoning the whole poll.
|
// Garbage is skipped rather than poisoning the whole poll.
|
||||||
@@ -1073,7 +1135,6 @@ mod tests {
|
|||||||
b"{}",
|
b"{}",
|
||||||
&econ,
|
&econ,
|
||||||
&store,
|
&store,
|
||||||
PERSONA,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(resp.status, 200);
|
assert_eq!(resp.status, 200);
|
||||||
@@ -1095,7 +1156,6 @@ mod tests {
|
|||||||
b"{}",
|
b"{}",
|
||||||
&econ,
|
&econ,
|
||||||
&store,
|
&store,
|
||||||
PERSONA,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(resp.status, 461);
|
assert_eq!(resp.status, 461);
|
||||||
@@ -1119,7 +1179,6 @@ mod tests {
|
|||||||
b"{}",
|
b"{}",
|
||||||
&econ,
|
&econ,
|
||||||
&store,
|
&store,
|
||||||
PERSONA,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(resp.status, 503);
|
assert_eq!(resp.status, 503);
|
||||||
@@ -1139,7 +1198,6 @@ mod tests {
|
|||||||
b"{}",
|
b"{}",
|
||||||
&econ,
|
&econ,
|
||||||
&store,
|
&store,
|
||||||
PERSONA,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(resp.status, 200);
|
assert_eq!(resp.status, 200);
|
||||||
@@ -1161,7 +1219,7 @@ mod tests {
|
|||||||
let e = econ.clone();
|
let e = econ.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let r =
|
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;
|
.await;
|
||||||
let body: Value = serde_json::from_slice(&r.body).unwrap();
|
let body: Value = serde_json::from_slice(&r.body).unwrap();
|
||||||
(r.status, body["auctionInfo"].as_array().unwrap().len())
|
(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`
|
/// stub — a stub leaves the Transfer List with an unrenderable row. `None`
|
||||||
/// only for rows written before this column existed (renders as a stub).
|
/// only for rows written before this column existed (renders as a stub).
|
||||||
pub item_json: Option<String>,
|
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 (
|
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,
|
owner TEXT,
|
||||||
state TEXT NOT NULL CHECK (state IN ('active','reserved','sold','cancelled')),
|
state TEXT NOT NULL CHECK (state IN ('active','reserved','sold','cancelled')),
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
item_json TEXT
|
item_json TEXT,
|
||||||
|
duration_secs INTEGER
|
||||||
)";
|
)";
|
||||||
|
|
||||||
fn now_millis() -> String {
|
fn now_millis() -> String {
|
||||||
@@ -190,6 +231,7 @@ fn row_to_listing(row: &sqlx::sqlite::SqliteRow) -> Listing {
|
|||||||
state: row.get("state"),
|
state: row.get("state"),
|
||||||
created_at: row.get("created_at"),
|
created_at: row.get("created_at"),
|
||||||
item_json: row.get("item_json"),
|
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
|
// 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
|
// file. Add it when absent so an existing market DB keeps working (old
|
||||||
// rows read back `None` and render the stub card).
|
// 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)
|
.fetch_all(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(db)?
|
.map_err(db)?
|
||||||
.iter()
|
.iter()
|
||||||
.any(|r| r.get::<String, _>("name") == "item_json");
|
.map(|r| r.get::<String, _>("name"))
|
||||||
if !has_item_json {
|
.collect();
|
||||||
sqlx::query("ALTER TABLE listings ADD COLUMN item_json TEXT")
|
for (col, decl) in [
|
||||||
.execute(&pool)
|
("item_json", "TEXT"),
|
||||||
.await
|
("duration_secs", "INTEGER"),
|
||||||
.map_err(db)?;
|
] {
|
||||||
|
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 {
|
Ok(MarketStore {
|
||||||
pool,
|
pool,
|
||||||
@@ -275,6 +323,9 @@ impl MarketStore {
|
|||||||
owner: Option<&str>,
|
owner: Option<&str>,
|
||||||
// The shaped FIFA card snapshot (`itemData`) for the auction record.
|
// The shaped FIFA card snapshot (`itemData`) for the auction record.
|
||||||
item_json: Option<&str>,
|
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> {
|
) -> Result<Listing, MarketError> {
|
||||||
let created_at = now_millis();
|
let created_at = now_millis();
|
||||||
let mut conn = self.pool.acquire().await.map_err(db)?;
|
let mut conn = self.pool.acquire().await.map_err(db)?;
|
||||||
@@ -284,8 +335,9 @@ impl MarketStore {
|
|||||||
.map_err(db)?;
|
.map_err(db)?;
|
||||||
let res = sqlx::query(
|
let res = sqlx::query(
|
||||||
"INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, \
|
"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) \
|
wire_resource_id, start_price, buy_now_price, owner, state, created_at, item_json, \
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)",
|
duration_secs) \
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(listing_id)
|
.bind(listing_id)
|
||||||
.bind(card_id)
|
.bind(card_id)
|
||||||
@@ -297,6 +349,7 @@ impl MarketStore {
|
|||||||
.bind(owner)
|
.bind(owner)
|
||||||
.bind(&created_at)
|
.bind(&created_at)
|
||||||
.bind(item_json)
|
.bind(item_json)
|
||||||
|
.bind(duration_secs)
|
||||||
.execute(&mut *conn)
|
.execute(&mut *conn)
|
||||||
.await;
|
.await;
|
||||||
match res {
|
match res {
|
||||||
@@ -317,6 +370,7 @@ impl MarketStore {
|
|||||||
state: "active".to_string(),
|
state: "active".to_string(),
|
||||||
created_at,
|
created_at,
|
||||||
item_json: item_json.map(str::to_string),
|
item_json: item_json.map(str::to_string),
|
||||||
|
duration_secs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -530,7 +584,7 @@ mod tests {
|
|||||||
|
|
||||||
async fn seed(store: &MarketStore, id: &str) -> Listing {
|
async fn seed(store: &MarketStore, id: &str) -> Listing {
|
||||||
store
|
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
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
@@ -561,7 +615,7 @@ mod tests {
|
|||||||
seed(&store, "900000001").await;
|
seed(&store, "900000001").await;
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
store
|
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,
|
.await,
|
||||||
Err(MarketError::Conflict)
|
Err(MarketError::Conflict)
|
||||||
));
|
));
|
||||||
@@ -626,6 +680,7 @@ mod tests {
|
|||||||
2500,
|
2500,
|
||||||
Some("alice"),
|
Some("alice"),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -698,6 +753,7 @@ mod tests {
|
|||||||
2500,
|
2500,
|
||||||
Some("alice"),
|
Some("alice"),
|
||||||
Some(r#"{"rating":84}"#),
|
Some(r#"{"rating":84}"#),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -910,34 +910,21 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
|||||||
k.sort();
|
k.sort();
|
||||||
k
|
k
|
||||||
};
|
};
|
||||||
// Our record is a deliberate SUPERSET of the oracle's. The oracle omits the
|
// Both sides emit exactly FIFA 17's twelve auctionInfo atoms, so this is a
|
||||||
// FIFA 17 ownership fields entirely, which is exactly why parity could not
|
// strict key-set equality. NOTE the limit of that: parity here proves we match
|
||||||
// catch the Actions-panel bug: the field was missing on BOTH sides, because the
|
// the oracle, NOT that either side is complete -- a field absent from BOTH is
|
||||||
// oracle's own remove flow was never driven by a real client either. So assert
|
// invisible to this check. That is exactly how the Transfer List Actions-panel
|
||||||
// (a) we cover every key the oracle emits, and (b) the extra keys are precisely
|
// bug hid, and the client binary's atom table is the authority that settled it
|
||||||
// the ownership set we added on purpose — a NEW unexplained divergence still
|
// (see docs/FIFA17_TRANSFER_MARKET_WIRE.md).
|
||||||
// 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();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
extra,
|
keys(o_rec),
|
||||||
vec!["offers", "sellerId", "tradeOwner"],
|
keys(r_rec),
|
||||||
"the ONLY keys we add beyond the oracle are the FIFA 17 ownership fields"
|
"tradePile auction-record key set parity"
|
||||||
);
|
|
||||||
// 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"
|
|
||||||
);
|
);
|
||||||
for f in [
|
for f in [
|
||||||
"sellerName",
|
"sellerName",
|
||||||
"bidState",
|
"bidState",
|
||||||
"currentBid",
|
"currentBid",
|
||||||
"expires",
|
|
||||||
"sellerEstablished",
|
"sellerEstablished",
|
||||||
"watched",
|
"watched",
|
||||||
"coinsProcessed",
|
"coinsProcessed",
|
||||||
@@ -948,6 +935,12 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
|||||||
r_rec["sellerName"], PERSONA_DISPLAY_NAME,
|
r_rec["sellerName"], PERSONA_DISPLAY_NAME,
|
||||||
"the player's own listing is sold BY the player, never by EA"
|
"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.
|
// itemData must be the full shaped card on both sides; a stub cannot render.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
o_rec["itemData"]["itemState"], r_rec["itemData"]["itemState"],
|
o_rec["itemData"]["itemState"], r_rec["itemData"]["itemState"],
|
||||||
|
|||||||
Reference in New Issue
Block a user