market: stop advertising unlisted pile members as tradeState:"inactive"
RE of the FUT front-end closed the question the Actions-panel investigation left open, and the answer retracts Q2 rather than completing it. `tradeState` reaches exactly ONE native branch in CardsDLL — `cmp …,0x4` at `0x18013e619`, "is it closed?" — and `inactive`(2) and `expired`(3) take the same edge, producing bit-identical `flagA`/`flagB` (exhaustive 22-site census of `[reg+0x88]` reads across the PE; confirmed live, both classes read glow=0 inbox=0). The value is then handed to the movie verbatim as the Flash property `STATE`, and the action gate lives in the APT/ActionScript FUT front-end: the trade-pile class partitions rows with `getCardsInAuction`/`isInActiveAuction` (traces `initPile() - IN AUCTION:` / `- NOT IN AUCTION:`) and only auction rows reach `PreCheckCardOptions` -> `handleTradeCardAction`. A non-auction row renders and can never be acted on, which is exactly what the operator saw. So the rows were never usable. "LIVE-CONFIRMED" established that they RENDER, which is not the same claim, and I treated it as if it were. The corpus said this before any of it was built — `plan-2026-08-06-transfer-market.md:731-733`: "`inactive` decodes but no client path treats it specially; do not emit it." The earlier note explaining that the warning "was written about the PRESENTATION function" was motivated reasoning. This also fires the corpus's own pre-registered falsifier E3 (:368-373). Removed: the `inactive` projection from `GET …/tradePile` and `…/trade/status`, `UnlistedCandidate`, `resolve_unlisted_pile`, `unlisted_record`, `Server::resolve_trade_pile`, and the two helpers that existed only to feed them (`MarketStore::blocking_core_items`, `Fifa17IdentityResolver::wire_for_owned_id`). Unlisted trade-pile membership is now internal state with no wire expression. Nothing is stranded: `/club` excludes only items with an ACTIVE listing, so an unlisted pile member stays visible in the club, which is where the client can act on it. Verified live after deploy — `/tradePile` total 7 -> 1 with zero `inactive` rows, `/trade/status` resolving only the real auction, coins unchanged at 29,843,976, and all six former rows present in `/club` (1965 items). Tests: 126 pass, fmt + clippy clean. Two guards replace the three tests that pinned the old behaviour: `the_trade_pile_advertises_only_real_auctions` and `trade_status_answers_only_about_real_auctions`. NOT fixed here, deliberately: `itemData.itemState: "listFS"` is not a FIFA 17 token (0 occurrences in CardsDLL md5 4de3493131d7d2ff7f8b360c5ac9b655, 0 in 4.26 GiB of process memory, decodes to -1; the real value is `forSale` = 5, and the Python oracle emits `listFS` too — which is why the differential never caught it). `CARD_OFFERSTATE` is one of three unresolved action-gate candidates and every actionable row observed carried -1, so that change ships alone with its own live A/B.
This commit is contained in:
@@ -1052,21 +1052,6 @@ impl Fifa17IdentityResolver {
|
||||
.unwrap_or(None)
|
||||
}
|
||||
|
||||
/// The wire item id ALREADY mapped to a Core owned instance, or `None`.
|
||||
///
|
||||
/// NEVER allocates, unlike the resolver's forward path: enumerating the trade
|
||||
/// pile must not mint identities for items the client has never seen, or a
|
||||
/// read would quietly consume wire ids.
|
||||
pub fn wire_for_owned_id(&self, core_id: &str) -> Option<i64> {
|
||||
self.store
|
||||
.external_for(
|
||||
Fifa17WireItemIdPolicy::GAME,
|
||||
Fifa17WireItemIdPolicy::OWNED_ITEM_KIND,
|
||||
core_id,
|
||||
)
|
||||
.unwrap_or(None)
|
||||
}
|
||||
|
||||
/// The FIFA `cardsubtypeid` for an owned item's definition (0 if unknown /
|
||||
/// a player), from the catalog — used by club-stats family aggregation.
|
||||
pub fn subtype_of(&self, item: &CoreOwnedItem) -> i64 {
|
||||
@@ -1133,7 +1118,6 @@ impl SquadWireResolver for Fifa17IdentityResolver {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ───────────────────────────── /club handler ────────────────────────────────
|
||||
|
||||
/// Safe, structured summary of a handled `/club` request (no auth/session/device
|
||||
@@ -1200,7 +1184,12 @@ fn paginate_items(items: &[Value], offset: Option<i64>, limit: Option<i64>) -> (
|
||||
let total = items.len() as i64;
|
||||
let off = offset.unwrap_or(0).max(0) as usize;
|
||||
let paged: Vec<Value> = match limit {
|
||||
Some(l) => items.iter().skip(off).take(l.max(0) as usize).cloned().collect(),
|
||||
Some(l) => items
|
||||
.iter()
|
||||
.skip(off)
|
||||
.take(l.max(0) as usize)
|
||||
.cloned()
|
||||
.collect(),
|
||||
None => items.iter().skip(off).cloned().collect(),
|
||||
};
|
||||
(paged, total)
|
||||
@@ -2208,39 +2197,6 @@ 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
|
||||
@@ -2385,48 +2341,34 @@ impl Server {
|
||||
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
||||
let m = method.to_string();
|
||||
bridge.block_on(async move {
|
||||
crate::market::handle_market_list(
|
||||
&m,
|
||||
resolved,
|
||||
econ.as_ref(),
|
||||
market.as_ref(),
|
||||
)
|
||||
.await
|
||||
crate::market::handle_market_list(&m, resolved, econ.as_ref(), market.as_ref())
|
||||
.await
|
||||
})
|
||||
}
|
||||
EconomyRoute::MarketQuery => {
|
||||
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",
|
||||
econ.as_ref(),
|
||||
market.as_ref(),
|
||||
&unlisted,
|
||||
)
|
||||
.await
|
||||
crate::market::handle_market_query("active", econ.as_ref(), market.as_ref())
|
||||
.await
|
||||
})
|
||||
}
|
||||
EconomyRoute::MarketCounts => {
|
||||
let (bridge, market) = (svc.bridge.clone(), svc.market.clone());
|
||||
bridge
|
||||
.block_on(async move { crate::market::handle_market_counts(market.as_ref()).await })
|
||||
bridge.block_on(async move {
|
||||
crate::market::handle_market_counts(market.as_ref()).await
|
||||
})
|
||||
}
|
||||
EconomyRoute::MarketStatus => {
|
||||
let (bridge, market, econ) =
|
||||
(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
|
||||
})
|
||||
@@ -2436,14 +2378,8 @@ impl Server {
|
||||
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
||||
let (m, p, body) = (method.to_string(), path.to_string(), body.to_vec());
|
||||
bridge.block_on(async move {
|
||||
crate::market::handle_market_buy(
|
||||
&m,
|
||||
&p,
|
||||
&body,
|
||||
econ.as_ref(),
|
||||
market.as_ref(),
|
||||
)
|
||||
.await
|
||||
crate::market::handle_market_buy(&m, &p, &body, econ.as_ref(), market.as_ref())
|
||||
.await
|
||||
})
|
||||
}
|
||||
EconomyRoute::MarketCancel => {
|
||||
@@ -2673,7 +2609,10 @@ impl Server {
|
||||
coins,
|
||||
ents.len()
|
||||
);
|
||||
json_status(200, &non_economy::account_sync_body(&req, coins, ents.len()))
|
||||
json_status(
|
||||
200,
|
||||
&non_economy::account_sync_body(&req, coins, ents.len()),
|
||||
)
|
||||
}
|
||||
|
||||
/// `GET/PUT/POST …/clientdata/<key>` — opaque per-user client blob store. GET
|
||||
@@ -2737,7 +2676,10 @@ impl Server {
|
||||
json_status(200, &body)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("utas-host owner=RUST route=userMassInfo status={} error=core", e.status);
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=userMassInfo status={} error=core",
|
||||
e.status
|
||||
);
|
||||
e
|
||||
}
|
||||
}
|
||||
@@ -2753,7 +2695,10 @@ impl Server {
|
||||
json_status(200, &json!({ "userInfo": user_info }))
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("utas-host owner=RUST route=user status={} error=core", e.status);
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=user status={} error=core",
|
||||
e.status
|
||||
);
|
||||
e
|
||||
}
|
||||
}
|
||||
@@ -2896,7 +2841,10 @@ impl Server {
|
||||
};
|
||||
let (bridge, market) = (svc.bridge.clone(), svc.market.clone());
|
||||
match bridge.block_on(async move { market.query_listings("active").await }) {
|
||||
Ok(listings) => listings.into_iter().filter_map(|l| l.core_item_id).collect(),
|
||||
Ok(listings) => listings
|
||||
.into_iter()
|
||||
.filter_map(|l| l.core_item_id)
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
eprintln!("utas-host WARN market read failed (club shows all): {e}");
|
||||
std::collections::HashSet::new()
|
||||
@@ -2994,7 +2942,10 @@ impl Server {
|
||||
.and_then(|s| s.econ.balance().ok())
|
||||
.unwrap_or(0);
|
||||
eprintln!("utas-host owner=RUST route=watchlist method=GET status=200 credits={credits}");
|
||||
json_status(200, &json!({ "auctionInfo": [], "credits": credits, "total": 0 }))
|
||||
json_status(
|
||||
200,
|
||||
&json!({ "auctionInfo": [], "credits": credits, "total": 0 }),
|
||||
)
|
||||
}
|
||||
|
||||
/// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and the
|
||||
@@ -4075,8 +4026,16 @@ mod tests {
|
||||
("GET", "/ut/game/fifa17/tradePile", Some(MarketQuery)),
|
||||
("GET", "/ut/game/fifa17/tradepile", Some(MarketQuery)),
|
||||
// The tally is a DISTINCT deserializer from the listing list.
|
||||
("GET", "/ut/game/fifa17/tradePile/counts", Some(MarketCounts)),
|
||||
("GET", "/ut/game/fifa17/tradepile/counts", Some(MarketCounts)),
|
||||
(
|
||||
"GET",
|
||||
"/ut/game/fifa17/tradePile/counts",
|
||||
Some(MarketCounts),
|
||||
),
|
||||
(
|
||||
"GET",
|
||||
"/ut/game/fifa17/tradepile/counts",
|
||||
Some(MarketCounts),
|
||||
),
|
||||
("POST", "/ut/game/fifa17/trade/900000001", Some(MarketBuy)),
|
||||
// The live-state poll MUST NOT land in the buy/view arm: `status` is
|
||||
// not a trade id, so that arm answers every poll with an empty set.
|
||||
@@ -4197,7 +4156,7 @@ mod tests {
|
||||
("POST", "/ut/game/fifa17/season"), // FUT-mode reads are GET-only
|
||||
("GET", "/ut/game/fifa17/user/club"), // mutating rename stays Python
|
||||
("POST", "/ut/game/fifa17/item/resource"), // item-defs are GET-only
|
||||
("GET", "/ut/game/fifa17/marketdatafoo"), // not the marketdata route
|
||||
("GET", "/ut/game/fifa17/marketdatafoo"), // not the marketdata route
|
||||
];
|
||||
for (m, p) in proxied {
|
||||
assert_eq!(classify(m, p), Route::Passthrough, "PROXY: {m} {p}");
|
||||
@@ -4212,7 +4171,10 @@ mod tests {
|
||||
fn item_defs_shape_matches_oracle() {
|
||||
// Two ids: the one hardcoded card (asset 20801) + a placeholder.
|
||||
assert_eq!(extract_long_ints("resourceId=20801"), vec![20801]);
|
||||
assert_eq!(extract_long_ints("idList=200389,200104&x=12"), vec![200389, 200104]);
|
||||
assert_eq!(
|
||||
extract_long_ints("idList=200389,200104&x=12"),
|
||||
vec![200389, 200104]
|
||||
);
|
||||
assert!(extract_long_ints("foo=ab").is_empty());
|
||||
|
||||
let body = non_economy::item_defs_body(&[20801, 200389]);
|
||||
@@ -4237,7 +4199,10 @@ mod tests {
|
||||
#[test]
|
||||
fn marketdata_container_types_are_load_bearing() {
|
||||
// /pricelimits MUST be a bare ARRAY (object-where-array froze a live client).
|
||||
assert_eq!(extract_defid_param("defId=200389,200104"), vec![200389, 200104]);
|
||||
assert_eq!(
|
||||
extract_defid_param("defId=200389,200104"),
|
||||
vec![200389, 200104]
|
||||
);
|
||||
let arr = non_economy::marketdata_pricelimits_body(&[200389, 200104]);
|
||||
assert!(arr.is_array(), "pricelimits must be a bare array");
|
||||
let arr = arr.as_array().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user