fix(market): make the transfer market work end-to-end (live-verified)
Four defects found by driving a real FIFA 17 client. Each was independently
sufficient to break listing, so all four had to go:
1. Every owned card was shaped `untradeable: true` (adapter item.rs), so the
client greyed out "Place/List on Transfer Market" for the whole club. Owned
and pack-pulled cards are TRADEABLE in FIFA 17; the oracle forces this off
for owned copies too (item_def keeps `true`; instances do not).
2. `POST /auctionhouse` required `itemData.resourceId`, which the client's
FutISStart body never sends (the oracle lists by wire id ALONE). Missing it,
the handler fail-closed and returned 200 while persisting NOTHING. It now
resolves server-side: wire id -> Core owned instance -> its card_id (minted on
a synthetic buy) + FIFA resourceId (the auction record). This also enforces
that a listing can only name a card the club actually owns.
3. An auction record's `itemData` was a 4-field STUB, so the Transfer List had a
row the client could not draw -> "1 item listed" but no visible sale. A
listing now persists a full shaped-card SNAPSHOT (new `listings.item_json`,
additive migration) built by the same `shape_item` shaper `/club` and the
squad projection use, so the auction card renders identically to the club
card. The seller's own pile stamps `itemState: listFS`; market search keeps
`forSale` (the oracle distinguishes these).
4. `/tradePile/counts` shared a handler with `/tradePile`. They are DIFFERENT
deserializers: `/counts` is FutGetAuctionCount, five scalar ints
(count/maxAuctionsAllowed/offered/selling/sold) that it reads and skips
everything else. Served the `auctionInfo` body it left every count at 0, so
the Transfer List screen showed no active sale while the hub tile showed one.
New Route::MarketCounts, classified BEFORE the base tradePile matcher (which
also accepts the /counts path).
Also: a listed card no longer appears in the club. `/club` and the hub's
`clubPlayers` now exclude the transfer pile. Pile membership is host-owned state
Core cannot filter on, so when anything is hidden `/club` reuses the existing
local-filter path (the one `rare=SP` already needed) and paginates the
club-visible set -- letting Core paginate would return short pages. With nothing
hidden the fast Core-paginated path is untouched, and only an EXPLICIT non-club
pile hides a card, so no-pile-row items still default to the club.
Fixed 5 pre-existing test fixtures across 4 targets that listed FABRICATED wire
ids -- only "valid" because the old handler skipped the ownership check.
Tests: 14 targets green + clippy clean, incl. new coverage for the 5-int tally
(asserting it must NOT carry auctionInfo), the full-card snapshot + listFS, and
club pile-exclusion with full-width pagination. The differential test against the
live Python oracle passes.
Verified live on prod: listed=true with a 21-field snapshot; counts
{count:1,selling:1,maxAuctionsAllowed:100}; tradePile renders the 94-rated card;
clubPlayers 1966 -> 1961 (exactly the 5 trade-pile items); listed wire absent
from the club page. Operator confirmed the card is visible in the Transfer List.
This commit is contained in:
@@ -146,6 +146,12 @@ pub struct Listing {
|
||||
pub state: String,
|
||||
/// Creation time, unix-epoch milliseconds as a string (sortable).
|
||||
pub created_at: String,
|
||||
/// The FIFA card object (`itemData`) as shaped at listing time, serialized.
|
||||
/// A listing is a SNAPSHOT: the auction record must carry the full card the
|
||||
/// client can render (rating/position/attributes/rareflag/assetId), not a
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
const CREATE_LISTINGS: &str = "CREATE TABLE IF NOT EXISTS listings (
|
||||
@@ -158,7 +164,8 @@ const CREATE_LISTINGS: &str = "CREATE TABLE IF NOT EXISTS listings (
|
||||
buy_now_price INTEGER NOT NULL,
|
||||
owner TEXT,
|
||||
state TEXT NOT NULL CHECK (state IN ('active','reserved','sold','cancelled')),
|
||||
created_at TEXT NOT NULL
|
||||
created_at TEXT NOT NULL,
|
||||
item_json TEXT
|
||||
)";
|
||||
|
||||
fn now_millis() -> String {
|
||||
@@ -182,6 +189,7 @@ fn row_to_listing(row: &sqlx::sqlite::SqliteRow) -> Listing {
|
||||
owner: row.get("owner"),
|
||||
state: row.get("state"),
|
||||
created_at: row.get("created_at"),
|
||||
item_json: row.get("item_json"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +231,22 @@ impl MarketStore {
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(db)?;
|
||||
// Additive migration: `item_json` was added after the first stores shipped,
|
||||
// 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)")
|
||||
.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)?;
|
||||
}
|
||||
Ok(MarketStore {
|
||||
pool,
|
||||
fault: StoreFault::default(),
|
||||
@@ -249,6 +273,8 @@ impl MarketStore {
|
||||
start_price: i64,
|
||||
buy_now_price: i64,
|
||||
owner: Option<&str>,
|
||||
// The shaped FIFA card snapshot (`itemData`) for the auction record.
|
||||
item_json: Option<&str>,
|
||||
) -> Result<Listing, MarketError> {
|
||||
let created_at = now_millis();
|
||||
let mut conn = self.pool.acquire().await.map_err(db)?;
|
||||
@@ -258,8 +284,8 @@ 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) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?)",
|
||||
wire_resource_id, start_price, buy_now_price, owner, state, created_at, item_json) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)",
|
||||
)
|
||||
.bind(listing_id)
|
||||
.bind(card_id)
|
||||
@@ -270,6 +296,7 @@ impl MarketStore {
|
||||
.bind(buy_now_price)
|
||||
.bind(owner)
|
||||
.bind(&created_at)
|
||||
.bind(item_json)
|
||||
.execute(&mut *conn)
|
||||
.await;
|
||||
match res {
|
||||
@@ -289,6 +316,7 @@ impl MarketStore {
|
||||
owner: owner.map(str::to_string),
|
||||
state: "active".to_string(),
|
||||
created_at,
|
||||
item_json: item_json.map(str::to_string),
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -502,7 +530,7 @@ mod tests {
|
||||
|
||||
async fn seed(store: &MarketStore, id: &str) -> Listing {
|
||||
store
|
||||
.create_listing(id, "card_pl_001", None, None, None, 900, 2500, None)
|
||||
.create_listing(id, "card_pl_001", None, None, None, 900, 2500, None, None)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
@@ -533,7 +561,7 @@ mod tests {
|
||||
seed(&store, "900000001").await;
|
||||
assert!(matches!(
|
||||
store
|
||||
.create_listing("900000001", "card_pl_002", None, None, None, 1, 2, None)
|
||||
.create_listing("900000001", "card_pl_002", None, None, None, 1, 2, None, None)
|
||||
.await,
|
||||
Err(MarketError::Conflict)
|
||||
));
|
||||
@@ -597,6 +625,7 @@ mod tests {
|
||||
900,
|
||||
2500,
|
||||
Some("alice"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -668,6 +697,7 @@ mod tests {
|
||||
900,
|
||||
2500,
|
||||
Some("alice"),
|
||||
Some(r#"{"rating":84}"#),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -683,5 +713,10 @@ mod tests {
|
||||
assert_eq!(got.core_item_id.as_deref(), Some("core-7"));
|
||||
assert_eq!(got.wire_item_id, Some(100004617));
|
||||
assert_eq!(got.owner.as_deref(), Some("alice"));
|
||||
assert_eq!(
|
||||
got.item_json.as_deref(),
|
||||
Some(r#"{"rating":84}"#),
|
||||
"card snapshot survives reopen"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user