fix(fifa17): preserve owned-pack reveal state for GET /purchased
Closes the reveal contract gap: POST /purchased opens a pack and returns metadata; the client then polls GET /purchased for the opened items. Store BUY returns items inline, but owned reward-pack (e.g. pack 70) opens had no reveal read path, so a real FIFA session would show nothing after opening. Faithful to the Python oracle (fut_store.last_pack / purchased pile): the reveal is the set of owned items currently in the FIFA "purchased" pile — durable, idempotent on repeat GET, cleared per-item when a card is moved to the club, and appended-to by each open. Not a replay cache; presentation state derived from the durable pile store + Core inventory. - pile_store.rs: `list_by_pile(pile) -> Vec<core_item_id>` (reveal membership). - economy_store.rs: `PurchasedPileSink` trait + optional `StoreDeps.purchased`; handle_store_buy/handle_pack_open record each minted item into the "purchased" pile. `shape_purchased_reveal` (pure): filter Core inventory to the purchased pile, shape with the SAME `shape_club_response` /club uses. Grants nothing, consumes no entitlement, allocates no id, moves no coins. - lib.rs: EconomyRoute::PackReveal + classify_economy (GET purchased); BridgedPurchasedSink (records via the runtime bridge from the sync dispatch thread); dispatch reads the pile async + Core inventory sync + pure-shapes. Scoping: single fifa17 profile/club (like the Python oracle), so all sessions share one purchased pile — DIFFERENT-BY-DESIGN vs a per-SID cache, matching the oracle's single-profile model. Tests: pile_store::list_by_pile_filters_and_reflects_moves; and the dispatch E2E now opens pack 70 (entitlement seeded via the Core economy API) and asserts GET /purchased reveals the opened items and is idempotent on repeat. host 71 lib + 2 integration + 24 host_test green; clippy -D warnings + fmt clean.
This commit is contained in:
@@ -23,6 +23,7 @@ use std::collections::HashSet;
|
|||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use serde_json::{json, Value};
|
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::economy_policy::pack_price;
|
||||||
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
||||||
use openfut_adapter_fifa17::fut::item::{shape_item, CoreOwnedItem, ItemIdentityResolver};
|
use openfut_adapter_fifa17::fut::item::{shape_item, CoreOwnedItem, ItemIdentityResolver};
|
||||||
@@ -39,6 +40,15 @@ use crate::{
|
|||||||
|
|
||||||
// ───────────────────────────── Dependencies ─────────────────────────────────
|
// ───────────────────────────── 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`]:
|
/// 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 host owns the Core transport and the item resolver; the adapter supplies
|
||||||
/// the pure pack generator over an injected candidate `pool`.
|
/// 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
|
/// Candidates that resolve in BOTH the FIFA catalogue and Core content. The
|
||||||
/// host builds this so the generator stays pure. Empty → fail-closed.
|
/// host builds this so the generator stays pure. Empty → fail-closed.
|
||||||
pub pool: &'a [GeneratedCandidate],
|
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
|
/// 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 {
|
fn insufficient_body(balance: i64) -> WireResponse {
|
||||||
json_status(
|
json_status(
|
||||||
461,
|
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) {
|
match draw_and_mint_coins(deps, pack, price, rng) {
|
||||||
Ok(minted) => {
|
Ok(minted) => {
|
||||||
let items = shape_minted(deps, &minted);
|
let items = shape_minted(deps, &minted);
|
||||||
|
record_purchased(deps, &minted);
|
||||||
let count = items.len();
|
let count = items.len();
|
||||||
json_response(&json!({
|
json_response(&json!({
|
||||||
"createPackResponse": {
|
"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)) {
|
match deps.econ.redeem_entitlement(&ent.id, &grants_of(&minted)) {
|
||||||
Ok(_definition_id) => {
|
Ok(_definition_id) => {
|
||||||
allocate_wire_ids(deps, &minted);
|
allocate_wire_ids(deps, &minted);
|
||||||
|
record_purchased(deps, &minted);
|
||||||
pack_open_body(pid, pack)
|
pack_open_body(pid, pack)
|
||||||
}
|
}
|
||||||
// Entitlement stays (Core's consume+add is atomic): fail-closed.
|
// 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) {
|
match draw_and_mint_coins(deps, pack, pack.price as i64, rng) {
|
||||||
Ok(minted) => {
|
Ok(minted) => {
|
||||||
allocate_wire_ids(deps, &minted);
|
allocate_wire_ids(deps, &minted);
|
||||||
|
record_purchased(deps, &minted);
|
||||||
pack_open_body(pid, pack)
|
pack_open_body(pid, pack)
|
||||||
}
|
}
|
||||||
Err(PackError::Insufficient(balance)) => insufficient_body(balance),
|
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<String>,
|
||||||
|
entities: &Fifa17Entities,
|
||||||
|
resolver: &(dyn ItemIdentityResolver + Send + Sync),
|
||||||
|
) -> WireResponse {
|
||||||
|
let filtered: Vec<CoreOwnedItem> = 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 ───────────────────────────────────
|
// ───────────────────────────── Quick-sell ───────────────────────────────────
|
||||||
|
|
||||||
/// Look up an owned item by its Core owned-instance id. Quick-sell needs the
|
/// Look up an owned item by its Core owned-instance id. Quick-sell needs the
|
||||||
@@ -635,6 +680,7 @@ mod tests {
|
|||||||
assets,
|
assets,
|
||||||
entities,
|
entities,
|
||||||
pool,
|
pool,
|
||||||
|
purchased: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -181,6 +181,8 @@ pub enum EconomyRoute {
|
|||||||
StoreBuy,
|
StoreBuy,
|
||||||
/// `POST …/purchased` — open a pack / redeem an owned entitlement.
|
/// `POST …/purchased` — open a pack / redeem an owned entitlement.
|
||||||
PackOpen,
|
PackOpen,
|
||||||
|
/// `GET …/purchased` — the pack-reveal screen (items in the "purchased" pile).
|
||||||
|
PackReveal,
|
||||||
/// `DELETE …/item/<id>` — single-card quick-sell.
|
/// `DELETE …/item/<id>` — single-card quick-sell.
|
||||||
QuickSellPath,
|
QuickSellPath,
|
||||||
/// `POST /ut/delete/game/<sku>/item` — bulk quick-sell.
|
/// `POST /ut/delete/game/<sku>/item` — bulk quick-sell.
|
||||||
@@ -237,6 +239,7 @@ pub fn classify_economy(method: &str, path: &str) -> Option<EconomyRoute> {
|
|||||||
Some(t) if get && t.starts_with("store/purchasegroup") => Some(EconomyRoute::PurchaseGroup),
|
Some(t) if get && t.starts_with("store/purchasegroup") => Some(EconomyRoute::PurchaseGroup),
|
||||||
Some("store/transaction") if put => Some(EconomyRoute::StoreBuy),
|
Some("store/transaction") if put => Some(EconomyRoute::StoreBuy),
|
||||||
Some("purchased") if post => Some(EconomyRoute::PackOpen),
|
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(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),
|
||||||
@@ -1744,6 +1747,29 @@ pub fn build_content_pool(
|
|||||||
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<crate::async_bridge::AsyncBridge>,
|
||||||
|
piles: Arc<crate::pile_store::PileStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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`).
|
/// The migration host. Cheap to clone (all shared state is `Arc`).
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Server {
|
pub struct Server {
|
||||||
@@ -1872,24 +1898,51 @@ impl Server {
|
|||||||
}
|
}
|
||||||
EconomyRoute::StoreBuy => {
|
EconomyRoute::StoreBuy => {
|
||||||
let mut rng = rand::rngs::StdRng::from_entropy();
|
let mut rng = rand::rngs::StdRng::from_entropy();
|
||||||
|
let sink = BridgedPurchasedSink {
|
||||||
|
bridge: svc.bridge.clone(),
|
||||||
|
piles: svc.piles.clone(),
|
||||||
|
};
|
||||||
let deps = StoreDeps {
|
let deps = StoreDeps {
|
||||||
econ: svc.econ.as_ref(),
|
econ: svc.econ.as_ref(),
|
||||||
assets: self.resolver.as_ref(),
|
assets: self.resolver.as_ref(),
|
||||||
entities: self.entities.as_ref(),
|
entities: self.entities.as_ref(),
|
||||||
pool: svc.pool.as_ref(),
|
pool: svc.pool.as_ref(),
|
||||||
|
purchased: Some(&sink),
|
||||||
};
|
};
|
||||||
handle_store_buy(body, &deps, &mut rng)
|
handle_store_buy(body, &deps, &mut rng)
|
||||||
}
|
}
|
||||||
EconomyRoute::PackOpen => {
|
EconomyRoute::PackOpen => {
|
||||||
let mut rng = rand::rngs::StdRng::from_entropy();
|
let mut rng = rand::rngs::StdRng::from_entropy();
|
||||||
|
let sink = BridgedPurchasedSink {
|
||||||
|
bridge: svc.bridge.clone(),
|
||||||
|
piles: svc.piles.clone(),
|
||||||
|
};
|
||||||
let deps = StoreDeps {
|
let deps = StoreDeps {
|
||||||
econ: svc.econ.as_ref(),
|
econ: svc.econ.as_ref(),
|
||||||
assets: self.resolver.as_ref(),
|
assets: self.resolver.as_ref(),
|
||||||
entities: self.entities.as_ref(),
|
entities: self.entities.as_ref(),
|
||||||
pool: svc.pool.as_ref(),
|
pool: svc.pool.as_ref(),
|
||||||
|
purchased: Some(&sink),
|
||||||
};
|
};
|
||||||
handle_pack_open(body, &deps, &mut rng)
|
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<String> = 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 => {
|
EconomyRoute::QuickSellPath => {
|
||||||
let id = ut_tail(path)
|
let id = ut_tail(path)
|
||||||
.and_then(|t| t.strip_prefix("item/"))
|
.and_then(|t| t.strip_prefix("item/"))
|
||||||
|
|||||||
@@ -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<Vec<String>, 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::<String, _>("core_item_id"))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -184,4 +200,22 @@ mod tests {
|
|||||||
Some("purchased")
|
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()]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -550,6 +550,61 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
|||||||
"buying a cancelled listing does not debit"
|
"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::<Value>(&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::<Value>(&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::<Value>(&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).
|
// 7) Move a still-owned minted card to the trade pile (durable pile metadata).
|
||||||
let move_wire = items[1]["id"].as_i64().unwrap();
|
let move_wire = items[1]["id"].as_i64().unwrap();
|
||||||
let mv = server
|
let mv = server
|
||||||
|
|||||||
Reference in New Issue
Block a user