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:
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user