feat(market): bounded Q2 candidate — one unlisted pile item as tradeState "inactive"
PHASE A settled the token from the CLIENT ITSELF, so this is not a guessed enum.
vocab_dump.py (new; static, read-only, VA->offset through the real PE section table)
dumps CardsDLL's NULL-terminated {const char*, int} vocabularies. The tradeState
table at 0x180229e40 reads exactly:
'active' = 1 'inactive' = 2 'expired' = 3 'closed' = 4
The sibling tables (type/zone/lev/pos) match the corpus verbatim, which validates the
dumper. So "inactive" is a token the client's own parser decodes.
PHASE B, bounded as instructed. `OPENFUT_FIFA17_UNLISTED_PROBE=<wire id>` exposes
EXACTLY ONE unlisted trade-pile item on /tradePile as a non-active record; unset,
behaviour is byte-identical to before. The other stranded pile items are untouched --
no bulk migration.
Why this shape is forced rather than chosen: the route table has exactly one
trade-pile route, it carries only twelve-atom auction records, `pile` (0x226) has no
deserializer arm so membership comes from the owning list, and of those atoms only
tradeState expresses lifecycle. The row carries tradeState "inactive" with
expires/prices/bid all zero so it cannot render a countdown or a price, and reuses the
item's stable tradeId because the client keys its record store on tradeId and
re-parents itemData -- so listing the item later UPDATES the row instead of leaving a
duplicate ghost.
Both preconditions are re-checked at response time: the item must actually be in the
`trade` pile, and it must not already own a listing. Two tests cover exactly those.
counts semantics deliberately unchanged -- the inactive row is not counted.
340 tests pass, 0 failed, clippy clean. Deployed; the wire now carries all three
lifecycle states at once (expired 1000000097, active 1000000155, inactive 1000000059)
and that body is preserved as a fixture.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
|
||||
=== tradeState (table walk) VA 0x180229e40 -> off 0x229240 ===
|
||||
'active' = 1
|
||||
'inactive' = 2
|
||||
'expired' = 3
|
||||
'closed' = 4
|
||||
-- NULL terminator after 4 rows --
|
||||
|
||||
=== type VA 0x180229c30 -> off 0x229030 ===
|
||||
'any' = -1
|
||||
'player' = 1
|
||||
'staff' = 2
|
||||
'clubInfo' = 3
|
||||
'training' = 4
|
||||
'development' = 5
|
||||
'stadium' = 6
|
||||
'ball' = 7
|
||||
-- NULL terminator after 8 rows --
|
||||
|
||||
=== zone VA 0x1802296e0 -> off 0x228ae0 ===
|
||||
'goalKeeper' = 0
|
||||
'defense' = 1
|
||||
'midfield' = 2
|
||||
'attacker' = 3
|
||||
-- NULL terminator after 4 rows --
|
||||
|
||||
=== lev VA 0x180229a60 -> off 0x228e60 ===
|
||||
'any' = 0
|
||||
'gold' = 3
|
||||
'silver' = 2
|
||||
'bronze' = 1
|
||||
-- NULL terminator after 4 rows --
|
||||
|
||||
=== pos VA 0x1802295c0 -> off 0x2289c0 ===
|
||||
'GK' = 0
|
||||
'RWB' = 2
|
||||
'RB' = 3
|
||||
'CB' = 5
|
||||
'LB' = 7
|
||||
'LWB' = 8
|
||||
'CDM' = 10
|
||||
'RM' = 12
|
||||
'CM' = 14
|
||||
'LM' = 16
|
||||
'CAM' = 18
|
||||
'RF' = 20
|
||||
'CF' = 21
|
||||
'LF' = 22
|
||||
'RW' = 23
|
||||
'ST' = 25
|
||||
'LW' = 27
|
||||
-- NULL terminator after 17 rows --
|
||||
|
||||
=== cat VA 0x180229ab0 -> off 0x228eb0 ===
|
||||
'any' = -1
|
||||
'playerGK' = 1
|
||||
'playerRD' = 2
|
||||
'playerRM' = 3
|
||||
'playerRA' = 4
|
||||
'manager' = 5
|
||||
'headCoach' = 6
|
||||
'GKCoach' = 7
|
||||
'fitnessCoach' = 8
|
||||
'physio' = 9
|
||||
'badge' = 10
|
||||
'kit' = 11
|
||||
'leagueLogo' = 12
|
||||
'playerTraining' = 13
|
||||
'GKTraining' = 14
|
||||
'position' = 15
|
||||
'playStyle' = 16
|
||||
'managerLeagueModifier' = 17
|
||||
'contract' = 18
|
||||
'fitness' = 19
|
||||
'healing' = 20
|
||||
'stadium' = 21
|
||||
'ball' = 22
|
||||
-- NULL terminator after 23 rows --
|
||||
|
||||
=== form VA 0x180229880 -> off 0x228c80 ===
|
||||
'f3412' = 23
|
||||
'f3421' = 24
|
||||
'f343' = 25
|
||||
'f352' = 27
|
||||
'f41212' = 14
|
||||
'f4231' = 3
|
||||
'f4222' = 13
|
||||
'f4312' = 6
|
||||
'f4321' = 7
|
||||
'f433' = 8
|
||||
'f4411' = 19
|
||||
'f442' = 16
|
||||
'f451' = 21
|
||||
'f5212' = 29
|
||||
'f5221' = 30
|
||||
'f532' = 31
|
||||
'f541' = 32
|
||||
'f41212a' = 15
|
||||
'f4141' = 2
|
||||
'f4231a' = 4
|
||||
'f433a' = 9
|
||||
'f433b' = 10
|
||||
'f433c' = 11
|
||||
'f433d' = 12
|
||||
'f442a' = 17
|
||||
'f451a' = 20
|
||||
-- NULL terminator after 26 rows --
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
{
|
||||
"auctionInfo": [
|
||||
{
|
||||
"bidState": "none",
|
||||
"buyNowPrice": 15000,
|
||||
"coinsProcessed": 0,
|
||||
"currentBid": 0,
|
||||
"expires": 0,
|
||||
"itemData": {
|
||||
"assetId": 20801,
|
||||
"attributeList": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 92
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"value": 92
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"value": 81
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"value": 91
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"value": 33
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"value": 80
|
||||
}
|
||||
],
|
||||
"cardassetid": 20801,
|
||||
"cardsubtypeid": 0,
|
||||
"contract": 7,
|
||||
"definitionId": 20801,
|
||||
"discardValue": 1500,
|
||||
"fitness": 99,
|
||||
"id": 100000097,
|
||||
"itemState": "listFS",
|
||||
"itemType": "player",
|
||||
"leagueId": 53,
|
||||
"nation": 38,
|
||||
"owners": 1,
|
||||
"playStyle": 250,
|
||||
"preferredPosition": "LW",
|
||||
"rareflag": 1,
|
||||
"rating": 94,
|
||||
"resourceId": 20801,
|
||||
"teamid": 243,
|
||||
"untradeable": false
|
||||
},
|
||||
"sellerEstablished": 1,
|
||||
"sellerName": "CAGE",
|
||||
"startingBid": 150,
|
||||
"tradeId": 1000000097,
|
||||
"tradeState": "expired",
|
||||
"watched": false
|
||||
},
|
||||
{
|
||||
"bidState": "none",
|
||||
"buyNowPrice": 15000,
|
||||
"coinsProcessed": 0,
|
||||
"currentBid": 0,
|
||||
"expires": 2996,
|
||||
"itemData": {
|
||||
"assetId": 158023,
|
||||
"attributeList": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 89
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"value": 90
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"value": 86
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"value": 96
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"value": 26
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"value": 61
|
||||
}
|
||||
],
|
||||
"cardassetid": 158023,
|
||||
"cardsubtypeid": 0,
|
||||
"contract": 7,
|
||||
"definitionId": 158023,
|
||||
"discardValue": 1500,
|
||||
"fitness": 99,
|
||||
"id": 100000155,
|
||||
"itemState": "listFS",
|
||||
"itemType": "player",
|
||||
"leagueId": 53,
|
||||
"nation": 52,
|
||||
"owners": 1,
|
||||
"playStyle": 250,
|
||||
"preferredPosition": "RW",
|
||||
"rareflag": 1,
|
||||
"rating": 93,
|
||||
"resourceId": 158023,
|
||||
"teamid": 241,
|
||||
"untradeable": false
|
||||
},
|
||||
"sellerEstablished": 1,
|
||||
"sellerName": "CAGE",
|
||||
"startingBid": 150,
|
||||
"tradeId": 1000000155,
|
||||
"tradeState": "active",
|
||||
"watched": false
|
||||
},
|
||||
{
|
||||
"bidState": "none",
|
||||
"buyNowPrice": 0,
|
||||
"coinsProcessed": 0,
|
||||
"currentBid": 0,
|
||||
"expires": 0,
|
||||
"itemData": {
|
||||
"assetId": 158023,
|
||||
"attributeList": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 89
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"value": 90
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"value": 86
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"value": 96
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"value": 26
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"value": 61
|
||||
}
|
||||
],
|
||||
"cardassetid": 158023,
|
||||
"cardsubtypeid": 0,
|
||||
"contract": 7,
|
||||
"definitionId": 158023,
|
||||
"discardValue": 1500,
|
||||
"fitness": 99,
|
||||
"id": 100000059,
|
||||
"itemState": "free",
|
||||
"itemType": "player",
|
||||
"leagueId": 53,
|
||||
"nation": 52,
|
||||
"owners": 1,
|
||||
"playStyle": 250,
|
||||
"preferredPosition": "RW",
|
||||
"rareflag": 1,
|
||||
"rating": 93,
|
||||
"resourceId": 158023,
|
||||
"teamid": 241,
|
||||
"untradeable": false
|
||||
},
|
||||
"sellerEstablished": 1,
|
||||
"sellerName": "CAGE",
|
||||
"startingBid": 0,
|
||||
"tradeId": 1000000059,
|
||||
"tradeState": "inactive",
|
||||
"watched": false
|
||||
}
|
||||
],
|
||||
"credits": 29843976,
|
||||
"duplicateItemIdList": [],
|
||||
"total": 3
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dump CardsDLL's NULL-terminated {const char*, int} vocabulary tables from the
|
||||
ON-DISK PE. READ-ONLY, static.
|
||||
|
||||
The transfer-market analysis records tradeState as decoding through a table walk at
|
||||
0x180229e40 and lists sibling vocabularies (type/zone/lev/pos) as tables of the same
|
||||
shape. This prints the exact token spellings and their integer codes, so the accepted
|
||||
strings come from the client rather than from inference.
|
||||
"""
|
||||
import struct
|
||||
|
||||
DLL = "/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll"
|
||||
TABLES = [
|
||||
(0x180229E40, "tradeState (table walk)"),
|
||||
(0x180229C30, "type"),
|
||||
(0x1802296E0, "zone"),
|
||||
(0x180229A60, "lev"),
|
||||
(0x1802295C0, "pos"),
|
||||
(0x180229AB0, "cat"),
|
||||
(0x180229880, "form"),
|
||||
]
|
||||
MAX_ROWS = 64
|
||||
|
||||
pe = open(DLL, "rb").read()
|
||||
e_lfanew = struct.unpack_from("<I", pe, 0x3C)[0]
|
||||
coff = e_lfanew + 4
|
||||
num_sections = struct.unpack_from("<H", pe, coff + 2)[0]
|
||||
opt_size = struct.unpack_from("<H", pe, coff + 16)[0]
|
||||
opt = coff + 20
|
||||
image_base = struct.unpack_from("<Q", pe, opt + 24)[0]
|
||||
sec_off = opt + opt_size
|
||||
sections = []
|
||||
for i in range(num_sections):
|
||||
b = sec_off + i * 40
|
||||
vsize, vaddr, rawsize, rawptr = struct.unpack_from("<IIII", pe, b + 8)
|
||||
sections.append((vaddr, vsize, rawptr, rawsize))
|
||||
|
||||
|
||||
def va2off(va):
|
||||
rva = va - image_base
|
||||
for vaddr, vsize, rawptr, rawsize in sections:
|
||||
if vaddr <= rva < vaddr + max(vsize, rawsize):
|
||||
off = rva - vaddr + rawptr
|
||||
if 0 <= off < len(pe):
|
||||
return off
|
||||
return None
|
||||
|
||||
|
||||
def cstr(va, limit=64):
|
||||
off = va2off(va)
|
||||
if off is None:
|
||||
return None
|
||||
end = pe.find(b"\0", off, off + limit)
|
||||
if end < 0:
|
||||
return None
|
||||
try:
|
||||
s = pe[off:end].decode("ascii")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
return s if s.isprintable() else None
|
||||
|
||||
|
||||
for table_va, name in TABLES:
|
||||
base = va2off(table_va)
|
||||
print("\n=== %s VA %#x -> off %s ===" % (name, table_va, hex(base) if base else None))
|
||||
if base is None:
|
||||
print(" (VA did not resolve)")
|
||||
continue
|
||||
for i in range(MAX_ROWS):
|
||||
ptr, code = struct.unpack_from("<Qi", pe, base + i * 16)
|
||||
if ptr == 0:
|
||||
print(" -- NULL terminator after %d rows --" % i)
|
||||
break
|
||||
s = cstr(ptr)
|
||||
if s is None:
|
||||
print(" row %d: ptr %#x does not resolve to a string; stopping" % (i, ptr))
|
||||
break
|
||||
print(" %-28s = %d" % (repr(s), code))
|
||||
@@ -2347,13 +2347,39 @@ impl Server {
|
||||
})
|
||||
}
|
||||
EconomyRoute::MarketQuery => {
|
||||
let (bridge, market, econ) =
|
||||
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
||||
// BOUNDED Q2 EXPERIMENT (off unless the env names one wire item id):
|
||||
// expose a SINGLE unlisted trade-pile item as a non-active auction
|
||||
// record. Resolved synchronously here because the identity/Core
|
||||
// resolvers are not `Send`, exactly like the market-list path.
|
||||
let unlisted = std::env::var("OPENFUT_FIFA17_UNLISTED_PROBE")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<i64>().ok())
|
||||
.filter(|id| *id > 0)
|
||||
.and_then(|id| {
|
||||
let lookup = CoreItemLookup {
|
||||
core: self.core.as_ref(),
|
||||
};
|
||||
crate::market::resolve_unlisted_candidate(
|
||||
id,
|
||||
self.resolver.as_ref(),
|
||||
self.resolver.as_ref(),
|
||||
&lookup,
|
||||
self.entities.as_ref(),
|
||||
)
|
||||
});
|
||||
let (bridge, market, econ, piles) = (
|
||||
svc.bridge.clone(),
|
||||
svc.market.clone(),
|
||||
svc.econ.clone(),
|
||||
svc.piles.clone(),
|
||||
);
|
||||
bridge.block_on(async move {
|
||||
crate::market::handle_market_query(
|
||||
"active",
|
||||
econ.as_ref(),
|
||||
market.as_ref(),
|
||||
piles.as_ref(),
|
||||
unlisted.as_ref(),
|
||||
)
|
||||
.await
|
||||
})
|
||||
|
||||
@@ -260,6 +260,86 @@ pub fn resolve_market_list<E: ReverseEntityResolver>(
|
||||
})
|
||||
}
|
||||
|
||||
/// A single unlisted trade-pile item to expose on `/tradePile` as a NON-ACTIVE
|
||||
/// auction record — the bounded Q2 experiment (see
|
||||
/// docs/FIFA17_TRANSFER_MARKET_WIRE.md).
|
||||
///
|
||||
/// Why this shape is forced rather than chosen: the CardsDLL route table has exactly
|
||||
/// ONE trade-pile route, it carries only twelve-atom auction records, `pile` has no
|
||||
/// deserializer arm (membership comes from the owning list), and of those atoms only
|
||||
/// `tradeState` can express lifecycle. Its vocabulary is a four-row `{const char*,
|
||||
/// int}` table dumped from the PE — `active=1 inactive=2 expired=3 closed=4` — so
|
||||
/// `"inactive"` is the client's own token for "in the pile, not on the market".
|
||||
pub struct UnlistedCandidate {
|
||||
/// FIFA wire item id.
|
||||
pub item_id: i64,
|
||||
/// Core owned-instance id, used to confirm the pile and the absence of an auction.
|
||||
pub core_id: String,
|
||||
/// The full shaped card, so the row renders like any other.
|
||||
pub item_json: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolve ONE wire item id to an [`UnlistedCandidate`]. Pure identity plus a single
|
||||
/// Core inventory read, so it runs OFF the async runtime exactly like
|
||||
/// [`resolve_market_list`] (the trait-object resolvers are not `Send`).
|
||||
pub fn resolve_unlisted_candidate<E: ReverseEntityResolver>(
|
||||
item_id: i64,
|
||||
reverse: &dyn SquadWireResolver,
|
||||
resolver: &dyn ItemIdentityResolver,
|
||||
items: &dyn OwnedItemLookup,
|
||||
ent: &E,
|
||||
) -> Option<UnlistedCandidate> {
|
||||
let core_id = reverse.owned_id_for_wire(item_id)?;
|
||||
let owned = items.owned_item(&core_id)?;
|
||||
let item_json = resolver
|
||||
.resolve(&owned)
|
||||
.map(|id| shape_item(&owned, id, ent))
|
||||
.and_then(|card| serde_json::to_string(&card).ok());
|
||||
Some(UnlistedCandidate {
|
||||
item_id,
|
||||
core_id,
|
||||
item_json,
|
||||
})
|
||||
}
|
||||
|
||||
/// The auction record for an item sitting in the trade pile with NO auction.
|
||||
///
|
||||
/// `tradeState: "inactive"` is the whole point; everything else is deliberately the
|
||||
/// zero/neutral value so the row cannot render a countdown or a price. `tradeId`
|
||||
/// reuses the item's stable auction id, because the client keys its record store on
|
||||
/// `tradeId` and re-parents `itemData` onto an existing record — so listing this
|
||||
/// item later UPDATES this row rather than creating a duplicate ghost.
|
||||
fn unlisted_record(c: &UnlistedCandidate) -> Value {
|
||||
let item_data = c
|
||||
.item_json
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<Value>(s).ok())
|
||||
.filter(Value::is_object)
|
||||
.map(|mut card| {
|
||||
card["id"] = json!(c.item_id);
|
||||
// `free` is what /club emits for an unheld card; there is no evidence of a
|
||||
// distinct itemState for a pile-resident item, so do not invent one.
|
||||
card["itemState"] = json!("free");
|
||||
card["untradeable"] = json!(false);
|
||||
card
|
||||
})
|
||||
.unwrap_or_else(|| json!({ "id": c.item_id, "itemState": "free", "untradeable": false }));
|
||||
json!({
|
||||
"tradeId": TRADE_ID_BASE + c.item_id,
|
||||
"itemData": item_data,
|
||||
"tradeState": "inactive",
|
||||
"buyNowPrice": 0,
|
||||
"startingBid": 0,
|
||||
"currentBid": 0,
|
||||
"bidState": "none",
|
||||
"expires": 0,
|
||||
"sellerName": non_economy::PERSONA_DISPLAY_NAME,
|
||||
"sellerEstablished": 1,
|
||||
"watched": false,
|
||||
"coinsProcessed": 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// `/auctionhouse` — search (GET), list-for-sale (POST FutISStart), relist (PUT).
|
||||
///
|
||||
/// * GET returns the durable active auctions plus the FutGetAuctionCount ints,
|
||||
@@ -383,15 +463,41 @@ pub async fn handle_market_query(
|
||||
state: &str,
|
||||
econ: &dyn CoreEconomy,
|
||||
store: &MarketStore,
|
||||
piles: &PileStore,
|
||||
unlisted: Option<&UnlistedCandidate>,
|
||||
) -> WireResponse {
|
||||
let listings = match store.query_listings(state).await {
|
||||
Ok(l) => l,
|
||||
Err(_) => return json_body(503, &json!({ "error": "market_store" })),
|
||||
};
|
||||
let auctions: Vec<Value> = listings
|
||||
let mut auctions: Vec<Value> = listings
|
||||
.iter()
|
||||
.map(|l| auction_record_as(l, "listFS"))
|
||||
.collect();
|
||||
|
||||
// BOUNDED Q2 EXPERIMENT: expose ONE unlisted trade-pile item as a non-active
|
||||
// record. Every precondition is re-checked here so the row can never contradict
|
||||
// the auction state: the item must actually be in the `trade` pile, and it must
|
||||
// NOT already own a listing (otherwise it would duplicate a real auction).
|
||||
if let Some(c) = unlisted {
|
||||
let in_trade_pile = matches!(piles.get(&c.core_id).await, Ok(Some(p)) if p == "trade");
|
||||
let already_listed = listings
|
||||
.iter()
|
||||
.any(|l| l.core_item_id.as_deref() == Some(c.core_id.as_str()));
|
||||
if in_trade_pile && !already_listed {
|
||||
auctions.push(unlisted_record(c));
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=market-query unlisted_candidate item_id={} state=inactive",
|
||||
c.item_id
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=market-query unlisted_candidate item_id={} skipped in_pile={in_trade_pile} listed={already_listed}",
|
||||
c.item_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -868,6 +974,95 @@ mod tests {
|
||||
(store, db)
|
||||
}
|
||||
|
||||
/// An empty pile store on its own temp file, for tradePile tests that do not
|
||||
/// exercise the unlisted candidate.
|
||||
async fn empty_piles() -> (PileStore, TempDb) {
|
||||
let db = TempDb::new("piles");
|
||||
let piles = PileStore::open(db.path()).await.unwrap();
|
||||
(piles, db)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unlisted_candidate_is_a_non_active_pile_row() {
|
||||
// Q2: an item in the trade pile with NO auction. The route table has exactly
|
||||
// one trade-pile route and it carries only auction records, `pile` has no
|
||||
// deserializer arm, and of the twelve atoms only `tradeState` expresses
|
||||
// lifecycle — whose PE string table is `active/inactive/expired/closed`. So
|
||||
// the row must be `inactive`, with no timer and no prices.
|
||||
let (store, _d) = store_at("unlisted").await;
|
||||
let (piles, _pd) = empty_piles().await;
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
let c = UnlistedCandidate {
|
||||
item_id: 100_000_059,
|
||||
core_id: "core-unlisted".into(),
|
||||
item_json: Some(r#"{"rating":93,"preferredPosition":"RW"}"#.into()),
|
||||
};
|
||||
|
||||
// Not in the trade pile yet -> deliberately NOT exposed.
|
||||
let body = parse(&handle_market_query("active", &econ, &store, &piles, Some(&c)).await);
|
||||
assert_eq!(
|
||||
body["auctionInfo"].as_array().unwrap().len(),
|
||||
0,
|
||||
"an item that is not in the trade pile must never be advertised as one"
|
||||
);
|
||||
|
||||
piles.set("core-unlisted", "trade").await.unwrap();
|
||||
let body = parse(&handle_market_query("active", &econ, &store, &piles, Some(&c)).await);
|
||||
let recs = body["auctionInfo"].as_array().unwrap();
|
||||
assert_eq!(recs.len(), 1, "now in the pile, so it is exposed");
|
||||
let r = &recs[0];
|
||||
assert_eq!(r["tradeState"], "inactive", "the PE's token for not-on-market");
|
||||
assert_eq!(r["expires"], 0, "no countdown on an item that is not listed");
|
||||
assert_eq!(r["buyNowPrice"], 0);
|
||||
assert_eq!(r["startingBid"], 0);
|
||||
assert_eq!(r["currentBid"], 0);
|
||||
assert_eq!(r["bidState"], "none");
|
||||
assert_eq!(r["itemData"]["id"], 100_000_059i64);
|
||||
assert_eq!(r["itemData"]["itemState"], "free");
|
||||
assert_eq!(r["itemData"]["rating"], 93, "the full card still renders");
|
||||
// Stable tradeId: the client keys its record store on it, so listing this
|
||||
// item later must UPDATE this row rather than add a duplicate ghost.
|
||||
assert_eq!(r["tradeId"], TRADE_ID_BASE + 100_000_059);
|
||||
// Still exactly the twelve atoms.
|
||||
let mut keys: Vec<&str> = r.as_object().unwrap().keys().map(String::as_str).collect();
|
||||
keys.sort_unstable();
|
||||
assert_eq!(keys.len(), 12, "no extra atoms on the inactive row");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unlisted_candidate_never_duplicates_a_real_auction() {
|
||||
// If the item already owns a listing, advertising it as `inactive` too would
|
||||
// put two records with the same tradeId in one body.
|
||||
let (store, _d) = store_at("unlistedduped").await;
|
||||
let (piles, _pd) = empty_piles().await;
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
store
|
||||
.create_listing(
|
||||
&(TRADE_ID_BASE + 100_000_059).to_string(),
|
||||
"169193",
|
||||
Some("core-unlisted"),
|
||||
Some(100_000_059),
|
||||
Some(169193),
|
||||
150,
|
||||
2500,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
piles.set("core-unlisted", "trade").await.unwrap();
|
||||
let c = UnlistedCandidate {
|
||||
item_id: 100_000_059,
|
||||
core_id: "core-unlisted".into(),
|
||||
item_json: None,
|
||||
};
|
||||
let body = parse(&handle_market_query("active", &econ, &store, &piles, Some(&c)).await);
|
||||
let recs = body["auctionInfo"].as_array().unwrap();
|
||||
assert_eq!(recs.len(), 1, "the real auction only");
|
||||
assert_eq!(recs[0]["tradeState"], "active");
|
||||
}
|
||||
|
||||
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, None)
|
||||
@@ -919,7 +1114,7 @@ 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).await;
|
||||
let pile = handle_market_query("active", &econ, &store, &empty_piles().await.0, None).await;
|
||||
let rec = parse(&pile)["auctionInfo"][0].clone();
|
||||
assert_eq!(rec["itemData"]["itemState"], "listFS");
|
||||
assert_eq!(rec["itemData"]["rating"], 84);
|
||||
@@ -1016,7 +1211,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).await;
|
||||
let resp = handle_market_query("active", &econ, &store, &empty_piles().await.0, None).await;
|
||||
let b = parse(&resp);
|
||||
assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(b["auctionInfo"][0]["tradeId"], 900000005i64);
|
||||
@@ -1066,7 +1261,7 @@ mod tests {
|
||||
let econ = CountingEconomy::with_balance(10_000);
|
||||
seed_listing(&store, "900000030", 2500).await;
|
||||
|
||||
let body = parse(&handle_market_query("active", &econ, &store).await);
|
||||
let body = parse(&handle_market_query("active", &econ, &store, &empty_piles().await.0, None).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();
|
||||
|
||||
Reference in New Issue
Block a user