fix(market): /trade/status must resolve the unlisted ids /tradePile advertises

Explains and fixes the Phase C partial failure WITHOUT changing a single wire field.

The operator saw a difference between the one-item probe (Time Remaining "-") and the
generalized rows (Time Remaining "Expired"). Cause: route coverage, not encoding.
/tradePile advertised the unlisted tradeIds while ISVIEWTRADE (GET .../trade/status)
resolved ids from the market store only -- and an unlisted pile member has no listing
row, so the poll returned an empty auctionInfo. Observed live as
`route=market-status requested=1 returned=0` repeating for the row the operator had
selected, while that same id was present in /tradePile. The client polls status for
the row it displays and degrades it when the answer is empty, which is also why no
actions were offered. The probe showed "-" only because the client had not yet polled
that id (logs of the time show only tradeIds=1000000097).

So expires, tradeState, itemData.itemState and pile were all innocent. Nothing was
guessed and no field changed: both routes now share one pile enumeration
(Server::resolve_trade_pile), so an id advertised by /tradePile always resolves on
/trade/status. The corpus predicted exactly this -- tradeId must resolve across
/transfermarket, /tradePile, /watchList AND /trade/status; we had stability but not
coverage. Same defect class as the original empty-trade/status bug.

Status still answers only the ids actually asked about, and a real auction always wins
over an inactive row for the same tradeId. Regression test covers all four cases.

Records the downgraded conclusion: "inactive" is a CONFIRMED section/lifecycle
discriminator; whether the full actionable contract is now complete is the operator's
next test. itemState/pile recovery was queued on the assumption the encoding was
incomplete -- neither was touched, and both remain the next candidates if actions are
still absent.

342 tests pass, 0 failed, clippy clean. Verified live: the six inactive ids went from
returned=0 to returned=6.
This commit is contained in:
funman300
2026-08-17 21:01:07 +00:00
parent afadb13de4
commit 11c028e6eb
3 changed files with 194 additions and 36 deletions
+40 -32
View File
@@ -2208,6 +2208,39 @@ impl Server {
self
}
/// Resolve every Core instance in the `trade` pile to a shaped unlisted
/// candidate, for the routes that must agree about those ids.
///
/// Three phases in this order for a reason: the pile read is async, the
/// identity/Core resolvers are NOT `Send` so they cannot cross an await, and the
/// caller's response build is async again.
///
/// Both `/tradePile` and `/trade/status` use this. They MUST: the client polls
/// `/trade/status` for the row it is showing, and answering there with an empty
/// body while `/tradePile` advertises the id makes the client degrade the row to
/// "Expired" with no actions. A tradeId has to resolve on every route that can be
/// asked about it.
fn resolve_trade_pile(&self, svc: &EconomyServices) -> Vec<crate::market::UnlistedCandidate> {
let piles = svc.piles.clone();
let trade_ids = svc
.bridge
.block_on(async move { piles.list_by_pile("trade").await })
.unwrap_or_default();
if trade_ids.is_empty() {
return Vec::new();
}
let lookup = crate::economy_store::CoreItemLookup {
core: self.core.as_ref(),
};
crate::market::resolve_unlisted_pile(
&trade_ids,
|core_id| self.resolver.wire_for_owned_id(core_id),
self.resolver.as_ref(),
&lookup,
self.entities.as_ref(),
)
}
/// Dispatch a FIFA17 economy route to its Rust handler, or `None` if the path
/// is not an economy route (or no economy services are wired). This is the
/// handler-wiring entry point exercised by the integration harness; it is
@@ -2362,38 +2395,9 @@ impl Server {
})
}
EconomyRoute::MarketQuery => {
// Unlisted trade-pile members are exposed as `tradeState: "inactive"`
// rows (LIVE-CONFIRMED; see docs/FIFA17_TRANSFER_MARKET_WIRE.md).
//
// Three steps, in this order for a reason: the pile read is async, the
// identity/Core resolvers are NOT `Send` so they cannot cross an await,
// and the response build is async again.
let (bridge, market, econ, piles) = (
svc.bridge.clone(),
svc.market.clone(),
svc.econ.clone(),
svc.piles.clone(),
);
let trade_ids = {
let p = piles.clone();
bridge
.block_on(async move { p.list_by_pile("trade").await })
.unwrap_or_default()
};
let unlisted = if trade_ids.is_empty() {
Vec::new()
} else {
let lookup = CoreItemLookup {
core: self.core.as_ref(),
};
crate::market::resolve_unlisted_pile(
&trade_ids,
|core_id| self.resolver.wire_for_owned_id(core_id),
self.resolver.as_ref(),
&lookup,
self.entities.as_ref(),
)
};
let (bridge, market, econ) =
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
let unlisted = self.resolve_trade_pile(svc);
bridge.block_on(async move {
crate::market::handle_market_query(
"active",
@@ -2414,11 +2418,15 @@ impl Server {
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
// The raw query carries `tradeIds`; `path` is already stripped.
let q = target.split_once('?').map(|(_, q)| q.to_string());
// ISViewTrade is asked about the same ids /tradePile advertises, so it
// needs the same pile enumeration or an unlisted row degrades.
let unlisted = self.resolve_trade_pile(svc);
bridge.block_on(async move {
crate::market::handle_market_status(
q.as_deref(),
econ.as_ref(),
market.as_ref(),
&unlisted,
)
.await
})