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:
funman300
2026-08-17 23:14:35 +00:00
parent 3ce69f8951
commit f9ca901a50
6 changed files with 299 additions and 467 deletions
+54 -89
View File
@@ -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();