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:
funman300
2026-08-17 20:25:46 +00:00
parent 2e97ff1461
commit a2bd048ace
5 changed files with 602 additions and 6 deletions
+28 -2
View File
@@ -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
})
+199 -4
View File
@@ -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();