diff --git a/openfut-utas-host/src/economy_store.rs b/openfut-utas-host/src/economy_store.rs index dd67c06..1e2f0b6 100644 --- a/openfut-utas-host/src/economy_store.rs +++ b/openfut-utas-host/src/economy_store.rs @@ -23,6 +23,7 @@ use std::collections::HashSet; use rand::Rng; use serde_json::{json, Value}; +use openfut_adapter_fifa17::fut::club_response::shape_club_response; use openfut_adapter_fifa17::fut::economy_policy::pack_price; use openfut_adapter_fifa17::fut::entities::Fifa17Entities; use openfut_adapter_fifa17::fut::item::{shape_item, CoreOwnedItem, ItemIdentityResolver}; @@ -39,6 +40,15 @@ use crate::{ // ───────────────────────────── Dependencies ───────────────────────────────── +/// Records a freshly-minted owned item into the FIFA "purchased" pile (the +/// reveal screen source; the client polls `GET /purchased` for it, and moving a +/// card to the club clears it). Injected so the pure store handlers stay +/// unaware of the durable async pile store; the production impl bridges to it. +/// A missing sink (`None`) simply skips reveal-pile tracking (unit tests). +pub trait PurchasedPileSink { + fn record_purchased(&self, core_id: &str); +} + /// Dependencies for the Store buy / pack-open paths. Mirrors [`crate::ClubDeps`]: /// the host owns the Core transport and the item resolver; the adapter supplies /// the pure pack generator over an injected candidate `pool`. @@ -52,6 +62,8 @@ pub struct StoreDeps<'a> { /// Candidates that resolve in BOTH the FIFA catalogue and Core content. The /// host builds this so the generator stays pure. Empty → fail-closed. pub pool: &'a [GeneratedCandidate], + /// Optional sink recording minted items into the "purchased" reveal pile. + pub purchased: Option<&'a dyn PurchasedPileSink>, } /// A card drawn by a pack, paired with the freshly-minted Core instance id it @@ -161,6 +173,15 @@ fn allocate_wire_ids(deps: &StoreDeps<'_>, minted: &[Minted]) { } } +/// Record each minted item into the "purchased" reveal pile, if a sink is wired. +fn record_purchased(deps: &StoreDeps<'_>, minted: &[Minted]) { + if let Some(sink) = deps.purchased { + for m in minted { + sink.record_purchased(&m.core_id); + } + } +} + fn insufficient_body(balance: i64) -> WireResponse { json_status( 461, @@ -196,6 +217,7 @@ pub fn handle_store_buy(body: &[u8], deps: &StoreDeps<'_>, rng: &mut impl Rng) - match draw_and_mint_coins(deps, pack, price, rng) { Ok(minted) => { let items = shape_minted(deps, &minted); + record_purchased(deps, &minted); let count = items.len(); json_response(&json!({ "createPackResponse": { @@ -270,6 +292,7 @@ pub fn handle_pack_open(body: &[u8], deps: &StoreDeps<'_>, rng: &mut impl Rng) - match deps.econ.redeem_entitlement(&ent.id, &grants_of(&minted)) { Ok(_definition_id) => { allocate_wire_ids(deps, &minted); + record_purchased(deps, &minted); pack_open_body(pid, pack) } // Entitlement stays (Core's consume+add is atomic): fail-closed. @@ -279,6 +302,7 @@ pub fn handle_pack_open(body: &[u8], deps: &StoreDeps<'_>, rng: &mut impl Rng) - match draw_and_mint_coins(deps, pack, pack.price as i64, rng) { Ok(minted) => { allocate_wire_ids(deps, &minted); + record_purchased(deps, &minted); pack_open_body(pid, pack) } Err(PackError::Insufficient(balance)) => insufficient_body(balance), @@ -287,6 +311,27 @@ pub fn handle_pack_open(body: &[u8], deps: &StoreDeps<'_>, rng: &mut impl Rng) - } } +/// Shape the `GET …/purchased` reveal: the owned items currently in the FIFA +/// "purchased" pile (`purchased_ids`), rendered with the SAME shaper `/club` +/// uses. Pure — the caller supplies the pile membership (from the durable pile +/// store) and Core's owned inventory; this filters + shapes. Presentation only: +/// it grants nothing, consumes no entitlement, allocates no id, moves no coins. +/// Repeated calls return the same reveal until a card is moved to the club. +pub fn shape_purchased_reveal( + owned: &[CoreOwnedItem], + purchased_ids: &HashSet, + entities: &Fifa17Entities, + resolver: &(dyn ItemIdentityResolver + Send + Sync), +) -> WireResponse { + let filtered: Vec = owned + .iter() + .filter(|it| purchased_ids.contains(&it.owned_card_id)) + .cloned() + .collect(); + let (body, _stats) = shape_club_response(&filtered, entities, resolver); + json_response(&body) +} + // ───────────────────────────── Quick-sell ─────────────────────────────────── /// Look up an owned item by its Core owned-instance id. Quick-sell needs the @@ -635,6 +680,7 @@ mod tests { assets, entities, pool, + purchased: None, } } diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 347f5fb..540e7d2 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -181,6 +181,8 @@ pub enum EconomyRoute { StoreBuy, /// `POST …/purchased` — open a pack / redeem an owned entitlement. PackOpen, + /// `GET …/purchased` — the pack-reveal screen (items in the "purchased" pile). + PackReveal, /// `DELETE …/item/` — single-card quick-sell. QuickSellPath, /// `POST /ut/delete/game//item` — bulk quick-sell. @@ -237,6 +239,7 @@ pub fn classify_economy(method: &str, path: &str) -> Option { Some(t) if get && t.starts_with("store/purchasegroup") => Some(EconomyRoute::PurchaseGroup), Some("store/transaction") if put => Some(EconomyRoute::StoreBuy), Some("purchased") if post => Some(EconomyRoute::PackOpen), + Some("purchased") if get => Some(EconomyRoute::PackReveal), 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), @@ -1744,6 +1747,29 @@ pub fn build_content_pool( pool } +/// Production [`crate::economy_store::PurchasedPileSink`]: records a minted item +/// into the durable pile store's "purchased" pile via the runtime bridge, from +/// the synchronous dispatch thread. A pile-write failure is logged, never fatal +/// to the mint (Core already committed the item; the reveal is presentation +/// only, and a missing pile row just omits it from the reveal screen). +struct BridgedPurchasedSink { + bridge: Arc, + piles: Arc, +} + +impl crate::economy_store::PurchasedPileSink for BridgedPurchasedSink { + fn record_purchased(&self, core_id: &str) { + let piles = self.piles.clone(); + let id = core_id.to_string(); + if let Err(e) = self + .bridge + .block_on(async move { piles.set(&id, "purchased").await }) + { + eprintln!("utas-host WARN purchased-pile record {core_id} failed: {e}"); + } + } +} + /// The migration host. Cheap to clone (all shared state is `Arc`). #[derive(Clone)] pub struct Server { @@ -1872,24 +1898,51 @@ impl Server { } EconomyRoute::StoreBuy => { let mut rng = rand::rngs::StdRng::from_entropy(); + let sink = BridgedPurchasedSink { + bridge: svc.bridge.clone(), + piles: svc.piles.clone(), + }; let deps = StoreDeps { econ: svc.econ.as_ref(), assets: self.resolver.as_ref(), entities: self.entities.as_ref(), pool: svc.pool.as_ref(), + purchased: Some(&sink), }; handle_store_buy(body, &deps, &mut rng) } EconomyRoute::PackOpen => { let mut rng = rand::rngs::StdRng::from_entropy(); + let sink = BridgedPurchasedSink { + bridge: svc.bridge.clone(), + piles: svc.piles.clone(), + }; let deps = StoreDeps { econ: svc.econ.as_ref(), assets: self.resolver.as_ref(), entities: self.entities.as_ref(), pool: svc.pool.as_ref(), + purchased: Some(&sink), }; handle_pack_open(body, &deps, &mut rng) } + EconomyRoute::PackReveal => { + // Async pile membership via the bridge; Core inventory read + // synchronously on this (non-runtime) dispatch thread; pure shape. + let (bridge, piles) = (svc.bridge.clone(), svc.piles.clone()); + let purchased_ids: std::collections::HashSet = bridge + .block_on(async move { piles.list_by_pile("purchased").await }) + .unwrap_or_default() + .into_iter() + .collect(); + let owned = self.core.all_owned().unwrap_or_default(); + crate::economy_store::shape_purchased_reveal( + &owned, + &purchased_ids, + self.entities.as_ref(), + self.resolver.as_ref(), + ) + } EconomyRoute::QuickSellPath => { let id = ut_tail(path) .and_then(|t| t.strip_prefix("item/")) diff --git a/openfut-utas-host/src/pile_store.rs b/openfut-utas-host/src/pile_store.rs index d9122eb..caa049a 100644 --- a/openfut-utas-host/src/pile_store.rs +++ b/openfut-utas-host/src/pile_store.rs @@ -130,6 +130,22 @@ impl PileStore { } } } + + /// Every Core owned-instance id currently recorded in `pile`, oldest first. + /// Used to render the `GET /purchased` reveal (the FIFA "purchased" pile). + pub async fn list_by_pile(&self, pile: &str) -> Result, PileError> { + let rows = sqlx::query( + "SELECT core_item_id FROM item_pile WHERE pile = ? ORDER BY updated_at ASC", + ) + .bind(pile) + .fetch_all(&self.pool) + .await + .map_err(db)?; + Ok(rows + .iter() + .map(|r| r.get::("core_item_id")) + .collect()) + } } #[cfg(test)] @@ -184,4 +200,22 @@ mod tests { Some("purchased") ); } + + #[tokio::test] + async fn list_by_pile_filters_and_reflects_moves() { + let db = TempDb::new(); + let store = PileStore::open(db.path()).await.unwrap(); + store.set("a", "purchased").await.unwrap(); + store.set("b", "purchased").await.unwrap(); + store.set("c", "club").await.unwrap(); + let mut purchased = store.list_by_pile("purchased").await.unwrap(); + purchased.sort(); + assert_eq!(purchased, vec!["a".to_string(), "b".to_string()]); + // Moving a card out of the purchased pile drops it from the reveal set. + store.set("a", "club").await.unwrap(); + assert_eq!( + store.list_by_pile("purchased").await.unwrap(), + vec!["b".to_string()] + ); + } } diff --git a/openfut-utas-host/tests/economy_integration.rs b/openfut-utas-host/tests/economy_integration.rs index 22173bd..7d16d49 100644 --- a/openfut-utas-host/tests/economy_integration.rs +++ b/openfut-utas-host/tests/economy_integration.rs @@ -550,6 +550,61 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult { "buying a cancelled listing does not debit" ); + // 6b) Owned-pack (70) open + GET /purchased reveal (Part 8 + 0B). Seed an + // unopened pack-70 entitlement via the Core economy API (cost 0), open it, + // and prove the reveal screen (GET /purchased) shows the freshly opened items + // and is idempotent on repeat (presentation state, not a second grant). + let http = reqwest::blocking::Client::new(); + post( + &http, + base, + "/economy/purchase-entitlement", + json!({ "cost": 0, "definition_id": "70" }), + ); + let reveal_before = { + let r = server + .try_handle_economy("GET", "/ut/game/fifa17/purchased", &[], b"", None) + .expect("reveal routed"); + serde_json::from_slice::(&r.body).unwrap()["itemData"] + .as_array() + .map(|a| a.len()) + .unwrap_or(0) + }; + let open = server + .try_handle_economy( + "POST", + "/ut/game/fifa17/purchased", + &[], + br#"{"packId":70}"#, + None, + ) + .expect("pack open routed"); + assert_eq!(open.status, 200, "pack-70 open 200"); + let ov: Value = serde_json::from_slice(&open.body).unwrap(); + assert_eq!(ov["packId"], 70, "open echoes the pack id"); + let reveal = server + .try_handle_economy("GET", "/ut/game/fifa17/purchased", &[], b"", None) + .expect("reveal routed"); + let reveal_items = serde_json::from_slice::(&reveal.body).unwrap()["itemData"] + .as_array() + .expect("reveal itemData") + .len(); + assert!( + reveal_items > reveal_before, + "GET /purchased reveals the opened items ({reveal_before} -> {reveal_items})" + ); + // Idempotent: a repeat GET does not re-grant or clear (same reveal). + let reveal2 = server + .try_handle_economy("GET", "/ut/game/fifa17/purchased", &[], b"", None) + .unwrap(); + let reveal2_items = serde_json::from_slice::(&reveal2.body).unwrap()["itemData"] + .as_array() + .unwrap() + .len(); + assert_eq!( + reveal2_items, reveal_items, + "repeated GET /purchased is idempotent" + ); // 7) Move a still-owned minted card to the trade pile (durable pile metadata). let move_wire = items[1]["id"].as_i64().unwrap(); let mv = server