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
@@ -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::<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).
let move_wire = items[1]["id"].as_i64().unwrap();
let mv = server