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:
+146
-88
@@ -33,7 +33,7 @@ use openfut_adapter_fifa17::fut::squad::SquadWireResolver;
|
||||
|
||||
use crate::economy_store::OwnedItemLookup;
|
||||
|
||||
use crate::market_store::{Listing, MarketError, MarketStore};
|
||||
use crate::market_store::{now_secs, Listing, MarketError, MarketStore};
|
||||
use crate::pile_store::PileStore;
|
||||
use crate::{CoreEconomy, CoreError, WireResponse};
|
||||
|
||||
@@ -82,13 +82,31 @@ fn trade_id_from_path(path: &str) -> Option<String> {
|
||||
/// `item_state` overrides the card's `itemState`: the seller's own pile uses
|
||||
/// `listFS` (list-for-sale), market search results use `forSale` — the oracle
|
||||
/// distinguishes these, so the caller passes the one its screen needs.
|
||||
fn auction_record_as(l: &Listing, item_state: &str, persona_id: i64) -> Value {
|
||||
fn auction_record_as(l: &Listing, item_state: &str) -> Value {
|
||||
let trade_id: i64 = l.listing_id.parse().unwrap_or(0);
|
||||
// resourceId is the FIFA wire identity the client listed (never the Core
|
||||
// card id). 0 means "no art", a valid int — never a fabricated FIFA asset.
|
||||
let resource = l.wire_resource_id.or(l.wire_item_id).unwrap_or(0);
|
||||
let item_id = l.wire_item_id.unwrap_or(trade_id);
|
||||
// `expires` is SECONDS REMAINING (a 64-bit int), never an epoch, and the
|
||||
// client renders a LIVE COUNTDOWN from it and expects it to reach 0. A frozen
|
||||
// constant is therefore wrong on the wire even though it renders: the auction
|
||||
// never appears to age. Derived from the stored creation time plus the
|
||||
// client-supplied listing duration.
|
||||
let expires = l.expires_in_secs(now_secs());
|
||||
// An unsold auction whose clock has run out reads `expired`/`none` with
|
||||
// `expires: 0` — that is FIFA 17's relistable state. Both vocabularies are
|
||||
// closed sets read out of the client: `tradeState` decodes through a table
|
||||
// walk (`active=1 inactive=2 expired=3 closed=4`, anything else -1) and
|
||||
// `bidState` through a strcmp ladder (`none=0 outbid=1 highest=2 buyNow=3`,
|
||||
// anything else silently `none`). NEVER invent a state string — an
|
||||
// unrecognised one is swallowed as `none` and produces a plausible-looking
|
||||
// but wrong UI. There is no `won`, `lost` or `sold`.
|
||||
//
|
||||
// This is a pure PROJECTION: no row is mutated, so nothing here can race the
|
||||
// economy or need a background sweeper.
|
||||
let (trade_state, bid_state, current_bid) = match l.state.as_str() {
|
||||
"active" if expires == 0 => ("expired", "none", 0),
|
||||
"active" => ("active", "none", 0),
|
||||
_ => ("closed", "highest", l.buy_now_price),
|
||||
};
|
||||
@@ -112,24 +130,17 @@ fn auction_record_as(l: &Listing, item_state: &str, persona_id: i64) -> Value {
|
||||
"untradeable": false,
|
||||
})
|
||||
});
|
||||
// OWNERSHIP. `tradeOwner` is FIFA 17's purpose-built "this auction is mine"
|
||||
// boolean, and it is the field the Transfer List needs before it will offer
|
||||
// Remove / Re-list on a row. We omitted it entirely, which is consistent with
|
||||
// the observed symptom: the listing rendered but selecting it opened nothing.
|
||||
// EXACTLY the twelve fields FIFA 17's auctionInfo deserializer (0x18013e410)
|
||||
// reads. Everything else falls through to its value-SKIP at 0x180135ff0, so an
|
||||
// extra key is not "harmless richness" — it is dead weight that misleads the
|
||||
// next reader about what the client consumes.
|
||||
//
|
||||
// Provenance: FIFA17-HISTORICAL (contemporaneous FIFA 17 clients carry
|
||||
// `tradeOwner`/`sellerId`/`offers` in auctionInfo). NOT confirmed from our own
|
||||
// capture, and notably ABSENT from the Python oracle too — which is why the
|
||||
// differential could not catch it: the oracle's own remove flow was never
|
||||
// exercised against a real client either.
|
||||
//
|
||||
// This store has no NPC seller, so an unnamed owner is the player. Comparing
|
||||
// rather than hardcoding `true` keeps the flag honest if a foreign seller is
|
||||
// ever introduced.
|
||||
let own = l
|
||||
.owner
|
||||
.as_deref()
|
||||
.is_none_or(|o| o == non_economy::PERSONA_DISPLAY_NAME);
|
||||
// In particular `tradeOwner` / `sellerId` / `offers` are NOT read by FIFA 17.
|
||||
// They were added here on the strength of contemporaneous FIFA 17 libraries
|
||||
// and are refuted by the PE's own atom table (see
|
||||
// docs/FIFA17_TRANSFER_MARKET_WIRE.md): the client cannot be told "this
|
||||
// auction is yours" through the record at all, so ownership is NOT the gate on
|
||||
// the Transfer List Actions panel.
|
||||
json!({
|
||||
"tradeId": trade_id,
|
||||
"itemData": item_data,
|
||||
@@ -137,21 +148,11 @@ fn auction_record_as(l: &Listing, item_state: &str, persona_id: i64) -> Value {
|
||||
"buyNowPrice": l.buy_now_price,
|
||||
"startingBid": l.start_price,
|
||||
"currentBid": current_bid,
|
||||
// Bid count. 0 is correct for an active/unbid auction alongside
|
||||
// `bidState: "none"` (FIFA17-HISTORICAL).
|
||||
"offers": 0,
|
||||
"bidState": bid_state,
|
||||
// SECONDS REMAINING, never an absolute epoch (FIFA17-HISTORICAL).
|
||||
"expires": 3600,
|
||||
"tradeOwner": own,
|
||||
// The seller identity must agree with `tradeOwner`: our own auction is sold
|
||||
// by our own persona. Numeric because every other persona id on this wire
|
||||
// is numeric; the id itself is never baked in (it comes from config, so it
|
||||
// keeps matching the persona LSX/Blaze/POW/UTAS agree on).
|
||||
"sellerId": if own { persona_id } else { 0 },
|
||||
// Every listing in this store is the player's OWN, so the seller defaults
|
||||
// to the player's persona name exactly as the oracle stamps it. EA's house
|
||||
// name here makes the player's own listing look foreign.
|
||||
"expires": expires,
|
||||
// Bounded copy, max 30 chars. The oracle stamps the player's persona here
|
||||
// and `fut_account.py` annotates that property as "UTAS sellerName", so
|
||||
// EA's house name would make the player's own listing look foreign.
|
||||
"sellerName": l
|
||||
.owner
|
||||
.clone()
|
||||
@@ -164,9 +165,9 @@ fn auction_record_as(l: &Listing, item_state: &str, persona_id: i64) -> Value {
|
||||
|
||||
/// Auction record for a market/search context (`itemState: forSale`), and for the
|
||||
/// closed/sold echoes the buy path returns.
|
||||
fn auction_record(l: &Listing, persona_id: i64) -> Value {
|
||||
fn auction_record(l: &Listing) -> Value {
|
||||
let state = if l.state == "active" { "forSale" } else { "free" };
|
||||
auction_record_as(l, state, persona_id)
|
||||
auction_record_as(l, state)
|
||||
}
|
||||
|
||||
/// Run a BLOCKING closure — the blocking Core client — on a fresh OS thread that
|
||||
@@ -206,6 +207,9 @@ pub struct ResolvedListing {
|
||||
/// The full shaped FIFA card (`itemData`) snapshot for the auction record.
|
||||
/// `None` only when the item has no resolvable FIFA identity (never faked).
|
||||
pub item_json: Option<String>,
|
||||
/// Listing duration in seconds from the client's body. `None` when the client
|
||||
/// omits it, which falls back to the store's default.
|
||||
pub duration: Option<i64>,
|
||||
}
|
||||
|
||||
/// Resolve a `/auctionhouse` POST (FutISStart) body to a [`ResolvedListing`],
|
||||
@@ -249,6 +253,10 @@ pub fn resolve_market_list<E: ReverseEntityResolver>(
|
||||
buy_now: b.get("buyNowPrice").and_then(Value::as_i64).unwrap_or(0),
|
||||
seller: b.get("sellerName").and_then(Value::as_str).map(str::to_string),
|
||||
item_json,
|
||||
// FIFA 17's ISStart body carries the listing duration in seconds. We
|
||||
// previously dropped it and reported a frozen `expires`, so the client's
|
||||
// countdown never moved and an auction could never run out.
|
||||
duration: b.get("duration").and_then(Value::as_i64).filter(|d| *d > 0),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -265,7 +273,6 @@ pub async fn handle_market_list(
|
||||
resolved: Option<ResolvedListing>,
|
||||
econ: &dyn CoreEconomy,
|
||||
store: &MarketStore,
|
||||
persona_id: i64,
|
||||
) -> WireResponse {
|
||||
match method {
|
||||
"POST" => {
|
||||
@@ -291,6 +298,7 @@ pub async fn handle_market_list(
|
||||
r.buy_now,
|
||||
r.seller.as_deref(),
|
||||
r.item_json.as_deref(),
|
||||
r.duration,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -313,7 +321,7 @@ pub async fn handle_market_list(
|
||||
};
|
||||
let auctions: Vec<Value> = listings
|
||||
.iter()
|
||||
.map(|l| auction_record(l, persona_id))
|
||||
.map(auction_record)
|
||||
.collect();
|
||||
ok_json(&json!({
|
||||
"auctionInfo": auctions,
|
||||
@@ -340,7 +348,6 @@ pub async fn handle_market_query(
|
||||
state: &str,
|
||||
econ: &dyn CoreEconomy,
|
||||
store: &MarketStore,
|
||||
persona_id: i64,
|
||||
) -> WireResponse {
|
||||
let listings = match store.query_listings(state).await {
|
||||
Ok(l) => l,
|
||||
@@ -348,12 +355,17 @@ pub async fn handle_market_query(
|
||||
};
|
||||
let auctions: Vec<Value> = listings
|
||||
.iter()
|
||||
.map(|l| auction_record_as(l, "listFS", persona_id))
|
||||
.map(|l| auction_record_as(l, "listFS"))
|
||||
.collect();
|
||||
// GetTradePile shares one deserializer (0x18013e7f0) with ISSearch and
|
||||
// ISWatchList, over exactly four members: `auctionInfo` (array), `credits`
|
||||
// (int), `duplicateItemIdList` (array of objects) and `total` (int). We were
|
||||
// omitting `duplicateItemIdList`; `[]` is the safe, recommended value.
|
||||
ok_json(&json!({
|
||||
"auctionInfo": auctions,
|
||||
"credits": credits_or_zero(econ),
|
||||
"total": auctions.len(),
|
||||
"duplicateItemIdList": [],
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -414,7 +426,6 @@ pub async fn handle_market_status(
|
||||
query: Option<&str>,
|
||||
econ: &dyn CoreEconomy,
|
||||
store: &MarketStore,
|
||||
persona_id: i64,
|
||||
) -> WireResponse {
|
||||
let ids = trade_ids_from_query(query);
|
||||
let listings = if ids.is_empty() {
|
||||
@@ -433,7 +444,7 @@ pub async fn handle_market_status(
|
||||
};
|
||||
let auctions: Vec<Value> = listings
|
||||
.iter()
|
||||
.map(|l| auction_record_as(l, "listFS", persona_id))
|
||||
.map(|l| auction_record_as(l, "listFS"))
|
||||
.collect();
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=market-status requested={} returned={} query={}",
|
||||
@@ -471,7 +482,6 @@ pub async fn handle_market_buy(
|
||||
body: &[u8],
|
||||
econ: &dyn CoreEconomy,
|
||||
store: &MarketStore,
|
||||
persona_id: i64,
|
||||
) -> WireResponse {
|
||||
let Some(id) = trade_id_from_path(path) else {
|
||||
return ok_json(&json!({ "auctionInfo": [], "credits": credits_or_zero(econ) }));
|
||||
@@ -480,7 +490,7 @@ pub async fn handle_market_buy(
|
||||
if method != "POST" && method != "PUT" {
|
||||
// GET: view one auction.
|
||||
let rec = match store.get_listing(&id).await {
|
||||
Ok(l) => vec![auction_record(&l, persona_id)],
|
||||
Ok(l) => vec![auction_record(&l)],
|
||||
Err(_) => vec![],
|
||||
};
|
||||
return ok_json(&json!({ "auctionInfo": rec, "credits": credits_or_zero(econ) }));
|
||||
@@ -501,10 +511,9 @@ pub async fn handle_market_buy(
|
||||
// A simple bid below buy-now: we are the sole bidder — echo the raised bid,
|
||||
// no coin movement, no reservation.
|
||||
if bid < listing.buy_now_price {
|
||||
let mut rec = auction_record(&listing, persona_id);
|
||||
let mut rec = auction_record(&listing);
|
||||
rec["currentBid"] = json!(bid);
|
||||
rec["bidState"] = json!("highest");
|
||||
rec["offers"] = json!(1);
|
||||
return ok_json(&json!({ "auctionInfo": [rec], "credits": credits_or_zero(econ) }));
|
||||
}
|
||||
|
||||
@@ -547,7 +556,7 @@ pub async fn handle_market_buy(
|
||||
if let Err(e) = store.complete_sale(&id).await {
|
||||
eprintln!("utas-host WARN market complete_sale({id}) after mint failed: {e}");
|
||||
}
|
||||
let mut rec = auction_record(&listing, persona_id);
|
||||
let mut rec = auction_record(&listing);
|
||||
rec["tradeState"] = json!("closed");
|
||||
rec["bidState"] = json!("highest");
|
||||
rec["currentBid"] = json!(price);
|
||||
@@ -613,10 +622,6 @@ mod tests {
|
||||
use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Seller persona for auction records under test. Matches the live profile so
|
||||
/// a `sellerId` assertion is meaningful rather than tautological.
|
||||
const PERSONA: i64 = 33_068_179;
|
||||
|
||||
// ---- temp DB helpers ---------------------------------------------------
|
||||
|
||||
struct TempDb(String);
|
||||
@@ -806,7 +811,7 @@ mod tests {
|
||||
|
||||
async fn seed_listing(store: &MarketStore, id: &str, buy_now: i64) {
|
||||
store
|
||||
.create_listing(id, "169193", None, None, Some(169193), 400, buy_now, None, None)
|
||||
.create_listing(id, "169193", None, None, Some(169193), 400, buy_now, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
@@ -839,7 +844,7 @@ mod tests {
|
||||
&items,
|
||||
&NoEntities,
|
||||
);
|
||||
let resp = handle_market_list("POST", resolved, &econ, &store, PERSONA).await;
|
||||
let resp = handle_market_list("POST", resolved, &econ, &store).await;
|
||||
assert_eq!(resp.status, 200);
|
||||
let trade_id = parse(&resp)["id"].as_i64().unwrap();
|
||||
assert_eq!(trade_id, TRADE_ID_BASE + 100004617);
|
||||
@@ -855,13 +860,13 @@ mod tests {
|
||||
assert_eq!(snap["preferredPosition"], "ST");
|
||||
assert_eq!(snap["attributeList"].as_array().unwrap().len(), 6);
|
||||
// tradePile embeds that full card and stamps the seller-pile state.
|
||||
let pile = handle_market_query("active", &econ, &store, PERSONA).await;
|
||||
let pile = handle_market_query("active", &econ, &store).await;
|
||||
let rec = parse(&pile)["auctionInfo"][0].clone();
|
||||
assert_eq!(rec["itemData"]["itemState"], "listFS");
|
||||
assert_eq!(rec["itemData"]["rating"], 84);
|
||||
assert_eq!(rec["itemData"]["id"], 100004617i64);
|
||||
assert_eq!(rec["itemData"]["resourceId"], 169193);
|
||||
let browse = handle_market_list("GET", None, &econ, &store, PERSONA).await;
|
||||
let browse = handle_market_list("GET", None, &econ, &store).await;
|
||||
let b = parse(&browse);
|
||||
assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(b["credits"], 10_000);
|
||||
@@ -872,7 +877,7 @@ mod tests {
|
||||
async fn list_put_is_ack() {
|
||||
let (store, _d) = store_at("put").await;
|
||||
let econ = CountingEconomy::with_balance(0);
|
||||
let resp = handle_market_list("PUT", None, &econ, &store, PERSONA).await;
|
||||
let resp = handle_market_list("PUT", None, &econ, &store).await;
|
||||
assert_eq!(resp.status, 200);
|
||||
assert_eq!(parse(&resp), json!({}));
|
||||
}
|
||||
@@ -896,7 +901,7 @@ mod tests {
|
||||
&NoEntities,
|
||||
);
|
||||
assert!(resolved.is_none(), "unresolved item must not build a listing");
|
||||
let resp = handle_market_list("POST", resolved, &econ, &store, PERSONA).await;
|
||||
let resp = handle_market_list("POST", resolved, &econ, &store).await;
|
||||
assert_eq!(resp.status, 200);
|
||||
assert_eq!(parse(&resp)["id"].as_i64().unwrap(), TRADE_ID_BASE);
|
||||
// Nothing persisted at the would-be trade id: not buyable.
|
||||
@@ -933,7 +938,7 @@ mod tests {
|
||||
&items,
|
||||
&NoEntities,
|
||||
);
|
||||
let resp = handle_market_list("POST", resolved, &econ, &store, PERSONA).await;
|
||||
let resp = handle_market_list("POST", resolved, &econ, &store).await;
|
||||
trade_id = parse(&resp)["id"].as_i64().unwrap();
|
||||
}
|
||||
// Reopen from the same file: both identities survive.
|
||||
@@ -952,7 +957,7 @@ mod tests {
|
||||
let (store, _d) = store_at("query").await;
|
||||
seed_listing(&store, "900000005", 2500).await;
|
||||
let econ = CountingEconomy::with_balance(50);
|
||||
let resp = handle_market_query("active", &econ, &store, PERSONA).await;
|
||||
let resp = handle_market_query("active", &econ, &store).await;
|
||||
let b = parse(&resp);
|
||||
assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(b["auctionInfo"][0]["tradeId"], 900000005i64);
|
||||
@@ -992,35 +997,89 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn own_auction_carries_tradeowner_and_a_consistent_seller_identity() {
|
||||
// The client renders a listing fine but will not offer Remove / Re-list
|
||||
// unless it can tell the auction is the player's OWN. `tradeOwner` is the
|
||||
// purpose-built flag for that; `sellerId`/`sellerName` must agree with it,
|
||||
// or the row claims to be ours while naming a different seller.
|
||||
let (store, _d) = store_at("owner").await;
|
||||
async fn auction_record_carries_exactly_the_twelve_fields_fifa17_reads() {
|
||||
// FIFA 17's auctionInfo deserializer (0x18013e410) reads TWELVE atoms and
|
||||
// value-SKIPs everything else. Emitting extras is not harmless richness: it
|
||||
// misleads the next reader about what the client consumes, and it is how
|
||||
// `tradeOwner`/`sellerId`/`offers` got added on library hearsay and then had
|
||||
// to be removed. Pin the set.
|
||||
let (store, _d) = store_at("atoms").await;
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
seed_listing(&store, "900000030", 2500).await;
|
||||
|
||||
let rec = parse(&handle_market_query("active", &econ, &store, PERSONA).await)["auctionInfo"]
|
||||
[0]
|
||||
.clone();
|
||||
assert_eq!(rec["tradeOwner"], true, "own listing must be flagged");
|
||||
assert_eq!(rec["sellerId"], PERSONA, "seller id is the player's persona");
|
||||
let body = parse(&handle_market_query("active", &econ, &store).await);
|
||||
let rec = body["auctionInfo"][0].clone();
|
||||
let mut got: Vec<&str> = rec.as_object().unwrap().keys().map(String::as_str).collect();
|
||||
got.sort_unstable();
|
||||
assert_eq!(
|
||||
rec["sellerName"],
|
||||
non_economy::PERSONA_DISPLAY_NAME,
|
||||
"seller name is the player, never EA's house name"
|
||||
got,
|
||||
[
|
||||
"bidState",
|
||||
"buyNowPrice",
|
||||
"coinsProcessed",
|
||||
"currentBid",
|
||||
"expires",
|
||||
"itemData",
|
||||
"sellerEstablished",
|
||||
"sellerName",
|
||||
"startingBid",
|
||||
"tradeId",
|
||||
"tradeState",
|
||||
"watched",
|
||||
],
|
||||
"auctionInfo must be exactly FIFA 17's twelve atoms"
|
||||
);
|
||||
// The unbid-active tuple the client expects alongside those.
|
||||
// "listed by user" is active / none / currentBid 0 / expires > 0. Both
|
||||
// vocabularies are closed sets; an unrecognised bidState is swallowed as
|
||||
// `none` and renders a plausible but wrong UI.
|
||||
assert_eq!(rec["tradeState"], "active");
|
||||
assert_eq!(rec["bidState"], "none");
|
||||
assert_eq!(rec["currentBid"], 0);
|
||||
assert_eq!(rec["offers"], 0);
|
||||
assert_eq!(rec["expires"], 3600, "seconds remaining, never an epoch");
|
||||
assert!(rec["expires"].as_i64().unwrap() > 0);
|
||||
assert_eq!(rec["sellerName"], non_economy::PERSONA_DISPLAY_NAME);
|
||||
|
||||
// The browse/search projection carries the same ownership truth.
|
||||
let browse = parse(&handle_market_list("GET", None, &econ, &store, PERSONA).await);
|
||||
assert_eq!(browse["auctionInfo"][0]["tradeOwner"], true);
|
||||
assert_eq!(browse["auctionInfo"][0]["sellerId"], PERSONA);
|
||||
// GetTradePile shares the IS-list body: four members, including the
|
||||
// `duplicateItemIdList` we used to omit.
|
||||
let mut env: Vec<&str> = body.as_object().unwrap().keys().map(String::as_str).collect();
|
||||
env.sort_unstable();
|
||||
assert_eq!(
|
||||
env,
|
||||
["auctionInfo", "credits", "duplicateItemIdList", "total"],
|
||||
"GetTradePile envelope is the shared IS-list body"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expires_counts_down_and_an_unsold_auction_reads_expired() {
|
||||
// `expires` is SECONDS REMAINING and the client renders a live countdown
|
||||
// that it expects to reach 0. A frozen constant means the auction never
|
||||
// ages and can never run out.
|
||||
use crate::market_store::DEFAULT_DURATION_SECS;
|
||||
let (store, _d) = store_at("clock").await;
|
||||
seed_listing(&store, "900000040", 2500).await;
|
||||
let l = store.get_listing("900000040").await.unwrap();
|
||||
|
||||
let created = l.created_at.parse::<i64>().unwrap() / 1000;
|
||||
assert_eq!(
|
||||
l.expires_in_secs(created),
|
||||
DEFAULT_DURATION_SECS,
|
||||
"a fresh listing has its whole duration left"
|
||||
);
|
||||
assert_eq!(
|
||||
l.expires_in_secs(created + 600),
|
||||
DEFAULT_DURATION_SECS - 600,
|
||||
"the clock actually advances"
|
||||
);
|
||||
assert_eq!(
|
||||
l.expires_in_secs(created + DEFAULT_DURATION_SECS + 5),
|
||||
0,
|
||||
"expiry clamps at 0, never negative"
|
||||
);
|
||||
|
||||
// A closed listing has no time left regardless of when it was created.
|
||||
let mut sold = l.clone();
|
||||
sold.state = "sold".into();
|
||||
assert_eq!(sold.expires_in_secs(created), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1034,25 +1093,28 @@ mod tests {
|
||||
seed_listing(&store, "900000031", 2500).await;
|
||||
|
||||
// No filter: answer with the player's own active pile.
|
||||
let all = parse(&handle_market_status(None, &econ, &store, PERSONA).await);
|
||||
let all = parse(&handle_market_status(None, &econ, &store).await);
|
||||
assert_eq!(
|
||||
all["auctionInfo"].as_array().unwrap().len(),
|
||||
1,
|
||||
"an unfiltered poll must not come back empty while a listing is active"
|
||||
);
|
||||
assert_eq!(all["auctionInfo"][0]["tradeId"], 900_000_031i64);
|
||||
assert_eq!(all["auctionInfo"][0]["tradeOwner"], true);
|
||||
// ISViewTrade's body is the auction list plus credits — no `total` and no
|
||||
// `duplicateItemIdList`, unlike the shared IS-list body.
|
||||
assert_eq!(all["auctionInfo"][0]["tradeState"], "active");
|
||||
assert!(all["credits"].is_i64());
|
||||
|
||||
// Explicit tradeIds filter returns exactly the requested auction.
|
||||
let one = parse(
|
||||
&handle_market_status(Some("tradeIds=900000031"), &econ, &store, PERSONA).await,
|
||||
&handle_market_status(Some("tradeIds=900000031"), &econ, &store).await,
|
||||
);
|
||||
assert_eq!(one["auctionInfo"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(one["auctionInfo"][0]["tradeId"], 900_000_031i64);
|
||||
|
||||
// An unknown id is absent, not an error: the poll must never fail closed.
|
||||
let miss =
|
||||
parse(&handle_market_status(Some("tradeIds=900000099"), &econ, &store, PERSONA).await);
|
||||
parse(&handle_market_status(Some("tradeIds=900000099"), &econ, &store).await);
|
||||
assert_eq!(miss["auctionInfo"].as_array().unwrap().len(), 0);
|
||||
|
||||
// Garbage is skipped rather than poisoning the whole poll.
|
||||
@@ -1073,7 +1135,6 @@ mod tests {
|
||||
b"{}",
|
||||
&econ,
|
||||
&store,
|
||||
PERSONA,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status, 200);
|
||||
@@ -1095,7 +1156,6 @@ mod tests {
|
||||
b"{}",
|
||||
&econ,
|
||||
&store,
|
||||
PERSONA,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status, 461);
|
||||
@@ -1119,7 +1179,6 @@ mod tests {
|
||||
b"{}",
|
||||
&econ,
|
||||
&store,
|
||||
PERSONA,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status, 503);
|
||||
@@ -1139,7 +1198,6 @@ mod tests {
|
||||
b"{}",
|
||||
&econ,
|
||||
&store,
|
||||
PERSONA,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status, 200);
|
||||
@@ -1161,7 +1219,7 @@ mod tests {
|
||||
let e = econ.clone();
|
||||
tokio::spawn(async move {
|
||||
let r =
|
||||
handle_market_buy("POST", "/ut/game/fifa17/trade/900000020", b"{}", &*e, &s, PERSONA)
|
||||
handle_market_buy("POST", "/ut/game/fifa17/trade/900000020", b"{}", &*e, &s)
|
||||
.await;
|
||||
let body: Value = serde_json::from_slice(&r.body).unwrap();
|
||||
(r.status, body["auctionInfo"].as_array().unwrap().len())
|
||||
|
||||
Reference in New Issue
Block a user