fix(market): make the transfer market work end-to-end (live-verified)
Four defects found by driving a real FIFA 17 client. Each was independently
sufficient to break listing, so all four had to go:
1. Every owned card was shaped `untradeable: true` (adapter item.rs), so the
client greyed out "Place/List on Transfer Market" for the whole club. Owned
and pack-pulled cards are TRADEABLE in FIFA 17; the oracle forces this off
for owned copies too (item_def keeps `true`; instances do not).
2. `POST /auctionhouse` required `itemData.resourceId`, which the client's
FutISStart body never sends (the oracle lists by wire id ALONE). Missing it,
the handler fail-closed and returned 200 while persisting NOTHING. It now
resolves server-side: wire id -> Core owned instance -> its card_id (minted on
a synthetic buy) + FIFA resourceId (the auction record). This also enforces
that a listing can only name a card the club actually owns.
3. An auction record's `itemData` was a 4-field STUB, so the Transfer List had a
row the client could not draw -> "1 item listed" but no visible sale. A
listing now persists a full shaped-card SNAPSHOT (new `listings.item_json`,
additive migration) built by the same `shape_item` shaper `/club` and the
squad projection use, so the auction card renders identically to the club
card. The seller's own pile stamps `itemState: listFS`; market search keeps
`forSale` (the oracle distinguishes these).
4. `/tradePile/counts` shared a handler with `/tradePile`. They are DIFFERENT
deserializers: `/counts` is FutGetAuctionCount, five scalar ints
(count/maxAuctionsAllowed/offered/selling/sold) that it reads and skips
everything else. Served the `auctionInfo` body it left every count at 0, so
the Transfer List screen showed no active sale while the hub tile showed one.
New Route::MarketCounts, classified BEFORE the base tradePile matcher (which
also accepts the /counts path).
Also: a listed card no longer appears in the club. `/club` and the hub's
`clubPlayers` now exclude the transfer pile. Pile membership is host-owned state
Core cannot filter on, so when anything is hidden `/club` reuses the existing
local-filter path (the one `rare=SP` already needed) and paginates the
club-visible set -- letting Core paginate would return short pages. With nothing
hidden the fast Core-paginated path is untouched, and only an EXPLICIT non-club
pile hides a card, so no-pile-row items still default to the club.
Fixed 5 pre-existing test fixtures across 4 targets that listed FABRICATED wire
ids -- only "valid" because the old handler skipped the ownership check.
Tests: 14 targets green + clippy clean, incl. new coverage for the 5-int tally
(asserting it must NOT carry auctionInfo), the full-card snapshot + listFS, and
club pile-exclusion with full-width pagination. The differential test against the
live Python oracle passes.
Verified live on prod: listed=true with a 21-field snapshot; counts
{count:1,selling:1,maxAuctionsAllowed:100}; tradePile renders the 94-rated card;
clubPlayers 1966 -> 1961 (exactly the 5 trade-pile items); listed wire absent
from the club page. Operator confirmed the card is visible in the Transfer List.
This commit is contained in:
+126
-43
@@ -345,6 +345,12 @@ pub enum EconomyRoute {
|
||||
MarketList,
|
||||
/// `GET …/tradePile` — the user's own active listings.
|
||||
MarketQuery,
|
||||
/// `GET …/tradePile/counts` — FutGetAuctionCount, the auction TALLY. A
|
||||
/// DISTINCT deserializer from `/tradePile`: it reads five scalar ints and
|
||||
/// skips everything else, so answering it with the `auctionInfo` listing body
|
||||
/// leaves every count at its constructor default (0) and the Transfer List
|
||||
/// screen shows no active sale even while the hub tile shows one.
|
||||
MarketCounts,
|
||||
/// `…/trade/<id>` — view / buy-now.
|
||||
MarketBuy,
|
||||
/// `DELETE /ut/delete/game/<sku>/trade/<id>` — cancel a listing.
|
||||
@@ -397,6 +403,14 @@ fn is_tradepile_tail(tail: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `tradePile/counts` exactly (case-insensitive) — the auction-tally sub-path,
|
||||
/// which MUST be classified before [`is_tradepile_tail`] because that matcher
|
||||
/// also accepts it (the two endpoints have different response shapes).
|
||||
fn is_tradepile_counts_tail(tail: &str) -> bool {
|
||||
const COUNTS: &str = "tradePile/counts";
|
||||
tail.eq_ignore_ascii_case(COUNTS)
|
||||
}
|
||||
|
||||
/// Classify a FIFA17 economy route from method + path, mirroring the Python
|
||||
/// oracle's route table (`utas_server.py` §1418-1553). Returns `None` for any
|
||||
/// non-economy path. Path is already query-stripped by the caller.
|
||||
@@ -434,6 +448,9 @@ pub fn classify_economy(method: &str, path: &str) -> Option<EconomyRoute> {
|
||||
Some(t) if delete && is_item_id_tail(t) => Some(EconomyRoute::QuickSellPath),
|
||||
Some("item") if put => Some(EconomyRoute::MoveItems),
|
||||
Some(t) if (t == "auctionhouse" || t == "transfermarket") => Some(EconomyRoute::MarketList),
|
||||
// MUST precede the base tradePile arm: that matcher also accepts
|
||||
// `tradePile/counts`, but the tally is a different deserializer.
|
||||
Some(t) if get && is_tradepile_counts_tail(t) => Some(EconomyRoute::MarketCounts),
|
||||
Some(t) if get && is_tradepile_tail(t) => Some(EconomyRoute::MarketQuery),
|
||||
Some(t) if t.starts_with("trade") => Some(EconomyRoute::MarketBuy),
|
||||
_ => None,
|
||||
@@ -1080,16 +1097,6 @@ impl SquadWireResolver for Fifa17IdentityResolver {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reverse a FIFA wire `resourceId` to the authoritative Core `card_id`, via the
|
||||
/// same catalog `/club` shaping uses — so a synthetic market buy mints real Core
|
||||
/// content, never the raw FIFA number. Out-of-range or unmapped → `None`
|
||||
/// (fail closed; Core never sees a FIFA resource id).
|
||||
impl crate::market::MarketCardResolver for Fifa17IdentityResolver {
|
||||
fn card_id_for_resource(&self, resource_id: i64) -> Option<String> {
|
||||
let rid = u32::try_from(resource_id).ok()?;
|
||||
self.catalog.card_id_for_resource(rid).map(str::to_string)
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── /club handler ────────────────────────────────
|
||||
|
||||
@@ -1111,6 +1118,11 @@ pub struct ClubDeps<'a> {
|
||||
pub core: &'a dyn CoreAccess,
|
||||
pub entities: &'a Fifa17Entities,
|
||||
pub assets: &'a (dyn ItemIdentityResolver + Send + Sync),
|
||||
/// Core owned-instance ids that are NOT in the club view — currently the
|
||||
/// transfer (`trade`) pile. In FIFA a card on the transfer list has LEFT the
|
||||
/// club, so it must not also appear here. Empty = show everything Core owns
|
||||
/// (an item with no recorded pile defaults to the club).
|
||||
pub hidden: &'a std::collections::HashSet<String>,
|
||||
}
|
||||
|
||||
/// Filter already-shaped `/club` items to SPECIALS (`rareflag > 1`) and paginate
|
||||
@@ -1145,6 +1157,20 @@ fn special_filter_page(
|
||||
(paged, total)
|
||||
}
|
||||
|
||||
/// Paginate an already-shaped, already-filtered `/club` item list locally,
|
||||
/// returning `(page, total_after_filtering)`. Used when the host filters on
|
||||
/// something Core cannot express (pile membership, rareflag), where Core's own
|
||||
/// offset/limit would paginate the WRONG set and yield short pages.
|
||||
fn paginate_items(items: &[Value], offset: Option<i64>, limit: Option<i64>) -> (Vec<Value>, 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(),
|
||||
None => items.iter().skip(off).cloned().collect(),
|
||||
};
|
||||
(paged, total)
|
||||
}
|
||||
|
||||
/// Handle `GET …/club?…` end to end: parse → map ids to names → Core query →
|
||||
/// shape to the FIFA `{itemData:[…]}` envelope. Always returns HTTP 200 with a
|
||||
/// JSON body (UTAS must never 401/403; an empty result is the safe degrade).
|
||||
@@ -1172,29 +1198,49 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog)
|
||||
let filter = summarize(&pairs);
|
||||
let (offset, limit) = (core_q.offset, core_q.limit);
|
||||
|
||||
// "Special" (rare=SP): rareflag lives in the FIFA catalog, not Core, so Core
|
||||
// cannot filter it. Fetch everything matching the OTHER filters (no
|
||||
// offset/limit), shape (which resolves each item's rareflag), keep only
|
||||
// specials (rareflag > 1), then paginate the filtered set locally.
|
||||
if core_q.special {
|
||||
// Host-side filters Core cannot express:
|
||||
// * "Special" (rare=SP): rareflag lives in the FIFA catalog, not Core.
|
||||
// * pile exclusion: the transfer pile is host-owned state.
|
||||
// Either way Core must NOT paginate — it would paginate the unfiltered set
|
||||
// and return short pages. So fetch everything matching the OTHER filters,
|
||||
// exclude/shape locally, then paginate the filtered set here. When neither
|
||||
// applies (the common case) the fast Core-paginated path below is unchanged.
|
||||
if core_q.special || !deps.hidden.is_empty() {
|
||||
// Log which host-side filter forced local pagination.
|
||||
let local_desc = match (core_q.special, deps.hidden.len()) {
|
||||
(true, 0) => format!("{filter},rare=SP"),
|
||||
(true, n) => format!("{filter},rare=SP,hidden={n}"),
|
||||
(false, n) => format!("{filter},hidden={n}"),
|
||||
};
|
||||
let mut base = core_q.clone();
|
||||
base.offset = None;
|
||||
base.limit = None;
|
||||
return match deps.core.query_owned(&base.to_query_pairs()) {
|
||||
Ok(page) => {
|
||||
let (body, stats) = shape_club_response(&page.items, deps.entities, deps.assets);
|
||||
// Exclude hidden instances BEFORE shaping: a card on the transfer
|
||||
// list is not in the club, so it must not consume a page slot.
|
||||
let visible: Vec<CoreOwnedItem> = page
|
||||
.items
|
||||
.into_iter()
|
||||
.filter(|it| !deps.hidden.contains(&it.owned_card_id))
|
||||
.collect();
|
||||
let (body, stats) = shape_club_response(&visible, deps.entities, deps.assets);
|
||||
let all = body
|
||||
.get("itemData")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let (paged, total) = special_filter_page(&all, offset, limit);
|
||||
let (paged, total) = if core_q.special {
|
||||
special_filter_page(&all, offset, limit)
|
||||
} else {
|
||||
paginate_items(&all, offset, limit)
|
||||
};
|
||||
let emitted = paged.len();
|
||||
(
|
||||
json_response(&json!({ "itemData": paged })),
|
||||
ClubLog {
|
||||
outcome: "ok",
|
||||
filter: format!("{filter},rare=SP"),
|
||||
filter: local_desc,
|
||||
total,
|
||||
emitted,
|
||||
dropped_no_asset: stats.dropped_no_asset,
|
||||
@@ -1204,12 +1250,12 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog)
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("utas-host ERROR /club (special) core query failed: {e}");
|
||||
eprintln!("utas-host ERROR /club (local-filter) core query failed: {e}");
|
||||
(
|
||||
json_response(&json!({ "itemData": [] })),
|
||||
ClubLog {
|
||||
outcome: "core_error",
|
||||
filter: format!("{filter},rare=SP"),
|
||||
filter: local_desc,
|
||||
total: 0,
|
||||
emitted: 0,
|
||||
dropped_no_asset: 0,
|
||||
@@ -2242,22 +2288,27 @@ impl Server {
|
||||
})
|
||||
}
|
||||
EconomyRoute::MarketList => {
|
||||
let (bridge, market, econ, resolver) = (
|
||||
svc.bridge.clone(),
|
||||
svc.market.clone(),
|
||||
svc.econ.clone(),
|
||||
self.resolver.clone(),
|
||||
);
|
||||
let (m, body) = (method.to_string(), body.to_vec());
|
||||
bridge.block_on(async move {
|
||||
crate::market::handle_market_list(
|
||||
&m,
|
||||
&body,
|
||||
econ.as_ref(),
|
||||
market.as_ref(),
|
||||
resolver.as_ref(),
|
||||
// Resolve the listed item synchronously (identity + one Core read)
|
||||
// on the dispatch thread, so the async persist future holds only
|
||||
// `Send` data (the trait-object resolvers are not `Send`).
|
||||
let resolved = {
|
||||
let lookup = CoreItemLookup {
|
||||
core: self.core.as_ref(),
|
||||
};
|
||||
crate::market::resolve_market_list(
|
||||
body,
|
||||
self.resolver.as_ref(),
|
||||
self.resolver.as_ref(),
|
||||
&lookup,
|
||||
self.entities.as_ref(),
|
||||
)
|
||||
.await
|
||||
};
|
||||
let (bridge, market, econ) =
|
||||
(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
|
||||
})
|
||||
}
|
||||
EconomyRoute::MarketQuery => {
|
||||
@@ -2268,6 +2319,11 @@ impl Server {
|
||||
.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 })
|
||||
}
|
||||
EconomyRoute::MarketBuy => {
|
||||
let (bridge, market, econ) =
|
||||
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
||||
@@ -2330,10 +2386,12 @@ impl Server {
|
||||
match classify(method, path) {
|
||||
Route::Club => {
|
||||
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
|
||||
let hidden = self.club_hidden_ids();
|
||||
let deps = ClubDeps {
|
||||
core: self.core.as_ref(),
|
||||
entities: self.entities.as_ref(),
|
||||
assets: self.resolver.as_ref(),
|
||||
hidden: &hidden,
|
||||
};
|
||||
let (resp, log) = handle_club(query, &deps);
|
||||
eprintln!(
|
||||
@@ -2703,18 +2761,42 @@ impl Server {
|
||||
json_status(status, &body)
|
||||
}
|
||||
|
||||
/// Core owned-instance ids that are NOT part of the CLUB view: currently the
|
||||
/// transfer (`trade`) pile. In FIFA a listed card has LEFT the club, so it must
|
||||
/// not appear in `/club` or the hub's `clubPlayers` tally while it sits on the
|
||||
/// transfer list. Only an EXPLICIT non-club pile hides an item — an item with
|
||||
/// no recorded pile defaults to the club, so an imported profile is unaffected.
|
||||
/// Empty when no economy services are wired (bare test server), and a pile-store
|
||||
/// read failure degrades to showing everything (never hides inventory silently).
|
||||
fn club_hidden_ids(&self) -> std::collections::HashSet<String> {
|
||||
let Some(svc) = self.economy.as_ref() else {
|
||||
return std::collections::HashSet::new();
|
||||
};
|
||||
let (bridge, piles) = (svc.bridge.clone(), svc.piles.clone());
|
||||
match bridge.block_on(async move { piles.list_by_pile("trade").await }) {
|
||||
Ok(ids) => ids.into_iter().collect(),
|
||||
Err(e) => {
|
||||
eprintln!("utas-host WARN pile read failed (club shows all): {e}");
|
||||
std::collections::HashSet::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET …/hub` — the FUT hub tile counts, owned in Rust (no Python). Derived
|
||||
/// from authoritative state: `clubPlayers` is the count of owned PLAYER cards
|
||||
/// in Core (consumables/staff excluded via the catalog kind), and the auction
|
||||
/// / tradePile counts are the user's active listings in the durable market
|
||||
/// store. `clubPlayers` may be lower than the Python oracle's profile count by
|
||||
/// exactly the deferred (unnameable Legend) instances — DIFFERENT-BY-DESIGN,
|
||||
/// since deferred cards are not owned in Core. Fail-closed on Core error (503);
|
||||
/// a market-store read failure degrades the cosmetic listing counts to 0.
|
||||
/// in Core that are in the club (transfer-pile cards excluded — they have left
|
||||
/// the club), and the auction / tradePile counts are the user's active listings
|
||||
/// in the durable market store. `clubPlayers` may be lower than the Python
|
||||
/// oracle's profile count by exactly the deferred (unnameable Legend)
|
||||
/// instances — DIFFERENT-BY-DESIGN, since deferred cards are not owned in
|
||||
/// Core. Fail-closed on Core error (503); a market-store read failure degrades
|
||||
/// the cosmetic listing counts to 0.
|
||||
fn handle_hub(&self) -> WireResponse {
|
||||
let hidden = self.club_hidden_ids();
|
||||
let club_players = match self.core.all_owned() {
|
||||
Ok(items) => items
|
||||
.iter()
|
||||
.filter(|it| !hidden.contains(&it.owned_card_id))
|
||||
.filter(|it| self.resolver.kind_of(it) == ContentKind::Player)
|
||||
.count(),
|
||||
Err(e) => {
|
||||
@@ -3870,8 +3952,9 @@ mod tests {
|
||||
("POST", "/ut/game/fifa17/transfermarket", Some(MarketList)),
|
||||
("GET", "/ut/game/fifa17/tradePile", Some(MarketQuery)),
|
||||
("GET", "/ut/game/fifa17/tradepile", Some(MarketQuery)),
|
||||
("GET", "/ut/game/fifa17/tradePile/counts", Some(MarketQuery)),
|
||||
("GET", "/ut/game/fifa17/tradepile/counts", 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)),
|
||||
("POST", "/ut/game/fifa17/trade/900000001", Some(MarketBuy)),
|
||||
(
|
||||
"DELETE",
|
||||
|
||||
Reference in New Issue
Block a user