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:
+307
-103
@@ -26,8 +26,12 @@
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use openfut_adapter_fifa17::fut::entities::ReverseEntityResolver;
|
||||
use openfut_adapter_fifa17::fut::item::{shape_item, ItemIdentityResolver};
|
||||
use openfut_adapter_fifa17::fut::squad::SquadWireResolver;
|
||||
|
||||
use crate::economy_store::OwnedItemLookup;
|
||||
|
||||
use crate::market_store::{Listing, MarketError, MarketStore};
|
||||
use crate::pile_store::PileStore;
|
||||
use crate::{CoreEconomy, CoreError, WireResponse};
|
||||
@@ -66,24 +70,50 @@ fn trade_id_from_path(path: &str) -> Option<String> {
|
||||
|
||||
/// Shape one listing into the FIFA auction record (0x18013e410 fields), sourced
|
||||
/// from durable listing state rather than a hardcoded sample pool.
|
||||
fn auction_record(l: &Listing) -> Value {
|
||||
///
|
||||
/// `itemData` MUST be a full card object — the same proven-safe shape that renders
|
||||
/// club/squad cards (the Python oracle's tradePile reuses its club card verbatim).
|
||||
/// A 4-field stub gives the client's card view-model nothing to draw, so the
|
||||
/// Transfer List shows a row with no visible card. The full card comes from the
|
||||
/// snapshot persisted at listing time; a row written before snapshots existed
|
||||
/// degrades to the stub (honest, not fabricated).
|
||||
///
|
||||
/// `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) -> 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);
|
||||
let (trade_state, item_state, bid_state, current_bid) = match l.state.as_str() {
|
||||
"active" => ("active", "forSale", "none", 0),
|
||||
_ => ("closed", "free", "highest", l.buy_now_price),
|
||||
let (trade_state, bid_state, current_bid) = match l.state.as_str() {
|
||||
"active" => ("active", "none", 0),
|
||||
_ => ("closed", "highest", l.buy_now_price),
|
||||
};
|
||||
let item_data = l
|
||||
.item_json
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<Value>(s).ok())
|
||||
.filter(Value::is_object)
|
||||
.map(|mut card| {
|
||||
// Keep the wire identity and presentation state authoritative here.
|
||||
card["id"] = json!(item_id);
|
||||
card["itemState"] = json!(item_state);
|
||||
card["untradeable"] = json!(false);
|
||||
card
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
json!({
|
||||
"id": item_id,
|
||||
"resourceId": resource,
|
||||
"itemState": item_state,
|
||||
"untradeable": false,
|
||||
})
|
||||
});
|
||||
json!({
|
||||
"tradeId": trade_id,
|
||||
"itemData": {
|
||||
"id": item_id,
|
||||
"resourceId": resource,
|
||||
"itemState": item_state,
|
||||
"untradeable": false,
|
||||
},
|
||||
"itemData": item_data,
|
||||
"tradeState": trade_state,
|
||||
"buyNowPrice": l.buy_now_price,
|
||||
"startingBid": l.start_price,
|
||||
@@ -97,6 +127,13 @@ fn auction_record(l: &Listing) -> 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) -> Value {
|
||||
let state = if l.state == "active" { "forSale" } else { "free" };
|
||||
auction_record_as(l, state)
|
||||
}
|
||||
|
||||
/// Run a BLOCKING closure — the blocking Core client — on a fresh OS thread that
|
||||
/// has NO Tokio runtime entered, then block until it finishes. The market
|
||||
/// handlers are `async` and driven on the shared runtime by the host bridge, but
|
||||
@@ -119,74 +156,115 @@ fn credits_or_zero(econ: &dyn CoreEconomy) -> i64 {
|
||||
off_runtime(|| econ.balance()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Maps a FIFA wire `resourceId` to the authoritative Core `card_id` a synthetic
|
||||
/// buy mints. Backed by the FIFA17 catalog reverse index; unknown → `None`
|
||||
/// (fail closed, never fabricated). Core stays unaware of FIFA resource ids.
|
||||
pub trait MarketCardResolver: Send + Sync {
|
||||
fn card_id_for_resource(&self, resource_id: i64) -> Option<String>;
|
||||
/// A FutISStart POST resolved to a persistable listing — all owned/`Send` data,
|
||||
/// so it can cross the async persist boundary. Built by [`resolve_market_list`]
|
||||
/// on the caller's thread from Core inventory (the request body carries only the
|
||||
/// wire item id, never the resourceId or Core card_id).
|
||||
pub struct ResolvedListing {
|
||||
pub item_id: i64,
|
||||
pub core_id: String,
|
||||
pub card_id: String,
|
||||
pub resource_id: Option<i64>,
|
||||
pub start: i64,
|
||||
pub buy_now: i64,
|
||||
pub seller: Option<String>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// Resolve a `/auctionhouse` POST (FutISStart) body to a [`ResolvedListing`],
|
||||
/// SERVER-SIDE: the client's body carries only the wire item id, so the owned
|
||||
/// card's Core `card_id` (minted on a synthetic buy) and FIFA `resourceId` (the
|
||||
/// auction record) come from Core inventory. Returns `None` when the body names
|
||||
/// no item OR the id does not reverse-resolve to an owned card — either way the
|
||||
/// caller acks a fresh trade id and persists nothing (Core stays the ownership
|
||||
/// authority; a phantom listing would mint a card the buy preflight rejects).
|
||||
/// Pure identity + a single Core inventory read; run it OFF the async runtime.
|
||||
pub fn resolve_market_list<E: ReverseEntityResolver>(
|
||||
body: &[u8],
|
||||
reverse: &dyn SquadWireResolver,
|
||||
resolver: &dyn ItemIdentityResolver,
|
||||
items: &dyn OwnedItemLookup,
|
||||
ent: &E,
|
||||
) -> Option<ResolvedListing> {
|
||||
let b = parse_body(body);
|
||||
let item_data = b.get("itemData");
|
||||
let item_id = item_data
|
||||
.and_then(|d| d.get("id"))
|
||||
.and_then(Value::as_i64)
|
||||
.or_else(|| b.get("itemId").and_then(Value::as_i64))?;
|
||||
let core_id = reverse.owned_id_for_wire(item_id)?;
|
||||
let owned = items.owned_item(&core_id)?;
|
||||
// Shape the FULL card ONCE, here, with the same shaper `/club` and the squad
|
||||
// projection use — so the auction record renders identically to the club card.
|
||||
// A listing is a snapshot; storing it avoids re-resolving on every tradePile
|
||||
// poll and keeps the (non-Send) resolvers off the async persist path.
|
||||
let identity = resolver.resolve(&owned);
|
||||
let resource_id = identity.map(|id| id.resource_id as i64);
|
||||
let item_json = identity
|
||||
.map(|id| shape_item(&owned, id, ent))
|
||||
.and_then(|card| serde_json::to_string(&card).ok());
|
||||
Some(ResolvedListing {
|
||||
item_id,
|
||||
core_id,
|
||||
card_id: owned.card_id,
|
||||
resource_id,
|
||||
start: b.get("startingBid").and_then(Value::as_i64).unwrap_or(150),
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
/// `/auctionhouse` — search (GET), list-for-sale (POST FutISStart), relist (PUT).
|
||||
///
|
||||
/// * GET returns the durable active auctions plus the FutGetAuctionCount ints,
|
||||
/// in the oracle's `_market_body` shape.
|
||||
/// * POST lists a club item: resolve its wire `resourceId` to the authoritative
|
||||
/// Core `card_id`, persist both, and return `{"id": tradeId}`. An unmappable
|
||||
/// resource fails closed (persists nothing).
|
||||
/// * POST persists the pre-resolved listing (see [`resolve_market_list`]) and
|
||||
/// returns `{"id": tradeId}`; an unresolved item acks a fresh id, persisting
|
||||
/// nothing.
|
||||
/// * PUT (relist-all) is an ack `{}`.
|
||||
pub async fn handle_market_list(
|
||||
method: &str,
|
||||
body: &[u8],
|
||||
resolved: Option<ResolvedListing>,
|
||||
econ: &dyn CoreEconomy,
|
||||
store: &MarketStore,
|
||||
mapper: &dyn MarketCardResolver,
|
||||
) -> WireResponse {
|
||||
match method {
|
||||
"POST" => {
|
||||
let b = parse_body(body);
|
||||
let item_data = b.get("itemData");
|
||||
let wire_item_id = item_data
|
||||
.and_then(|d| d.get("id"))
|
||||
.and_then(Value::as_i64)
|
||||
.or_else(|| b.get("itemId").and_then(Value::as_i64));
|
||||
let start = b.get("startingBid").and_then(Value::as_i64).unwrap_or(150);
|
||||
let buy_now = b.get("buyNowPrice").and_then(Value::as_i64).unwrap_or(0);
|
||||
let Some(item_id) = wire_item_id else {
|
||||
// No item to list: mirror the oracle's fresh-id ack, persist nothing.
|
||||
return ok_json(&json!({ "id": TRADE_ID_BASE }));
|
||||
};
|
||||
// Resolve the FIFA wire resourceId to the authoritative Core card id
|
||||
// the synthetic buy will MINT. Fail closed on an unmappable resource:
|
||||
// persist nothing (a non-existent listing cannot be bought), so a bad
|
||||
// resource never becomes a mint Core's content preflight would reject.
|
||||
let Some(resource_id) = item_data
|
||||
.and_then(|d| d.get("resourceId"))
|
||||
.and_then(Value::as_i64)
|
||||
else {
|
||||
return ok_json(&json!({ "id": TRADE_ID_BASE }));
|
||||
};
|
||||
let Some(core_card_id) = mapper.card_id_for_resource(resource_id) else {
|
||||
let Some(r) = resolved else {
|
||||
// No item, or the id did not resolve to an owned card: fresh-id ack.
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=market-list POST listed=false reason=unresolved"
|
||||
);
|
||||
return ok_json(&json!({ "id": TRADE_ID_BASE }));
|
||||
};
|
||||
// Trade-id space is offset from the wire item id, so each owned item
|
||||
// maps to a unique, stable auction id (no modular wraparound).
|
||||
let trade_id = TRADE_ID_BASE + item_id;
|
||||
let trade_id = TRADE_ID_BASE + r.item_id;
|
||||
let listing_id = trade_id.to_string();
|
||||
let seller = b.get("sellerName").and_then(Value::as_str);
|
||||
match store
|
||||
.create_listing(
|
||||
&listing_id,
|
||||
&core_card_id,
|
||||
None,
|
||||
Some(item_id),
|
||||
Some(resource_id),
|
||||
start,
|
||||
buy_now,
|
||||
seller,
|
||||
&r.card_id,
|
||||
Some(&r.core_id),
|
||||
Some(r.item_id),
|
||||
r.resource_id,
|
||||
r.start,
|
||||
r.buy_now,
|
||||
r.seller.as_deref(),
|
||||
r.item_json.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) | Err(MarketError::Conflict) => ok_json(&json!({ "id": trade_id })),
|
||||
Ok(_) | Err(MarketError::Conflict) => {
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=market-list POST item_id={} listed=true trade_id={trade_id}",
|
||||
r.item_id
|
||||
);
|
||||
ok_json(&json!({ "id": trade_id }))
|
||||
}
|
||||
Err(_) => json_body(503, &json!({ "error": "market_store" })),
|
||||
}
|
||||
}
|
||||
@@ -215,6 +293,10 @@ pub async fn handle_market_list(
|
||||
|
||||
/// Query listings in a given `state` (e.g. the user's own sale pile is the
|
||||
/// `active` set). Returns the oracle's tradePile shape.
|
||||
///
|
||||
/// This is the SELLER's own pile, so each card carries `itemState: "listFS"`
|
||||
/// (list-for-sale) — the state the oracle stamps on a tradePile card, distinct
|
||||
/// from the `forSale` used for market search results.
|
||||
pub async fn handle_market_query(
|
||||
state: &str,
|
||||
econ: &dyn CoreEconomy,
|
||||
@@ -224,7 +306,10 @@ pub async fn handle_market_query(
|
||||
Ok(l) => l,
|
||||
Err(_) => return json_body(503, &json!({ "error": "market_store" })),
|
||||
};
|
||||
let auctions: Vec<Value> = listings.iter().map(auction_record).collect();
|
||||
let auctions: Vec<Value> = listings
|
||||
.iter()
|
||||
.map(|l| auction_record_as(l, "listFS"))
|
||||
.collect();
|
||||
ok_json(&json!({
|
||||
"auctionInfo": auctions,
|
||||
"credits": credits_or_zero(econ),
|
||||
@@ -232,6 +317,30 @@ pub async fn handle_market_query(
|
||||
}))
|
||||
}
|
||||
|
||||
/// `GET …/tradePile/counts` — FutGetAuctionCount (the auction TALLY), a DISTINCT
|
||||
/// deserializer from `/tradePile`. It reads exactly five SCALAR INTS — `count`,
|
||||
/// `maxAuctionsAllowed`, `offered`, `selling`, `sold` — and skips anything else,
|
||||
/// so answering it with the `auctionInfo` listing body leaves every count at its
|
||||
/// constructor default (0): the hub tile shows a listing while the Transfer List
|
||||
/// screen shows none. All five being ints means there is no container-type
|
||||
/// freeze risk. They are the only inputs to IS_MAX_AUCTIONS, so
|
||||
/// `maxAuctionsAllowed = 100` with `selling < 100` keeps the listing cap open.
|
||||
/// A store read failure degrades to zeros (cosmetic tally, never fail-closed).
|
||||
pub async fn handle_market_counts(store: &MarketStore) -> WireResponse {
|
||||
let n = store
|
||||
.query_listings("active")
|
||||
.await
|
||||
.map(|l| l.len() as i64)
|
||||
.unwrap_or(0);
|
||||
ok_json(&json!({
|
||||
"count": n,
|
||||
"maxAuctionsAllowed": 100,
|
||||
"offered": 0,
|
||||
"selling": n,
|
||||
"sold": 0,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `DELETE /ut/delete/game/<sku>/trade/<id>` — remove a listing from the sale
|
||||
/// pile. Cancels the `active` listing once; the oracle always acks `{}`, so a
|
||||
/// missing/already-closed listing is not surfaced as an error to the client
|
||||
@@ -525,20 +634,55 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Permissive test resolver: maps any wire resourceId to its own string, so
|
||||
/// the existing list tests keep their prior card-id semantics. A dedicated
|
||||
/// test covers the unknown-resource fail-closed path.
|
||||
struct AllowAllResolver;
|
||||
impl MarketCardResolver for AllowAllResolver {
|
||||
fn card_id_for_resource(&self, resource_id: i64) -> Option<String> {
|
||||
Some(resource_id.to_string())
|
||||
// ---- ItemIdentityResolver + OwnedItemLookup doubles -------------------
|
||||
use openfut_adapter_fifa17::fut::item::{CoreOwnedItem, Fifa17Identity};
|
||||
|
||||
/// Owned-inventory double: core id -> CoreOwnedItem.
|
||||
struct FakeItems(HashMap<String, CoreOwnedItem>);
|
||||
impl OwnedItemLookup for FakeItems {
|
||||
fn owned_item(&self, core_id: &str) -> Option<CoreOwnedItem> {
|
||||
self.0.get(core_id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Test resolver that maps nothing (every resourceId is unknown).
|
||||
struct DenyAllResolver;
|
||||
impl MarketCardResolver for DenyAllResolver {
|
||||
fn card_id_for_resource(&self, _resource_id: i64) -> Option<String> {
|
||||
/// Identity double: resolves every owned item to a fixed FIFA resourceId.
|
||||
struct FixedIdentity(u32);
|
||||
impl ItemIdentityResolver for FixedIdentity {
|
||||
fn resolve(&self, _item: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||
Some(Fifa17Identity {
|
||||
item_id: 0,
|
||||
asset_id: self.0,
|
||||
resource_id: self.0,
|
||||
rareflag: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A CoreOwnedItem with the given core id + card id (other fields dummy).
|
||||
fn owned(core_id: &str, card_id: &str) -> CoreOwnedItem {
|
||||
CoreOwnedItem {
|
||||
owned_card_id: core_id.to_string(),
|
||||
card_id: card_id.to_string(),
|
||||
rating: 84,
|
||||
position: "ST".to_string(),
|
||||
nation: String::new(),
|
||||
league: String::new(),
|
||||
club: String::new(),
|
||||
attributes: [80, 80, 80, 80, 80, 80],
|
||||
}
|
||||
}
|
||||
|
||||
/// Entity double: no reverse entity mappings, so shaped cards carry ids 0
|
||||
/// (a valid int — the shaper never fabricates an entity id).
|
||||
struct NoEntities;
|
||||
impl ReverseEntityResolver for NoEntities {
|
||||
fn league_id(&self, _name: &str) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
fn nation_id(&self, _name: &str) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
fn team_id(&self, _name: &str) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -551,7 +695,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)
|
||||
.create_listing(id, "169193", None, None, Some(169193), 400, buy_now, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
@@ -566,23 +710,47 @@ mod tests {
|
||||
async fn list_post_persists_and_returns_trade_id() {
|
||||
let (store, _d) = store_at("post").await;
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
let body = json!({ "itemData": { "id": 100004617, "resourceId": 169193 },
|
||||
// The client body carries only the wire item id; the server resolves the
|
||||
// owned card's card_id + resourceId from Core inventory.
|
||||
let body = json!({ "itemData": { "id": 100004617 },
|
||||
"startingBid": 300, "buyNowPrice": 2500 });
|
||||
let resp = handle_market_list(
|
||||
"POST",
|
||||
let reverse = MapResolver::new(&[(100004617, "core-1")]);
|
||||
let items = FakeItems(
|
||||
[("core-1".to_string(), owned("core-1", "169193"))]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
);
|
||||
let ident = FixedIdentity(169193);
|
||||
let resolved = resolve_market_list(
|
||||
body.to_string().as_bytes(),
|
||||
&econ,
|
||||
&store,
|
||||
&AllowAllResolver,
|
||||
)
|
||||
.await;
|
||||
&reverse,
|
||||
&ident,
|
||||
&items,
|
||||
&NoEntities,
|
||||
);
|
||||
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);
|
||||
// Persisted + browsable.
|
||||
// Persisted with the resolved Core card_id + wire resourceId, browsable.
|
||||
let listed = store.get_listing(&trade_id.to_string()).await.unwrap();
|
||||
assert_eq!(listed.buy_now_price, 2500);
|
||||
let browse = handle_market_list("GET", b"", &econ, &store, &AllowAllResolver).await;
|
||||
assert_eq!(listed.card_id, "169193");
|
||||
assert_eq!(listed.wire_resource_id, Some(169193));
|
||||
// The listing carries the FULL shaped card snapshot, not a 4-field stub:
|
||||
// a stub leaves the Transfer List with an unrenderable row (the live bug).
|
||||
let snap: Value = serde_json::from_str(listed.item_json.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(snap["rating"], 84);
|
||||
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).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).await;
|
||||
let b = parse(&browse);
|
||||
assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(b["credits"], 10_000);
|
||||
@@ -593,27 +761,31 @@ 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", b"", &econ, &store, &AllowAllResolver).await;
|
||||
let resp = handle_market_list("PUT", None, &econ, &store).await;
|
||||
assert_eq!(resp.status, 200);
|
||||
assert_eq!(parse(&resp), json!({}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_unknown_resource_fails_closed_no_listing() {
|
||||
// An unmappable wire resourceId must NOT create a listing (a synthetic buy
|
||||
// would otherwise mint a card id Core cannot resolve). Acks neutrally.
|
||||
async fn list_unresolved_item_fails_closed_no_listing() {
|
||||
// A wire id that does not resolve to an owned card must NOT create a listing
|
||||
// (a synthetic buy would otherwise mint a card Core cannot resolve). Acks.
|
||||
let (store, _d) = store_at("deny").await;
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
let body = json!({ "itemData": { "id": 100004617, "resourceId": 424242 },
|
||||
let body = json!({ "itemData": { "id": 100004617 },
|
||||
"startingBid": 300, "buyNowPrice": 2500 });
|
||||
let resp = handle_market_list(
|
||||
"POST",
|
||||
let reverse = MapResolver::new(&[]); // maps nothing -> unresolved
|
||||
let items = FakeItems(HashMap::new());
|
||||
let ident = FixedIdentity(0);
|
||||
let resolved = resolve_market_list(
|
||||
body.to_string().as_bytes(),
|
||||
&econ,
|
||||
&store,
|
||||
&DenyAllResolver,
|
||||
)
|
||||
.await;
|
||||
&reverse,
|
||||
&ident,
|
||||
&items,
|
||||
&NoEntities,
|
||||
);
|
||||
assert!(resolved.is_none(), "unresolved item must not build a listing");
|
||||
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.
|
||||
@@ -634,23 +806,23 @@ mod tests {
|
||||
{
|
||||
let store = MarketStore::open(db.path()).await.unwrap();
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
// A resolver that maps resourceId 20801 -> Core card_id "card_pl_042".
|
||||
struct FixedResolver;
|
||||
impl MarketCardResolver for FixedResolver {
|
||||
fn card_id_for_resource(&self, resource_id: i64) -> Option<String> {
|
||||
(resource_id == 20801).then(|| "card_pl_042".to_string())
|
||||
}
|
||||
}
|
||||
let body = json!({ "itemData": { "id": 100004900, "resourceId": 20801 },
|
||||
let body = json!({ "itemData": { "id": 100004900 },
|
||||
"startingBid": 300, "buyNowPrice": 2500 });
|
||||
let resp = handle_market_list(
|
||||
"POST",
|
||||
let reverse = MapResolver::new(&[(100004900, "core-9")]);
|
||||
let items = FakeItems(
|
||||
[("core-9".to_string(), owned("core-9", "card_pl_042"))]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
);
|
||||
let ident = FixedIdentity(20801);
|
||||
let resolved = resolve_market_list(
|
||||
body.to_string().as_bytes(),
|
||||
&econ,
|
||||
&store,
|
||||
&FixedResolver,
|
||||
)
|
||||
.await;
|
||||
&reverse,
|
||||
&ident,
|
||||
&items,
|
||||
&NoEntities,
|
||||
);
|
||||
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.
|
||||
@@ -676,6 +848,38 @@ mod tests {
|
||||
assert_eq!(b["credits"], 50);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn counts_is_the_five_int_tally_not_the_listing_body() {
|
||||
// FutGetAuctionCount is a DISTINCT deserializer from /tradePile: five
|
||||
// scalar ints, no auctionInfo. Answering it with the listing body leaves
|
||||
// every count at its constructor default, so the Transfer List screen
|
||||
// shows no active sale even while the hub tile reports one (live bug).
|
||||
let (store, _d) = store_at("counts").await;
|
||||
let b = parse(&handle_market_counts(&store).await);
|
||||
assert_eq!(b["count"], 0);
|
||||
assert_eq!(b["selling"], 0);
|
||||
|
||||
seed_listing(&store, "900000007", 2500).await;
|
||||
let resp = handle_market_counts(&store).await;
|
||||
assert_eq!(resp.status, 200);
|
||||
let b = parse(&resp);
|
||||
assert_eq!(b["count"], 1, "tally counts the active listing");
|
||||
assert_eq!(b["selling"], 1);
|
||||
assert_eq!(b["sold"], 0);
|
||||
assert_eq!(b["offered"], 0);
|
||||
assert_eq!(
|
||||
b["maxAuctionsAllowed"], 100,
|
||||
"cap stays open for IS_MAX_AUCTIONS"
|
||||
);
|
||||
assert!(
|
||||
b.get("auctionInfo").is_none(),
|
||||
"the tally must NOT carry the listing body"
|
||||
);
|
||||
for k in ["count", "maxAuctionsAllowed", "offered", "selling", "sold"] {
|
||||
assert!(b[k].is_i64(), "{k} must be a scalar int");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn buy_now_debits_mints_and_closes() {
|
||||
let (store, _d) = store_at("buy").await;
|
||||
|
||||
Reference in New Issue
Block a user