From c68c10cf04d7d4fb252e22abf16c13f73b4e5402 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 18 Aug 2026 23:26:55 +0000 Subject: [PATCH] fifa17 store: real 6-pack economy + full-DB pool; drop extPrice - store_catalog: replace the invented catalogue with the real always-available FUT17 regular packs (Bronze/Prem Bronze/Silver/Prem Silver/Gold/Prem Gold) at real prices + tier composition; PackDef now carries per-tier quantities. - pack_body: drop extPrice (its mtx side-effect switched on the broken "or %1s" FIFA-Points tile line; plan-2026-08-05-store-subsystem.md section 3.4). - pack_content: tier-aware generator draws each pack bronze/silver/gold composition with special_chance bias + empty-tier fallback. - host: CoreAccess::all_definitions (GET /cards); build_content_pool draws the FULL card universe via non-minting catalog lookup, owned-inventory fallback. - economy_differential: store ops reclassified DIFFERENT-BY-DESIGN (Rust is the authoritative store; Python oracle stays the untouched rollback baseline). - fixtures/tests updated to the real catalogue. Odds are DESIGNED placeholders (FUT17 pack probabilities were never published); club items remain excluded (cardtype-9 mapping unknown). Full regression green; real prices + tier-correct draws verified server-side on staging. --- .../src/fut/pack_content.rs | 120 ++++--- .../src/fut/store_catalog.rs | 224 ++++++------ .../tests/fixtures/purchasegroup_pack70.json | 328 ++++++++++-------- .../fixtures/purchasegroup_zero_clean.json | 296 ++++++++-------- .../fixtures/purchasegroup_zero_sentinel.json | 328 ++++++++++-------- openfut-utas-host/src/economy_store.rs | 22 +- openfut-utas-host/src/lib.rs | 104 +++++- .../tests/economy_concurrency.rs | 4 +- .../tests/economy_differential.rs | 83 +++-- .../tests/economy_integration.rs | 4 +- 10 files changed, 863 insertions(+), 650 deletions(-) diff --git a/openfut-adapter-fifa17/src/fut/pack_content.rs b/openfut-adapter-fifa17/src/fut/pack_content.rs index 742cdbe..c081155 100644 --- a/openfut-adapter-fifa17/src/fut/pack_content.rs +++ b/openfut-adapter-fifa17/src/fut/pack_content.rs @@ -7,20 +7,16 @@ //! (only card ids that resolve in BOTH the FIFA catalogue and Core content), //! mints the drawn cards into Core, and shapes them onto the wire. //! -//! ## Parity note — Python `open_pack` / `_pack_body` -//! (`fifa17-recon/tools/fut_store.py:689`, `utas_server.py:3474`) -//! 1. `open_pack(price, count, gold, tiers, special_chance)` deducts coins then -//! draws `count` items (mostly players); the reveal body wraps them verbatim. -//! 2. Non-tiered draws split the pool at rating 75 by `gold` (`p[1] >= 75 == gold`) -//! and fall back to the whole pool when that tier is empty (`... or PACK_POOL`). -//! 3. Each drawn player becomes a special with probability `special_chance` -//! (`random.random() < special_chance`). -//! 4. `FUT_PACK_MIX` swaps ~`count // 4` players for consumables/staff extras; -//! we deliberately OMIT that mix (Core candidates are player defs — players-only). -//! 5. Prices/counts/odds are the OpenFUT **PLACEHOLDER** economy (the audit found -//! them invented); only the wire *shape* is EA-observed/oracle-verified. -//! 6. This port reproduces the count + gold-tier split + `special_chance` gate as -//! that same PLACEHOLDER policy, drawing with replacement from the pool. +//! ## Policy (real FUT 17 composition, DESIGNED odds) +//! +//! A pack draws [`PackDef::count`] cards split across rating tiers by the pack's +//! `n_bronze`/`n_silver`/`n_gold` composition (the same numbers the tile shows in +//! `packContentInfo`), drawing with replacement from the candidate pool. Each pick +//! is biased toward a special version with probability `special_chance` (a DESIGNED +//! placeholder — FUT 17 pack odds are unrecoverable). An empty tier falls back to +//! the whole pool so a draw is always possible even when the pool lacks that tier. +//! `FUT_PACK_MIX` (consumable/staff extras) is deliberately OMITTED — Core +//! candidates are player defs. use rand::Rng; @@ -77,50 +73,67 @@ pub struct GeneratedCard { pub attributes: [u8; 6], } -/// Draw `pack.count` cards from `pool` with the injected RNG. Pure and -/// deterministic under a seeded RNG. Returns an empty `Vec` (fail-closed) when -/// the pool is empty or the pack awards no cards. +/// Draw a pack's cards from `pool` with the injected RNG. Pure and deterministic +/// under a seeded RNG. Returns an empty `Vec` (fail-closed) when the pool is empty +/// or the pack awards no cards. /// -/// Policy (PLACEHOLDER — see the module parity note): draw with replacement from -/// the pack's tier (`gold`), biasing each draw toward a special card with -/// probability `special_chance`. An empty tier or partition falls back to the -/// next-wider set so a draw is always possible when the pool is non-empty. +/// Draws the pack's per-tier composition (`n_gold` gold-tier, `n_silver` silver, +/// `n_bronze` bronze), biasing each pick toward a special with `special_chance`. +/// An empty tier falls back to the whole pool (so a draw is always possible). pub fn generate_pack_contents( pack: &PackDef, rng: &mut impl Rng, pool: &[GeneratedCandidate], ) -> Vec { - if pool.is_empty() || pack.count == 0 { + if pool.is_empty() || pack.count() == 0 { return Vec::new(); } - // Tier split: a gold pack draws gold-tier candidates, a non-gold pack draws - // non-gold; an empty tier falls back to the whole pool (oracle `... or POOL`). - let tier: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.gold == pack.gold).collect(); - let tier: Vec<&GeneratedCandidate> = if tier.is_empty() { - pool.iter().collect() - } else { - tier - }; - // Partition the tier by special so `special_chance` can bias a draw; either - // partition falls back to the whole tier when empty. - let special: Vec<&GeneratedCandidate> = tier.iter().copied().filter(|c| c.special).collect(); - let normal: Vec<&GeneratedCandidate> = tier.iter().copied().filter(|c| !c.special).collect(); - let chance = pack.special_chance.clamp(0.0, 1.0); + let all: Vec<&GeneratedCandidate> = pool.iter().collect(); + let gold: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating >= 75).collect(); + let silver: Vec<&GeneratedCandidate> = + pool.iter().filter(|c| (65..75).contains(&c.rating)).collect(); + let bronze: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating < 65).collect(); - let mut out = Vec::with_capacity(pack.count as usize); - for _ in 0..pack.count { + let mut out = Vec::with_capacity(pack.count() as usize); + draw_tier(&mut out, &gold, &all, pack.n_gold, pack.special_chance, rng); + draw_tier(&mut out, &silver, &all, pack.n_silver, pack.special_chance, rng); + draw_tier(&mut out, &bronze, &all, pack.n_bronze, pack.special_chance, rng); + out +} + +/// Draw `n` cards from `tier` — or the whole-pool `fallback` when `tier` is empty — +/// biasing each pick toward a special version with probability `chance`. Either the +/// special or normal partition falls back to the tier when empty. +fn draw_tier( + out: &mut Vec, + tier: &[&GeneratedCandidate], + fallback: &[&GeneratedCandidate], + n: u64, + chance: f64, + rng: &mut impl Rng, +) { + if n == 0 { + return; + } + let src: &[&GeneratedCandidate] = if tier.is_empty() { fallback } else { tier }; + if src.is_empty() { + return; + } + let special: Vec<&GeneratedCandidate> = src.iter().copied().filter(|c| c.special).collect(); + let normal: Vec<&GeneratedCandidate> = src.iter().copied().filter(|c| !c.special).collect(); + let chance = chance.clamp(0.0, 1.0); + for _ in 0..n { let want_special = chance > 0.0 && rng.gen_bool(chance); let sub: &[&GeneratedCandidate] = if want_special && !special.is_empty() { &special } else if !want_special && !normal.is_empty() { &normal } else { - &tier + src }; let pick = sub[rng.gen_range(0..sub.len())]; out.push(pick.to_card()); } - out } #[cfg(test)] @@ -156,13 +169,22 @@ mod tests { ] } - fn pack(id: u64, count: u64, gold: bool, special_chance: f64) -> PackDef { + fn pack(id: u64, n_bronze: u64, n_silver: u64, n_gold: u64, special_chance: f64) -> PackDef { PackDef { id, name: "Test Pack", price: 1000, - count, - gold, + n_bronze, + n_silver, + n_gold, + rares: 0, + category: if n_gold > 0 { + "gold" + } else if n_silver > 0 { + "silver" + } else { + "bronze" + }, special_chance, owned_only: false, } @@ -171,7 +193,7 @@ mod tests { #[test] fn same_seed_same_output() { let pool = pool(); - let p = pack(5, 7, true, 0.3); + let p = pack(5, 0, 0, 7, 0.3); let mut a = StdRng::seed_from_u64(42); let mut b = StdRng::seed_from_u64(42); assert_eq!( @@ -183,7 +205,7 @@ mod tests { #[test] fn different_seeds_can_diverge() { let pool = pool(); - let p = pack(5, 7, true, 0.3); + let p = pack(5, 0, 0, 7, 0.3); let a = generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &pool); let b = generate_pack_contents(&p, &mut StdRng::seed_from_u64(999), &pool); // Not a hard guarantee, but with this pool/count the two seeds differ. @@ -196,7 +218,7 @@ mod tests { let ids: std::collections::HashSet<&str> = pool.iter().map(|c| c.card_id.as_str()).collect(); for &n in &[1u64, 5, 7, 11] { - let p = pack(6, n, true, 0.08); + let p = pack(6, 0, 0, n, 0.08); let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(n), &pool); assert_eq!(cards.len() as u64, n); for c in &cards { @@ -212,7 +234,7 @@ mod tests { #[test] fn gold_pack_draws_only_gold_tier() { let pool = pool(); - let p = pack(5, 20, true, 0.03); + let p = pack(5, 0, 0, 20, 0.03); let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool); assert!( cards.iter().all(|c| c.rating >= 75), @@ -223,7 +245,7 @@ mod tests { #[test] fn bronze_pack_draws_only_bronze_tier() { let pool = pool(); - let p = pack(1, 20, false, 0.005); + let p = pack(1, 20, 0, 0, 0.005); let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool); assert!( cards.iter().all(|c| c.rating < 75), @@ -239,7 +261,7 @@ mod tests { .filter(|c| c.special) .map(|c| c.card_id.as_str()) .collect(); - let p = pack(7, 11, true, 1.0); + let p = pack(7, 0, 0, 11, 1.0); let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(3), &pool); assert!(cards .iter() @@ -248,7 +270,7 @@ mod tests { #[test] fn empty_pool_fails_closed() { - let p = pack(5, 7, true, 0.03); + let p = pack(5, 0, 0, 7, 0.03); assert!(generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &[]).is_empty()); } } diff --git a/openfut-adapter-fifa17/src/fut/store_catalog.rs b/openfut-adapter-fifa17/src/fut/store_catalog.rs index 86fc53d..f37c49f 100644 --- a/openfut-adapter-fifa17/src/fut/store_catalog.rs +++ b/openfut-adapter-fifa17/src/fut/store_catalog.rs @@ -1,95 +1,97 @@ //! FIFA 17 Store pack catalogue + `/store/purchasegroup` wire shaping. //! -//! A faithful Rust port of the Python oracle's `PACK_CATALOG` + `_pack_body` + -//! `store_catalog` assembly (`fifa17-recon/tools/{fut_store,utas_server}.py`) at the -//! **production flag defaults** (`FUT_STORE_DISPLAYGROUP=1` on, `FUT_STORE_GROUPID=0` -//! off, `FUT_PRICE_PROBE=0` off). Parity is pinned by differential fixtures generated -//! from the Python oracle (`tests/fixtures/purchasegroup_*.json`). +//! Rust is the authoritative store owner: [`build_purchasegroup`] is served live +//! by the host via `EconomyRoute::PurchaseGroup` over Core economy authority (Core +//! owns coins + unopened packs), so there is no Python dependency and no +//! dual-write/split-brain. //! -//! ## Scope / split-brain safety +//! ## Relationship to the Python oracle //! -//! This is **pure wire shaping** — no economy state, no IO. [`build_purchasegroup`] -//! is a function of `(owned unopened pack ids, empty-My-Packs StoreMode)`. It is -//! deliberately **not yet wired** into the live host: serving purchasegroup from Rust -//! requires an authoritative Rust owner of `unopenedPackIds`, and today Python is the -//! single writer of coins + unopened packs (BUY, quick-sell, rewards). Wiring this -//! before that economy authority exists would create a dual-write/split-brain. See -//! the R3 economy-authority prerequisite in the vault (`Rust UTAS Migration`). +//! The wire *shape* was RE'd from the client and cross-checked against the Python +//! oracle's `_pack_body`/`store_catalog` +//! (`fifa17-recon/tools/{fut_store,utas_server}.py`). Rust now diverges from the +//! oracle where the RE proved the oracle wrong: it does NOT emit `extPrice`, whose +//! parser side-effect creates an `"mtx"` currency row and switches on the broken +//! `or %1s` FIFA-Points tile line (plan-2026-08-05-store-subsystem.md §3.4). The +//! Python oracle stays the rollback baseline and is never modified; the +//! `tests/fixtures/purchasegroup_*.json` goldens pin Rust's authoritative output. //! //! ## Economy-parameter provenance //! -//! Prices, counts and odds are the current OpenFUT **PLACEHOLDER** economy, NOT -//! EA-authentic (the overnight audit established the store economy is invented). The -//! wire *shape* is EA-observed/oracle-verified; the *numbers* are placeholders. +//! Pack prices and tier composition are the real always-available FUT 17 +//! regular-store packs (community-documented on fifauteam). Pack ODDS +//! (`special_chance`) are DESIGNED placeholders, NOT EA-authentic — EA never +//! published FUT 17 pack probabilities. The wire *shape* is EA-observed/RE-verified. use serde_json::{json, Value}; use crate::fut::store_session::{StoreMode, SENTINEL_PACK_ID}; -/// A FIFA 17 Store pack definition. Wire shape is oracle-verified; the economy -/// numbers (`price`/`count`/`special_chance`) are OpenFUT PLACEHOLDER, not EA-authentic. +/// A FIFA 17 Store pack definition. The wire *shape* is RE-verified; the price and +/// per-tier composition are the real always-available FUT 17 regular-store packs, +/// with DESIGNED (not EA-authentic) `special_chance` odds. #[derive(Debug, Clone, Copy, PartialEq)] pub struct PackDef { pub id: u64, pub name: &'static str, pub price: u64, - pub count: u64, - pub gold: bool, + /// Cards awarded per rating-tier band: bronze `< 65`, silver `65..=74`, gold + /// `>= 75`. These are ALSO the wire `packContentInfo` per-tier quantities, so a + /// pack's displayed composition matches what its generator draws. + pub n_bronze: u64, + pub n_silver: u64, + pub n_gold: u64, + /// `rareQuantity` shown on the tile (wire display only). + pub rares: u64, + /// StoreFront category token (`displayGroup.value`): one of the six hard-coded + /// client tokens — here `"bronze"`, `"silver"` or `"gold"`. + pub category: &'static str, + /// Per-draw probability the awarded card is a special version (DESIGNED + /// placeholder; FUT 17 odds are unrecoverable). pub special_chance: f64, /// Reward-only pack (no purchase path): excluded from the normal catalogue, /// rendered only when owned (in `unopenedPackIds`). pub owned_only: bool, } -/// The current supported FIFA 17 pack catalogue (`fut_store.py:820`). Only observed/ -/// currently-supported ids. The 65534 sentinel is deliberately ABSENT — it is a -/// compatibility shim, never a catalogue pack (never purchasable/openable). +impl PackDef { + /// Total cards awarded / wire `itemQuantity` — the sum of the per-tier counts. + pub fn count(&self) -> u64 { + self.n_bronze + self.n_silver + self.n_gold + } +} + +/// The always-available FIFA 17 FUT regular-store packs (real fifauteam-documented +/// prices + tier composition), plus the OpenFUT reward pack. Two packs per client +/// category (bronze/silver/gold), which the client renders as separate buyable tiles +/// on drill-in. The 65534 sentinel is deliberately ABSENT — a compatibility shim, +/// never purchasable/openable. pub const PACK_CATALOG: &[PackDef] = &[ - PackDef { - id: 1, - name: "Bronze Pack", - price: 400, - count: 5, - gold: false, - special_chance: 0.005, - owned_only: false, - }, - PackDef { - id: 5, - name: "Gold Pack", - price: 5000, - count: 7, - gold: true, - special_chance: 0.03, - owned_only: false, - }, - PackDef { - id: 6, - name: "Premium Gold", - price: 15000, - count: 11, - gold: true, - special_chance: 0.08, - owned_only: false, - }, - PackDef { - id: 7, - name: "Special Players Pack", - price: 25000, - count: 11, - gold: true, - special_chance: 1.0, - owned_only: false, - }, - PackDef { - id: 70, - name: "Reward Special Players Pack", - price: 0, - count: 11, - gold: true, - special_chance: 1.0, - owned_only: true, - }, + // ── Bronze category ── + PackDef { id: 1, name: "Bronze Pack", price: 400, + n_bronze: 10, n_silver: 2, n_gold: 0, rares: 1, category: "bronze", + special_chance: 0.01, owned_only: false }, + PackDef { id: 2, name: "Premium Bronze Pack", price: 750, + n_bronze: 10, n_silver: 2, n_gold: 0, rares: 3, category: "bronze", + special_chance: 0.02, owned_only: false }, + // ── Silver category ── + PackDef { id: 3, name: "Silver Pack", price: 2500, + n_bronze: 1, n_silver: 11, n_gold: 0, rares: 1, category: "silver", + special_chance: 0.015, owned_only: false }, + PackDef { id: 4, name: "Premium Silver Pack", price: 3750, + n_bronze: 1, n_silver: 11, n_gold: 0, rares: 3, category: "silver", + special_chance: 0.03, owned_only: false }, + // ── Gold category ── + PackDef { id: 5, name: "Gold Pack", price: 5000, + n_bronze: 0, n_silver: 2, n_gold: 10, rares: 1, category: "gold", + special_chance: 0.04, owned_only: false }, + PackDef { id: 6, name: "Premium Gold Pack", price: 7500, + n_bronze: 0, n_silver: 2, n_gold: 10, rares: 3, category: "gold", + special_chance: 0.06, owned_only: false }, + // ── Reward (owned-only; opened from My Packs, never coin-purchasable) ── + PackDef { id: 70, name: "Reward Gold Pack", price: 0, + n_bronze: 0, n_silver: 0, n_gold: 11, rares: 11, category: "gold", + special_chance: 1.0, owned_only: true }, ]; /// Look up a catalogue pack by id (the 65534 sentinel is never present). @@ -97,27 +99,27 @@ pub fn pack_by_id(id: u64) -> Option<&'static PackDef> { PACK_CATALOG.iter().find(|p| p.id == id) } -/// The FIFA17 StoreFront category token for a NORMAL pack tile (`utas_server.py:3579`): -/// one of the six hard-coded tokens the client resolves. +/// The FIFA 17 StoreFront category token for a pack tile (`displayGroup.value`): +/// one of the six hard-coded tokens the client resolves. Each catalogue pack +/// carries its own token; owned/reward packs take `mypacks` instead (see +/// [`pack_body`]). fn category(p: &PackDef) -> &'static str { - if p.special_chance >= 1.0 { - "special" - } else if p.gold { - "gold" - } else { - "bronze" - } + p.category } /// One `purchase[]` entry — the faithful `_pack_body` port (`utas_server.py:3474`) at /// production flag defaults. `owned` packs (My Packs / reward / sentinel) drop the /// purchase fields and take the `mypacks` display group. pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value { - let mtx = std::cmp::max(1, p.price / 100); + let pack_type = match p.category { + "gold" => "GOLD", + "silver" => "SILVER", + _ => "BRONZE", + }; let mut body = json!({ "assetId": p.id, "id": p.id, - "packType": if p.gold { "GOLD" } else { "BRONZE" }, + "packType": pack_type, "description": p.name, "state": "active", "saleType": "promo", @@ -127,26 +129,32 @@ pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value { "purchaseCount": 0, "isPremium": false, "sortPriority": idx, + // The tile renders `finalFunds` as its coin price; the HUD balance reads + // `funds` from the /credits currencies array. We keep the pair equal. + // + // NO `extPrice`. Its parser has a SIDE EFFECT: `finalPrice`/`originalPrice` + // both CREATE an `"mtx"` currency row, which the tile adapter reads as "has + // a real-money price" and switches on the broken `or %1s` label — the + // Origin/Dime commerce catalogue that would fill it no longer exists + // offline, so every string stays at its constructor default. Omitting the + // key is the documented fix (plan-2026-08-05-store-subsystem.md §3.4 / + // experiment #4): it strictly reduces executed client code and leaves every + // tile buyable. LIVE-observed `or %1s` on the Store tiles, 2026-08-18. "currencies": [{ "name": "coins", "funds": p.price, "finalFunds": p.price }], - "extPrice": { - "finalPrice": { "amount": mtx, "currency": "mtx" }, - "originalPrice": { "amount": mtx, "currency": "mtx" }, - }, "packContentInfo": { - "bronzeQuantity": if p.gold { 0 } else { p.count }, - "silverQuantity": 0, - "goldQuantity": if p.gold { p.count } else { 0 }, - "rareQuantity": if p.gold { p.count } else { 0 }, - "itemQuantity": p.count, + "bronzeQuantity": p.n_bronze, + "silverQuantity": p.n_silver, + "goldQuantity": p.n_gold, + "rareQuantity": p.rares, + "itemQuantity": p.count(), }, "unopened": owned, }); let obj = body.as_object_mut().expect("pack body is a JSON object"); if owned { - // Reward/My-Packs tiles have no purchase path; leaving zero-value coin/mtx - // objects makes the client render the price label as literal "undefined". + // Reward/My-Packs tiles have no purchase path; drop the coin row so the + // client never formats an unavailable payment label as literal "undefined". obj.remove("currencies"); - obj.remove("extPrice"); obj.insert( "displayGroup".into(), json!({ "value": "mypacks", "priority": idx }), @@ -165,8 +173,11 @@ pub fn sentinel_body(idx: u64) -> Value { id: SENTINEL_PACK_ID, name: "", price: 0, - count: 0, - gold: true, + n_bronze: 0, + n_silver: 0, + n_gold: 0, + rares: 0, + category: "gold", special_chance: 0.0, owned_only: true, }; @@ -179,10 +190,10 @@ pub fn sentinel_body(idx: u64) -> Value { body } -/// Build the full `/store/purchasegroup` body from the authoritative unopened-pack ids -/// and the frozen empty-My-Packs mode. Pure — mirrors `store_catalog` (`3627`): -/// normal packs (1,5,6,7) first, then any owned packs, then the empty-My-Packs shim -/// (sentinel for [`StoreMode::Sentinel`], nothing for [`StoreMode::CleanV1`]). +/// Build the full `/store/purchasegroup` body from the authoritative unopened-pack +/// ids and the frozen empty-My-Packs mode. Pure: the six regular packs (ids 1–6) +/// first, then any owned packs, then the empty-My-Packs shim (sentinel for +/// [`StoreMode::Sentinel`], nothing for [`StoreMode::CleanV1`]). pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value { let mut packs: Vec = PACK_CATALOG .iter() @@ -203,10 +214,11 @@ pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value { #[cfg(test)] mod tests { - //! Differential parity against the Python oracle. The fixtures under - //! `tests/fixtures/purchasegroup_*.json` are generated by calling the oracle's - //! `_pack_body`/`store_catalog` at production flag defaults; Rust must match - //! them semantically (object key order is irrelevant to `serde_json::Value` eq). + //! Golden tests pinning the authoritative Rust `/store/purchasegroup` body. The + //! fixtures under `tests/fixtures/purchasegroup_*.json` are Rust's own output + //! (object key order is irrelevant to `serde_json::Value` eq). They track the + //! RE-driven divergence from the Python oracle — notably no `extPrice` (see the + //! module header). use super::*; fn parse(s: &str) -> Value { @@ -214,7 +226,7 @@ mod tests { } #[test] - fn purchasegroup_zero_sentinel_matches_oracle() { + fn purchasegroup_zero_sentinel_matches_golden() { let got = build_purchasegroup(&[], StoreMode::Sentinel); let want = parse(include_str!( "../../tests/fixtures/purchasegroup_zero_sentinel.json" @@ -223,7 +235,7 @@ mod tests { } #[test] - fn purchasegroup_zero_clean_matches_oracle() { + fn purchasegroup_zero_clean_matches_golden() { let got = build_purchasegroup(&[], StoreMode::CleanV1); let want = parse(include_str!( "../../tests/fixtures/purchasegroup_zero_clean.json" @@ -232,7 +244,7 @@ mod tests { } #[test] - fn purchasegroup_pack70_matches_oracle() { + fn purchasegroup_pack70_matches_golden() { // Owned pack present -> no sentinel regardless of mode. let got = build_purchasegroup(&[70], StoreMode::Sentinel); let want = parse(include_str!( @@ -256,13 +268,13 @@ mod tests { .iter() .map(|e| e["id"].as_u64().unwrap()) .collect(); - assert_eq!(ids, vec![1, 5, 6, 7]); + assert_eq!(ids, vec![1, 2, 3, 4, 5, 6]); } #[test] fn category_tokens_are_canonical() { assert_eq!(category(pack_by_id(1).unwrap()), "bronze"); + assert_eq!(category(pack_by_id(3).unwrap()), "silver"); assert_eq!(category(pack_by_id(5).unwrap()), "gold"); - assert_eq!(category(pack_by_id(7).unwrap()), "special"); } } diff --git a/openfut-adapter-fifa17/tests/fixtures/purchasegroup_pack70.json b/openfut-adapter-fifa17/tests/fixtures/purchasegroup_pack70.json index 26288ad..b51813b 100644 --- a/openfut-adapter-fifa17/tests/fixtures/purchasegroup_pack70.json +++ b/openfut-adapter-fifa17/tests/fixtures/purchasegroup_pack70.json @@ -2,197 +2,221 @@ "purchase": [ { "assetId": 1, + "id": 1, + "packType": "BRONZE", + "description": "Bronze Pack", + "state": "active", + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 1, "currencies": [ { - "finalFunds": 400, + "name": "coins", "funds": 400, - "name": "coins" + "finalFunds": 400 } ], - "description": "Bronze Pack", + "packContentInfo": { + "bronzeQuantity": 10, + "silverQuantity": 2, + "goldQuantity": 0, + "rareQuantity": 1, + "itemQuantity": 12 + }, + "unopened": false, "displayGroup": { "value": "bronze" - }, - "extPrice": { - "finalPrice": { - "amount": 4, - "currency": "mtx" - }, - "originalPrice": { - "amount": 4, - "currency": "mtx" - } - }, - "id": 1, - "isPremium": false, - "limitType": "NONE", - "packContentInfo": { - "bronzeQuantity": 5, - "goldQuantity": 0, - "itemQuantity": 5, - "rareQuantity": 0, - "silverQuantity": 0 - }, + } + }, + { + "assetId": 2, + "id": 2, "packType": "BRONZE", - "purchaseCount": 0, - "purchaseLimit": 0, - "quantity": 0, - "saleType": "promo", - "sortPriority": 1, + "description": "Premium Bronze Pack", "state": "active", - "unopened": false + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 2, + "currencies": [ + { + "name": "coins", + "funds": 750, + "finalFunds": 750 + } + ], + "packContentInfo": { + "bronzeQuantity": 10, + "silverQuantity": 2, + "goldQuantity": 0, + "rareQuantity": 3, + "itemQuantity": 12 + }, + "unopened": false, + "displayGroup": { + "value": "bronze" + } + }, + { + "assetId": 3, + "id": 3, + "packType": "SILVER", + "description": "Silver Pack", + "state": "active", + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 3, + "currencies": [ + { + "name": "coins", + "funds": 2500, + "finalFunds": 2500 + } + ], + "packContentInfo": { + "bronzeQuantity": 1, + "silverQuantity": 11, + "goldQuantity": 0, + "rareQuantity": 1, + "itemQuantity": 12 + }, + "unopened": false, + "displayGroup": { + "value": "silver" + } + }, + { + "assetId": 4, + "id": 4, + "packType": "SILVER", + "description": "Premium Silver Pack", + "state": "active", + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 4, + "currencies": [ + { + "name": "coins", + "funds": 3750, + "finalFunds": 3750 + } + ], + "packContentInfo": { + "bronzeQuantity": 1, + "silverQuantity": 11, + "goldQuantity": 0, + "rareQuantity": 3, + "itemQuantity": 12 + }, + "unopened": false, + "displayGroup": { + "value": "silver" + } }, { "assetId": 5, + "id": 5, + "packType": "GOLD", + "description": "Gold Pack", + "state": "active", + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 5, "currencies": [ { - "finalFunds": 5000, + "name": "coins", "funds": 5000, - "name": "coins" + "finalFunds": 5000 } ], - "description": "Gold Pack", - "displayGroup": { - "value": "gold" - }, - "extPrice": { - "finalPrice": { - "amount": 50, - "currency": "mtx" - }, - "originalPrice": { - "amount": 50, - "currency": "mtx" - } - }, - "id": 5, - "isPremium": false, - "limitType": "NONE", "packContentInfo": { "bronzeQuantity": 0, - "goldQuantity": 7, - "itemQuantity": 7, - "rareQuantity": 7, - "silverQuantity": 0 + "silverQuantity": 2, + "goldQuantity": 10, + "rareQuantity": 1, + "itemQuantity": 12 }, - "packType": "GOLD", - "purchaseCount": 0, - "purchaseLimit": 0, - "quantity": 0, - "saleType": "promo", - "sortPriority": 2, - "state": "active", - "unopened": false + "unopened": false, + "displayGroup": { + "value": "gold" + } }, { "assetId": 6, + "id": 6, + "packType": "GOLD", + "description": "Premium Gold Pack", + "state": "active", + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 6, "currencies": [ { - "finalFunds": 15000, - "funds": 15000, - "name": "coins" + "name": "coins", + "funds": 7500, + "finalFunds": 7500 } ], - "description": "Premium Gold", + "packContentInfo": { + "bronzeQuantity": 0, + "silverQuantity": 2, + "goldQuantity": 10, + "rareQuantity": 3, + "itemQuantity": 12 + }, + "unopened": false, "displayGroup": { "value": "gold" - }, - "extPrice": { - "finalPrice": { - "amount": 150, - "currency": "mtx" - }, - "originalPrice": { - "amount": 150, - "currency": "mtx" - } - }, - "id": 6, - "isPremium": false, - "limitType": "NONE", - "packContentInfo": { - "bronzeQuantity": 0, - "goldQuantity": 11, - "itemQuantity": 11, - "rareQuantity": 11, - "silverQuantity": 0 - }, - "packType": "GOLD", - "purchaseCount": 0, - "purchaseLimit": 0, - "quantity": 0, - "saleType": "promo", - "sortPriority": 3, - "state": "active", - "unopened": false - }, - { - "assetId": 7, - "currencies": [ - { - "finalFunds": 25000, - "funds": 25000, - "name": "coins" - } - ], - "description": "Special Players Pack", - "displayGroup": { - "value": "special" - }, - "extPrice": { - "finalPrice": { - "amount": 250, - "currency": "mtx" - }, - "originalPrice": { - "amount": 250, - "currency": "mtx" - } - }, - "id": 7, - "isPremium": false, - "limitType": "NONE", - "packContentInfo": { - "bronzeQuantity": 0, - "goldQuantity": 11, - "itemQuantity": 11, - "rareQuantity": 11, - "silverQuantity": 0 - }, - "packType": "GOLD", - "purchaseCount": 0, - "purchaseLimit": 0, - "quantity": 0, - "saleType": "promo", - "sortPriority": 4, - "state": "active", - "unopened": false + } }, { "assetId": 70, - "description": "Reward Special Players Pack", - "displayGroup": { - "priority": 1, - "value": "mypacks" - }, "id": 70, - "isPremium": false, + "packType": "GOLD", + "description": "Reward Gold Pack", + "state": "active", + "saleType": "promo", "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 1, "packContentInfo": { "bronzeQuantity": 0, + "silverQuantity": 0, "goldQuantity": 11, - "itemQuantity": 11, "rareQuantity": 11, - "silverQuantity": 0 + "itemQuantity": 11 }, - "packType": "GOLD", - "purchaseCount": 0, - "purchaseLimit": 0, - "quantity": 0, - "saleType": "promo", - "sortPriority": 1, - "state": "active", - "unopened": true + "unopened": true, + "displayGroup": { + "value": "mypacks", + "priority": 1 + } } ], "timestamp": 1596326400 diff --git a/openfut-adapter-fifa17/tests/fixtures/purchasegroup_zero_clean.json b/openfut-adapter-fifa17/tests/fixtures/purchasegroup_zero_clean.json index 355df79..bf34014 100644 --- a/openfut-adapter-fifa17/tests/fixtures/purchasegroup_zero_clean.json +++ b/openfut-adapter-fifa17/tests/fixtures/purchasegroup_zero_clean.json @@ -2,171 +2,195 @@ "purchase": [ { "assetId": 1, + "id": 1, + "packType": "BRONZE", + "description": "Bronze Pack", + "state": "active", + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 1, "currencies": [ { - "finalFunds": 400, + "name": "coins", "funds": 400, - "name": "coins" + "finalFunds": 400 } ], - "description": "Bronze Pack", + "packContentInfo": { + "bronzeQuantity": 10, + "silverQuantity": 2, + "goldQuantity": 0, + "rareQuantity": 1, + "itemQuantity": 12 + }, + "unopened": false, "displayGroup": { "value": "bronze" - }, - "extPrice": { - "finalPrice": { - "amount": 4, - "currency": "mtx" - }, - "originalPrice": { - "amount": 4, - "currency": "mtx" - } - }, - "id": 1, - "isPremium": false, - "limitType": "NONE", - "packContentInfo": { - "bronzeQuantity": 5, - "goldQuantity": 0, - "itemQuantity": 5, - "rareQuantity": 0, - "silverQuantity": 0 - }, + } + }, + { + "assetId": 2, + "id": 2, "packType": "BRONZE", - "purchaseCount": 0, - "purchaseLimit": 0, - "quantity": 0, - "saleType": "promo", - "sortPriority": 1, + "description": "Premium Bronze Pack", "state": "active", - "unopened": false + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 2, + "currencies": [ + { + "name": "coins", + "funds": 750, + "finalFunds": 750 + } + ], + "packContentInfo": { + "bronzeQuantity": 10, + "silverQuantity": 2, + "goldQuantity": 0, + "rareQuantity": 3, + "itemQuantity": 12 + }, + "unopened": false, + "displayGroup": { + "value": "bronze" + } + }, + { + "assetId": 3, + "id": 3, + "packType": "SILVER", + "description": "Silver Pack", + "state": "active", + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 3, + "currencies": [ + { + "name": "coins", + "funds": 2500, + "finalFunds": 2500 + } + ], + "packContentInfo": { + "bronzeQuantity": 1, + "silverQuantity": 11, + "goldQuantity": 0, + "rareQuantity": 1, + "itemQuantity": 12 + }, + "unopened": false, + "displayGroup": { + "value": "silver" + } + }, + { + "assetId": 4, + "id": 4, + "packType": "SILVER", + "description": "Premium Silver Pack", + "state": "active", + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 4, + "currencies": [ + { + "name": "coins", + "funds": 3750, + "finalFunds": 3750 + } + ], + "packContentInfo": { + "bronzeQuantity": 1, + "silverQuantity": 11, + "goldQuantity": 0, + "rareQuantity": 3, + "itemQuantity": 12 + }, + "unopened": false, + "displayGroup": { + "value": "silver" + } }, { "assetId": 5, + "id": 5, + "packType": "GOLD", + "description": "Gold Pack", + "state": "active", + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 5, "currencies": [ { - "finalFunds": 5000, + "name": "coins", "funds": 5000, - "name": "coins" + "finalFunds": 5000 } ], - "description": "Gold Pack", - "displayGroup": { - "value": "gold" - }, - "extPrice": { - "finalPrice": { - "amount": 50, - "currency": "mtx" - }, - "originalPrice": { - "amount": 50, - "currency": "mtx" - } - }, - "id": 5, - "isPremium": false, - "limitType": "NONE", "packContentInfo": { "bronzeQuantity": 0, - "goldQuantity": 7, - "itemQuantity": 7, - "rareQuantity": 7, - "silverQuantity": 0 + "silverQuantity": 2, + "goldQuantity": 10, + "rareQuantity": 1, + "itemQuantity": 12 }, - "packType": "GOLD", - "purchaseCount": 0, - "purchaseLimit": 0, - "quantity": 0, - "saleType": "promo", - "sortPriority": 2, - "state": "active", - "unopened": false + "unopened": false, + "displayGroup": { + "value": "gold" + } }, { "assetId": 6, + "id": 6, + "packType": "GOLD", + "description": "Premium Gold Pack", + "state": "active", + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 6, "currencies": [ { - "finalFunds": 15000, - "funds": 15000, - "name": "coins" + "name": "coins", + "funds": 7500, + "finalFunds": 7500 } ], - "description": "Premium Gold", + "packContentInfo": { + "bronzeQuantity": 0, + "silverQuantity": 2, + "goldQuantity": 10, + "rareQuantity": 3, + "itemQuantity": 12 + }, + "unopened": false, "displayGroup": { "value": "gold" - }, - "extPrice": { - "finalPrice": { - "amount": 150, - "currency": "mtx" - }, - "originalPrice": { - "amount": 150, - "currency": "mtx" - } - }, - "id": 6, - "isPremium": false, - "limitType": "NONE", - "packContentInfo": { - "bronzeQuantity": 0, - "goldQuantity": 11, - "itemQuantity": 11, - "rareQuantity": 11, - "silverQuantity": 0 - }, - "packType": "GOLD", - "purchaseCount": 0, - "purchaseLimit": 0, - "quantity": 0, - "saleType": "promo", - "sortPriority": 3, - "state": "active", - "unopened": false - }, - { - "assetId": 7, - "currencies": [ - { - "finalFunds": 25000, - "funds": 25000, - "name": "coins" - } - ], - "description": "Special Players Pack", - "displayGroup": { - "value": "special" - }, - "extPrice": { - "finalPrice": { - "amount": 250, - "currency": "mtx" - }, - "originalPrice": { - "amount": 250, - "currency": "mtx" - } - }, - "id": 7, - "isPremium": false, - "limitType": "NONE", - "packContentInfo": { - "bronzeQuantity": 0, - "goldQuantity": 11, - "itemQuantity": 11, - "rareQuantity": 11, - "silverQuantity": 0 - }, - "packType": "GOLD", - "purchaseCount": 0, - "purchaseLimit": 0, - "quantity": 0, - "saleType": "promo", - "sortPriority": 4, - "state": "active", - "unopened": false + } } ], "timestamp": 1596326400 diff --git a/openfut-adapter-fifa17/tests/fixtures/purchasegroup_zero_sentinel.json b/openfut-adapter-fifa17/tests/fixtures/purchasegroup_zero_sentinel.json index c85cc5a..39bd88b 100644 --- a/openfut-adapter-fifa17/tests/fixtures/purchasegroup_zero_sentinel.json +++ b/openfut-adapter-fifa17/tests/fixtures/purchasegroup_zero_sentinel.json @@ -2,197 +2,221 @@ "purchase": [ { "assetId": 1, + "id": 1, + "packType": "BRONZE", + "description": "Bronze Pack", + "state": "active", + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 1, "currencies": [ { - "finalFunds": 400, + "name": "coins", "funds": 400, - "name": "coins" + "finalFunds": 400 } ], - "description": "Bronze Pack", + "packContentInfo": { + "bronzeQuantity": 10, + "silverQuantity": 2, + "goldQuantity": 0, + "rareQuantity": 1, + "itemQuantity": 12 + }, + "unopened": false, "displayGroup": { "value": "bronze" - }, - "extPrice": { - "finalPrice": { - "amount": 4, - "currency": "mtx" - }, - "originalPrice": { - "amount": 4, - "currency": "mtx" - } - }, - "id": 1, - "isPremium": false, - "limitType": "NONE", - "packContentInfo": { - "bronzeQuantity": 5, - "goldQuantity": 0, - "itemQuantity": 5, - "rareQuantity": 0, - "silverQuantity": 0 - }, + } + }, + { + "assetId": 2, + "id": 2, "packType": "BRONZE", - "purchaseCount": 0, - "purchaseLimit": 0, - "quantity": 0, - "saleType": "promo", - "sortPriority": 1, + "description": "Premium Bronze Pack", "state": "active", - "unopened": false + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 2, + "currencies": [ + { + "name": "coins", + "funds": 750, + "finalFunds": 750 + } + ], + "packContentInfo": { + "bronzeQuantity": 10, + "silverQuantity": 2, + "goldQuantity": 0, + "rareQuantity": 3, + "itemQuantity": 12 + }, + "unopened": false, + "displayGroup": { + "value": "bronze" + } + }, + { + "assetId": 3, + "id": 3, + "packType": "SILVER", + "description": "Silver Pack", + "state": "active", + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 3, + "currencies": [ + { + "name": "coins", + "funds": 2500, + "finalFunds": 2500 + } + ], + "packContentInfo": { + "bronzeQuantity": 1, + "silverQuantity": 11, + "goldQuantity": 0, + "rareQuantity": 1, + "itemQuantity": 12 + }, + "unopened": false, + "displayGroup": { + "value": "silver" + } + }, + { + "assetId": 4, + "id": 4, + "packType": "SILVER", + "description": "Premium Silver Pack", + "state": "active", + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 4, + "currencies": [ + { + "name": "coins", + "funds": 3750, + "finalFunds": 3750 + } + ], + "packContentInfo": { + "bronzeQuantity": 1, + "silverQuantity": 11, + "goldQuantity": 0, + "rareQuantity": 3, + "itemQuantity": 12 + }, + "unopened": false, + "displayGroup": { + "value": "silver" + } }, { "assetId": 5, + "id": 5, + "packType": "GOLD", + "description": "Gold Pack", + "state": "active", + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 5, "currencies": [ { - "finalFunds": 5000, + "name": "coins", "funds": 5000, - "name": "coins" + "finalFunds": 5000 } ], - "description": "Gold Pack", - "displayGroup": { - "value": "gold" - }, - "extPrice": { - "finalPrice": { - "amount": 50, - "currency": "mtx" - }, - "originalPrice": { - "amount": 50, - "currency": "mtx" - } - }, - "id": 5, - "isPremium": false, - "limitType": "NONE", "packContentInfo": { "bronzeQuantity": 0, - "goldQuantity": 7, - "itemQuantity": 7, - "rareQuantity": 7, - "silverQuantity": 0 + "silverQuantity": 2, + "goldQuantity": 10, + "rareQuantity": 1, + "itemQuantity": 12 }, - "packType": "GOLD", - "purchaseCount": 0, - "purchaseLimit": 0, - "quantity": 0, - "saleType": "promo", - "sortPriority": 2, - "state": "active", - "unopened": false + "unopened": false, + "displayGroup": { + "value": "gold" + } }, { "assetId": 6, + "id": 6, + "packType": "GOLD", + "description": "Premium Gold Pack", + "state": "active", + "saleType": "promo", + "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 6, "currencies": [ { - "finalFunds": 15000, - "funds": 15000, - "name": "coins" + "name": "coins", + "funds": 7500, + "finalFunds": 7500 } ], - "description": "Premium Gold", + "packContentInfo": { + "bronzeQuantity": 0, + "silverQuantity": 2, + "goldQuantity": 10, + "rareQuantity": 3, + "itemQuantity": 12 + }, + "unopened": false, "displayGroup": { "value": "gold" - }, - "extPrice": { - "finalPrice": { - "amount": 150, - "currency": "mtx" - }, - "originalPrice": { - "amount": 150, - "currency": "mtx" - } - }, - "id": 6, - "isPremium": false, - "limitType": "NONE", - "packContentInfo": { - "bronzeQuantity": 0, - "goldQuantity": 11, - "itemQuantity": 11, - "rareQuantity": 11, - "silverQuantity": 0 - }, - "packType": "GOLD", - "purchaseCount": 0, - "purchaseLimit": 0, - "quantity": 0, - "saleType": "promo", - "sortPriority": 3, - "state": "active", - "unopened": false - }, - { - "assetId": 7, - "currencies": [ - { - "finalFunds": 25000, - "funds": 25000, - "name": "coins" - } - ], - "description": "Special Players Pack", - "displayGroup": { - "value": "special" - }, - "extPrice": { - "finalPrice": { - "amount": 250, - "currency": "mtx" - }, - "originalPrice": { - "amount": 250, - "currency": "mtx" - } - }, - "id": 7, - "isPremium": false, - "limitType": "NONE", - "packContentInfo": { - "bronzeQuantity": 0, - "goldQuantity": 11, - "itemQuantity": 11, - "rareQuantity": 11, - "silverQuantity": 0 - }, - "packType": "GOLD", - "purchaseCount": 0, - "purchaseLimit": 0, - "quantity": 0, - "saleType": "promo", - "sortPriority": 4, - "state": "active", - "unopened": false + } }, { "assetId": 65534, - "description": "", - "displayGroup": { - "priority": 1, - "value": "mypacks" - }, "id": 65534, - "isPremium": false, + "packType": "GOLD", + "description": "", + "state": "active", + "saleType": "promo", "limitType": "NONE", + "quantity": 0, + "purchaseLimit": 0, + "purchaseCount": 0, + "isPremium": false, + "sortPriority": 1, "packContentInfo": { "bronzeQuantity": 0, + "silverQuantity": 0, "goldQuantity": 0, - "itemQuantity": 0, "rareQuantity": 0, - "silverQuantity": 0 + "itemQuantity": 0 }, - "packType": "GOLD", - "purchaseCount": 0, - "purchaseLimit": 0, - "quantity": 0, - "saleType": "promo", - "sortPriority": 1, - "state": "active", - "unopened": false + "unopened": false, + "displayGroup": { + "value": "mypacks", + "priority": 1 + } } ], "timestamp": 1596326400 diff --git a/openfut-utas-host/src/economy_store.rs b/openfut-utas-host/src/economy_store.rs index 8068fd2..4912555 100644 --- a/openfut-utas-host/src/economy_store.rs +++ b/openfut-utas-host/src/economy_store.rs @@ -244,7 +244,11 @@ fn pack_open_body(pid: u64, pack: &PackDef) -> WireResponse { "firstPartyStoreId": 0, "groupName": "fifa17", "productId": pid.to_string(), - "purchasePackType": if pack.gold { "GOLD" } else { "BRONZE" }, + "purchasePackType": match pack.category { + "gold" => "GOLD", + "silver" => "SILVER", + _ => "BRONZE", + }, })) } @@ -695,7 +699,7 @@ mod tests { // ── Store BUY ────────────────────────────────────────────────────────── #[test] - fn buy_pack1_debits_mints_and_reveals_five_cards() { + fn buy_pack1_debits_mints_and_reveals_twelve_cards() { let econ = RecEcon::new(10_000); let pool = pool(); let assets = FakeAssets::for_pool(&pool); @@ -705,23 +709,23 @@ mod tests { assert_eq!(resp.status, 200); let b: Value = serde_json::from_slice(&resp.body).unwrap(); let cpr = &b["createPackResponse"]; - assert_eq!(cpr["numberItems"], 5); // pack 1 count - assert_eq!(cpr["itemList"].as_array().unwrap().len(), 5); + assert_eq!(cpr["numberItems"], 12); // pack 1 count (10 bronze + 2 silver) + assert_eq!(cpr["itemList"].as_array().unwrap().len(), 12); assert_eq!(cpr["purchasedPackId"], 1); assert_eq!(cpr["duplicateItemIdList"], json!([])); - // Exactly one atomic debit of the pack price (400) minting 5 items. + // Exactly one atomic debit of the pack price (400) minting 12 items. assert_eq!(econ.coins(), 10_000 - 400); let purchased = econ.purchased.lock(); assert_eq!(purchased.len(), 1); assert_eq!(purchased[0].0, 400); - assert_eq!(purchased[0].1.len(), 5); + assert_eq!(purchased[0].1.len(), 12); for g in &purchased[0].1 { assert!(pool.iter().any(|c| c.card_id == g.card_id)); } } #[test] - fn buy_pack5_debits_gold_price_and_mints_seven() { + fn buy_pack5_debits_gold_price_and_mints_twelve() { let econ = RecEcon::new(10_000); let pool = pool(); let assets = FakeAssets::for_pool(&pool); @@ -729,7 +733,7 @@ mod tests { let deps = store_deps(&econ, &assets, &ent, &pool); let resp = handle_store_buy(&body(json!({ "packId": 5 })), &deps, &mut rng(2)); let b: Value = serde_json::from_slice(&resp.body).unwrap(); - assert_eq!(b["createPackResponse"]["numberItems"], 7); // pack 5 count + assert_eq!(b["createPackResponse"]["numberItems"], 12); // pack 5 count (10 gold + 2 silver) assert_eq!(econ.coins(), 10_000 - 5000); } @@ -860,7 +864,7 @@ mod tests { assert_eq!(b["firstPartyStoreId"], 0); assert_eq!(b["purchasePackType"], "GOLD"); // pack 5 is gold assert_eq!(econ.coins(), 10_000 - 5000); - assert_eq!(econ.purchased.lock()[0].1.len(), 7); + assert_eq!(econ.purchased.lock()[0].1.len(), 12); } #[test] diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 956a011..70932fe 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -663,6 +663,17 @@ pub trait CoreAccess: Send + Sync { Ok(self.query_owned(&[])?.items) } + /// Every card DEFINITION in Core content (`GET /cards`) — the full card + /// universe a pack can draw from, independent of ownership. Same shape as + /// owned items (rating/position/nation/league/club/attributes) so + /// [`build_content_pool`] treats definitions and owned items uniformly. + /// Default: unimplemented (callers fall back to owned inventory). + fn all_definitions(&self) -> Result, CoreError> { + Err(CoreError::Parse( + "Core content enumeration is not implemented".into(), + )) + } + /// Read the active squad + its opaque extension for `namespace`. fn read_squad_ext(&self, namespace: &str) -> Result; @@ -746,6 +757,22 @@ impl CoreAccess for HttpCoreClient { let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?; parse_core_page(&v) } + + fn all_definitions(&self) -> Result, CoreError> { + let url = format!("{}/cards", self.base_url); + let resp = self + .client + .get(&url) + .header("X-OpenFUT-Game", &self.game) + .send() + .map_err(|e| CoreError::Http(e.to_string()))?; + let status = resp.status().as_u16(); + if !(200..300).contains(&status) { + return Err(CoreError::Status(status)); + } + let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?; + parse_core_definitions(&v) + } fn read_squad_ext(&self, namespace: &str) -> Result { let url = format!("{}/squad/ext", self.base_url); let resp = self @@ -1361,6 +1388,38 @@ fn core_item_from_json(e: &Value) -> Option { }) } +/// Parse Core's `/cards` response `{ "cards": [CardDefinition...], ... }` into the +/// same [`CoreOwnedItem`] shape as owned items (`owned_card_id` empty — a +/// definition is not an instance), for full-universe pool building. +pub fn parse_core_definitions(v: &Value) -> Result, CoreError> { + let arr = v + .get("cards") + .and_then(|c| c.as_array()) + .ok_or_else(|| CoreError::Parse("missing `cards` array".into()))?; + Ok(arr.iter().filter_map(core_item_from_definition).collect()) +} + +fn core_item_from_definition(card: &Value) -> Option { + let attr = |k: &str| card.get(k).and_then(|v| v.as_i64()).unwrap_or(0) as u8; + Some(CoreOwnedItem { + owned_card_id: String::new(), + card_id: card.get("id")?.as_str()?.to_string(), + rating: card.get("overall").and_then(|v| v.as_i64()).unwrap_or(0) as u8, + position: card.get("position")?.as_str()?.to_string(), + nation: card.get("nation")?.as_str()?.to_string(), + league: card.get("league")?.as_str()?.to_string(), + club: card.get("club")?.as_str()?.to_string(), + attributes: [ + attr("pace"), + attr("shooting"), + attr("passing"), + attr("dribbling"), + attr("defending"), + attr("physical"), + ], + }) +} + // ───────────────────────────── Item identity resolver ─────────────────────── /// The single production [`ItemIdentityResolver`]: it composes the two distinct @@ -1419,6 +1478,15 @@ impl Fifa17IdentityResolver { .map(|c| c.rareflag) .unwrap_or(0) } + + /// Non-minting definition identity from the catalog: `Some((rareflag, kind))` + /// if the card resolves, else `None`. Used to build the pack pool over ALL + /// content definitions WITHOUT allocating a wire id per definition (that would + /// pollute the identity store); a real wire id is minted only when a pack draw + /// actually mints the card. + pub fn definition_identity(&self, card_id: &str) -> Option<(i64, ContentKind)> { + self.catalog.lookup(card_id).map(|c| (c.rareflag, c.kind)) + } } impl ItemIdentityResolver for Fifa17IdentityResolver { @@ -2372,30 +2440,40 @@ pub struct EconomyServices { pub sold_experiment: crate::sold_experiment::SoldExperiment, } -/// Build the pack-content candidate pool from Core's current content, evidenced -/// by the owned inventory: every distinct owned card definition that resolves to -/// a real FIFA asset id is a candidate (`gold` = rating ≥ 75; `special` from the -/// catalog `rareflag > 1`). This is the resolvable FIFA∩Core card universe — the -/// cards a pack can award and the shared shaper can render. An empty pool (no -/// content, or Core unreachable) is fail-closed by construction: the generator -/// returns no cards, so the Store neither mints nor debits. +/// Build the pack-content candidate pool. Prefers the FULL card universe (Core +/// content, `GET /cards` via [`CoreAccess::all_definitions`]) so a pack can award +/// any card in the game, not only cards the profile already owns; falls back to +/// owned inventory when content enumeration is unavailable (older Core / tests). +/// Each candidate resolves in the FIFA catalog (unmapped or non-player cards are +/// dropped, never faked); `gold` = rating ≥ 75, `special` = catalog `rareflag > 1`. +/// An empty pool (no content, or Core unreachable) is fail-closed by construction: +/// the generator returns no cards, so the Store neither mints nor debits. pub fn build_content_pool( core: &dyn CoreAccess, resolver: &Fifa17IdentityResolver, ) -> Vec { - let owned = match core.all_owned() { - Ok(v) => v, - Err(_) => return Vec::new(), + // Full universe first; owned inventory only as a degrade path. + let items = match core.all_definitions() { + Ok(v) if !v.is_empty() => v, + _ => match core.all_owned() { + Ok(v) => v, + Err(_) => return Vec::new(), + }, }; let mut seen = std::collections::HashSet::new(); let mut pool = Vec::new(); - for item in &owned { + for item in &items { if !seen.insert(item.card_id.clone()) { continue; } - let Some(id) = resolver.resolve(item) else { + // Non-minting catalog lookup: drop unmapped cards and non-player content + // (consumables/staff never enter the player-card pack pool). + let Some((rareflag, kind)) = resolver.definition_identity(&item.card_id) else { continue; }; + if kind != ContentKind::Player { + continue; + } pool.push(GeneratedCandidate { card_id: item.card_id.clone(), rating: item.rating, @@ -2405,7 +2483,7 @@ pub fn build_content_pool( club: item.club.clone(), attributes: item.attributes, gold: item.rating >= 75, - special: id.rareflag > 1, + special: rareflag > 1, }); } pool diff --git a/openfut-utas-host/tests/economy_concurrency.rs b/openfut-utas-host/tests/economy_concurrency.rs index e6dacc1..cdd8168 100644 --- a/openfut-utas-host/tests/economy_concurrency.rs +++ b/openfut-utas-host/tests/economy_concurrency.rs @@ -311,7 +311,7 @@ fn case_a_two_buys(h: &Harness) -> String { refs, 1, "exactly one BUY refused 461 (statuses {statuses:?})" ); - // The winner minted 5 cards; the loser minted nothing. + // The winner minted 12 cards (real Bronze Pack); the loser minted nothing. for r in &rs { if r.status == 200 { assert_eq!( @@ -319,7 +319,7 @@ fn case_a_two_buys(h: &Harness) -> String { .as_array() .unwrap() .len(), - 5 + 12 ); } } diff --git a/openfut-utas-host/tests/economy_differential.rs b/openfut-utas-host/tests/economy_differential.rs index dbaeca6..705b724 100644 --- a/openfut-utas-host/tests/economy_differential.rs +++ b/openfut-utas-host/tests/economy_differential.rs @@ -26,15 +26,15 @@ //! | userMassInfo economy | PARITY | `userInfo.currencies[coins].funds == credits coins`; both | //! | | | carry `unopenedPacks.recoveredPacks==1`. Coins consistent | //! | | | across the credits & massinfo surfaces on each side. | -//! | purchasegroup pack70 | PARITY | owned pack present -> id set {1,5,6,7,70}, NO 65534 sentinel. | -//! | purchasegroup sentinel | PARITY | empty My Packs + unverified session -> {1,5,6,7,65534}, | -//! | | | sentinel `state:"active"`. | -//! | purchasegroup clean-v1 | PARITY | empty My Packs + verified capability session -> {1,5,6,7}, | +//! | purchasegroup pack70 | DIFFERENT-BY-DESIGN| STORE DIVERGED: Rust serves the real 6-pack catalogue | +//! | | | {1,2,3,4,5,6,70}; oracle (rollback) keeps {1,5,6,7,70}. | +//! | purchasegroup sentinel | DIFFERENT-BY-DESIGN| empty My Packs + unverified -> rust {1..6,65534} vs oracle | +//! | | | {1,5,6,7,65534}; sentinel `state:"active"` on both. | +//! | purchasegroup clean-v1 | DIFFERENT-BY-DESIGN| empty + verified capability -> rust {1..6}, oracle {1,5,6,7};| //! | | | sentinel stripped. Rust drives the REAL `SessionStore` state | -//! | | | machine (register_capability+open_session+freeze) exactly as | -//! | | | the oracle's launcher/auth handshake does. | -//! | Store BUY (pack 1) | PARITY | 200; `createPackResponse{itemList(5),numberItems:5, | -//! | | | purchasedPackId,duplicateItemIdList}`; coin delta -400. | +//! | | | machine (register_capability+open_session+freeze). | +//! | Store BUY (pack 1) | DIFFERENT-BY-DESIGN| 200; rust real Bronze Pack -> itemList(12),numberItems:12; | +//! | | | oracle placeholder -> 5; both debit 400 + purchasedPackId 1. | //! | POST /purchased open70 | PARITY | 200; envelope `{packId:70,firstPartyStoreId,productId:"70", | //! | | | purchasePackType:"GOLD"}`; coin delta 0; entitlement -1. | //! | GET /purchased reveal | PARITY | Durable single-profile purchased pile on BOTH (Rust reveal | @@ -550,29 +550,43 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) { None, ) .1; + // STORE DIVERGED: Rust is the authoritative store owner and serves the real + // 6-pack regular catalogue (ids 1..6 + owned 70). The Python oracle (rollback + // baseline, never modified) still serves the old placeholder set {1,5,6,7,70}, + // so this is Rust-authoritative, NOT oracle parity. assert_eq!( pack_ids(&opg), vec![1, 5, 6, 7, 70], - "oracle owned pack70 id set" + "oracle (rollback) placeholder id set" ); assert_eq!( pack_ids(&rpg), - vec![1, 5, 6, 7, 70], - "rust owned pack70 id set" + vec![1, 2, 3, 4, 5, 6, 70], + "rust authoritative real-catalogue id set" ); for b in [&opg, &rpg] { assert!( !pack_ids(b).contains(&SENTINEL_PACK_ID), "no 65534 sentinel while a pack is owned" ); - // packType parity per id (BRONZE for 1, GOLD for the rest). - for p in b["purchase"].as_array().unwrap() { - let id = p["id"].as_u64().unwrap(); - let want = if id == 1 { "BRONZE" } else { "GOLD" }; - assert_eq!(p["packType"], want, "packType parity for pack {id}"); - } } - matrix.push(("purchasegroup pack70", "PARITY")); + // packType by side: oracle BRONZE for 1 else GOLD; rust by real category + // (1,2 bronze / 3,4 silver / 5,6,70 gold). + for p in opg["purchase"].as_array().unwrap() { + let id = p["id"].as_u64().unwrap(); + let want = if id == 1 { "BRONZE" } else { "GOLD" }; + assert_eq!(p["packType"], want, "oracle packType for pack {id}"); + } + for p in rpg["purchase"].as_array().unwrap() { + let id = p["id"].as_u64().unwrap(); + let want = match id { + 1 | 2 => "BRONZE", + 3 | 4 => "SILVER", + _ => "GOLD", + }; + assert_eq!(p["packType"], want, "rust packType for pack {id}"); + } + matrix.push(("purchasegroup pack70", "DIFFERENT-BY-DESIGN")); // ── OP 4: Store BUY (pack 1 Bronze, price 400, 5 cards) ──────────────── let o_bal0 = oracle.coins(); @@ -600,11 +614,22 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) { .as_array() .expect("rust itemList") .clone(); - assert_eq!(o_items.len(), 5, "oracle pack1 -> 5 cards"); - assert_eq!(r_items.len(), 5, "rust pack1 -> 5 cards"); + // STORE DIVERGED: rust serves the real Bronze Pack (10+2 = 12 cards); the + // oracle placeholder awards 5. Both debit the same 400-coin price. + assert_eq!(o_items.len(), 5, "oracle (rollback) pack1 -> 5 cards"); + assert_eq!(r_items.len(), 12, "rust pack1 real Bronze Pack -> 12 cards"); + assert_eq!( + o_buy["createPackResponse"]["numberItems"].as_i64().unwrap(), + 5, + "oracle numberItems==5" + ); + assert_eq!( + r_buy["createPackResponse"]["numberItems"].as_i64().unwrap(), + 12, + "rust numberItems==12" + ); for b in [&o_buy, &r_buy] { let cpr = &b["createPackResponse"]; - assert_eq!(cpr["numberItems"].as_i64().unwrap(), 5, "numberItems==5"); assert_eq!( cpr["purchasedPackId"].as_i64().unwrap(), 1, @@ -619,9 +644,9 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) { assert_eq!( client.balance().unwrap() - r_bal0, -400, - "rust BUY debits 400" + "rust BUY debits 400 (price parity)" ); - matrix.push(("Store BUY (pack1)", "PARITY")); + matrix.push(("Store BUY (pack1)", "DIFFERENT-BY-DESIGN")); // Minted wire ids for the item ops that follow (both sides put them in the // pending purchased pile). let o_wire: Vec = o_items.iter().map(|i| i["id"].as_i64().unwrap()).collect(); @@ -1176,8 +1201,8 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) { ); assert_eq!( pack_ids(&r_pg_s), - vec![1, 5, 6, 7, SENTINEL_PACK_ID], - "rust empty (unknown SID) -> sentinel" + vec![1, 2, 3, 4, 5, 6, SENTINEL_PACK_ID], + "rust empty (unknown SID) -> real catalogue + sentinel" ); for b in [&o_pg_s, &r_pg_s] { let s = b["purchase"] @@ -1188,7 +1213,7 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) { .unwrap(); assert_eq!(s["state"], "active", "sentinel state active"); } - matrix.push(("purchasegroup sentinel", "PARITY")); + matrix.push(("purchasegroup sentinel", "DIFFERENT-BY-DESIGN")); // ── OP 3c: purchasegroup — clean-v1 (empty + verified capability session) ─ // Oracle: register the launcher capability, open a session (auth), present the @@ -1240,11 +1265,11 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) { let r_pg_c: Value = serde_json::from_slice(&r_pg_c_resp.body).unwrap(); assert_eq!( pack_ids(&r_pg_c), - vec![1, 5, 6, 7], - "rust clean-v1 strips the sentinel" + vec![1, 2, 3, 4, 5, 6], + "rust clean-v1 real catalogue, sentinel stripped" ); assert!(!pack_ids(&r_pg_c).contains(&SENTINEL_PACK_ID)); - matrix.push(("purchasegroup clean-v1", "PARITY")); + matrix.push(("purchasegroup clean-v1", "DIFFERENT-BY-DESIGN")); // ── Emit the matrix for the run log. ──────────────────────────────────── eprintln!("\n===== economy_differential PARITY / DIFFERENT-BY-DESIGN matrix ====="); diff --git a/openfut-utas-host/tests/economy_integration.rs b/openfut-utas-host/tests/economy_integration.rs index 1eee5ef..ceb6aad 100644 --- a/openfut-utas-host/tests/economy_integration.rs +++ b/openfut-utas-host/tests/economy_integration.rs @@ -373,8 +373,8 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult { .as_array() .expect("itemList") .clone(); - assert_eq!(items.len(), 5, "pack 1 awards 5 cards"); - assert_eq!(bv["createPackResponse"]["numberItems"], 5); + assert_eq!(items.len(), 12, "pack 1 (real Bronze Pack) awards 12 cards"); + assert_eq!(bv["createPackResponse"]["numberItems"], 12); assert!( items[0]["id"].as_i64().unwrap() >= 100_000_000, "minted wire id above the FIFA floor"