diff --git a/Cargo.lock b/Cargo.lock index 22e8817..19d839b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3166,6 +3166,7 @@ dependencies = [ name = "openfut-bridge" version = "0.1.0" dependencies = [ + "aes", "anyhow", "axum", "bytes", 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/sbc.rs b/openfut-adapter-fifa17/src/fut/sbc.rs index 907ddc7..9ca3173 100644 --- a/openfut-adapter-fifa17/src/fut/sbc.rs +++ b/openfut-adapter-fifa17/src/fut/sbc.rs @@ -66,7 +66,7 @@ pub fn sets_body(challenges: &[ChallengeView]) -> Value { "description": challenge.description, "priority": challenge.identity.priority, "challengesCount": 1, - "challengesCompletedCount": i64::from(challenge.times_completed > 0), + "challengesCompletedCount": i64::from(!challenge.repeatable && challenge.times_completed > 0), "awards": [], "hidden": false, "endTime": 4_102_444_800_i64 @@ -89,6 +89,17 @@ pub fn challenges_body(set_id: i64, challenges: &[ChallengeView]) -> Value { .iter() .filter(|challenge| challenge.identity.set_id == set_id) .map(|challenge| { + // A repeatable challenge is always available to enter again. The FIFA 17 + // client gates challenge re-entry on `timesCompleted` (not on `repeatable`): + // a nonzero count renders the tile COMPLETED and refuses re-entry. So a + // repeatable challenge never reports itself as terminally completed here. + // Core keeps the true completion record (economy authority); this is + // presentation only. Live-proven on the retail client 2026-08-18. + let times_completed = if challenge.repeatable { + 0 + } else { + challenge.times_completed + }; json!({ "challengeId": challenge.identity.challenge_id, "setId": challenge.identity.set_id, @@ -103,7 +114,7 @@ pub fn challenges_body(set_id: i64, challenges: &[ChallengeView]) -> Value { "repeatable": challenge.repeatable, "trophyId": 0, "status": "OPEN", - "timesCompleted": challenge.times_completed, + "timesCompleted": times_completed, "awards": [], "elgReq": [] }) @@ -178,9 +189,15 @@ impl std::error::Error for SbcWireError {} /// Extract FIFA wire item ids from a saved/submitted challenge squad. /// -/// Retail request captures for this body are unavailable. The parser therefore accepts -/// only the two already-proven FIFA 17 squad containers: a normal `players` array or the -/// SBC `squad` array. Within either, only `itemData.id` is interpreted. +/// The exact retail challenge-squad body is now captured (2026-08-18 Gate C, +/// `PUT /ut/game/fifa17/sbs/challenge/101/squad`): a fixed 23-entry `players` +/// array of `{index, itemData:{id, dream}}` (empty slots carry `id == 0`), +/// alongside sibling `chemistry`, `rating`, `formation`, and a `manager` array +/// of `{id, dream}`. Only `players[].itemData.id` selects the consumed cards; +/// the manager, chemistry, rating, formation, dream, and index fields are +/// presentation/validation hints and are never consumed. The `squad` array +/// container remains accepted for the alternate proven shape. Within either, +/// only non-zero `itemData.id` values are interpreted. pub fn parse_wire_item_ids(body: &[u8]) -> Result, SbcWireError> { let root: Value = serde_json::from_slice(body).map_err(|error| SbcWireError::Json(error.to_string()))?; @@ -240,6 +257,52 @@ mod tests { assert!(submit["grantedSetAwards"].is_array()); } + #[test] + fn repeatable_completed_challenge_stays_enterable() { + // The FIFA 17 client refuses challenge re-entry when timesCompleted > 0, so + // a repeatable challenge must always project as not-yet-completed while a + // non-repeatable one keeps its true count. Core holds the real record. + let repeatable = ChallengeView { + identity: CHALLENGES[0], + name: "Bronze Upgrade".into(), + description: "Submit players".into(), + repeatable: true, + times_completed: 3, + }; + let once = ChallengeView { + identity: CHALLENGES[1], + name: "Hybrid Nations".into(), + description: "Submit a hybrid squad".into(), + repeatable: false, + times_completed: 1, + }; + + let repeatable_view = challenges_body(CHALLENGES[0].set_id, &[repeatable.clone()]); + assert_eq!(repeatable_view["challenges"][0]["timesCompleted"], 0); + assert_eq!(repeatable_view["challenges"][0]["status"], "OPEN"); + let once_view = challenges_body(CHALLENGES[1].set_id, &[once.clone()]); + assert_eq!(once_view["challenges"][0]["timesCompleted"], 1); + + let sets = sets_body(&[repeatable, once]); + let sets_arr = sets["categories"][0]["sets"].as_array().unwrap(); + let repeatable_set = sets_arr + .iter() + .find(|set| set["setId"] == CHALLENGES[0].set_id) + .unwrap(); + let once_set = sets_arr + .iter() + .find(|set| set["setId"] == CHALLENGES[1].set_id) + .unwrap(); + assert_eq!( + repeatable_set["challengesCompletedCount"], 0, + "a repeatable set never reports itself terminally completed" + ); + assert_eq!( + once_set["challengesCompletedCount"], 1, + "a one-shot set counts its single completion" + ); + } + #[test] fn parser_accepts_only_known_squad_containers_and_item_ids() { let normal = br#"{"players":[{"index":0,"itemData":{"id":100000001}},{"index":1,"itemData":{"id":0}}]}"#; @@ -259,4 +322,49 @@ mod tests { Err(SbcWireError::Json(_)) )); } + + #[test] + fn parser_matches_captured_retail_challenge_squad_body() { + // Verbatim shape from the 2026-08-18 Gate C retail capture of + // PUT /ut/game/fifa17/sbs/challenge/101/squad (11 filled + 12 empty + // slots, plus manager/chemistry/rating/formation siblings). Only the 11 + // non-zero player itemData.id values are consumed, in wire order; the + // manager and all presentation fields are ignored. + let retail = br#"{"chemistry":21,"rating":86,"formation":"f433", + "manager":[{"id":100000427,"dream":false}], + "players":[ + {"index":0,"itemData":{"id":100004227,"dream":false}}, + {"index":1,"itemData":{"id":100004233,"dream":false}}, + {"index":2,"itemData":{"id":100001317,"dream":false}}, + {"index":3,"itemData":{"id":100001531,"dream":false}}, + {"index":4,"itemData":{"id":100000966,"dream":false}}, + {"index":5,"itemData":{"id":100001947,"dream":false}}, + {"index":6,"itemData":{"id":100000169,"dream":false}}, + {"index":7,"itemData":{"id":100002017,"dream":false}}, + {"index":8,"itemData":{"id":100002765,"dream":false}}, + {"index":9,"itemData":{"id":100000147,"dream":false}}, + {"index":10,"itemData":{"id":100000311,"dream":false}}, + {"index":11,"itemData":{"id":0,"dream":false}}, + {"index":12,"itemData":{"id":0,"dream":false}}, + {"index":13,"itemData":{"id":0,"dream":false}}, + {"index":14,"itemData":{"id":0,"dream":false}}, + {"index":15,"itemData":{"id":0,"dream":false}}, + {"index":16,"itemData":{"id":0,"dream":false}}, + {"index":17,"itemData":{"id":0,"dream":false}}, + {"index":18,"itemData":{"id":0,"dream":false}}, + {"index":19,"itemData":{"id":0,"dream":false}}, + {"index":20,"itemData":{"id":0,"dream":false}}, + {"index":21,"itemData":{"id":0,"dream":false}}, + {"index":22,"itemData":{"id":0,"dream":false}} + ]}"#; + assert_eq!( + parse_wire_item_ids(retail).unwrap(), + [ + 100_004_227, 100_004_233, 100_001_317, 100_001_531, 100_000_966, + 100_001_947, 100_000_169, 100_002_017, 100_002_765, 100_000_147, + 100_000_311 + ], + "exactly the 11 non-zero players in wire order; manager and empty slots ignored" + ); + } } diff --git a/openfut-adapter-fifa17/src/fut/store_catalog.rs b/openfut-adapter-fifa17/src/fut/store_catalog.rs index 86fc53d..053c9fe 100644 --- a/openfut-adapter-fifa17/src/fut/store_catalog.rs +++ b/openfut-adapter-fifa17/src/fut/store_catalog.rs @@ -1,95 +1,112 @@ //! 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 }, + PackDef { id: 71, name: "Bronze Pack", price: 0, + n_bronze: 10, n_silver: 2, n_gold: 0, rares: 1, category: "bronze", + special_chance: 0.01, owned_only: true }, + PackDef { id: 72, name: "Silver Pack", price: 0, + n_bronze: 1, n_silver: 11, n_gold: 0, rares: 1, category: "silver", + special_chance: 0.02, owned_only: true }, + PackDef { id: 73, name: "Gold Pack", price: 0, + n_bronze: 0, n_silver: 2, n_gold: 10, rares: 1, category: "gold", + special_chance: 0.05, owned_only: true }, + PackDef { id: 74, name: "Rare Gold Pack", price: 0, + n_bronze: 0, n_silver: 2, n_gold: 10, rares: 3, category: "gold", + special_chance: 0.10, owned_only: true }, + PackDef { id: 75, name: "Icon Pack", price: 0, + n_bronze: 0, n_silver: 0, n_gold: 12, rares: 12, category: "gold", + special_chance: 1.0, owned_only: true }, ]; /// Look up a catalogue pack by id (the 65534 sentinel is never present). @@ -97,27 +114,88 @@ 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. -fn category(p: &PackDef) -> &'static str { - if p.special_chance >= 1.0 { - "special" - } else if p.gold { - "gold" - } else { - "bronze" +/// Resolve a Core entitlement's opaque `definition_id` to the FIFA 17 numeric +/// owned-only pack it renders and opens as. Accepts a numeric id (imported +/// entitlements, e.g. `"70"`) or one of the symbolic reward-pack names Core's +/// reward services grant (SBC, draft, season, check-in, FUT Champions). Only +/// owned-only packs qualify, so an unknown or non-reward entitlement resolves to +/// `None` and is simply not shown as an openable pack rather than faked. +pub fn owned_pack_id_for_definition(definition_id: &str) -> Option { + if let Ok(numeric) = definition_id.parse::() { + return pack_by_id(numeric) + .filter(|pack| pack.owned_only) + .map(|pack| pack.id); } + let id = match definition_id { + "bronze_pack" => 71, + "silver_pack" => 72, + "gold_pack" => 73, + "rare_gold_pack" => 74, + "icon_pack" => 75, + _ => return None, + }; + Some(id) +} + +/// 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 { + 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", + }; + // Group background texture: FIFA 17's all-groups landing (shown on first store + // entry) renders one tile per group whose art is the client-bundled + // `packs_backgrounds_%d.dds` selected by this field. LIVE-PROBED 2026-08-18: + // index 0 is blank; 1/2/3 render real pack art — so assign non-zero per + // category and that landing shows native art instead of blank shields. The + // persistent tabbed store draws its pack art from the packs themselves and + // does not depend on this. + let group_bg: u64 = if owned { + 3 + } else { + match p.category { + "gold" => 3, + "silver" => 2, + _ => 1, + } + }; + // My Packs cover art. LIVE-MAPPED on the retail client 2026-08-18 across all + // three store tabs and two reward tiles: + // * `assetId` only gates whether art renders AT ALL. A reward pack's own id + // (70-75) is not a known client asset, so its tile renders BLANK; any valid + // catalogue asset (1-6) makes art appear. + // * WHICH art is drawn comes from `packType` + `packContentInfo.rareQuantity`, + // not from `assetId`: BRONZE+1rare -> bronze card, BRONZE+3 -> silver, + // SILVER+1 -> gold, SILVER+3 -> silver trio, GOLD+1 -> blue special, + // GOLD+3 -> red inform. (Remapping assetId 5->3 and 3->2 left both frames + // unchanged and only rotated the featured player, which proves this.) + // So a reward tile automatically shows the same art as the equivalent + // purchasable pack; we only need a valid asset, and we use the tier's own store + // pack for clarity. `id` stays the pack's own id (the open packId / SERVER_ID). + let art_asset: u64 = if owned { + match p.category { + "gold" => 5, + "silver" => 3, + _ => 1, + } + } else { + p.id + }; let mut body = json!({ - "assetId": p.id, + "assetId": art_asset, "id": p.id, - "packType": if p.gold { "GOLD" } else { "BRONZE" }, + "packType": pack_type, "description": p.name, "state": "active", "saleType": "promo", @@ -127,26 +205,37 @@ pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value { "purchaseCount": 0, "isPremium": false, "sortPriority": idx, + "displayGroupAssetId": group_bg, + // 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". - obj.remove("currencies"); - obj.remove("extPrice"); + // Reward/My-Packs tiles KEEP the coins currency at the pack price (0 for + // reward packs). My Packs opens through the store purchase flow, so a tile + // with no currency row is not actionable — clicking navigates instead of + // opening. With a free coin row the client sends POST /purchased and the + // server (owned-only) opens it for free. No extPrice (that is the `or %1s` + // mtx bug), so the free coin row formats as "0", not an unavailable label. + // LIVE-PROVEN 2026-08-18: a reward Silver Pack opened and revealed cards. obj.insert( "displayGroup".into(), json!({ "value": "mypacks", "priority": idx }), @@ -165,8 +254,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, }; @@ -176,13 +268,19 @@ pub fn sentinel_body(idx: u64) -> Value { .expect("sentinel body is a JSON object"); obj.insert("state".into(), json!("active")); obj.insert("unopened".into(), json!(false)); + // The sentinel must stay non-openable (it is only a resolve-without-crash shim + // for an empty My Packs), so it keeps no purchase path. + obj.remove("currencies"); + // Keep the sentinel's own id as its asset: it must render as an inert blank + // placeholder, never borrow a real pack's cover. + obj.insert("assetId".into(), json!(SENTINEL_PACK_ID)); 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 +301,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 +313,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 +322,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 +331,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!( @@ -247,6 +346,68 @@ mod tests { assert!(PACK_CATALOG.iter().all(|p| p.id != SENTINEL_PACK_ID)); } + #[test] + fn reward_pack_definitions_resolve_to_openable_owned_packs() { + // Core reward services grant symbolic pack names; each must resolve to an + // owned-only catalogue pack so it renders as an openable My Packs tile. + for (def, want) in [ + ("bronze_pack", 71), + ("silver_pack", 72), + ("gold_pack", 73), + ("rare_gold_pack", 74), + ("icon_pack", 75), + ] { + let id = owned_pack_id_for_definition(def).expect("reward def resolves"); + assert_eq!(id, want); + assert!( + pack_by_id(id).unwrap().owned_only, + "a reward pack must be owned-only" + ); + } + // Imported numeric owned-pack ids resolve to themselves. + assert_eq!(owned_pack_id_for_definition("70"), Some(70)); + // Purchasable (non-owned) numeric ids and unknown names never resolve. + assert_eq!(owned_pack_id_for_definition("5"), None); + assert_eq!(owned_pack_id_for_definition("mystery_pack"), None); + } + + #[test] + fn reward_tiles_carry_a_renderable_cover_asset() { + // A reward pack's own id is not a client art asset, so its My Packs tile + // renders blank. Each reward tile must therefore carry a valid catalogue + // assetId (its tier's store pack) while `id` stays the open packId. + for (reward_id, want_asset) in [(71, 1), (72, 3), (73, 5), (74, 5), (75, 5)] { + let pack = pack_by_id(reward_id).expect("reward pack in catalogue"); + let tile = pack_body(pack, 1, true); + assert_eq!(tile["id"], reward_id, "open packId stays the pack's own id"); + assert_eq!( + tile["assetId"], want_asset, + "reward tile borrows its tier's store-pack cover asset" + ); + assert!( + pack_by_id(tile["assetId"].as_u64().unwrap()).is_some_and(|p| !p.owned_only), + "the cover asset must be a real purchasable catalogue pack" + ); + } + // The sentinel must NOT borrow a real cover — it stays an inert placeholder. + assert_eq!(sentinel_body(1)["assetId"], SENTINEL_PACK_ID); + } + + #[test] + fn symbolic_reward_pack_renders_as_openable_my_packs_tile() { + // A granted silver reward pack (resolved to id 72) must appear as an owned + // My Packs tile, not the non-openable sentinel shim. + let got = build_purchasegroup(&[72], StoreMode::Sentinel); + let packs = got["purchase"].as_array().unwrap(); + let reward = packs + .iter() + .find(|p| p["id"] == 72) + .expect("reward pack tile present"); + assert_eq!(reward["displayGroup"]["value"], "mypacks"); + assert!(reward["unopened"].as_bool().unwrap()); + assert!(packs.iter().all(|p| p["id"] != SENTINEL_PACK_ID)); + } + #[test] fn clean_v1_empty_emits_no_mypacks_group() { let got = build_purchasegroup(&[], StoreMode::CleanV1); @@ -256,13 +417,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..218d8a5 100644 --- a/openfut-adapter-fifa17/tests/fixtures/purchasegroup_pack70.json +++ b/openfut-adapter-fifa17/tests/fixtures/purchasegroup_pack70.json @@ -2,197 +2,228 @@ "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, + "displayGroupAssetId": 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, + "displayGroupAssetId": 1, + "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, + "displayGroupAssetId": 2, + "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, + "displayGroupAssetId": 2, + "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, + "displayGroupAssetId": 3, "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, + "displayGroupAssetId": 3, "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, + "displayGroupAssetId": 3, "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..7f03c91 100644 --- a/openfut-adapter-fifa17/tests/fixtures/purchasegroup_zero_clean.json +++ b/openfut-adapter-fifa17/tests/fixtures/purchasegroup_zero_clean.json @@ -2,171 +2,201 @@ "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, + "displayGroupAssetId": 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, + "displayGroupAssetId": 1, + "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, + "displayGroupAssetId": 2, + "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, + "displayGroupAssetId": 2, + "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, + "displayGroupAssetId": 3, "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, + "displayGroupAssetId": 3, "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..b2c61a6 100644 --- a/openfut-adapter-fifa17/tests/fixtures/purchasegroup_zero_sentinel.json +++ b/openfut-adapter-fifa17/tests/fixtures/purchasegroup_zero_sentinel.json @@ -2,197 +2,228 @@ "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, + "displayGroupAssetId": 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, + "displayGroupAssetId": 1, + "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, + "displayGroupAssetId": 2, + "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, + "displayGroupAssetId": 2, + "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, + "displayGroupAssetId": 3, "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, + "displayGroupAssetId": 3, "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, + "displayGroupAssetId": 3, "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..5344c51 100644 --- a/openfut-utas-host/src/economy_store.rs +++ b/openfut-utas-host/src/economy_store.rs @@ -31,7 +31,9 @@ use openfut_adapter_fifa17::fut::pack_content::{ generate_pack_contents, GeneratedCandidate, GeneratedCard, }; use openfut_adapter_fifa17::fut::squad::SquadWireResolver; -use openfut_adapter_fifa17::fut::store_catalog::{pack_by_id, PackDef}; +use openfut_adapter_fifa17::fut::store_catalog::{ + owned_pack_id_for_definition, pack_by_id, PackDef, +}; use crate::{ error_response, json_response, json_status, CoreAccess, CoreEconomy, CoreError, @@ -244,7 +246,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", + }, })) } @@ -269,11 +275,12 @@ pub fn handle_pack_open(body: &[u8], deps: &StoreDeps<'_>, rng: &mut impl Rng) - Ok(e) => e, Err(_) => return error_response(503, "core_unavailable"), }; - // The unopened pack instance is an entitlement whose definition id is the - // pack id. Absent → already consumed / never granted: honest empty reveal. + // The unopened pack instance is an entitlement whose definition id resolves + // to this owned-only pack id (a numeric id or a symbolic reward-pack name). + // Absent → already consumed / never granted: honest empty reveal. let ent = match ents .into_iter() - .find(|e| e.definition_id.parse::().ok() == Some(pid)) + .find(|e| owned_pack_id_for_definition(&e.definition_id) == Some(pid)) { Some(e) => e, None => return json_response(&json!({ "itemData": [] })), @@ -695,7 +702,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 +712,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 +736,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 +867,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] @@ -886,6 +893,31 @@ mod tests { assert_eq!(redeemed[0].1.len(), 11); // pack 70 count } + #[test] + fn open_symbolic_silver_reward_redeems_entitlement_without_debit() { + // A Core reward grant ("silver_pack") resolves to owned-only pack 72 and + // opens for free by consuming its entitlement — the SBC reward-pack fix. + let econ = RecEcon::with_entitlements(4600, &["silver_pack"]); + let pool = pool(); + let assets = FakeAssets::for_pool(&pool); + let ent = Fifa17Entities::default(); + let deps = store_deps(&econ, &assets, &ent, &pool); + let resp = handle_pack_open(&body(json!({ "packId": 72 })), &deps, &mut rng(12)); + assert_eq!(resp.status, 200); + let b: Value = serde_json::from_slice(&resp.body).unwrap(); + assert_eq!(b["packId"], 72); + assert_eq!(b["purchasePackType"], "SILVER"); + assert_eq!(econ.coins(), 4600, "a reward pack opens for free"); + assert!( + econ.entitlements.lock().is_empty(), + "the reward entitlement is consumed once" + ); + let redeemed = econ.redeemed.lock(); + assert_eq!(redeemed.len(), 1); + assert_eq!(redeemed[0].0, "e0"); + assert_eq!(redeemed[0].1.len(), 12); // 1 bronze + 11 silver + } + #[test] fn open_owned_70_twice_is_consume_once() { let econ = RecEcon::with_entitlements(4600, &["70"]); diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 956a011..edc0f3e 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -73,7 +73,9 @@ use openfut_adapter_fifa17::fut::squad_projection::{ project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput, SquadProjection, SquadProjectionInput, }; -use openfut_adapter_fifa17::fut::store_catalog::build_purchasegroup; +use openfut_adapter_fifa17::fut::store_catalog::{ + build_purchasegroup, owned_pack_id_for_definition, +}; use openfut_adapter_fifa17::fut::store_session::{ validate_capability, SessionStore, StoreMode, SENTINEL_PACK_ID, }; @@ -663,6 +665,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 +759,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 +1390,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 +1480,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 { @@ -2148,11 +2218,14 @@ pub fn handle_credits(econ: &dyn CoreEconomy) -> WireResponse { } } -/// Map Core entitlements to FIFA unopened pack ids (definition_id parsed as the -/// numeric pack id; unparseable entries are skipped, never faked). +/// Map Core entitlements to FIFA 17 unopened pack ids. `definition_id` is either +/// a numeric owned-only pack id (imported entitlements) or a symbolic reward-pack +/// name granted by Core's reward services; both resolve via +/// [`owned_pack_id_for_definition`]. Unresolvable entitlements are skipped, never +/// faked. fn entitlement_pack_ids(ents: &[EconomyEntitlement]) -> Vec { ents.iter() - .filter_map(|e| e.definition_id.parse::().ok()) + .filter_map(|e| owned_pack_id_for_definition(&e.definition_id)) .collect() } @@ -2372,30 +2445,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 +2488,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"