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:
OpenFUT Agent
2026-08-13 21:51:12 +00:00
parent fe72f0def2
commit 747cc234c1
4 changed files with 188 additions and 0 deletions
+53
View File
@@ -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/<id>` — single-card quick-sell.
QuickSellPath,
/// `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("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<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`).
#[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<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 => {
let id = ut_tail(path)
.and_then(|t| t.strip_prefix("item/"))