diff --git a/openfut-adapter-fifa17/src/fut/catalog.rs b/openfut-adapter-fifa17/src/fut/catalog.rs index 081b86a..06eee2d 100644 --- a/openfut-adapter-fifa17/src/fut/catalog.rs +++ b/openfut-adapter-fifa17/src/fut/catalog.rs @@ -125,6 +125,10 @@ fn default_rareflag() -> i64 { #[derive(Debug, Default, Clone)] pub struct Fifa17CardCatalog { by_card: HashMap, + /// Reverse index: full versioned `resource_id` → the card definition id. The + /// wire carries a `resourceId`; the synthetic market must mint the + /// authoritative Core `card_id`, never the raw FIFA number. + by_resource: HashMap, } impl Fifa17CardCatalog { @@ -168,7 +172,10 @@ impl Fifa17CardCatalog { }, ); } - Ok(Fifa17CardCatalog { by_card }) + Ok(Fifa17CardCatalog { + by_card, + by_resource, + }) } /// Load a catalog from a JSON file. @@ -183,6 +190,13 @@ impl Fifa17CardCatalog { self.by_card.get(card_id).copied() } + /// Reverse a FIFA wire `resource_id` (full versioned id) to its authoritative + /// Core `card_id`, or `None` (never a fabricated/heuristic id). Used by the + /// synthetic transfer market so a purchase mints real Core content. + pub fn card_id_for_resource(&self, resource_id: u32) -> Option<&str> { + self.by_resource.get(&resource_id).map(String::as_str) + } + pub fn len(&self) -> usize { self.by_card.len() } diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index f45a7fc..347f5fb 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -863,6 +863,17 @@ 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 ──────────────────────────────── /// Safe, structured summary of a handled `/club` request (no auth/session/device @@ -1914,12 +1925,22 @@ impl Server { }) } EconomyRoute::MarketList => { - let (bridge, market, econ) = - (svc.bridge.clone(), svc.market.clone(), svc.econ.clone()); + 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()) - .await + crate::market::handle_market_list( + &m, + &body, + econ.as_ref(), + market.as_ref(), + resolver.as_ref(), + ) + .await }) } EconomyRoute::MarketQuery => { diff --git a/openfut-utas-host/src/market.rs b/openfut-utas-host/src/market.rs index 38168a9..d10ac9b 100644 --- a/openfut-utas-host/src/market.rs +++ b/openfut-utas-host/src/market.rs @@ -68,14 +68,9 @@ fn trade_id_from_path(path: &str) -> Option { /// from durable listing state rather than a hardcoded sample pool. fn auction_record(l: &Listing) -> Value { let trade_id: i64 = l.listing_id.parse().unwrap_or(0); - // resourceId is the card definition when numeric; fall back to the wire - // item id. Never a fabricated FIFA asset — 0 means "no art", a valid int. - let resource = l - .card_id - .parse::() - .ok() - .or(l.wire_item_id) - .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), @@ -124,18 +119,27 @@ 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; +} + /// `/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 an owned club item and returns `{"id": tradeId}`; the listing is -/// persisted so a later buy/cancel is durable. +/// * 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). /// * PUT (relist-all) is an ack `{}`. pub async fn handle_market_list( method: &str, body: &[u8], econ: &dyn CoreEconomy, store: &MarketStore, + mapper: &dyn MarketCardResolver, ) -> WireResponse { match method { "POST" => { @@ -151,13 +155,19 @@ pub async fn handle_market_list( // No item to list: mirror the oracle's fresh-id ack, persist nothing. return ok_json(&json!({ "id": TRADE_ID_BASE })); }; - // Card definition, if the client sent the full item; else the wire id - // string (a user listing does not drive the synthetic-seller mint). - let card_id = item_data + // 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) - .map(|r| r.to_string()) - .unwrap_or_else(|| item_id.to_string()); + 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 })); + }; // 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; @@ -166,9 +176,10 @@ pub async fn handle_market_list( match store .create_listing( &listing_id, - &card_id, + &core_card_id, None, Some(item_id), + Some(resource_id), start, buy_now, seller, @@ -514,6 +525,24 @@ 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()) + } + } + + /// 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 { + None + } + } + async fn store_at(tag: &str) -> (MarketStore, TempDb) { let db = TempDb::new(tag); let store = MarketStore::open(db.path()).await.unwrap(); @@ -522,7 +551,7 @@ mod tests { async fn seed_listing(store: &MarketStore, id: &str, buy_now: i64) { store - .create_listing(id, "169193", None, None, 400, buy_now, None) + .create_listing(id, "169193", None, None, Some(169193), 400, buy_now, None) .await .unwrap(); } @@ -539,14 +568,21 @@ mod tests { let econ = CountingEconomy::with_balance(10_000); let body = json!({ "itemData": { "id": 100004617, "resourceId": 169193 }, "startingBid": 300, "buyNowPrice": 2500 }); - let resp = handle_market_list("POST", body.to_string().as_bytes(), &econ, &store).await; + let resp = handle_market_list( + "POST", + body.to_string().as_bytes(), + &econ, + &store, + &AllowAllResolver, + ) + .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. 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).await; + let browse = handle_market_list("GET", b"", &econ, &store, &AllowAllResolver).await; let b = parse(&browse); assert_eq!(b["auctionInfo"].as_array().unwrap().len(), 1); assert_eq!(b["credits"], 10_000); @@ -557,11 +593,77 @@ 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).await; + let resp = handle_market_list("PUT", b"", &econ, &store, &AllowAllResolver).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. + let (store, _d) = store_at("deny").await; + let econ = CountingEconomy::with_balance(10_000); + let body = json!({ "itemData": { "id": 100004617, "resourceId": 424242 }, + "startingBid": 300, "buyNowPrice": 2500 }); + let resp = handle_market_list( + "POST", + body.to_string().as_bytes(), + &econ, + &store, + &DenyAllResolver, + ) + .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. + assert!(matches!( + store + .get_listing(&(TRADE_ID_BASE + 100004617).to_string()) + .await, + Err(MarketError::NotFound) + )); + } + + #[tokio::test] + async fn list_persists_core_card_and_wire_resource_across_reopen() { + // The listing carries BOTH the authoritative Core card_id (for the mint) + // and the FIFA wire resourceId (for the auction record), durably. + let db = TempDb::new("reopen-map"); + let trade_id; + { + 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 }, + "startingBid": 300, "buyNowPrice": 2500 }); + let resp = handle_market_list( + "POST", + body.to_string().as_bytes(), + &econ, + &store, + &FixedResolver, + ) + .await; + trade_id = parse(&resp)["id"].as_i64().unwrap(); + } + // Reopen from the same file: both identities survive. + let reopened = MarketStore::open(db.path()).await.unwrap(); + let l = reopened.get_listing(&trade_id.to_string()).await.unwrap(); + assert_eq!(l.card_id, "card_pl_042", "Core card_id minted on buy"); + assert_eq!( + l.wire_resource_id, + Some(20801), + "wire resourceId for the record" + ); + } + #[tokio::test] async fn query_returns_active_pile() { let (store, _d) = store_at("query").await; diff --git a/openfut-utas-host/src/market_store.rs b/openfut-utas-host/src/market_store.rs index 8c79868..59d049e 100644 --- a/openfut-utas-host/src/market_store.rs +++ b/openfut-utas-host/src/market_store.rs @@ -76,12 +76,17 @@ fn db(e: sqlx::Error) -> MarketError { #[derive(Debug, Clone, PartialEq, Eq)] pub struct Listing { pub listing_id: String, + /// Authoritative Core content/card id — what a synthetic buy MINTS. Distinct + /// from the FIFA wire `resourceId` (see `wire_resource_id`). pub card_id: String, /// Set for a seller-listed owned item; `None` for a synthetic-seller /// listing (the buy path mints a fresh Core item instead of transferring). pub core_item_id: Option, /// The FIFA wire item id of a seller-listed owned item, if any. pub wire_item_id: Option, + /// The FIFA wire `resourceId` (versioned) the client listed, echoed back in + /// the auction record. Never used to mint — the mint uses `card_id`. + pub wire_resource_id: Option, pub start_price: i64, pub buy_now_price: i64, /// Opaque seller identity; `None` for synthetic listings. @@ -97,6 +102,7 @@ const CREATE_LISTINGS: &str = "CREATE TABLE IF NOT EXISTS listings ( card_id TEXT NOT NULL, core_item_id TEXT, wire_item_id INTEGER, + wire_resource_id INTEGER, start_price INTEGER NOT NULL, buy_now_price INTEGER NOT NULL, owner TEXT, @@ -119,6 +125,7 @@ fn row_to_listing(row: &sqlx::sqlite::SqliteRow) -> Listing { card_id: row.get("card_id"), core_item_id: row.get("core_item_id"), wire_item_id: row.get("wire_item_id"), + wire_resource_id: row.get("wire_resource_id"), start_price: row.get("start_price"), buy_now_price: row.get("buy_now_price"), owner: row.get("owner"), @@ -177,6 +184,7 @@ impl MarketStore { card_id: &str, core_item_id: Option<&str>, wire_item_id: Option, + wire_resource_id: Option, start_price: i64, buy_now_price: i64, owner: Option<&str>, @@ -189,13 +197,14 @@ impl MarketStore { .map_err(db)?; let res = sqlx::query( "INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, \ - start_price, buy_now_price, owner, state, created_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?)", + wire_resource_id, start_price, buy_now_price, owner, state, created_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?)", ) .bind(listing_id) .bind(card_id) .bind(core_item_id) .bind(wire_item_id) + .bind(wire_resource_id) .bind(start_price) .bind(buy_now_price) .bind(owner) @@ -213,6 +222,7 @@ impl MarketStore { card_id: card_id.to_string(), core_item_id: core_item_id.map(str::to_string), wire_item_id, + wire_resource_id, start_price, buy_now_price, owner: owner.map(str::to_string), @@ -425,7 +435,7 @@ mod tests { async fn seed(store: &MarketStore, id: &str) -> Listing { store - .create_listing(id, "card_pl_001", None, None, 900, 2500, None) + .create_listing(id, "card_pl_001", None, None, None, 900, 2500, None) .await .unwrap() } @@ -456,7 +466,7 @@ mod tests { seed(&store, "900000001").await; assert!(matches!( store - .create_listing("900000001", "card_pl_002", None, None, 1, 2, None) + .create_listing("900000001", "card_pl_002", None, None, None, 1, 2, None) .await, Err(MarketError::Conflict) )); @@ -516,6 +526,7 @@ mod tests { "card_pl_001", None, None, + None, 900, 2500, Some("alice"), @@ -586,6 +597,7 @@ mod tests { "card_pl_001", Some("core-7"), Some(100004617), + Some(169193), 900, 2500, Some("alice"), diff --git a/openfut-utas-host/tests/economy_integration.rs b/openfut-utas-host/tests/economy_integration.rs index dee3676..22173bd 100644 --- a/openfut-utas-host/tests/economy_integration.rs +++ b/openfut-utas-host/tests/economy_integration.rs @@ -259,8 +259,9 @@ async fn economy_end_to_end_and_restart_persistence() { // sequence runs on a plain OS thread (no ambient Tokio runtime), exactly like the // thread-per-connection server, so the bridge takes its DIRECT `block_on` path. -/// Facts captured from the write sequence, re-checked after reopening the stores. +/// Facts captured from the write sequence, re-checked after a full restart. struct SeqResult { + final_balance: i64, sold_listing: String, moved_core_id: String, } @@ -273,7 +274,7 @@ struct SeqResult { fn build_econ_server( base: &str, dir: &std::path::Path, -) -> (Server, HttpCoreClient, Arc) { +) -> (Server, HttpCoreClient, Arc, i64) { let probe = HttpCoreClient::new(base, "fifa17"); let owned = probe.all_owned().expect("core collection"); assert!(!owned.is_empty(), "seed must grant a starter collection"); @@ -337,7 +338,9 @@ fn build_econ_server( 33068179, ) .with_economy(services); - (server, probe, resolver) + // asset 20000 is assigned to the first distinct seeded definition, so wire + // resourceId 20000 reverse-maps to a real Core card_id (a valid synthetic mint). + (server, probe, resolver, 20000) } /// Drive the whole writer+reader cluster through the real dispatch. Panics on any @@ -346,7 +349,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) = build_econ_server(base, dir); + let (server, client, resolver, sample_resource) = build_econ_server(base, dir); let start = client.balance().unwrap(); assert!(start >= 5000, "seeded dev balance present ({start})"); @@ -440,7 +443,10 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult { "POST", "/ut/game/fifa17/auctionhouse", &[], - br#"{"itemData":{"id":777,"resourceId":777},"buyNowPrice":1000,"startingBid":500}"#, + format!( + r#"{{"itemData":{{"id":777,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"# + ) + .as_bytes(), None, ) .expect("market list routed"); @@ -501,7 +507,10 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult { "POST", "/ut/game/fifa17/auctionhouse", &[], - br#"{"itemData":{"id":888,"resourceId":888},"buyNowPrice":1000,"startingBid":500}"#, + format!( + r#"{{"itemData":{{"id":888,"resourceId":{sample_resource}}},"buyNowPrice":1000,"startingBid":500}}"# + ) + .as_bytes(), None, ) .unwrap(); @@ -559,17 +568,25 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult { .expect("moved item reverses to a Core id"); SeqResult { + final_balance: client.balance().unwrap(), sold_listing: trade_id.to_string(), moved_core_id, } } -/// Reopen the durable market/pile SQLite stores from the SAME files (a fresh -/// process would do exactly this) and prove the sold listing and the pile move -/// persisted. Core-side coin/inventory persistence across a full Core restart is -/// proven by `economy_end_to_end_and_restart_persistence`; here the focus is the -/// host-owned durable stores. -fn verify_store_durability(dir: &std::path::Path, seq: &SeqResult) { +/// After a FULL restart from the SAME on-disk state — Core rebooted from its +/// SQLite file, and the durable market/pile stores reopened from their files — +/// coins, the sold listing, and the pile move all persist. The synthetic market +/// buy now mints a REAL Core `card_id` (resourceId reverse-mapped), so Core's +/// content preflight passes on reboot. +fn verify_economy_restart(base: &str, dir: &std::path::Path, seq: &SeqResult) { + wait_ready(base); + let client = HttpCoreClient::new(base, "fifa17"); + assert_eq!( + client.balance().unwrap(), + seq.final_balance, + "coins persisted across Core restart" + ); let bridge = AsyncBridge::new().unwrap(); let market_path = dir.join("market.db").to_string_lossy().into_owned(); let market = bridge @@ -598,7 +615,7 @@ fn verify_store_durability(dir: &std::path::Path, seq: &SeqResult) { } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn economy_full_sequence_through_dispatch() { +async fn economy_full_sequence_through_dispatch_and_restart() { let dir = std::env::temp_dir().join(format!( "openfut-econ-dispatch-{}-{}", std::process::id(), @@ -622,18 +639,18 @@ async fn economy_full_sequence_through_dispatch() { .expect("write sequence"); h1.abort(); - // Durable host stores: reopen the market/pile files from disk (as a fresh - // process would) and prove sold/pile state persisted. No Core rebuild — the - // synthetic market mint uses the wire resourceId as a placeholder card id, - // which Core's content preflight (correctly) rejects on reboot; Core-side - // coin/inventory restart persistence is covered by the sibling test. - let d2 = dir.clone(); + // Core #2: same on-disk Core DB + same market/pile files — prove full restart + // persistence (coins + sold listing + pile). Core content preflight passes + // because the synthetic mint used a real reverse-mapped card_id. + let (h2, base2) = start_core_seeded(&db_url, false).await; + let (b2, d2) = (base2.clone(), dir.clone()); tokio::task::spawn_blocking(move || { - let t = std::thread::spawn(move || verify_store_durability(&d2, &seq)); - t.join().expect("durability thread") + let t = std::thread::spawn(move || verify_economy_restart(&b2, &d2, &seq)); + t.join().expect("restart thread") }) .await - .expect("durability phase"); + .expect("restart phase"); + h2.abort(); std::fs::remove_dir_all(&dir).ok(); }