diff --git a/docs/PRODUCTION_AUTHORITY_MATRIX.md b/docs/PRODUCTION_AUTHORITY_MATRIX.md index 910edd8..9a9997a 100644 --- a/docs/PRODUCTION_AUTHORITY_MATRIX.md +++ b/docs/PRODUCTION_AUTHORITY_MATRIX.md @@ -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 | | POST /ut/delete/../match | R | coins | handle_match_end | 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/ (POST/PUT/GET) | R | coins,inv,listings | handle_market_buy | NO | | DELETE /ut/delete/../trade/ | 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) | 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/PUT /clientdata/ | R | host ClientDataStore (JSON-persisted); PUT acks {}, GET returns blob or {} | | 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/ | R | Core squad projection + tx; GET /squad/0 == active squad (verified structurally identical) | | 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) | @@ -49,7 +65,7 @@ Legend: owner R = Rust/Core, P = Python oracle (:8199 proxied via PYTHON_FALLBAC | GET /leaderboards/options | R | {} | | PUT /match/reset | R | {} | | 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 /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) | diff --git a/openfut-adapter-fifa17/src/fut/item.rs b/openfut-adapter-fifa17/src/fut/item.rs index e0da033..e95bc80 100644 --- a/openfut-adapter-fifa17/src/fut/item.rs +++ b/openfut-adapter-fifa17/src/fut/item.rs @@ -146,7 +146,12 @@ pub fn shape_item( "attributeList": attribute_list, "itemState": "free", "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, "fitness": 99, "discardValue": discard_value(item.rating), diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index ec33e30..3e39ce7 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -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/` — view / buy-now. MarketBuy, /// `DELETE /ut/delete/game//trade/` — 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 { 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 { - 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, } /// 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, limit: Option) -> (Vec, i64) { + let total = items.len() as i64; + let off = offset.unwrap_or(0).max(0) as usize; + let paged: Vec = 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 = 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 { + 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", diff --git a/openfut-utas-host/src/market.rs b/openfut-utas-host/src/market.rs index d10ac9b..5290538 100644 --- a/openfut-utas-host/src/market.rs +++ b/openfut-utas-host/src/market.rs @@ -26,8 +26,12 @@ 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 crate::economy_store::OwnedItemLookup; + use crate::market_store::{Listing, MarketError, MarketStore}; use crate::pile_store::PileStore; use crate::{CoreEconomy, CoreError, WireResponse}; @@ -66,24 +70,50 @@ fn trade_id_from_path(path: &str) -> Option { /// Shape one listing into the FIFA auction record (0x18013e410 fields), sourced /// 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); // 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. 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 (trade_state, item_state, bid_state, current_bid) = match l.state.as_str() { - "active" => ("active", "forSale", "none", 0), - _ => ("closed", "free", "highest", l.buy_now_price), + let (trade_state, bid_state, current_bid) = match l.state.as_str() { + "active" => ("active", "none", 0), + _ => ("closed", "highest", l.buy_now_price), }; + let item_data = l + .item_json + .as_deref() + .and_then(|s| serde_json::from_str::(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!({ + "id": item_id, + "resourceId": resource, + "itemState": item_state, + "untradeable": false, + }) + }); json!({ "tradeId": trade_id, - "itemData": { - "id": item_id, - "resourceId": resource, - "itemState": item_state, - "untradeable": false, - }, + "itemData": item_data, "tradeState": trade_state, "buyNowPrice": l.buy_now_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 /// 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 @@ -119,74 +156,115 @@ fn credits_or_zero(econ: &dyn CoreEconomy) -> i64 { off_runtime(|| econ.balance()).unwrap_or(0) } -/// Maps a FIFA wire `resourceId` to the authoritative Core `card_id` a synthetic -/// buy mints. Backed by the FIFA17 catalog reverse index; unknown → `None` -/// (fail closed, never fabricated). Core stays unaware of FIFA resource ids. -pub trait MarketCardResolver: Send + Sync { - fn card_id_for_resource(&self, resource_id: i64) -> Option; +/// A FutISStart POST resolved to a persistable listing — all owned/`Send` data, +/// so it can cross the async persist boundary. Built by [`resolve_market_list`] +/// on the caller's thread from Core inventory (the request body carries only the +/// wire item id, never the resourceId or Core card_id). +pub struct ResolvedListing { + pub item_id: i64, + pub core_id: String, + pub card_id: String, + pub resource_id: Option, + pub start: i64, + pub buy_now: i64, + pub seller: Option, + /// 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, +} + +/// 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( + body: &[u8], + reverse: &dyn SquadWireResolver, + resolver: &dyn ItemIdentityResolver, + items: &dyn OwnedItemLookup, + ent: &E, +) -> Option { + 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). /// /// * GET returns the durable active auctions plus the FutGetAuctionCount ints, /// in the oracle's `_market_body` shape. -/// * POST lists a club item: resolve its wire `resourceId` to the authoritative -/// Core `card_id`, persist both, and return `{"id": tradeId}`. An unmappable -/// resource fails closed (persists nothing). +/// * POST persists the pre-resolved listing (see [`resolve_market_list`]) and +/// returns `{"id": tradeId}`; an unresolved item acks a fresh id, persisting +/// nothing. /// * PUT (relist-all) is an ack `{}`. pub async fn handle_market_list( method: &str, - body: &[u8], + resolved: Option, econ: &dyn CoreEconomy, store: &MarketStore, - mapper: &dyn MarketCardResolver, ) -> WireResponse { match method { "POST" => { - let b = parse_body(body); - let item_data = b.get("itemData"); - let wire_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 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 { + let Some(r) = resolved else { + // No item, or the id did not resolve to an owned card: fresh-id ack. + eprintln!( + "utas-host owner=RUST route=market-list POST listed=false reason=unresolved" + ); return ok_json(&json!({ "id": TRADE_ID_BASE })); }; // Trade-id space is offset from the wire item id, so each owned item // 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 seller = b.get("sellerName").and_then(Value::as_str); match store .create_listing( &listing_id, - &core_card_id, - None, - Some(item_id), - Some(resource_id), - start, - buy_now, - seller, + &r.card_id, + Some(&r.core_id), + Some(r.item_id), + r.resource_id, + r.start, + r.buy_now, + r.seller.as_deref(), + r.item_json.as_deref(), ) .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" })), } } @@ -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 /// `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( state: &str, econ: &dyn CoreEconomy, @@ -224,7 +306,10 @@ pub async fn handle_market_query( Ok(l) => l, Err(_) => return json_body(503, &json!({ "error": "market_store" })), }; - let auctions: Vec = listings.iter().map(auction_record).collect(); + let auctions: Vec = listings + .iter() + .map(|l| auction_record_as(l, "listFS")) + .collect(); ok_json(&json!({ "auctionInfo": auctions, "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//trade/` — remove a listing from the sale /// 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 @@ -525,20 +634,55 @@ mod tests { } } - /// Permissive test resolver: maps any wire resourceId to its own string, so - /// the existing list tests keep their prior card-id semantics. A dedicated - /// test covers the unknown-resource fail-closed path. - struct AllowAllResolver; - impl MarketCardResolver for AllowAllResolver { - fn card_id_for_resource(&self, resource_id: i64) -> Option { - Some(resource_id.to_string()) + // ---- ItemIdentityResolver + OwnedItemLookup doubles ------------------- + use openfut_adapter_fifa17::fut::item::{CoreOwnedItem, Fifa17Identity}; + + /// Owned-inventory double: core id -> CoreOwnedItem. + struct FakeItems(HashMap); + impl OwnedItemLookup for FakeItems { + fn owned_item(&self, core_id: &str) -> Option { + self.0.get(core_id).cloned() } } - /// Test resolver that maps nothing (every resourceId is unknown). - struct DenyAllResolver; - impl MarketCardResolver for DenyAllResolver { - fn card_id_for_resource(&self, _resource_id: i64) -> Option { + /// Identity double: resolves every owned item to a fixed FIFA resourceId. + struct FixedIdentity(u32); + impl ItemIdentityResolver for FixedIdentity { + fn resolve(&self, _item: &CoreOwnedItem) -> Option { + 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 { + None + } + fn nation_id(&self, _name: &str) -> Option { + None + } + fn team_id(&self, _name: &str) -> Option { None } } @@ -551,7 +695,7 @@ mod tests { async fn seed_listing(store: &MarketStore, id: &str, buy_now: i64) { 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 .unwrap(); } @@ -566,23 +710,47 @@ mod tests { async fn list_post_persists_and_returns_trade_id() { let (store, _d) = store_at("post").await; 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 }); - let resp = handle_market_list( - "POST", + let reverse = MapResolver::new(&[(100004617, "core-1")]); + 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(), - &econ, - &store, - &AllowAllResolver, - ) - .await; + &reverse, + &ident, + &items, + &NoEntities, + ); + let resp = handle_market_list("POST", resolved, &econ, &store).await; assert_eq!(resp.status, 200); let trade_id = parse(&resp)["id"].as_i64().unwrap(); 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(); 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); assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1); assert_eq!(b["credits"], 10_000); @@ -593,27 +761,31 @@ mod tests { async fn list_put_is_ack() { let (store, _d) = store_at("put").await; 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!(parse(&resp), json!({})); } #[tokio::test] - async fn list_unknown_resource_fails_closed_no_listing() { - // An unmappable wire resourceId must NOT create a listing (a synthetic buy - // would otherwise mint a card id Core cannot resolve). Acks neutrally. + async fn list_unresolved_item_fails_closed_no_listing() { + // A wire id that does not resolve to an owned card must NOT create a listing + // (a synthetic buy would otherwise mint a card Core cannot resolve). Acks. let (store, _d) = store_at("deny").await; 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 }); - let resp = handle_market_list( - "POST", + let reverse = MapResolver::new(&[]); // maps nothing -> unresolved + let items = FakeItems(HashMap::new()); + let ident = FixedIdentity(0); + let resolved = resolve_market_list( body.to_string().as_bytes(), - &econ, - &store, - &DenyAllResolver, - ) - .await; + &reverse, + &ident, + &items, + &NoEntities, + ); + 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!(parse(&resp)["id"].as_i64().unwrap(), TRADE_ID_BASE); // 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 econ = CountingEconomy::with_balance(10_000); - // A resolver that maps resourceId 20801 -> Core card_id "card_pl_042". - struct FixedResolver; - impl MarketCardResolver for FixedResolver { - fn card_id_for_resource(&self, resource_id: i64) -> Option { - (resource_id == 20801).then(|| "card_pl_042".to_string()) - } - } - let body = json!({ "itemData": { "id": 100004900, "resourceId": 20801 }, + let body = json!({ "itemData": { "id": 100004900 }, "startingBid": 300, "buyNowPrice": 2500 }); - let resp = handle_market_list( - "POST", + let reverse = MapResolver::new(&[(100004900, "core-9")]); + 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(), - &econ, - &store, - &FixedResolver, - ) - .await; + &reverse, + &ident, + &items, + &NoEntities, + ); + let resp = handle_market_list("POST", resolved, &econ, &store).await; trade_id = parse(&resp)["id"].as_i64().unwrap(); } // Reopen from the same file: both identities survive. @@ -676,6 +848,38 @@ mod tests { 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] async fn buy_now_debits_mints_and_closes() { let (store, _d) = store_at("buy").await; diff --git a/openfut-utas-host/src/market_store.rs b/openfut-utas-host/src/market_store.rs index 70f3903..c2db121 100644 --- a/openfut-utas-host/src/market_store.rs +++ b/openfut-utas-host/src/market_store.rs @@ -146,6 +146,12 @@ pub struct Listing { pub state: String, /// Creation time, unix-epoch milliseconds as a string (sortable). 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, } 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, owner TEXT, 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 { @@ -182,6 +189,7 @@ fn row_to_listing(row: &sqlx::sqlite::SqliteRow) -> Listing { owner: row.get("owner"), state: row.get("state"), created_at: row.get("created_at"), + item_json: row.get("item_json"), } } @@ -223,6 +231,22 @@ impl MarketStore { .execute(&pool) .await .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::("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 { pool, fault: StoreFault::default(), @@ -249,6 +273,8 @@ impl MarketStore { start_price: i64, buy_now_price: i64, owner: Option<&str>, + // The shaped FIFA card snapshot (`itemData`) for the auction record. + item_json: Option<&str>, ) -> Result { let created_at = now_millis(); let mut conn = self.pool.acquire().await.map_err(db)?; @@ -258,8 +284,8 @@ impl MarketStore { .map_err(db)?; let res = sqlx::query( "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) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?)", + wire_resource_id, start_price, buy_now_price, owner, state, created_at, item_json) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)", ) .bind(listing_id) .bind(card_id) @@ -270,6 +296,7 @@ impl MarketStore { .bind(buy_now_price) .bind(owner) .bind(&created_at) + .bind(item_json) .execute(&mut *conn) .await; match res { @@ -289,6 +316,7 @@ impl MarketStore { owner: owner.map(str::to_string), state: "active".to_string(), created_at, + item_json: item_json.map(str::to_string), }) } Err(e) => { @@ -502,7 +530,7 @@ mod tests { async fn seed(store: &MarketStore, id: &str) -> Listing { 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 .unwrap() } @@ -533,7 +561,7 @@ mod tests { seed(&store, "900000001").await; assert!(matches!( 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, Err(MarketError::Conflict) )); @@ -597,6 +625,7 @@ mod tests { 900, 2500, Some("alice"), + None, ) .await .unwrap(); @@ -668,6 +697,7 @@ mod tests { 900, 2500, Some("alice"), + Some(r#"{"rating":84}"#), ) .await .unwrap(); @@ -683,5 +713,10 @@ mod tests { assert_eq!(got.core_item_id.as_deref(), Some("core-7")); assert_eq!(got.wire_item_id, Some(100004617)); assert_eq!(got.owner.as_deref(), Some("alice")); + assert_eq!( + got.item_json.as_deref(), + Some(r#"{"rating":84}"#), + "card snapshot survives reopen" + ); } } diff --git a/openfut-utas-host/tests/economy_concurrency.rs b/openfut-utas-host/tests/economy_concurrency.rs index b40f838..f471290 100644 --- a/openfut-utas-host/tests/economy_concurrency.rs +++ b/openfut-utas-host/tests/economy_concurrency.rs @@ -423,20 +423,19 @@ fn case_c_dup_quicksell(h: &Harness) -> String { /// exactly one debit; exactly one mint. fn case_d_two_market_buyers(h: &Harness) -> String { 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); - let item_id = 500_000 + i as i64; // unique listing per iteration let list = h .server .try_handle_economy( "POST", "/ut/game/fifa17/auctionhouse", &[], - format!( - r#"{{"itemData":{{"id":{item_id},"resourceId":{}}},"buyNowPrice":1000,"startingBid":500}}"#, - h.sample_resource - ) - .as_bytes(), + format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#) + .as_bytes(), None, ) .expect("list routed"); diff --git a/openfut-utas-host/tests/economy_differential.rs b/openfut-utas-host/tests/economy_differential.rs index 4edfa2c..fb4cf1f 100644 --- a/openfut-utas-host/tests/economy_differential.rs +++ b/openfut-utas-host/tests/economy_differential.rs @@ -407,7 +407,7 @@ fn pack_ids(pg: &Value) -> Vec { fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) { wait_ready(core_base); 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. ── // 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, ); 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"); assert_eq!(o_ls, 200); 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" ); // 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_cs, _) = rust( &server, diff --git a/openfut-utas-host/tests/economy_failure.rs b/openfut-utas-host/tests/economy_failure.rs index 37be11f..3fc9d43 100644 --- a/openfut-utas-host/tests/economy_failure.rs +++ b/openfut-utas-host/tests/economy_failure.rs @@ -257,7 +257,6 @@ struct FailHarness { bridge: Arc, core: Arc, entities: Arc, - sample_resource: i64, } fn catalog_from_core(core: &dyn CoreAccess) -> Fifa17CardCatalog { @@ -341,7 +340,6 @@ fn build_fail_harness(base: &str, dir: &std::path::Path) -> FailHarness { bridge, core, entities, - sample_resource: 20000, } } @@ -676,16 +674,15 @@ fn case_move_pile_failure(h: &FailHarness) -> String { /// MARKET RESERVE failure → no debit, no grant, listing stays legal (active). 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); - let item_id = 700_001i64; let list = h.dispatch( "POST", "/ut/game/fifa17/auctionhouse", - format!( - r#"{{"itemData":{{"id":{item_id},"resourceId":{}}},"buyNowPrice":1000,"startingBid":500}}"#, - h.sample_resource - ) - .as_bytes(), + format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#) + .as_bytes(), ); let trade_id = bj(&list)["id"].as_i64().expect("trade id"); let before = h.client.balance().unwrap(); @@ -712,16 +709,13 @@ fn case_market_reserve_failure(h: &FailHarness) -> String { /// MARKET Core purchase_item failure AFTER reserve → reservation rolls back to /// active, no debit, no mint. fn case_market_purchase_failure(h: &FailHarness) -> String { + let (item_id, _core) = h.mint_one(); set_balance(&h.client, 50_000); - let item_id = 700_002i64; let list = h.dispatch( "POST", "/ut/game/fifa17/auctionhouse", - format!( - r#"{{"itemData":{{"id":{item_id},"resourceId":{}}},"buyNowPrice":1000,"startingBid":500}}"#, - h.sample_resource - ) - .as_bytes(), + format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#) + .as_bytes(), ); let trade_id = bj(&list)["id"].as_i64().expect("trade id"); let before = h.client.balance().unwrap(); @@ -754,16 +748,13 @@ fn case_market_purchase_failure(h: &FailHarness) -> String { /// active), so no further `active -> reserved` CAS can succeed → not buyable, /// with exactly one debit + one mint. Returns ("SAFE"|"E3", detail). fn case_market_complete_sale_failure(h: &FailHarness) -> (String, String) { + let (item_id, _core) = h.mint_one(); set_balance(&h.client, 50_000); - let item_id = 700_003i64; let list = h.dispatch( "POST", "/ut/game/fifa17/auctionhouse", - format!( - r#"{{"itemData":{{"id":{item_id},"resourceId":{}}},"buyNowPrice":1000,"startingBid":500}}"#, - h.sample_resource - ) - .as_bytes(), + format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#) + .as_bytes(), ); let trade_id = bj(&list)["id"].as_i64().expect("trade id"); let mint_id = format!("market-buy:{trade_id}"); diff --git a/openfut-utas-host/tests/economy_integration.rs b/openfut-utas-host/tests/economy_integration.rs index 2010d2b..52f7c18 100644 --- a/openfut-utas-host/tests/economy_integration.rs +++ b/openfut-utas-host/tests/economy_integration.rs @@ -350,7 +350,7 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult { wait_ready(base); // Core is seeded (start_core_seeded): a fifa17 profile with 100k coins + one // 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"); let start = client.balance().unwrap(); 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 -> // 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 .try_handle_economy( "POST", "/ut/game/fifa17/auctionhouse", &[], format!( - r#"{{"itemData":{{"id":777,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"# + r#"{{"itemData":{{"id":{list_wire}}},"buyNowPrice":1000,"startingBid":500}}"# ) .as_bytes(), None, @@ -504,13 +508,14 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult { ); // 6) MARKET cancel: a cancelled listing cannot be bought. + let cancel_wire = items[3]["id"].as_i64().unwrap(); let clist = server .try_handle_economy( "POST", "/ut/game/fifa17/auctionhouse", &[], format!( - r#"{{"itemData":{{"id":888,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"# + r#"{{"itemData":{{"id":{cancel_wire}}},"buyNowPrice":1000,"startingBid":500}}"# ) .as_bytes(), None, @@ -796,6 +801,11 @@ fn exercise_from_config(base: &str, dir: &std::path::Path) -> i64 { ) .expect("buy routed"); 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::(&buy.body).unwrap()["createPackResponse"] + ["itemList"][0]["id"] + .as_i64() + .expect("minted wire id"); assert_eq!( client.balance().unwrap(), start - 400, @@ -808,7 +818,8 @@ fn exercise_from_config(base: &str, dir: &std::path::Path) -> i64 { "POST", "/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, ) .expect("list routed"); diff --git a/openfut-utas-host/tests/host_test.rs b/openfut-utas-host/tests/host_test.rs index f26a742..7368891 100644 --- a/openfut-utas-host/tests/host_test.rs +++ b/openfut-utas-host/tests/host_test.rs @@ -13,10 +13,10 @@ use openfut_adapter_fifa17::fut::club_response::{CoreOwnedItem, ItemIdentityReso use openfut_adapter_fifa17::fut::entities::Fifa17Entities; use openfut_identity::JsonIdentityStore; use openfut_utas_host::{ - classify, handle_put_squad, handle_squad_active, handle_squad_list, handle_user_mass_info, - read_request, CoreAccess, CoreError, CoreExtState, CorePage, CoreReplaceRequest, - CoreReplaceResult, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, - PassClient, Route, Server, SquadDeps, + classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list, + handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState, CorePage, + CoreReplaceRequest, CoreReplaceResult, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver, + HttpCoreClient, PassClient, Route, Server, SquadDeps, }; use parking_lot::Mutex; 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 { + let entries: Vec = 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 = ["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 = 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 = 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 ───────────────────────────────────────────────── #[test]