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 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<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 ───────────────────────────────────
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user