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:
@@ -29,10 +29,26 @@ Legend: owner R = Rust/Core, P = Python oracle (:8199 proxied via PYTHON_FALLBAC
|
|||||||
| PUT /item | R | inv,pile | handle_move_items | NO |
|
| PUT /item | R | inv,pile | handle_move_items | NO |
|
||||||
| POST /ut/delete/../match | R | coins | handle_match_end | NO |
|
| POST /ut/delete/../match | R | coins | handle_match_end | NO |
|
||||||
| POST /auctionhouse,/transfermarket | R | listings | handle_market_list | NO |
|
| POST /auctionhouse,/transfermarket | R | listings | handle_market_list | NO |
|
||||||
| GET /tradePile[/counts] | R | no | handle_market_query | NO |
|
| GET /tradePile | R | no | handle_market_query | NO |
|
||||||
|
| GET /tradePile/counts | R | no | handle_market_counts | NO |
|
||||||
| /trade/<id> (POST/PUT/GET) | R | coins,inv,listings | handle_market_buy | NO |
|
| /trade/<id> (POST/PUT/GET) | R | coins,inv,listings | handle_market_buy | NO |
|
||||||
| DELETE /ut/delete/../trade/<id> | R | listings | handle_market_cancel | NO |
|
| DELETE /ut/delete/../trade/<id> | R | listings | handle_market_cancel | NO |
|
||||||
|
|
||||||
|
**Transfer-market semantics (live-verified 2026-08-17).** Three things here are
|
||||||
|
load-bearing and were each a live defect:
|
||||||
|
1. `/tradePile` and `/tradePile/counts` are **different deserializers** and MUST NOT
|
||||||
|
share a handler. `/counts` is FutGetAuctionCount: five scalar ints
|
||||||
|
(`count`, `maxAuctionsAllowed`, `offered`, `selling`, `sold`) and nothing else.
|
||||||
|
Served the `auctionInfo` body it skips every field, leaving the counts at 0 — the
|
||||||
|
hub tile shows a listing while the Transfer List screen shows no active sale.
|
||||||
|
2. An auction record's `itemData` MUST be the **full card object** (the same shape
|
||||||
|
`/club` emits), not a stub. A listing therefore persists a shaped-card SNAPSHOT
|
||||||
|
(`listings.item_json`) at list time. The seller's own pile stamps
|
||||||
|
`itemState: listFS`; market search uses `forSale`.
|
||||||
|
3. `POST /auctionhouse` resolves the listed card **server-side** from the wire item
|
||||||
|
id (`wire → Core instance → card_id + resourceId`); the client's FutISStart body
|
||||||
|
carries only the id. This also enforces that you can only list what you own.
|
||||||
|
|
||||||
### NON-ECONOMY — Rust-owned (route migration 2026-08-17, OBSERVED prod log)
|
### NON-ECONOMY — Rust-owned (route migration 2026-08-17, OBSERVED prod log)
|
||||||
| Route | Owner | Notes |
|
| Route | Owner | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -41,7 +57,7 @@ Legend: owner R = Rust/Core, P = Python oracle (:8199 proxied via PYTHON_FALLBAC
|
|||||||
| GET /userMassInfo | R | FULL Rust envelope (userInfo+squad+settings+pileSizeClientData); no Python. coins from Core, squad == /squad/active |
|
| GET /userMassInfo | R | FULL Rust envelope (userInfo+squad+settings+pileSizeClientData); no Python. coins from Core, squad == /squad/active |
|
||||||
| GET/PUT /clientdata/<key> | R | host ClientDataStore (JSON-persisted); PUT acks {}, GET returns blob or {} |
|
| GET/PUT /clientdata/<key> | R | host ClientDataStore (JSON-persisted); PUT acks {}, GET returns blob or {} |
|
||||||
| capability (/openfut/fifa17/capability) | R | -> Bound (CleanV1) |
|
| capability (/openfut/fifa17/capability) | R | -> Bound (CleanV1) |
|
||||||
| GET /club, /club/* readers | R | Core-backed collection (dropped_no_asset=0) |
|
| GET /club, /club/* readers | R | Core-backed collection (dropped_no_asset=0). Cards in the **transfer pile are excluded** — a listed card has left the club. Pagination then runs over the club-visible set (Core cannot filter on host-owned pile state, so letting it paginate would yield short pages); with nothing hidden the fast Core-paginated path is unchanged. Only an EXPLICIT non-club pile hides a card, so no-pile-row items default to the club. |
|
||||||
| GET /squad/0, /squad/active, /squad/list; PUT /squad/<n> | R | Core squad projection + tx; GET /squad/0 == active squad (verified structurally identical) |
|
| GET /squad/0, /squad/active, /squad/list; PUT /squad/<n> | R | Core squad projection + tx; GET /squad/0 == active squad (verified structurally identical) |
|
||||||
| GET /user/accountinfo | R | {} |
|
| GET /user/accountinfo | R | {} |
|
||||||
| GET /user | R | `{"userInfo": …}` — same userInfo builder as userMassInfo (shared); squad rating is Core-authoritative (DIFFERENT-BY-DESIGN vs Python's stale value) |
|
| GET /user | R | `{"userInfo": …}` — same userInfo builder as userMassInfo (shared); squad rating is Core-authoritative (DIFFERENT-BY-DESIGN vs Python's stale value) |
|
||||||
@@ -49,7 +65,7 @@ Legend: owner R = Rust/Core, P = Python oracle (:8199 proxied via PYTHON_FALLBAC
|
|||||||
| GET /leaderboards/options | R | {} |
|
| GET /leaderboards/options | R | {} |
|
||||||
| PUT /match/reset | R | {} |
|
| PUT /match/reset | R | {} |
|
||||||
| GET /phishing/trusteddevice | R | security-question stateless ack |
|
| GET /phishing/trusteddevice | R | security-question stateless ack |
|
||||||
| GET /hub | R | Core-derived counts |
|
| GET /hub | R | Core-derived counts; `clubPlayers` excludes transfer-pile cards (1966 → 1961 with 5 listed/moved), `auctionCount`/`tradePile` from the durable market store |
|
||||||
| GET /club/stats/{year,consumables,staff,country,league,team} | R | Core aggregation; context buckets keyed nation/league/team (owned=1982); staff={} |
|
| GET /club/stats/{year,consumables,staff,country,league,team} | R | Core aggregation; context buckets keyed nation/league/team (owned=1982); staff={} |
|
||||||
| GET /store, /match/keepalive, /captcha, /tfa, /livemessage, /activeMessage | R | unconditional constant acks (byte-identical to the oracle; StaticAck route) |
|
| GET /store, /match/keepalive, /captcha, /tfa, /livemessage, /activeMessage | R | unconditional constant acks (byte-identical to the oracle; StaticAck route) |
|
||||||
| GET /watchList (+ PUT/POST/DELETE) | R | empty watch list + authoritative Core credits; add/remove is a no-op ack (oracle persists none) |
|
| GET /watchList (+ PUT/POST/DELETE) | R | empty watch list + authoritative Core credits; add/remove is a no-op ack (oracle persists none) |
|
||||||
|
|||||||
@@ -146,7 +146,12 @@ pub fn shape_item(
|
|||||||
"attributeList": attribute_list,
|
"attributeList": attribute_list,
|
||||||
"itemState": "free",
|
"itemState": "free",
|
||||||
"owners": 1,
|
"owners": 1,
|
||||||
"untradeable": true,
|
// Owned/pack-pulled cards are TRADEABLE in FIFA 17 (untradeable is the
|
||||||
|
// exception for SBC/promo rewards, which Core does not model). Emitting
|
||||||
|
// `true` greyed out "Place on Transfer Market" for every card — the same
|
||||||
|
// "our own data showing through" bug the Python oracle fixed by forcing
|
||||||
|
// this off for owned copies (item_def keeps `true`; instances do not).
|
||||||
|
"untradeable": false,
|
||||||
"contract": 7,
|
"contract": 7,
|
||||||
"fitness": 99,
|
"fitness": 99,
|
||||||
"discardValue": discard_value(item.rating),
|
"discardValue": discard_value(item.rating),
|
||||||
|
|||||||
+125
-42
@@ -345,6 +345,12 @@ pub enum EconomyRoute {
|
|||||||
MarketList,
|
MarketList,
|
||||||
/// `GET …/tradePile` — the user's own active listings.
|
/// `GET …/tradePile` — the user's own active listings.
|
||||||
MarketQuery,
|
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.
|
/// `…/trade/<id>` — view / buy-now.
|
||||||
MarketBuy,
|
MarketBuy,
|
||||||
/// `DELETE /ut/delete/game/<sku>/trade/<id>` — cancel a listing.
|
/// `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
|
/// Classify a FIFA17 economy route from method + path, mirroring the Python
|
||||||
/// oracle's route table (`utas_server.py` §1418-1553). Returns `None` for any
|
/// oracle's route table (`utas_server.py` §1418-1553). Returns `None` for any
|
||||||
/// non-economy path. Path is already query-stripped by the caller.
|
/// 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(t) if delete && is_item_id_tail(t) => Some(EconomyRoute::QuickSellPath),
|
||||||
Some("item") if put => Some(EconomyRoute::MoveItems),
|
Some("item") if put => Some(EconomyRoute::MoveItems),
|
||||||
Some(t) if (t == "auctionhouse" || t == "transfermarket") => Some(EconomyRoute::MarketList),
|
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 get && is_tradepile_tail(t) => Some(EconomyRoute::MarketQuery),
|
||||||
Some(t) if t.starts_with("trade") => Some(EconomyRoute::MarketBuy),
|
Some(t) if t.starts_with("trade") => Some(EconomyRoute::MarketBuy),
|
||||||
_ => None,
|
_ => 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 ────────────────────────────────
|
// ───────────────────────────── /club handler ────────────────────────────────
|
||||||
|
|
||||||
@@ -1111,6 +1118,11 @@ pub struct ClubDeps<'a> {
|
|||||||
pub core: &'a dyn CoreAccess,
|
pub core: &'a dyn CoreAccess,
|
||||||
pub entities: &'a Fifa17Entities,
|
pub entities: &'a Fifa17Entities,
|
||||||
pub assets: &'a (dyn ItemIdentityResolver + Send + Sync),
|
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
|
/// Filter already-shaped `/club` items to SPECIALS (`rareflag > 1`) and paginate
|
||||||
@@ -1145,6 +1157,20 @@ fn special_filter_page(
|
|||||||
(paged, total)
|
(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 →
|
/// 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
|
/// 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).
|
/// 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 filter = summarize(&pairs);
|
||||||
let (offset, limit) = (core_q.offset, core_q.limit);
|
let (offset, limit) = (core_q.offset, core_q.limit);
|
||||||
|
|
||||||
// "Special" (rare=SP): rareflag lives in the FIFA catalog, not Core, so Core
|
// Host-side filters Core cannot express:
|
||||||
// cannot filter it. Fetch everything matching the OTHER filters (no
|
// * "Special" (rare=SP): rareflag lives in the FIFA catalog, not Core.
|
||||||
// offset/limit), shape (which resolves each item's rareflag), keep only
|
// * pile exclusion: the transfer pile is host-owned state.
|
||||||
// specials (rareflag > 1), then paginate the filtered set locally.
|
// Either way Core must NOT paginate — it would paginate the unfiltered set
|
||||||
if core_q.special {
|
// 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();
|
let mut base = core_q.clone();
|
||||||
base.offset = None;
|
base.offset = None;
|
||||||
base.limit = None;
|
base.limit = None;
|
||||||
return match deps.core.query_owned(&base.to_query_pairs()) {
|
return match deps.core.query_owned(&base.to_query_pairs()) {
|
||||||
Ok(page) => {
|
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
|
let all = body
|
||||||
.get("itemData")
|
.get("itemData")
|
||||||
.and_then(|v| v.as_array())
|
.and_then(|v| v.as_array())
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default();
|
.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();
|
let emitted = paged.len();
|
||||||
(
|
(
|
||||||
json_response(&json!({ "itemData": paged })),
|
json_response(&json!({ "itemData": paged })),
|
||||||
ClubLog {
|
ClubLog {
|
||||||
outcome: "ok",
|
outcome: "ok",
|
||||||
filter: format!("{filter},rare=SP"),
|
filter: local_desc,
|
||||||
total,
|
total,
|
||||||
emitted,
|
emitted,
|
||||||
dropped_no_asset: stats.dropped_no_asset,
|
dropped_no_asset: stats.dropped_no_asset,
|
||||||
@@ -1204,12 +1250,12 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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": [] })),
|
json_response(&json!({ "itemData": [] })),
|
||||||
ClubLog {
|
ClubLog {
|
||||||
outcome: "core_error",
|
outcome: "core_error",
|
||||||
filter: format!("{filter},rare=SP"),
|
filter: local_desc,
|
||||||
total: 0,
|
total: 0,
|
||||||
emitted: 0,
|
emitted: 0,
|
||||||
dropped_no_asset: 0,
|
dropped_no_asset: 0,
|
||||||
@@ -2242,21 +2288,26 @@ impl Server {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
EconomyRoute::MarketList => {
|
EconomyRoute::MarketList => {
|
||||||
let (bridge, market, econ, resolver) = (
|
// Resolve the listed item synchronously (identity + one Core read)
|
||||||
svc.bridge.clone(),
|
// on the dispatch thread, so the async persist future holds only
|
||||||
svc.market.clone(),
|
// `Send` data (the trait-object resolvers are not `Send`).
|
||||||
svc.econ.clone(),
|
let resolved = {
|
||||||
self.resolver.clone(),
|
let lookup = CoreItemLookup {
|
||||||
);
|
core: self.core.as_ref(),
|
||||||
let (m, body) = (method.to_string(), body.to_vec());
|
};
|
||||||
bridge.block_on(async move {
|
crate::market::resolve_market_list(
|
||||||
crate::market::handle_market_list(
|
body,
|
||||||
&m,
|
self.resolver.as_ref(),
|
||||||
&body,
|
self.resolver.as_ref(),
|
||||||
econ.as_ref(),
|
&lookup,
|
||||||
market.as_ref(),
|
self.entities.as_ref(),
|
||||||
resolver.as_ref(),
|
|
||||||
)
|
)
|
||||||
|
};
|
||||||
|
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
|
.await
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -2268,6 +2319,11 @@ impl Server {
|
|||||||
.await
|
.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 => {
|
EconomyRoute::MarketBuy => {
|
||||||
let (bridge, market, econ) =
|
let (bridge, market, econ) =
|
||||||
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
||||||
@@ -2330,10 +2386,12 @@ impl Server {
|
|||||||
match classify(method, path) {
|
match classify(method, path) {
|
||||||
Route::Club => {
|
Route::Club => {
|
||||||
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
|
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
|
||||||
|
let hidden = self.club_hidden_ids();
|
||||||
let deps = ClubDeps {
|
let deps = ClubDeps {
|
||||||
core: self.core.as_ref(),
|
core: self.core.as_ref(),
|
||||||
entities: self.entities.as_ref(),
|
entities: self.entities.as_ref(),
|
||||||
assets: self.resolver.as_ref(),
|
assets: self.resolver.as_ref(),
|
||||||
|
hidden: &hidden,
|
||||||
};
|
};
|
||||||
let (resp, log) = handle_club(query, &deps);
|
let (resp, log) = handle_club(query, &deps);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
@@ -2703,18 +2761,42 @@ impl Server {
|
|||||||
json_status(status, &body)
|
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
|
/// `GET …/hub` — the FUT hub tile counts, owned in Rust (no Python). Derived
|
||||||
/// from authoritative state: `clubPlayers` is the count of owned PLAYER cards
|
/// from authoritative state: `clubPlayers` is the count of owned PLAYER cards
|
||||||
/// in Core (consumables/staff excluded via the catalog kind), and the auction
|
/// in Core that are in the club (transfer-pile cards excluded — they have left
|
||||||
/// / tradePile counts are the user's active listings in the durable market
|
/// the club), and the auction / tradePile counts are the user's active listings
|
||||||
/// store. `clubPlayers` may be lower than the Python oracle's profile count by
|
/// in the durable market store. `clubPlayers` may be lower than the Python
|
||||||
/// exactly the deferred (unnameable Legend) instances — DIFFERENT-BY-DESIGN,
|
/// oracle's profile count by exactly the deferred (unnameable Legend)
|
||||||
/// since deferred cards are not owned in Core. Fail-closed on Core error (503);
|
/// instances — DIFFERENT-BY-DESIGN, since deferred cards are not owned in
|
||||||
/// a market-store read failure degrades the cosmetic listing counts to 0.
|
/// Core. Fail-closed on Core error (503); a market-store read failure degrades
|
||||||
|
/// the cosmetic listing counts to 0.
|
||||||
fn handle_hub(&self) -> WireResponse {
|
fn handle_hub(&self) -> WireResponse {
|
||||||
|
let hidden = self.club_hidden_ids();
|
||||||
let club_players = match self.core.all_owned() {
|
let club_players = match self.core.all_owned() {
|
||||||
Ok(items) => items
|
Ok(items) => items
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|it| !hidden.contains(&it.owned_card_id))
|
||||||
.filter(|it| self.resolver.kind_of(it) == ContentKind::Player)
|
.filter(|it| self.resolver.kind_of(it) == ContentKind::Player)
|
||||||
.count(),
|
.count(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -3870,8 +3952,9 @@ mod tests {
|
|||||||
("POST", "/ut/game/fifa17/transfermarket", Some(MarketList)),
|
("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", Some(MarketQuery)),
|
("GET", "/ut/game/fifa17/tradepile", 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(MarketQuery)),
|
("GET", "/ut/game/fifa17/tradePile/counts", Some(MarketCounts)),
|
||||||
|
("GET", "/ut/game/fifa17/tradepile/counts", Some(MarketCounts)),
|
||||||
("POST", "/ut/game/fifa17/trade/900000001", Some(MarketBuy)),
|
("POST", "/ut/game/fifa17/trade/900000001", Some(MarketBuy)),
|
||||||
(
|
(
|
||||||
"DELETE",
|
"DELETE",
|
||||||
|
|||||||
+304
-100
@@ -26,8 +26,12 @@
|
|||||||
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use openfut_adapter_fifa17::fut::entities::ReverseEntityResolver;
|
||||||
|
use openfut_adapter_fifa17::fut::item::{shape_item, ItemIdentityResolver};
|
||||||
use openfut_adapter_fifa17::fut::squad::SquadWireResolver;
|
use openfut_adapter_fifa17::fut::squad::SquadWireResolver;
|
||||||
|
|
||||||
|
use crate::economy_store::OwnedItemLookup;
|
||||||
|
|
||||||
use crate::market_store::{Listing, MarketError, MarketStore};
|
use crate::market_store::{Listing, MarketError, MarketStore};
|
||||||
use crate::pile_store::PileStore;
|
use crate::pile_store::PileStore;
|
||||||
use crate::{CoreEconomy, CoreError, WireResponse};
|
use crate::{CoreEconomy, CoreError, WireResponse};
|
||||||
@@ -66,24 +70,50 @@ fn trade_id_from_path(path: &str) -> Option<String> {
|
|||||||
|
|
||||||
/// Shape one listing into the FIFA auction record (0x18013e410 fields), sourced
|
/// Shape one listing into the FIFA auction record (0x18013e410 fields), sourced
|
||||||
/// from durable listing state rather than a hardcoded sample pool.
|
/// from durable listing state rather than a hardcoded sample pool.
|
||||||
fn auction_record(l: &Listing) -> Value {
|
///
|
||||||
|
/// `itemData` MUST be a full card object — the same proven-safe shape that renders
|
||||||
|
/// club/squad cards (the Python oracle's tradePile reuses its club card verbatim).
|
||||||
|
/// A 4-field stub gives the client's card view-model nothing to draw, so the
|
||||||
|
/// Transfer List shows a row with no visible card. The full card comes from the
|
||||||
|
/// snapshot persisted at listing time; a row written before snapshots existed
|
||||||
|
/// degrades to the stub (honest, not fabricated).
|
||||||
|
///
|
||||||
|
/// `item_state` overrides the card's `itemState`: the seller's own pile uses
|
||||||
|
/// `listFS` (list-for-sale), market search results use `forSale` — the oracle
|
||||||
|
/// distinguishes these, so the caller passes the one its screen needs.
|
||||||
|
fn auction_record_as(l: &Listing, item_state: &str) -> Value {
|
||||||
let trade_id: i64 = l.listing_id.parse().unwrap_or(0);
|
let trade_id: i64 = l.listing_id.parse().unwrap_or(0);
|
||||||
// resourceId is the FIFA wire identity the client listed (never the Core
|
// resourceId is the FIFA wire identity the client listed (never the Core
|
||||||
// card id). 0 means "no art", a valid int — never a fabricated FIFA asset.
|
// card id). 0 means "no art", a valid int — never a fabricated FIFA asset.
|
||||||
let resource = l.wire_resource_id.or(l.wire_item_id).unwrap_or(0);
|
let resource = l.wire_resource_id.or(l.wire_item_id).unwrap_or(0);
|
||||||
let item_id = l.wire_item_id.unwrap_or(trade_id);
|
let item_id = l.wire_item_id.unwrap_or(trade_id);
|
||||||
let (trade_state, item_state, bid_state, current_bid) = match l.state.as_str() {
|
let (trade_state, bid_state, current_bid) = match l.state.as_str() {
|
||||||
"active" => ("active", "forSale", "none", 0),
|
"active" => ("active", "none", 0),
|
||||||
_ => ("closed", "free", "highest", l.buy_now_price),
|
_ => ("closed", "highest", l.buy_now_price),
|
||||||
};
|
};
|
||||||
|
let item_data = l
|
||||||
|
.item_json
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str::<Value>(s).ok())
|
||||||
|
.filter(Value::is_object)
|
||||||
|
.map(|mut card| {
|
||||||
|
// Keep the wire identity and presentation state authoritative here.
|
||||||
|
card["id"] = json!(item_id);
|
||||||
|
card["itemState"] = json!(item_state);
|
||||||
|
card["untradeable"] = json!(false);
|
||||||
|
card
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
json!({
|
json!({
|
||||||
"tradeId": trade_id,
|
|
||||||
"itemData": {
|
|
||||||
"id": item_id,
|
"id": item_id,
|
||||||
"resourceId": resource,
|
"resourceId": resource,
|
||||||
"itemState": item_state,
|
"itemState": item_state,
|
||||||
"untradeable": false,
|
"untradeable": false,
|
||||||
},
|
})
|
||||||
|
});
|
||||||
|
json!({
|
||||||
|
"tradeId": trade_id,
|
||||||
|
"itemData": item_data,
|
||||||
"tradeState": trade_state,
|
"tradeState": trade_state,
|
||||||
"buyNowPrice": l.buy_now_price,
|
"buyNowPrice": l.buy_now_price,
|
||||||
"startingBid": l.start_price,
|
"startingBid": l.start_price,
|
||||||
@@ -97,6 +127,13 @@ fn auction_record(l: &Listing) -> Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Auction record for a market/search context (`itemState: forSale`), and for the
|
||||||
|
/// closed/sold echoes the buy path returns.
|
||||||
|
fn auction_record(l: &Listing) -> Value {
|
||||||
|
let state = if l.state == "active" { "forSale" } else { "free" };
|
||||||
|
auction_record_as(l, state)
|
||||||
|
}
|
||||||
|
|
||||||
/// Run a BLOCKING closure — the blocking Core client — on a fresh OS thread that
|
/// Run a BLOCKING closure — the blocking Core client — on a fresh OS thread that
|
||||||
/// has NO Tokio runtime entered, then block until it finishes. The market
|
/// has NO Tokio runtime entered, then block until it finishes. The market
|
||||||
/// handlers are `async` and driven on the shared runtime by the host bridge, but
|
/// handlers are `async` and driven on the shared runtime by the host bridge, but
|
||||||
@@ -119,74 +156,115 @@ fn credits_or_zero(econ: &dyn CoreEconomy) -> i64 {
|
|||||||
off_runtime(|| econ.balance()).unwrap_or(0)
|
off_runtime(|| econ.balance()).unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps a FIFA wire `resourceId` to the authoritative Core `card_id` a synthetic
|
/// A FutISStart POST resolved to a persistable listing — all owned/`Send` data,
|
||||||
/// buy mints. Backed by the FIFA17 catalog reverse index; unknown → `None`
|
/// so it can cross the async persist boundary. Built by [`resolve_market_list`]
|
||||||
/// (fail closed, never fabricated). Core stays unaware of FIFA resource ids.
|
/// on the caller's thread from Core inventory (the request body carries only the
|
||||||
pub trait MarketCardResolver: Send + Sync {
|
/// wire item id, never the resourceId or Core card_id).
|
||||||
fn card_id_for_resource(&self, resource_id: i64) -> Option<String>;
|
pub struct ResolvedListing {
|
||||||
|
pub item_id: i64,
|
||||||
|
pub core_id: String,
|
||||||
|
pub card_id: String,
|
||||||
|
pub resource_id: Option<i64>,
|
||||||
|
pub start: i64,
|
||||||
|
pub buy_now: i64,
|
||||||
|
pub seller: Option<String>,
|
||||||
|
/// The full shaped FIFA card (`itemData`) snapshot for the auction record.
|
||||||
|
/// `None` only when the item has no resolvable FIFA identity (never faked).
|
||||||
|
pub item_json: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a `/auctionhouse` POST (FutISStart) body to a [`ResolvedListing`],
|
||||||
|
/// SERVER-SIDE: the client's body carries only the wire item id, so the owned
|
||||||
|
/// card's Core `card_id` (minted on a synthetic buy) and FIFA `resourceId` (the
|
||||||
|
/// auction record) come from Core inventory. Returns `None` when the body names
|
||||||
|
/// no item OR the id does not reverse-resolve to an owned card — either way the
|
||||||
|
/// caller acks a fresh trade id and persists nothing (Core stays the ownership
|
||||||
|
/// authority; a phantom listing would mint a card the buy preflight rejects).
|
||||||
|
/// Pure identity + a single Core inventory read; run it OFF the async runtime.
|
||||||
|
pub fn resolve_market_list<E: ReverseEntityResolver>(
|
||||||
|
body: &[u8],
|
||||||
|
reverse: &dyn SquadWireResolver,
|
||||||
|
resolver: &dyn ItemIdentityResolver,
|
||||||
|
items: &dyn OwnedItemLookup,
|
||||||
|
ent: &E,
|
||||||
|
) -> Option<ResolvedListing> {
|
||||||
|
let b = parse_body(body);
|
||||||
|
let item_data = b.get("itemData");
|
||||||
|
let item_id = item_data
|
||||||
|
.and_then(|d| d.get("id"))
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.or_else(|| b.get("itemId").and_then(Value::as_i64))?;
|
||||||
|
let core_id = reverse.owned_id_for_wire(item_id)?;
|
||||||
|
let owned = items.owned_item(&core_id)?;
|
||||||
|
// Shape the FULL card ONCE, here, with the same shaper `/club` and the squad
|
||||||
|
// projection use — so the auction record renders identically to the club card.
|
||||||
|
// A listing is a snapshot; storing it avoids re-resolving on every tradePile
|
||||||
|
// poll and keeps the (non-Send) resolvers off the async persist path.
|
||||||
|
let identity = resolver.resolve(&owned);
|
||||||
|
let resource_id = identity.map(|id| id.resource_id as i64);
|
||||||
|
let item_json = identity
|
||||||
|
.map(|id| shape_item(&owned, id, ent))
|
||||||
|
.and_then(|card| serde_json::to_string(&card).ok());
|
||||||
|
Some(ResolvedListing {
|
||||||
|
item_id,
|
||||||
|
core_id,
|
||||||
|
card_id: owned.card_id,
|
||||||
|
resource_id,
|
||||||
|
start: b.get("startingBid").and_then(Value::as_i64).unwrap_or(150),
|
||||||
|
buy_now: b.get("buyNowPrice").and_then(Value::as_i64).unwrap_or(0),
|
||||||
|
seller: b.get("sellerName").and_then(Value::as_str).map(str::to_string),
|
||||||
|
item_json,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `/auctionhouse` — search (GET), list-for-sale (POST FutISStart), relist (PUT).
|
/// `/auctionhouse` — search (GET), list-for-sale (POST FutISStart), relist (PUT).
|
||||||
///
|
///
|
||||||
/// * GET returns the durable active auctions plus the FutGetAuctionCount ints,
|
/// * GET returns the durable active auctions plus the FutGetAuctionCount ints,
|
||||||
/// in the oracle's `_market_body` shape.
|
/// in the oracle's `_market_body` shape.
|
||||||
/// * POST lists a club item: resolve its wire `resourceId` to the authoritative
|
/// * POST persists the pre-resolved listing (see [`resolve_market_list`]) and
|
||||||
/// Core `card_id`, persist both, and return `{"id": tradeId}`. An unmappable
|
/// returns `{"id": tradeId}`; an unresolved item acks a fresh id, persisting
|
||||||
/// resource fails closed (persists nothing).
|
/// nothing.
|
||||||
/// * PUT (relist-all) is an ack `{}`.
|
/// * PUT (relist-all) is an ack `{}`.
|
||||||
pub async fn handle_market_list(
|
pub async fn handle_market_list(
|
||||||
method: &str,
|
method: &str,
|
||||||
body: &[u8],
|
resolved: Option<ResolvedListing>,
|
||||||
econ: &dyn CoreEconomy,
|
econ: &dyn CoreEconomy,
|
||||||
store: &MarketStore,
|
store: &MarketStore,
|
||||||
mapper: &dyn MarketCardResolver,
|
|
||||||
) -> WireResponse {
|
) -> WireResponse {
|
||||||
match method {
|
match method {
|
||||||
"POST" => {
|
"POST" => {
|
||||||
let b = parse_body(body);
|
let Some(r) = resolved else {
|
||||||
let item_data = b.get("itemData");
|
// No item, or the id did not resolve to an owned card: fresh-id ack.
|
||||||
let wire_item_id = item_data
|
eprintln!(
|
||||||
.and_then(|d| d.get("id"))
|
"utas-host owner=RUST route=market-list POST listed=false reason=unresolved"
|
||||||
.and_then(Value::as_i64)
|
);
|
||||||
.or_else(|| b.get("itemId").and_then(Value::as_i64));
|
|
||||||
let start = b.get("startingBid").and_then(Value::as_i64).unwrap_or(150);
|
|
||||||
let buy_now = b.get("buyNowPrice").and_then(Value::as_i64).unwrap_or(0);
|
|
||||||
let Some(item_id) = wire_item_id else {
|
|
||||||
// No item to list: mirror the oracle's fresh-id ack, persist nothing.
|
|
||||||
return ok_json(&json!({ "id": TRADE_ID_BASE }));
|
|
||||||
};
|
|
||||||
// Resolve the FIFA wire resourceId to the authoritative Core card id
|
|
||||||
// the synthetic buy will MINT. Fail closed on an unmappable resource:
|
|
||||||
// persist nothing (a non-existent listing cannot be bought), so a bad
|
|
||||||
// resource never becomes a mint Core's content preflight would reject.
|
|
||||||
let Some(resource_id) = item_data
|
|
||||||
.and_then(|d| d.get("resourceId"))
|
|
||||||
.and_then(Value::as_i64)
|
|
||||||
else {
|
|
||||||
return ok_json(&json!({ "id": TRADE_ID_BASE }));
|
|
||||||
};
|
|
||||||
let Some(core_card_id) = mapper.card_id_for_resource(resource_id) else {
|
|
||||||
return ok_json(&json!({ "id": TRADE_ID_BASE }));
|
return ok_json(&json!({ "id": TRADE_ID_BASE }));
|
||||||
};
|
};
|
||||||
// Trade-id space is offset from the wire item id, so each owned item
|
// Trade-id space is offset from the wire item id, so each owned item
|
||||||
// maps to a unique, stable auction id (no modular wraparound).
|
// maps to a unique, stable auction id (no modular wraparound).
|
||||||
let trade_id = TRADE_ID_BASE + item_id;
|
let trade_id = TRADE_ID_BASE + r.item_id;
|
||||||
let listing_id = trade_id.to_string();
|
let listing_id = trade_id.to_string();
|
||||||
let seller = b.get("sellerName").and_then(Value::as_str);
|
|
||||||
match store
|
match store
|
||||||
.create_listing(
|
.create_listing(
|
||||||
&listing_id,
|
&listing_id,
|
||||||
&core_card_id,
|
&r.card_id,
|
||||||
None,
|
Some(&r.core_id),
|
||||||
Some(item_id),
|
Some(r.item_id),
|
||||||
Some(resource_id),
|
r.resource_id,
|
||||||
start,
|
r.start,
|
||||||
buy_now,
|
r.buy_now,
|
||||||
seller,
|
r.seller.as_deref(),
|
||||||
|
r.item_json.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) | Err(MarketError::Conflict) => ok_json(&json!({ "id": trade_id })),
|
Ok(_) | Err(MarketError::Conflict) => {
|
||||||
|
eprintln!(
|
||||||
|
"utas-host owner=RUST route=market-list POST item_id={} listed=true trade_id={trade_id}",
|
||||||
|
r.item_id
|
||||||
|
);
|
||||||
|
ok_json(&json!({ "id": trade_id }))
|
||||||
|
}
|
||||||
Err(_) => json_body(503, &json!({ "error": "market_store" })),
|
Err(_) => json_body(503, &json!({ "error": "market_store" })),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,6 +293,10 @@ pub async fn handle_market_list(
|
|||||||
|
|
||||||
/// Query listings in a given `state` (e.g. the user's own sale pile is the
|
/// Query listings in a given `state` (e.g. the user's own sale pile is the
|
||||||
/// `active` set). Returns the oracle's tradePile shape.
|
/// `active` set). Returns the oracle's tradePile shape.
|
||||||
|
///
|
||||||
|
/// This is the SELLER's own pile, so each card carries `itemState: "listFS"`
|
||||||
|
/// (list-for-sale) — the state the oracle stamps on a tradePile card, distinct
|
||||||
|
/// from the `forSale` used for market search results.
|
||||||
pub async fn handle_market_query(
|
pub async fn handle_market_query(
|
||||||
state: &str,
|
state: &str,
|
||||||
econ: &dyn CoreEconomy,
|
econ: &dyn CoreEconomy,
|
||||||
@@ -224,7 +306,10 @@ pub async fn handle_market_query(
|
|||||||
Ok(l) => l,
|
Ok(l) => l,
|
||||||
Err(_) => return json_body(503, &json!({ "error": "market_store" })),
|
Err(_) => return json_body(503, &json!({ "error": "market_store" })),
|
||||||
};
|
};
|
||||||
let auctions: Vec<Value> = listings.iter().map(auction_record).collect();
|
let auctions: Vec<Value> = listings
|
||||||
|
.iter()
|
||||||
|
.map(|l| auction_record_as(l, "listFS"))
|
||||||
|
.collect();
|
||||||
ok_json(&json!({
|
ok_json(&json!({
|
||||||
"auctionInfo": auctions,
|
"auctionInfo": auctions,
|
||||||
"credits": credits_or_zero(econ),
|
"credits": credits_or_zero(econ),
|
||||||
@@ -232,6 +317,30 @@ pub async fn handle_market_query(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `GET …/tradePile/counts` — FutGetAuctionCount (the auction TALLY), a DISTINCT
|
||||||
|
/// deserializer from `/tradePile`. It reads exactly five SCALAR INTS — `count`,
|
||||||
|
/// `maxAuctionsAllowed`, `offered`, `selling`, `sold` — and skips anything else,
|
||||||
|
/// so answering it with the `auctionInfo` listing body leaves every count at its
|
||||||
|
/// constructor default (0): the hub tile shows a listing while the Transfer List
|
||||||
|
/// screen shows none. All five being ints means there is no container-type
|
||||||
|
/// freeze risk. They are the only inputs to IS_MAX_AUCTIONS, so
|
||||||
|
/// `maxAuctionsAllowed = 100` with `selling < 100` keeps the listing cap open.
|
||||||
|
/// A store read failure degrades to zeros (cosmetic tally, never fail-closed).
|
||||||
|
pub async fn handle_market_counts(store: &MarketStore) -> WireResponse {
|
||||||
|
let n = store
|
||||||
|
.query_listings("active")
|
||||||
|
.await
|
||||||
|
.map(|l| l.len() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
ok_json(&json!({
|
||||||
|
"count": n,
|
||||||
|
"maxAuctionsAllowed": 100,
|
||||||
|
"offered": 0,
|
||||||
|
"selling": n,
|
||||||
|
"sold": 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// `DELETE /ut/delete/game/<sku>/trade/<id>` — remove a listing from the sale
|
/// `DELETE /ut/delete/game/<sku>/trade/<id>` — remove a listing from the sale
|
||||||
/// pile. Cancels the `active` listing once; the oracle always acks `{}`, so a
|
/// pile. Cancels the `active` listing once; the oracle always acks `{}`, so a
|
||||||
/// missing/already-closed listing is not surfaced as an error to the client
|
/// missing/already-closed listing is not surfaced as an error to the client
|
||||||
@@ -525,20 +634,55 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Permissive test resolver: maps any wire resourceId to its own string, so
|
// ---- ItemIdentityResolver + OwnedItemLookup doubles -------------------
|
||||||
/// the existing list tests keep their prior card-id semantics. A dedicated
|
use openfut_adapter_fifa17::fut::item::{CoreOwnedItem, Fifa17Identity};
|
||||||
/// test covers the unknown-resource fail-closed path.
|
|
||||||
struct AllowAllResolver;
|
/// Owned-inventory double: core id -> CoreOwnedItem.
|
||||||
impl MarketCardResolver for AllowAllResolver {
|
struct FakeItems(HashMap<String, CoreOwnedItem>);
|
||||||
fn card_id_for_resource(&self, resource_id: i64) -> Option<String> {
|
impl OwnedItemLookup for FakeItems {
|
||||||
Some(resource_id.to_string())
|
fn owned_item(&self, core_id: &str) -> Option<CoreOwnedItem> {
|
||||||
|
self.0.get(core_id).cloned()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test resolver that maps nothing (every resourceId is unknown).
|
/// Identity double: resolves every owned item to a fixed FIFA resourceId.
|
||||||
struct DenyAllResolver;
|
struct FixedIdentity(u32);
|
||||||
impl MarketCardResolver for DenyAllResolver {
|
impl ItemIdentityResolver for FixedIdentity {
|
||||||
fn card_id_for_resource(&self, _resource_id: i64) -> Option<String> {
|
fn resolve(&self, _item: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||||
|
Some(Fifa17Identity {
|
||||||
|
item_id: 0,
|
||||||
|
asset_id: self.0,
|
||||||
|
resource_id: self.0,
|
||||||
|
rareflag: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A CoreOwnedItem with the given core id + card id (other fields dummy).
|
||||||
|
fn owned(core_id: &str, card_id: &str) -> CoreOwnedItem {
|
||||||
|
CoreOwnedItem {
|
||||||
|
owned_card_id: core_id.to_string(),
|
||||||
|
card_id: card_id.to_string(),
|
||||||
|
rating: 84,
|
||||||
|
position: "ST".to_string(),
|
||||||
|
nation: String::new(),
|
||||||
|
league: String::new(),
|
||||||
|
club: String::new(),
|
||||||
|
attributes: [80, 80, 80, 80, 80, 80],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entity double: no reverse entity mappings, so shaped cards carry ids 0
|
||||||
|
/// (a valid int — the shaper never fabricates an entity id).
|
||||||
|
struct NoEntities;
|
||||||
|
impl ReverseEntityResolver for NoEntities {
|
||||||
|
fn league_id(&self, _name: &str) -> Option<u32> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn nation_id(&self, _name: &str) -> Option<u32> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn team_id(&self, _name: &str) -> Option<u32> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -551,7 +695,7 @@ mod tests {
|
|||||||
|
|
||||||
async fn seed_listing(store: &MarketStore, id: &str, buy_now: i64) {
|
async fn seed_listing(store: &MarketStore, id: &str, buy_now: i64) {
|
||||||
store
|
store
|
||||||
.create_listing(id, "169193", None, None, Some(169193), 400, buy_now, None)
|
.create_listing(id, "169193", None, None, Some(169193), 400, buy_now, None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
@@ -566,23 +710,47 @@ mod tests {
|
|||||||
async fn list_post_persists_and_returns_trade_id() {
|
async fn list_post_persists_and_returns_trade_id() {
|
||||||
let (store, _d) = store_at("post").await;
|
let (store, _d) = store_at("post").await;
|
||||||
let econ = CountingEconomy::with_balance(10_000);
|
let econ = CountingEconomy::with_balance(10_000);
|
||||||
let body = json!({ "itemData": { "id": 100004617, "resourceId": 169193 },
|
// The client body carries only the wire item id; the server resolves the
|
||||||
|
// owned card's card_id + resourceId from Core inventory.
|
||||||
|
let body = json!({ "itemData": { "id": 100004617 },
|
||||||
"startingBid": 300, "buyNowPrice": 2500 });
|
"startingBid": 300, "buyNowPrice": 2500 });
|
||||||
let resp = handle_market_list(
|
let reverse = MapResolver::new(&[(100004617, "core-1")]);
|
||||||
"POST",
|
let items = FakeItems(
|
||||||
|
[("core-1".to_string(), owned("core-1", "169193"))]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
let ident = FixedIdentity(169193);
|
||||||
|
let resolved = resolve_market_list(
|
||||||
body.to_string().as_bytes(),
|
body.to_string().as_bytes(),
|
||||||
&econ,
|
&reverse,
|
||||||
&store,
|
&ident,
|
||||||
&AllowAllResolver,
|
&items,
|
||||||
)
|
&NoEntities,
|
||||||
.await;
|
);
|
||||||
|
let resp = handle_market_list("POST", resolved, &econ, &store).await;
|
||||||
assert_eq!(resp.status, 200);
|
assert_eq!(resp.status, 200);
|
||||||
let trade_id = parse(&resp)["id"].as_i64().unwrap();
|
let trade_id = parse(&resp)["id"].as_i64().unwrap();
|
||||||
assert_eq!(trade_id, TRADE_ID_BASE + 100004617);
|
assert_eq!(trade_id, TRADE_ID_BASE + 100004617);
|
||||||
// Persisted + browsable.
|
// Persisted with the resolved Core card_id + wire resourceId, browsable.
|
||||||
let listed = store.get_listing(&trade_id.to_string()).await.unwrap();
|
let listed = store.get_listing(&trade_id.to_string()).await.unwrap();
|
||||||
assert_eq!(listed.buy_now_price, 2500);
|
assert_eq!(listed.buy_now_price, 2500);
|
||||||
let browse = handle_market_list("GET", b"", &econ, &store, &AllowAllResolver).await;
|
assert_eq!(listed.card_id, "169193");
|
||||||
|
assert_eq!(listed.wire_resource_id, Some(169193));
|
||||||
|
// The listing carries the FULL shaped card snapshot, not a 4-field stub:
|
||||||
|
// a stub leaves the Transfer List with an unrenderable row (the live bug).
|
||||||
|
let snap: Value = serde_json::from_str(listed.item_json.as_deref().unwrap()).unwrap();
|
||||||
|
assert_eq!(snap["rating"], 84);
|
||||||
|
assert_eq!(snap["preferredPosition"], "ST");
|
||||||
|
assert_eq!(snap["attributeList"].as_array().unwrap().len(), 6);
|
||||||
|
// tradePile embeds that full card and stamps the seller-pile state.
|
||||||
|
let pile = handle_market_query("active", &econ, &store).await;
|
||||||
|
let rec = parse(&pile)["auctionInfo"][0].clone();
|
||||||
|
assert_eq!(rec["itemData"]["itemState"], "listFS");
|
||||||
|
assert_eq!(rec["itemData"]["rating"], 84);
|
||||||
|
assert_eq!(rec["itemData"]["id"], 100004617i64);
|
||||||
|
assert_eq!(rec["itemData"]["resourceId"], 169193);
|
||||||
|
let browse = handle_market_list("GET", None, &econ, &store).await;
|
||||||
let b = parse(&browse);
|
let b = parse(&browse);
|
||||||
assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1);
|
assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1);
|
||||||
assert_eq!(b["credits"], 10_000);
|
assert_eq!(b["credits"], 10_000);
|
||||||
@@ -593,27 +761,31 @@ mod tests {
|
|||||||
async fn list_put_is_ack() {
|
async fn list_put_is_ack() {
|
||||||
let (store, _d) = store_at("put").await;
|
let (store, _d) = store_at("put").await;
|
||||||
let econ = CountingEconomy::with_balance(0);
|
let econ = CountingEconomy::with_balance(0);
|
||||||
let resp = handle_market_list("PUT", b"", &econ, &store, &AllowAllResolver).await;
|
let resp = handle_market_list("PUT", None, &econ, &store).await;
|
||||||
assert_eq!(resp.status, 200);
|
assert_eq!(resp.status, 200);
|
||||||
assert_eq!(parse(&resp), json!({}));
|
assert_eq!(parse(&resp), json!({}));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn list_unknown_resource_fails_closed_no_listing() {
|
async fn list_unresolved_item_fails_closed_no_listing() {
|
||||||
// An unmappable wire resourceId must NOT create a listing (a synthetic buy
|
// A wire id that does not resolve to an owned card must NOT create a listing
|
||||||
// would otherwise mint a card id Core cannot resolve). Acks neutrally.
|
// (a synthetic buy would otherwise mint a card Core cannot resolve). Acks.
|
||||||
let (store, _d) = store_at("deny").await;
|
let (store, _d) = store_at("deny").await;
|
||||||
let econ = CountingEconomy::with_balance(10_000);
|
let econ = CountingEconomy::with_balance(10_000);
|
||||||
let body = json!({ "itemData": { "id": 100004617, "resourceId": 424242 },
|
let body = json!({ "itemData": { "id": 100004617 },
|
||||||
"startingBid": 300, "buyNowPrice": 2500 });
|
"startingBid": 300, "buyNowPrice": 2500 });
|
||||||
let resp = handle_market_list(
|
let reverse = MapResolver::new(&[]); // maps nothing -> unresolved
|
||||||
"POST",
|
let items = FakeItems(HashMap::new());
|
||||||
|
let ident = FixedIdentity(0);
|
||||||
|
let resolved = resolve_market_list(
|
||||||
body.to_string().as_bytes(),
|
body.to_string().as_bytes(),
|
||||||
&econ,
|
&reverse,
|
||||||
&store,
|
&ident,
|
||||||
&DenyAllResolver,
|
&items,
|
||||||
)
|
&NoEntities,
|
||||||
.await;
|
);
|
||||||
|
assert!(resolved.is_none(), "unresolved item must not build a listing");
|
||||||
|
let resp = handle_market_list("POST", resolved, &econ, &store).await;
|
||||||
assert_eq!(resp.status, 200);
|
assert_eq!(resp.status, 200);
|
||||||
assert_eq!(parse(&resp)["id"].as_i64().unwrap(), TRADE_ID_BASE);
|
assert_eq!(parse(&resp)["id"].as_i64().unwrap(), TRADE_ID_BASE);
|
||||||
// Nothing persisted at the would-be trade id: not buyable.
|
// Nothing persisted at the would-be trade id: not buyable.
|
||||||
@@ -634,23 +806,23 @@ mod tests {
|
|||||||
{
|
{
|
||||||
let store = MarketStore::open(db.path()).await.unwrap();
|
let store = MarketStore::open(db.path()).await.unwrap();
|
||||||
let econ = CountingEconomy::with_balance(10_000);
|
let econ = CountingEconomy::with_balance(10_000);
|
||||||
// A resolver that maps resourceId 20801 -> Core card_id "card_pl_042".
|
let body = json!({ "itemData": { "id": 100004900 },
|
||||||
struct FixedResolver;
|
|
||||||
impl MarketCardResolver for FixedResolver {
|
|
||||||
fn card_id_for_resource(&self, resource_id: i64) -> Option<String> {
|
|
||||||
(resource_id == 20801).then(|| "card_pl_042".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let body = json!({ "itemData": { "id": 100004900, "resourceId": 20801 },
|
|
||||||
"startingBid": 300, "buyNowPrice": 2500 });
|
"startingBid": 300, "buyNowPrice": 2500 });
|
||||||
let resp = handle_market_list(
|
let reverse = MapResolver::new(&[(100004900, "core-9")]);
|
||||||
"POST",
|
let items = FakeItems(
|
||||||
|
[("core-9".to_string(), owned("core-9", "card_pl_042"))]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
let ident = FixedIdentity(20801);
|
||||||
|
let resolved = resolve_market_list(
|
||||||
body.to_string().as_bytes(),
|
body.to_string().as_bytes(),
|
||||||
&econ,
|
&reverse,
|
||||||
&store,
|
&ident,
|
||||||
&FixedResolver,
|
&items,
|
||||||
)
|
&NoEntities,
|
||||||
.await;
|
);
|
||||||
|
let resp = handle_market_list("POST", resolved, &econ, &store).await;
|
||||||
trade_id = parse(&resp)["id"].as_i64().unwrap();
|
trade_id = parse(&resp)["id"].as_i64().unwrap();
|
||||||
}
|
}
|
||||||
// Reopen from the same file: both identities survive.
|
// Reopen from the same file: both identities survive.
|
||||||
@@ -676,6 +848,38 @@ mod tests {
|
|||||||
assert_eq!(b["credits"], 50);
|
assert_eq!(b["credits"], 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn counts_is_the_five_int_tally_not_the_listing_body() {
|
||||||
|
// FutGetAuctionCount is a DISTINCT deserializer from /tradePile: five
|
||||||
|
// scalar ints, no auctionInfo. Answering it with the listing body leaves
|
||||||
|
// every count at its constructor default, so the Transfer List screen
|
||||||
|
// shows no active sale even while the hub tile reports one (live bug).
|
||||||
|
let (store, _d) = store_at("counts").await;
|
||||||
|
let b = parse(&handle_market_counts(&store).await);
|
||||||
|
assert_eq!(b["count"], 0);
|
||||||
|
assert_eq!(b["selling"], 0);
|
||||||
|
|
||||||
|
seed_listing(&store, "900000007", 2500).await;
|
||||||
|
let resp = handle_market_counts(&store).await;
|
||||||
|
assert_eq!(resp.status, 200);
|
||||||
|
let b = parse(&resp);
|
||||||
|
assert_eq!(b["count"], 1, "tally counts the active listing");
|
||||||
|
assert_eq!(b["selling"], 1);
|
||||||
|
assert_eq!(b["sold"], 0);
|
||||||
|
assert_eq!(b["offered"], 0);
|
||||||
|
assert_eq!(
|
||||||
|
b["maxAuctionsAllowed"], 100,
|
||||||
|
"cap stays open for IS_MAX_AUCTIONS"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
b.get("auctionInfo").is_none(),
|
||||||
|
"the tally must NOT carry the listing body"
|
||||||
|
);
|
||||||
|
for k in ["count", "maxAuctionsAllowed", "offered", "selling", "sold"] {
|
||||||
|
assert!(b[k].is_i64(), "{k} must be a scalar int");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn buy_now_debits_mints_and_closes() {
|
async fn buy_now_debits_mints_and_closes() {
|
||||||
let (store, _d) = store_at("buy").await;
|
let (store, _d) = store_at("buy").await;
|
||||||
|
|||||||
@@ -146,6 +146,12 @@ pub struct Listing {
|
|||||||
pub state: String,
|
pub state: String,
|
||||||
/// Creation time, unix-epoch milliseconds as a string (sortable).
|
/// Creation time, unix-epoch milliseconds as a string (sortable).
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
/// The FIFA card object (`itemData`) as shaped at listing time, serialized.
|
||||||
|
/// A listing is a SNAPSHOT: the auction record must carry the full card the
|
||||||
|
/// client can render (rating/position/attributes/rareflag/assetId), not a
|
||||||
|
/// stub — a stub leaves the Transfer List with an unrenderable row. `None`
|
||||||
|
/// only for rows written before this column existed (renders as a stub).
|
||||||
|
pub item_json: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const CREATE_LISTINGS: &str = "CREATE TABLE IF NOT EXISTS listings (
|
const CREATE_LISTINGS: &str = "CREATE TABLE IF NOT EXISTS listings (
|
||||||
@@ -158,7 +164,8 @@ const CREATE_LISTINGS: &str = "CREATE TABLE IF NOT EXISTS listings (
|
|||||||
buy_now_price INTEGER NOT NULL,
|
buy_now_price INTEGER NOT NULL,
|
||||||
owner TEXT,
|
owner TEXT,
|
||||||
state TEXT NOT NULL CHECK (state IN ('active','reserved','sold','cancelled')),
|
state TEXT NOT NULL CHECK (state IN ('active','reserved','sold','cancelled')),
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL,
|
||||||
|
item_json TEXT
|
||||||
)";
|
)";
|
||||||
|
|
||||||
fn now_millis() -> String {
|
fn now_millis() -> String {
|
||||||
@@ -182,6 +189,7 @@ fn row_to_listing(row: &sqlx::sqlite::SqliteRow) -> Listing {
|
|||||||
owner: row.get("owner"),
|
owner: row.get("owner"),
|
||||||
state: row.get("state"),
|
state: row.get("state"),
|
||||||
created_at: row.get("created_at"),
|
created_at: row.get("created_at"),
|
||||||
|
item_json: row.get("item_json"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +231,22 @@ impl MarketStore {
|
|||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(db)?;
|
.map_err(db)?;
|
||||||
|
// Additive migration: `item_json` was added after the first stores shipped,
|
||||||
|
// and `CREATE TABLE IF NOT EXISTS` will not add a column to an existing
|
||||||
|
// file. Add it when absent so an existing market DB keeps working (old
|
||||||
|
// rows read back `None` and render the stub card).
|
||||||
|
let has_item_json = sqlx::query("PRAGMA table_info(listings)")
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(db)?
|
||||||
|
.iter()
|
||||||
|
.any(|r| r.get::<String, _>("name") == "item_json");
|
||||||
|
if !has_item_json {
|
||||||
|
sqlx::query("ALTER TABLE listings ADD COLUMN item_json TEXT")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(db)?;
|
||||||
|
}
|
||||||
Ok(MarketStore {
|
Ok(MarketStore {
|
||||||
pool,
|
pool,
|
||||||
fault: StoreFault::default(),
|
fault: StoreFault::default(),
|
||||||
@@ -249,6 +273,8 @@ impl MarketStore {
|
|||||||
start_price: i64,
|
start_price: i64,
|
||||||
buy_now_price: i64,
|
buy_now_price: i64,
|
||||||
owner: Option<&str>,
|
owner: Option<&str>,
|
||||||
|
// The shaped FIFA card snapshot (`itemData`) for the auction record.
|
||||||
|
item_json: Option<&str>,
|
||||||
) -> Result<Listing, MarketError> {
|
) -> Result<Listing, MarketError> {
|
||||||
let created_at = now_millis();
|
let created_at = now_millis();
|
||||||
let mut conn = self.pool.acquire().await.map_err(db)?;
|
let mut conn = self.pool.acquire().await.map_err(db)?;
|
||||||
@@ -258,8 +284,8 @@ impl MarketStore {
|
|||||||
.map_err(db)?;
|
.map_err(db)?;
|
||||||
let res = sqlx::query(
|
let res = sqlx::query(
|
||||||
"INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, \
|
"INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, \
|
||||||
wire_resource_id, start_price, buy_now_price, owner, state, created_at) \
|
wire_resource_id, start_price, buy_now_price, owner, state, created_at, item_json) \
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?)",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(listing_id)
|
.bind(listing_id)
|
||||||
.bind(card_id)
|
.bind(card_id)
|
||||||
@@ -270,6 +296,7 @@ impl MarketStore {
|
|||||||
.bind(buy_now_price)
|
.bind(buy_now_price)
|
||||||
.bind(owner)
|
.bind(owner)
|
||||||
.bind(&created_at)
|
.bind(&created_at)
|
||||||
|
.bind(item_json)
|
||||||
.execute(&mut *conn)
|
.execute(&mut *conn)
|
||||||
.await;
|
.await;
|
||||||
match res {
|
match res {
|
||||||
@@ -289,6 +316,7 @@ impl MarketStore {
|
|||||||
owner: owner.map(str::to_string),
|
owner: owner.map(str::to_string),
|
||||||
state: "active".to_string(),
|
state: "active".to_string(),
|
||||||
created_at,
|
created_at,
|
||||||
|
item_json: item_json.map(str::to_string),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -502,7 +530,7 @@ mod tests {
|
|||||||
|
|
||||||
async fn seed(store: &MarketStore, id: &str) -> Listing {
|
async fn seed(store: &MarketStore, id: &str) -> Listing {
|
||||||
store
|
store
|
||||||
.create_listing(id, "card_pl_001", None, None, None, 900, 2500, None)
|
.create_listing(id, "card_pl_001", None, None, None, 900, 2500, None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
@@ -533,7 +561,7 @@ mod tests {
|
|||||||
seed(&store, "900000001").await;
|
seed(&store, "900000001").await;
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
store
|
store
|
||||||
.create_listing("900000001", "card_pl_002", None, None, None, 1, 2, None)
|
.create_listing("900000001", "card_pl_002", None, None, None, 1, 2, None, None)
|
||||||
.await,
|
.await,
|
||||||
Err(MarketError::Conflict)
|
Err(MarketError::Conflict)
|
||||||
));
|
));
|
||||||
@@ -597,6 +625,7 @@ mod tests {
|
|||||||
900,
|
900,
|
||||||
2500,
|
2500,
|
||||||
Some("alice"),
|
Some("alice"),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -668,6 +697,7 @@ mod tests {
|
|||||||
900,
|
900,
|
||||||
2500,
|
2500,
|
||||||
Some("alice"),
|
Some("alice"),
|
||||||
|
Some(r#"{"rating":84}"#),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -683,5 +713,10 @@ mod tests {
|
|||||||
assert_eq!(got.core_item_id.as_deref(), Some("core-7"));
|
assert_eq!(got.core_item_id.as_deref(), Some("core-7"));
|
||||||
assert_eq!(got.wire_item_id, Some(100004617));
|
assert_eq!(got.wire_item_id, Some(100004617));
|
||||||
assert_eq!(got.owner.as_deref(), Some("alice"));
|
assert_eq!(got.owner.as_deref(), Some("alice"));
|
||||||
|
assert_eq!(
|
||||||
|
got.item_json.as_deref(),
|
||||||
|
Some(r#"{"rating":84}"#),
|
||||||
|
"card snapshot survives reopen"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -423,19 +423,18 @@ fn case_c_dup_quicksell(h: &Harness) -> String {
|
|||||||
/// exactly one debit; exactly one mint.
|
/// exactly one debit; exactly one mint.
|
||||||
fn case_d_two_market_buyers(h: &Harness) -> String {
|
fn case_d_two_market_buyers(h: &Harness) -> String {
|
||||||
let mut wins = 0u32;
|
let mut wins = 0u32;
|
||||||
for i in 0..ITERS {
|
for _ in 0..ITERS {
|
||||||
|
// List a genuinely-owned card: the server resolves the Core card_id +
|
||||||
|
// resourceId from inventory via the wire id (you can only list what you own).
|
||||||
|
let (item_id, _core) = mint_one(h);
|
||||||
set_balance(&h.client, 50_000);
|
set_balance(&h.client, 50_000);
|
||||||
let item_id = 500_000 + i as i64; // unique listing per iteration
|
|
||||||
let list = h
|
let list = h
|
||||||
.server
|
.server
|
||||||
.try_handle_economy(
|
.try_handle_economy(
|
||||||
"POST",
|
"POST",
|
||||||
"/ut/game/fifa17/auctionhouse",
|
"/ut/game/fifa17/auctionhouse",
|
||||||
&[],
|
&[],
|
||||||
format!(
|
format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
|
||||||
r#"{{"itemData":{{"id":{item_id},"resourceId":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
|
|
||||||
h.sample_resource
|
|
||||||
)
|
|
||||||
.as_bytes(),
|
.as_bytes(),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ fn pack_ids(pg: &Value) -> Vec<u64> {
|
|||||||
fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||||
wait_ready(core_base);
|
wait_ready(core_base);
|
||||||
let http = reqwest::blocking::Client::new();
|
let http = reqwest::blocking::Client::new();
|
||||||
let (server, client, sample_resource) = build_econ_server(core_base, dir);
|
let (server, client, _sample_resource) = build_econ_server(core_base, dir);
|
||||||
|
|
||||||
// ── Fixture alignment: both sides own exactly one pack-70 entitlement. ──
|
// ── Fixture alignment: both sides own exactly one pack-70 entitlement. ──
|
||||||
// Oracle: fresh profile already owns pack 70. Core: grant the "70" entitlement
|
// Oracle: fresh profile already owns pack 70. Core: grant the "70" entitlement
|
||||||
@@ -847,7 +847,20 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let o_trade_id = o_list["id"].as_i64().expect("oracle trade id");
|
let o_trade_id = o_list["id"].as_i64().expect("oracle trade id");
|
||||||
let (r_ls, r_list) = rust(&server, "POST", "/ut/game/fifa17/auctionhouse", format!(r#"{{"itemData":{{"id":777,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"#).as_bytes(), None);
|
// Same body shape as the oracle: the wire id ALONE (the server resolves the
|
||||||
|
// owned card's card_id + resourceId from inventory). `r_wire[1]` is the card
|
||||||
|
// moved to the trade pile in OP 9 — the Rust parallel of the oracle's o_wire[1].
|
||||||
|
let (r_ls, r_list) = rust(
|
||||||
|
&server,
|
||||||
|
"POST",
|
||||||
|
"/ut/game/fifa17/auctionhouse",
|
||||||
|
format!(
|
||||||
|
r#"{{"itemData":{{"id":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
|
||||||
|
r_wire[1]
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
let r_trade_id = r_list["id"].as_i64().expect("rust trade id");
|
let r_trade_id = r_list["id"].as_i64().expect("rust trade id");
|
||||||
assert_eq!(o_ls, 200);
|
assert_eq!(o_ls, 200);
|
||||||
assert_eq!(r_ls, 200, "market list status parity");
|
assert_eq!(r_ls, 200, "market list status parity");
|
||||||
@@ -992,7 +1005,19 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
|||||||
"oracle cancelled listing gone from tradePile"
|
"oracle cancelled listing gone from tradePile"
|
||||||
);
|
);
|
||||||
// Rust: list a fresh item, cancel it, then a buy is a 0-delta empty auction.
|
// Rust: list a fresh item, cancel it, then a buy is a 0-delta empty auction.
|
||||||
let clist = rust(&server, "POST", "/ut/game/fifa17/auctionhouse", format!(r#"{{"itemData":{{"id":888,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"#).as_bytes(), None).1;
|
// A still-owned card (r_wire[0]/[3] were quick-sold, [1] is listed above).
|
||||||
|
let clist = rust(
|
||||||
|
&server,
|
||||||
|
"POST",
|
||||||
|
"/ut/game/fifa17/auctionhouse",
|
||||||
|
format!(
|
||||||
|
r#"{{"itemData":{{"id":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
|
||||||
|
r_wire[2]
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.1;
|
||||||
let r_cancel_id = clist["id"].as_i64().unwrap();
|
let r_cancel_id = clist["id"].as_i64().unwrap();
|
||||||
let (r_cs, _) = rust(
|
let (r_cs, _) = rust(
|
||||||
&server,
|
&server,
|
||||||
|
|||||||
@@ -257,7 +257,6 @@ struct FailHarness {
|
|||||||
bridge: Arc<AsyncBridge>,
|
bridge: Arc<AsyncBridge>,
|
||||||
core: Arc<dyn CoreAccess>,
|
core: Arc<dyn CoreAccess>,
|
||||||
entities: Arc<Fifa17Entities>,
|
entities: Arc<Fifa17Entities>,
|
||||||
sample_resource: i64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn catalog_from_core(core: &dyn CoreAccess) -> Fifa17CardCatalog {
|
fn catalog_from_core(core: &dyn CoreAccess) -> Fifa17CardCatalog {
|
||||||
@@ -341,7 +340,6 @@ fn build_fail_harness(base: &str, dir: &std::path::Path) -> FailHarness {
|
|||||||
bridge,
|
bridge,
|
||||||
core,
|
core,
|
||||||
entities,
|
entities,
|
||||||
sample_resource: 20000,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -676,15 +674,14 @@ fn case_move_pile_failure(h: &FailHarness) -> String {
|
|||||||
|
|
||||||
/// MARKET RESERVE failure → no debit, no grant, listing stays legal (active).
|
/// MARKET RESERVE failure → no debit, no grant, listing stays legal (active).
|
||||||
fn case_market_reserve_failure(h: &FailHarness) -> String {
|
fn case_market_reserve_failure(h: &FailHarness) -> String {
|
||||||
|
// List a genuinely-owned card: the server resolves card_id + resourceId from
|
||||||
|
// Core inventory via the wire id (you can only list what you own).
|
||||||
|
let (item_id, _core) = h.mint_one();
|
||||||
set_balance(&h.client, 50_000);
|
set_balance(&h.client, 50_000);
|
||||||
let item_id = 700_001i64;
|
|
||||||
let list = h.dispatch(
|
let list = h.dispatch(
|
||||||
"POST",
|
"POST",
|
||||||
"/ut/game/fifa17/auctionhouse",
|
"/ut/game/fifa17/auctionhouse",
|
||||||
format!(
|
format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
|
||||||
r#"{{"itemData":{{"id":{item_id},"resourceId":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
|
|
||||||
h.sample_resource
|
|
||||||
)
|
|
||||||
.as_bytes(),
|
.as_bytes(),
|
||||||
);
|
);
|
||||||
let trade_id = bj(&list)["id"].as_i64().expect("trade id");
|
let trade_id = bj(&list)["id"].as_i64().expect("trade id");
|
||||||
@@ -712,15 +709,12 @@ fn case_market_reserve_failure(h: &FailHarness) -> String {
|
|||||||
/// MARKET Core purchase_item failure AFTER reserve → reservation rolls back to
|
/// MARKET Core purchase_item failure AFTER reserve → reservation rolls back to
|
||||||
/// active, no debit, no mint.
|
/// active, no debit, no mint.
|
||||||
fn case_market_purchase_failure(h: &FailHarness) -> String {
|
fn case_market_purchase_failure(h: &FailHarness) -> String {
|
||||||
|
let (item_id, _core) = h.mint_one();
|
||||||
set_balance(&h.client, 50_000);
|
set_balance(&h.client, 50_000);
|
||||||
let item_id = 700_002i64;
|
|
||||||
let list = h.dispatch(
|
let list = h.dispatch(
|
||||||
"POST",
|
"POST",
|
||||||
"/ut/game/fifa17/auctionhouse",
|
"/ut/game/fifa17/auctionhouse",
|
||||||
format!(
|
format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
|
||||||
r#"{{"itemData":{{"id":{item_id},"resourceId":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
|
|
||||||
h.sample_resource
|
|
||||||
)
|
|
||||||
.as_bytes(),
|
.as_bytes(),
|
||||||
);
|
);
|
||||||
let trade_id = bj(&list)["id"].as_i64().expect("trade id");
|
let trade_id = bj(&list)["id"].as_i64().expect("trade id");
|
||||||
@@ -754,15 +748,12 @@ fn case_market_purchase_failure(h: &FailHarness) -> String {
|
|||||||
/// active), so no further `active -> reserved` CAS can succeed → not buyable,
|
/// active), so no further `active -> reserved` CAS can succeed → not buyable,
|
||||||
/// with exactly one debit + one mint. Returns ("SAFE"|"E3", detail).
|
/// with exactly one debit + one mint. Returns ("SAFE"|"E3", detail).
|
||||||
fn case_market_complete_sale_failure(h: &FailHarness) -> (String, String) {
|
fn case_market_complete_sale_failure(h: &FailHarness) -> (String, String) {
|
||||||
|
let (item_id, _core) = h.mint_one();
|
||||||
set_balance(&h.client, 50_000);
|
set_balance(&h.client, 50_000);
|
||||||
let item_id = 700_003i64;
|
|
||||||
let list = h.dispatch(
|
let list = h.dispatch(
|
||||||
"POST",
|
"POST",
|
||||||
"/ut/game/fifa17/auctionhouse",
|
"/ut/game/fifa17/auctionhouse",
|
||||||
format!(
|
format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
|
||||||
r#"{{"itemData":{{"id":{item_id},"resourceId":{}}},"buyNowPrice":1000,"startingBid":500}}"#,
|
|
||||||
h.sample_resource
|
|
||||||
)
|
|
||||||
.as_bytes(),
|
.as_bytes(),
|
||||||
);
|
);
|
||||||
let trade_id = bj(&list)["id"].as_i64().expect("trade id");
|
let trade_id = bj(&list)["id"].as_i64().expect("trade id");
|
||||||
|
|||||||
@@ -350,7 +350,7 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
|||||||
wait_ready(base);
|
wait_ready(base);
|
||||||
// Core is seeded (start_core_seeded): a fifa17 profile with 100k coins + one
|
// Core is seeded (start_core_seeded): a fifa17 profile with 100k coins + one
|
||||||
// owned instance per definition. No /auth/local — the profile already exists.
|
// owned instance per definition. No /auth/local — the profile already exists.
|
||||||
let (server, client, resolver, sample_resource) =
|
let (server, client, resolver, _sample_resource) =
|
||||||
build_econ_server(base, dir, "http://127.0.0.1:9");
|
build_econ_server(base, dir, "http://127.0.0.1:9");
|
||||||
let start = client.balance().unwrap();
|
let start = client.balance().unwrap();
|
||||||
assert!(start >= 5000, "seeded dev balance present ({start})");
|
assert!(start >= 5000, "seeded dev balance present ({start})");
|
||||||
@@ -440,13 +440,17 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
|||||||
|
|
||||||
// 5) MARKET buy-now (async handlers via the bridge): list -> query -> buy ->
|
// 5) MARKET buy-now (async handlers via the bridge): list -> query -> buy ->
|
||||||
// query -> second buy fails, exactly one debit + one sale.
|
// query -> second buy fails, exactly one debit + one sale.
|
||||||
|
// List a still-owned minted card (items[0] was quick-sold, items[1] is moved
|
||||||
|
// below). The body carries the wire id ALONE — the server resolves the owned
|
||||||
|
// card's Core card_id + FIFA resourceId from inventory.
|
||||||
|
let list_wire = items[2]["id"].as_i64().unwrap();
|
||||||
let list = server
|
let list = server
|
||||||
.try_handle_economy(
|
.try_handle_economy(
|
||||||
"POST",
|
"POST",
|
||||||
"/ut/game/fifa17/auctionhouse",
|
"/ut/game/fifa17/auctionhouse",
|
||||||
&[],
|
&[],
|
||||||
format!(
|
format!(
|
||||||
r#"{{"itemData":{{"id":777,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"#
|
r#"{{"itemData":{{"id":{list_wire}}},"buyNowPrice":1000,"startingBid":500}}"#
|
||||||
)
|
)
|
||||||
.as_bytes(),
|
.as_bytes(),
|
||||||
None,
|
None,
|
||||||
@@ -504,13 +508,14 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 6) MARKET cancel: a cancelled listing cannot be bought.
|
// 6) MARKET cancel: a cancelled listing cannot be bought.
|
||||||
|
let cancel_wire = items[3]["id"].as_i64().unwrap();
|
||||||
let clist = server
|
let clist = server
|
||||||
.try_handle_economy(
|
.try_handle_economy(
|
||||||
"POST",
|
"POST",
|
||||||
"/ut/game/fifa17/auctionhouse",
|
"/ut/game/fifa17/auctionhouse",
|
||||||
&[],
|
&[],
|
||||||
format!(
|
format!(
|
||||||
r#"{{"itemData":{{"id":888,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"#
|
r#"{{"itemData":{{"id":{cancel_wire}}},"buyNowPrice":1000,"startingBid":500}}"#
|
||||||
)
|
)
|
||||||
.as_bytes(),
|
.as_bytes(),
|
||||||
None,
|
None,
|
||||||
@@ -796,6 +801,11 @@ fn exercise_from_config(base: &str, dir: &std::path::Path) -> i64 {
|
|||||||
)
|
)
|
||||||
.expect("buy routed");
|
.expect("buy routed");
|
||||||
assert_eq!(buy.status, 200);
|
assert_eq!(buy.status, 200);
|
||||||
|
// A listing must name a card the club actually owns, so take one the BUY minted.
|
||||||
|
let list_wire = serde_json::from_slice::<Value>(&buy.body).unwrap()["createPackResponse"]
|
||||||
|
["itemList"][0]["id"]
|
||||||
|
.as_i64()
|
||||||
|
.expect("minted wire id");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client.balance().unwrap(),
|
client.balance().unwrap(),
|
||||||
start - 400,
|
start - 400,
|
||||||
@@ -808,7 +818,8 @@ fn exercise_from_config(base: &str, dir: &std::path::Path) -> i64 {
|
|||||||
"POST",
|
"POST",
|
||||||
"/ut/game/fifa17/auctionhouse",
|
"/ut/game/fifa17/auctionhouse",
|
||||||
&[],
|
&[],
|
||||||
br#"{"itemData":{"id":555,"resourceId":20000},"buyNowPrice":1000,"startingBid":500}"#,
|
format!(r#"{{"itemData":{{"id":{list_wire}}},"buyNowPrice":1000,"startingBid":500}}"#)
|
||||||
|
.as_bytes(),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.expect("list routed");
|
.expect("list routed");
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ use openfut_adapter_fifa17::fut::club_response::{CoreOwnedItem, ItemIdentityReso
|
|||||||
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
||||||
use openfut_identity::JsonIdentityStore;
|
use openfut_identity::JsonIdentityStore;
|
||||||
use openfut_utas_host::{
|
use openfut_utas_host::{
|
||||||
classify, handle_put_squad, handle_squad_active, handle_squad_list, handle_user_mass_info,
|
classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list,
|
||||||
read_request, CoreAccess, CoreError, CoreExtState, CorePage, CoreReplaceRequest,
|
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState, CorePage,
|
||||||
CoreReplaceResult, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient,
|
CoreReplaceRequest, CoreReplaceResult, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver,
|
||||||
PassClient, Route, Server, SquadDeps,
|
HttpCoreClient, PassClient, Route, Server, SquadDeps,
|
||||||
};
|
};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -279,6 +279,86 @@ fn build_server(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A real identity resolver over a `card_id -> asset_id` map (same construction
|
||||||
|
/// `build_server` uses), for tests that call `handle_club` directly.
|
||||||
|
fn resolver_for(cards: &[(&str, u32)]) -> Arc<Fifa17IdentityResolver> {
|
||||||
|
let entries: Vec<String> = cards
|
||||||
|
.iter()
|
||||||
|
.map(|(id, asset)| format!("\"{id}\":{{\"asset_id\":{asset}}}"))
|
||||||
|
.collect();
|
||||||
|
let doc = format!(
|
||||||
|
"{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{}}}}}",
|
||||||
|
entries.join(",")
|
||||||
|
);
|
||||||
|
let catalog = Fifa17CardCatalog::from_json_str(&doc).unwrap();
|
||||||
|
let store = JsonIdentityStore::open(unique_store_path()).unwrap();
|
||||||
|
Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A card on the transfer list has LEFT the club: `/club` must not show it, and
|
||||||
|
/// pagination must run over the CLUB-VISIBLE set (never Core's unfiltered page,
|
||||||
|
/// which would hand back short pages).
|
||||||
|
#[test]
|
||||||
|
fn club_excludes_transfer_pile_items_and_paginates_the_visible_set() {
|
||||||
|
let core = Arc::new(FakeCore::new(
|
||||||
|
vec![
|
||||||
|
item("oc1", "card_a", 86, "CDM", "Argentina", "Premier League", "Chelsea"),
|
||||||
|
item("oc2", "card_b", 85, "ST", "Argentina", "Premier League", "Chelsea"),
|
||||||
|
item("oc3", "card_c", 84, "CB", "Argentina", "Premier League", "Chelsea"),
|
||||||
|
],
|
||||||
|
3,
|
||||||
|
));
|
||||||
|
let resolver = resolver_for(&[("card_a", 20801), ("card_b", 20802), ("card_c", 20803)]);
|
||||||
|
let ents = entities();
|
||||||
|
|
||||||
|
// oc2 is listed on the transfer market.
|
||||||
|
let hidden: std::collections::HashSet<String> = ["oc2".to_string()].into_iter().collect();
|
||||||
|
let deps = ClubDeps {
|
||||||
|
core: core.as_ref(),
|
||||||
|
entities: &ents,
|
||||||
|
assets: resolver.as_ref(),
|
||||||
|
hidden: &hidden,
|
||||||
|
};
|
||||||
|
let (resp, log) = handle_club("", &deps);
|
||||||
|
let v: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||||
|
let assets: Vec<i64> = v["itemData"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|i| i["assetId"].as_i64().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
assets,
|
||||||
|
vec![20801, 20803],
|
||||||
|
"the transfer-pile card is not in the club"
|
||||||
|
);
|
||||||
|
assert_eq!(log.total, 2, "total is the club-visible count");
|
||||||
|
|
||||||
|
// A page of 2 over a 2-item visible set is FULL — not short because a hidden
|
||||||
|
// item consumed a slot.
|
||||||
|
let (resp2, log2) = handle_club("count=2", &deps);
|
||||||
|
let v2: Value = serde_json::from_slice(&resp2.body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v2["itemData"].as_array().unwrap().len(),
|
||||||
|
2,
|
||||||
|
"full-width page from the visible set"
|
||||||
|
);
|
||||||
|
assert_eq!(log2.total, 2);
|
||||||
|
|
||||||
|
// Nothing hidden → the fast Core-paginated path, all three visible.
|
||||||
|
let none: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
let deps_all = ClubDeps {
|
||||||
|
core: core.as_ref(),
|
||||||
|
entities: &ents,
|
||||||
|
assets: resolver.as_ref(),
|
||||||
|
hidden: &none,
|
||||||
|
};
|
||||||
|
let (resp3, log3) = handle_club("", &deps_all);
|
||||||
|
let v3: Value = serde_json::from_slice(&resp3.body).unwrap();
|
||||||
|
assert_eq!(v3["itemData"].as_array().unwrap().len(), 3);
|
||||||
|
assert_eq!(log3.total, 3);
|
||||||
|
}
|
||||||
|
|
||||||
// ── /club served from Core ─────────────────────────────────────────────────
|
// ── /club served from Core ─────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user