wip(fifa17): pre-existing SBC/economy candidate snapshot

Snapshot of the uncommitted economy/SBC candidate work that built the tested
sbc-host on top of e8d1c1d (NOT authored in this session; committed to leave a
clean tree). Covers pack_content, sbc, store_catalog, host economy_store/lib,
purchasegroup fixtures, economy integration/concurrency/differential tests,
and Cargo.lock. Content matches the running staging host binary.
This commit is contained in:
funman300
2026-08-19 20:12:06 +00:00
parent 3bb4814760
commit 8afb812338
12 changed files with 1189 additions and 665 deletions
Generated
+1
View File
@@ -3166,6 +3166,7 @@ dependencies = [
name = "openfut-bridge" name = "openfut-bridge"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"aes",
"anyhow", "anyhow",
"axum", "axum",
"bytes", "bytes",
+71 -49
View File
@@ -7,20 +7,16 @@
//! (only card ids that resolve in BOTH the FIFA catalogue and Core content), //! (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. //! mints the drawn cards into Core, and shapes them onto the wire.
//! //!
//! ## Parity note — Python `open_pack` / `_pack_body` //! ## Policy (real FUT 17 composition, DESIGNED odds)
//! (`fifa17-recon/tools/fut_store.py:689`, `utas_server.py:3474`) //!
//! 1. `open_pack(price, count, gold, tiers, special_chance)` deducts coins then //! A pack draws [`PackDef::count`] cards split across rating tiers by the pack's
//! draws `count` items (mostly players); the reveal body wraps them verbatim. //! `n_bronze`/`n_silver`/`n_gold` composition (the same numbers the tile shows in
//! 2. Non-tiered draws split the pool at rating 75 by `gold` (`p[1] >= 75 == gold`) //! `packContentInfo`), drawing with replacement from the candidate pool. Each pick
//! and fall back to the whole pool when that tier is empty (`... or PACK_POOL`). //! is biased toward a special version with probability `special_chance` (a DESIGNED
//! 3. Each drawn player becomes a special with probability `special_chance` //! placeholder — FUT 17 pack odds are unrecoverable). An empty tier falls back to
//! (`random.random() < special_chance`). //! the whole pool so a draw is always possible even when the pool lacks that tier.
//! 4. `FUT_PACK_MIX` swaps ~`count // 4` players for consumables/staff extras; //! `FUT_PACK_MIX` (consumable/staff extras) is deliberately OMITTED — Core
//! we deliberately OMIT that mix (Core candidates are player defs — players-only). //! candidates are player defs.
//! 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.
use rand::Rng; use rand::Rng;
@@ -77,50 +73,67 @@ pub struct GeneratedCard {
pub attributes: [u8; 6], pub attributes: [u8; 6],
} }
/// Draw `pack.count` cards from `pool` with the injected RNG. Pure and /// Draw a pack's cards from `pool` with the injected RNG. Pure and deterministic
/// deterministic under a seeded RNG. Returns an empty `Vec` (fail-closed) when /// under a seeded RNG. Returns an empty `Vec` (fail-closed) when the pool is empty
/// the pool is empty or the pack awards no cards. /// or the pack awards no cards.
/// ///
/// Policy (PLACEHOLDER — see the module parity note): draw with replacement from /// Draws the pack's per-tier composition (`n_gold` gold-tier, `n_silver` silver,
/// the pack's tier (`gold`), biasing each draw toward a special card with /// `n_bronze` bronze), biasing each pick toward a special with `special_chance`.
/// probability `special_chance`. An empty tier or partition falls back to the /// An empty tier falls back to the whole pool (so a draw is always possible).
/// next-wider set so a draw is always possible when the pool is non-empty.
pub fn generate_pack_contents( pub fn generate_pack_contents(
pack: &PackDef, pack: &PackDef,
rng: &mut impl Rng, rng: &mut impl Rng,
pool: &[GeneratedCandidate], pool: &[GeneratedCandidate],
) -> Vec<GeneratedCard> { ) -> Vec<GeneratedCard> {
if pool.is_empty() || pack.count == 0 { if pool.is_empty() || pack.count() == 0 {
return Vec::new(); return Vec::new();
} }
// Tier split: a gold pack draws gold-tier candidates, a non-gold pack draws let all: Vec<&GeneratedCandidate> = pool.iter().collect();
// non-gold; an empty tier falls back to the whole pool (oracle `... or POOL`). let gold: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating >= 75).collect();
let tier: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.gold == pack.gold).collect(); let silver: Vec<&GeneratedCandidate> =
let tier: Vec<&GeneratedCandidate> = if tier.is_empty() { pool.iter().filter(|c| (65..75).contains(&c.rating)).collect();
pool.iter().collect() let bronze: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating < 65).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 mut out = Vec::with_capacity(pack.count as usize); let mut out = Vec::with_capacity(pack.count() as usize);
for _ in 0..pack.count { 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<GeneratedCard>,
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 want_special = chance > 0.0 && rng.gen_bool(chance);
let sub: &[&GeneratedCandidate] = if want_special && !special.is_empty() { let sub: &[&GeneratedCandidate] = if want_special && !special.is_empty() {
&special &special
} else if !want_special && !normal.is_empty() { } else if !want_special && !normal.is_empty() {
&normal &normal
} else { } else {
&tier src
}; };
let pick = sub[rng.gen_range(0..sub.len())]; let pick = sub[rng.gen_range(0..sub.len())];
out.push(pick.to_card()); out.push(pick.to_card());
} }
out
} }
#[cfg(test)] #[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 { PackDef {
id, id,
name: "Test Pack", name: "Test Pack",
price: 1000, price: 1000,
count, n_bronze,
gold, n_silver,
n_gold,
rares: 0,
category: if n_gold > 0 {
"gold"
} else if n_silver > 0 {
"silver"
} else {
"bronze"
},
special_chance, special_chance,
owned_only: false, owned_only: false,
} }
@@ -171,7 +193,7 @@ mod tests {
#[test] #[test]
fn same_seed_same_output() { fn same_seed_same_output() {
let pool = pool(); 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 a = StdRng::seed_from_u64(42);
let mut b = StdRng::seed_from_u64(42); let mut b = StdRng::seed_from_u64(42);
assert_eq!( assert_eq!(
@@ -183,7 +205,7 @@ mod tests {
#[test] #[test]
fn different_seeds_can_diverge() { fn different_seeds_can_diverge() {
let pool = pool(); 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 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); 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. // 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> = let ids: std::collections::HashSet<&str> =
pool.iter().map(|c| c.card_id.as_str()).collect(); pool.iter().map(|c| c.card_id.as_str()).collect();
for &n in &[1u64, 5, 7, 11] { 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); let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(n), &pool);
assert_eq!(cards.len() as u64, n); assert_eq!(cards.len() as u64, n);
for c in &cards { for c in &cards {
@@ -212,7 +234,7 @@ mod tests {
#[test] #[test]
fn gold_pack_draws_only_gold_tier() { fn gold_pack_draws_only_gold_tier() {
let pool = pool(); 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); let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
assert!( assert!(
cards.iter().all(|c| c.rating >= 75), cards.iter().all(|c| c.rating >= 75),
@@ -223,7 +245,7 @@ mod tests {
#[test] #[test]
fn bronze_pack_draws_only_bronze_tier() { fn bronze_pack_draws_only_bronze_tier() {
let pool = pool(); 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); let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
assert!( assert!(
cards.iter().all(|c| c.rating < 75), cards.iter().all(|c| c.rating < 75),
@@ -239,7 +261,7 @@ mod tests {
.filter(|c| c.special) .filter(|c| c.special)
.map(|c| c.card_id.as_str()) .map(|c| c.card_id.as_str())
.collect(); .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); let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(3), &pool);
assert!(cards assert!(cards
.iter() .iter()
@@ -248,7 +270,7 @@ mod tests {
#[test] #[test]
fn empty_pool_fails_closed() { 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()); assert!(generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &[]).is_empty());
} }
} }
+113 -5
View File
@@ -66,7 +66,7 @@ pub fn sets_body(challenges: &[ChallengeView]) -> Value {
"description": challenge.description, "description": challenge.description,
"priority": challenge.identity.priority, "priority": challenge.identity.priority,
"challengesCount": 1, "challengesCount": 1,
"challengesCompletedCount": i64::from(challenge.times_completed > 0), "challengesCompletedCount": i64::from(!challenge.repeatable && challenge.times_completed > 0),
"awards": [], "awards": [],
"hidden": false, "hidden": false,
"endTime": 4_102_444_800_i64 "endTime": 4_102_444_800_i64
@@ -89,6 +89,17 @@ pub fn challenges_body(set_id: i64, challenges: &[ChallengeView]) -> Value {
.iter() .iter()
.filter(|challenge| challenge.identity.set_id == set_id) .filter(|challenge| challenge.identity.set_id == set_id)
.map(|challenge| { .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!({ json!({
"challengeId": challenge.identity.challenge_id, "challengeId": challenge.identity.challenge_id,
"setId": challenge.identity.set_id, "setId": challenge.identity.set_id,
@@ -103,7 +114,7 @@ pub fn challenges_body(set_id: i64, challenges: &[ChallengeView]) -> Value {
"repeatable": challenge.repeatable, "repeatable": challenge.repeatable,
"trophyId": 0, "trophyId": 0,
"status": "OPEN", "status": "OPEN",
"timesCompleted": challenge.times_completed, "timesCompleted": times_completed,
"awards": [], "awards": [],
"elgReq": [] "elgReq": []
}) })
@@ -178,9 +189,15 @@ impl std::error::Error for SbcWireError {}
/// Extract FIFA wire item ids from a saved/submitted challenge squad. /// Extract FIFA wire item ids from a saved/submitted challenge squad.
/// ///
/// Retail request captures for this body are unavailable. The parser therefore accepts /// The exact retail challenge-squad body is now captured (2026-08-18 Gate C,
/// only the two already-proven FIFA 17 squad containers: a normal `players` array or the /// `PUT /ut/game/fifa17/sbs/challenge/101/squad`): a fixed 23-entry `players`
/// SBC `squad` array. Within either, only `itemData.id` is interpreted. /// 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<Vec<i64>, SbcWireError> { pub fn parse_wire_item_ids(body: &[u8]) -> Result<Vec<i64>, SbcWireError> {
let root: Value = let root: Value =
serde_json::from_slice(body).map_err(|error| SbcWireError::Json(error.to_string()))?; serde_json::from_slice(body).map_err(|error| SbcWireError::Json(error.to_string()))?;
@@ -240,6 +257,52 @@ mod tests {
assert!(submit["grantedSetAwards"].is_array()); 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] #[test]
fn parser_accepts_only_known_squad_containers_and_item_ids() { fn parser_accepts_only_known_squad_containers_and_item_ids() {
let normal = br#"{"players":[{"index":0,"itemData":{"id":100000001}},{"index":1,"itemData":{"id":0}}]}"#; let normal = br#"{"players":[{"index":0,"itemData":{"id":100000001}},{"index":1,"itemData":{"id":0}}]}"#;
@@ -259,4 +322,49 @@ mod tests {
Err(SbcWireError::Json(_)) 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"
);
}
} }
+269 -108
View File
@@ -1,95 +1,112 @@
//! FIFA 17 Store pack catalogue + `/store/purchasegroup` wire shaping. //! FIFA 17 Store pack catalogue + `/store/purchasegroup` wire shaping.
//! //!
//! A faithful Rust port of the Python oracle's `PACK_CATALOG` + `_pack_body` + //! Rust is the authoritative store owner: [`build_purchasegroup`] is served live
//! `store_catalog` assembly (`fifa17-recon/tools/{fut_store,utas_server}.py`) at the //! by the host via `EconomyRoute::PurchaseGroup` over Core economy authority (Core
//! **production flag defaults** (`FUT_STORE_DISPLAYGROUP=1` on, `FUT_STORE_GROUPID=0` //! owns coins + unopened packs), so there is no Python dependency and no
//! off, `FUT_PRICE_PROBE=0` off). Parity is pinned by differential fixtures generated //! dual-write/split-brain.
//! from the Python oracle (`tests/fixtures/purchasegroup_*.json`).
//! //!
//! ## Scope / split-brain safety //! ## Relationship to the Python oracle
//! //!
//! This is **pure wire shaping** — no economy state, no IO. [`build_purchasegroup`] //! The wire *shape* was RE'd from the client and cross-checked against the Python
//! is a function of `(owned unopened pack ids, empty-My-Packs StoreMode)`. It is //! oracle's `_pack_body`/`store_catalog`
//! deliberately **not yet wired** into the live host: serving purchasegroup from Rust //! (`fifa17-recon/tools/{fut_store,utas_server}.py`). Rust now diverges from the
//! requires an authoritative Rust owner of `unopenedPackIds`, and today Python is the //! oracle where the RE proved the oracle wrong: it does NOT emit `extPrice`, whose
//! single writer of coins + unopened packs (BUY, quick-sell, rewards). Wiring this //! parser side-effect creates an `"mtx"` currency row and switches on the broken
//! before that economy authority exists would create a dual-write/split-brain. See //! `or %1s` FIFA-Points tile line (plan-2026-08-05-store-subsystem.md §3.4). The
//! the R3 economy-authority prerequisite in the vault (`Rust UTAS Migration`). //! Python oracle stays the rollback baseline and is never modified; the
//! `tests/fixtures/purchasegroup_*.json` goldens pin Rust's authoritative output.
//! //!
//! ## Economy-parameter provenance //! ## Economy-parameter provenance
//! //!
//! Prices, counts and odds are the current OpenFUT **PLACEHOLDER** economy, NOT //! Pack prices and tier composition are the real always-available FUT 17
//! EA-authentic (the overnight audit established the store economy is invented). The //! regular-store packs (community-documented on fifauteam). Pack ODDS
//! wire *shape* is EA-observed/oracle-verified; the *numbers* are placeholders. //! (`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 serde_json::{json, Value};
use crate::fut::store_session::{StoreMode, SENTINEL_PACK_ID}; use crate::fut::store_session::{StoreMode, SENTINEL_PACK_ID};
/// A FIFA 17 Store pack definition. Wire shape is oracle-verified; the economy /// A FIFA 17 Store pack definition. The wire *shape* is RE-verified; the price and
/// numbers (`price`/`count`/`special_chance`) are OpenFUT PLACEHOLDER, not EA-authentic. /// 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)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct PackDef { pub struct PackDef {
pub id: u64, pub id: u64,
pub name: &'static str, pub name: &'static str,
pub price: u64, pub price: u64,
pub count: u64, /// Cards awarded per rating-tier band: bronze `< 65`, silver `65..=74`, gold
pub gold: bool, /// `>= 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, pub special_chance: f64,
/// Reward-only pack (no purchase path): excluded from the normal catalogue, /// Reward-only pack (no purchase path): excluded from the normal catalogue,
/// rendered only when owned (in `unopenedPackIds`). /// rendered only when owned (in `unopenedPackIds`).
pub owned_only: bool, pub owned_only: bool,
} }
/// The current supported FIFA 17 pack catalogue (`fut_store.py:820`). Only observed/ impl PackDef {
/// currently-supported ids. The 65534 sentinel is deliberately ABSENT — it is a /// Total cards awarded / wire `itemQuantity` — the sum of the per-tier counts.
/// compatibility shim, never a catalogue pack (never purchasable/openable). 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] = &[ pub const PACK_CATALOG: &[PackDef] = &[
PackDef { // ── Bronze category ──
id: 1, PackDef { id: 1, name: "Bronze Pack", price: 400,
name: "Bronze Pack", n_bronze: 10, n_silver: 2, n_gold: 0, rares: 1, category: "bronze",
price: 400, special_chance: 0.01, owned_only: false },
count: 5, PackDef { id: 2, name: "Premium Bronze Pack", price: 750,
gold: false, n_bronze: 10, n_silver: 2, n_gold: 0, rares: 3, category: "bronze",
special_chance: 0.005, special_chance: 0.02, owned_only: false },
owned_only: false, // ── Silver category ──
}, PackDef { id: 3, name: "Silver Pack", price: 2500,
PackDef { n_bronze: 1, n_silver: 11, n_gold: 0, rares: 1, category: "silver",
id: 5, special_chance: 0.015, owned_only: false },
name: "Gold Pack", PackDef { id: 4, name: "Premium Silver Pack", price: 3750,
price: 5000, n_bronze: 1, n_silver: 11, n_gold: 0, rares: 3, category: "silver",
count: 7, special_chance: 0.03, owned_only: false },
gold: true, // ── Gold category ──
special_chance: 0.03, PackDef { id: 5, name: "Gold Pack", price: 5000,
owned_only: false, n_bronze: 0, n_silver: 2, n_gold: 10, rares: 1, category: "gold",
}, special_chance: 0.04, owned_only: false },
PackDef { PackDef { id: 6, name: "Premium Gold Pack", price: 7500,
id: 6, n_bronze: 0, n_silver: 2, n_gold: 10, rares: 3, category: "gold",
name: "Premium Gold", special_chance: 0.06, owned_only: false },
price: 15000, // ── Reward (owned-only; opened from My Packs, never coin-purchasable) ──
count: 11, PackDef { id: 70, name: "Reward Gold Pack", price: 0,
gold: true, n_bronze: 0, n_silver: 0, n_gold: 11, rares: 11, category: "gold",
special_chance: 0.08, special_chance: 1.0, owned_only: true },
owned_only: false, PackDef { id: 71, name: "Bronze Pack", price: 0,
}, n_bronze: 10, n_silver: 2, n_gold: 0, rares: 1, category: "bronze",
PackDef { special_chance: 0.01, owned_only: true },
id: 7, PackDef { id: 72, name: "Silver Pack", price: 0,
name: "Special Players Pack", n_bronze: 1, n_silver: 11, n_gold: 0, rares: 1, category: "silver",
price: 25000, special_chance: 0.02, owned_only: true },
count: 11, PackDef { id: 73, name: "Gold Pack", price: 0,
gold: true, n_bronze: 0, n_silver: 2, n_gold: 10, rares: 1, category: "gold",
special_chance: 1.0, special_chance: 0.05, owned_only: true },
owned_only: false, PackDef { id: 74, name: "Rare Gold Pack", price: 0,
}, n_bronze: 0, n_silver: 2, n_gold: 10, rares: 3, category: "gold",
PackDef { special_chance: 0.10, owned_only: true },
id: 70, PackDef { id: 75, name: "Icon Pack", price: 0,
name: "Reward Special Players Pack", n_bronze: 0, n_silver: 0, n_gold: 12, rares: 12, category: "gold",
price: 0, special_chance: 1.0, owned_only: true },
count: 11,
gold: true,
special_chance: 1.0,
owned_only: true,
},
]; ];
/// Look up a catalogue pack by id (the 65534 sentinel is never present). /// 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) PACK_CATALOG.iter().find(|p| p.id == id)
} }
/// The FIFA17 StoreFront category token for a NORMAL pack tile (`utas_server.py:3579`): /// Resolve a Core entitlement's opaque `definition_id` to the FIFA 17 numeric
/// one of the six hard-coded tokens the client resolves. /// owned-only pack it renders and opens as. Accepts a numeric id (imported
fn category(p: &PackDef) -> &'static str { /// entitlements, e.g. `"70"`) or one of the symbolic reward-pack names Core's
if p.special_chance >= 1.0 { /// reward services grant (SBC, draft, season, check-in, FUT Champions). Only
"special" /// owned-only packs qualify, so an unknown or non-reward entitlement resolves to
} else if p.gold { /// `None` and is simply not shown as an openable pack rather than faked.
"gold" pub fn owned_pack_id_for_definition(definition_id: &str) -> Option<u64> {
} else { if let Ok(numeric) = definition_id.parse::<u64>() {
"bronze" 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 /// One `purchase[]` entry — the faithful `_pack_body` port (`utas_server.py:3474`) at
/// production flag defaults. `owned` packs (My Packs / reward / sentinel) drop the /// production flag defaults. `owned` packs (My Packs / reward / sentinel) drop the
/// purchase fields and take the `mypacks` display group. /// purchase fields and take the `mypacks` display group.
pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value { 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!({ let mut body = json!({
"assetId": p.id, "assetId": art_asset,
"id": p.id, "id": p.id,
"packType": if p.gold { "GOLD" } else { "BRONZE" }, "packType": pack_type,
"description": p.name, "description": p.name,
"state": "active", "state": "active",
"saleType": "promo", "saleType": "promo",
@@ -127,26 +205,37 @@ pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value {
"purchaseCount": 0, "purchaseCount": 0,
"isPremium": false, "isPremium": false,
"sortPriority": idx, "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 }], "currencies": [{ "name": "coins", "funds": p.price, "finalFunds": p.price }],
"extPrice": {
"finalPrice": { "amount": mtx, "currency": "mtx" },
"originalPrice": { "amount": mtx, "currency": "mtx" },
},
"packContentInfo": { "packContentInfo": {
"bronzeQuantity": if p.gold { 0 } else { p.count }, "bronzeQuantity": p.n_bronze,
"silverQuantity": 0, "silverQuantity": p.n_silver,
"goldQuantity": if p.gold { p.count } else { 0 }, "goldQuantity": p.n_gold,
"rareQuantity": if p.gold { p.count } else { 0 }, "rareQuantity": p.rares,
"itemQuantity": p.count, "itemQuantity": p.count(),
}, },
"unopened": owned, "unopened": owned,
}); });
let obj = body.as_object_mut().expect("pack body is a JSON object"); let obj = body.as_object_mut().expect("pack body is a JSON object");
if owned { if owned {
// Reward/My-Packs tiles have no purchase path; leaving zero-value coin/mtx // Reward/My-Packs tiles KEEP the coins currency at the pack price (0 for
// objects makes the client render the price label as literal "undefined". // reward packs). My Packs opens through the store purchase flow, so a tile
obj.remove("currencies"); // with no currency row is not actionable — clicking navigates instead of
obj.remove("extPrice"); // 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( obj.insert(
"displayGroup".into(), "displayGroup".into(),
json!({ "value": "mypacks", "priority": idx }), json!({ "value": "mypacks", "priority": idx }),
@@ -165,8 +254,11 @@ pub fn sentinel_body(idx: u64) -> Value {
id: SENTINEL_PACK_ID, id: SENTINEL_PACK_ID,
name: "", name: "",
price: 0, price: 0,
count: 0, n_bronze: 0,
gold: true, n_silver: 0,
n_gold: 0,
rares: 0,
category: "gold",
special_chance: 0.0, special_chance: 0.0,
owned_only: true, owned_only: true,
}; };
@@ -176,13 +268,19 @@ pub fn sentinel_body(idx: u64) -> Value {
.expect("sentinel body is a JSON object"); .expect("sentinel body is a JSON object");
obj.insert("state".into(), json!("active")); obj.insert("state".into(), json!("active"));
obj.insert("unopened".into(), json!(false)); 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 body
} }
/// Build the full `/store/purchasegroup` body from the authoritative unopened-pack ids /// Build the full `/store/purchasegroup` body from the authoritative unopened-pack
/// and the frozen empty-My-Packs mode. Pure — mirrors `store_catalog` (`3627`): /// ids and the frozen empty-My-Packs mode. Pure: the six regular packs (ids 16)
/// normal packs (1,5,6,7) first, then any owned packs, then the empty-My-Packs shim /// first, then any owned packs, then the empty-My-Packs shim (sentinel for
/// (sentinel for [`StoreMode::Sentinel`], nothing for [`StoreMode::CleanV1`]). /// [`StoreMode::Sentinel`], nothing for [`StoreMode::CleanV1`]).
pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value { pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value {
let mut packs: Vec<Value> = PACK_CATALOG let mut packs: Vec<Value> = PACK_CATALOG
.iter() .iter()
@@ -203,10 +301,11 @@ pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
//! Differential parity against the Python oracle. The fixtures under //! Golden tests pinning the authoritative Rust `/store/purchasegroup` body. The
//! `tests/fixtures/purchasegroup_*.json` are generated by calling the oracle's //! fixtures under `tests/fixtures/purchasegroup_*.json` are Rust's own output
//! `_pack_body`/`store_catalog` at production flag defaults; Rust must match //! (object key order is irrelevant to `serde_json::Value` eq). They track the
//! them semantically (object key order is irrelevant to `serde_json::Value` eq). //! RE-driven divergence from the Python oracle — notably no `extPrice` (see the
//! module header).
use super::*; use super::*;
fn parse(s: &str) -> Value { fn parse(s: &str) -> Value {
@@ -214,7 +313,7 @@ mod tests {
} }
#[test] #[test]
fn purchasegroup_zero_sentinel_matches_oracle() { fn purchasegroup_zero_sentinel_matches_golden() {
let got = build_purchasegroup(&[], StoreMode::Sentinel); let got = build_purchasegroup(&[], StoreMode::Sentinel);
let want = parse(include_str!( let want = parse(include_str!(
"../../tests/fixtures/purchasegroup_zero_sentinel.json" "../../tests/fixtures/purchasegroup_zero_sentinel.json"
@@ -223,7 +322,7 @@ mod tests {
} }
#[test] #[test]
fn purchasegroup_zero_clean_matches_oracle() { fn purchasegroup_zero_clean_matches_golden() {
let got = build_purchasegroup(&[], StoreMode::CleanV1); let got = build_purchasegroup(&[], StoreMode::CleanV1);
let want = parse(include_str!( let want = parse(include_str!(
"../../tests/fixtures/purchasegroup_zero_clean.json" "../../tests/fixtures/purchasegroup_zero_clean.json"
@@ -232,7 +331,7 @@ mod tests {
} }
#[test] #[test]
fn purchasegroup_pack70_matches_oracle() { fn purchasegroup_pack70_matches_golden() {
// Owned pack present -> no sentinel regardless of mode. // Owned pack present -> no sentinel regardless of mode.
let got = build_purchasegroup(&[70], StoreMode::Sentinel); let got = build_purchasegroup(&[70], StoreMode::Sentinel);
let want = parse(include_str!( let want = parse(include_str!(
@@ -247,6 +346,68 @@ mod tests {
assert!(PACK_CATALOG.iter().all(|p| p.id != SENTINEL_PACK_ID)); 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] #[test]
fn clean_v1_empty_emits_no_mypacks_group() { fn clean_v1_empty_emits_no_mypacks_group() {
let got = build_purchasegroup(&[], StoreMode::CleanV1); let got = build_purchasegroup(&[], StoreMode::CleanV1);
@@ -256,13 +417,13 @@ mod tests {
.iter() .iter()
.map(|e| e["id"].as_u64().unwrap()) .map(|e| e["id"].as_u64().unwrap())
.collect(); .collect();
assert_eq!(ids, vec![1, 5, 6, 7]); assert_eq!(ids, vec![1, 2, 3, 4, 5, 6]);
} }
#[test] #[test]
fn category_tokens_are_canonical() { fn category_tokens_are_canonical() {
assert_eq!(category(pack_by_id(1).unwrap()), "bronze"); 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(5).unwrap()), "gold");
assert_eq!(category(pack_by_id(7).unwrap()), "special");
} }
} }
+180 -149
View File
@@ -2,197 +2,228 @@
"purchase": [ "purchase": [
{ {
"assetId": 1, "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": [ "currencies": [
{ {
"finalFunds": 400, "name": "coins",
"funds": 400, "funds": 400,
"name": "coins" "finalFunds": 400
} }
], ],
"description": "Bronze Pack", "packContentInfo": {
"bronzeQuantity": 10,
"silverQuantity": 2,
"goldQuantity": 0,
"rareQuantity": 1,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": { "displayGroup": {
"value": "bronze" "value": "bronze"
},
"extPrice": {
"finalPrice": {
"amount": 4,
"currency": "mtx"
},
"originalPrice": {
"amount": 4,
"currency": "mtx"
} }
}, },
"id": 1, {
"isPremium": false, "assetId": 2,
"limitType": "NONE", "id": 2,
"packContentInfo": {
"bronzeQuantity": 5,
"goldQuantity": 0,
"itemQuantity": 5,
"rareQuantity": 0,
"silverQuantity": 0
},
"packType": "BRONZE", "packType": "BRONZE",
"purchaseCount": 0, "description": "Premium Bronze Pack",
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"state": "active", "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, "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": [ "currencies": [
{ {
"finalFunds": 5000, "name": "coins",
"funds": 5000, "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": { "packContentInfo": {
"bronzeQuantity": 0, "bronzeQuantity": 0,
"goldQuantity": 7, "silverQuantity": 2,
"itemQuantity": 7, "goldQuantity": 10,
"rareQuantity": 7, "rareQuantity": 1,
"silverQuantity": 0 "itemQuantity": 12
}, },
"packType": "GOLD", "unopened": false,
"purchaseCount": 0, "displayGroup": {
"purchaseLimit": 0, "value": "gold"
"quantity": 0, }
"saleType": "promo",
"sortPriority": 2,
"state": "active",
"unopened": false
}, },
{ {
"assetId": 6, "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": [ "currencies": [
{ {
"finalFunds": 15000, "name": "coins",
"funds": 15000, "funds": 7500,
"name": "coins" "finalFunds": 7500
} }
], ],
"description": "Premium Gold", "packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 2,
"goldQuantity": 10,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": { "displayGroup": {
"value": "gold" "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, "assetId": 70,
"description": "Reward Special Players Pack",
"displayGroup": {
"priority": 1,
"value": "mypacks"
},
"id": 70, "id": 70,
"isPremium": false, "packType": "GOLD",
"description": "Reward Gold Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE", "limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 1,
"displayGroupAssetId": 3,
"packContentInfo": { "packContentInfo": {
"bronzeQuantity": 0, "bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 11, "goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11, "rareQuantity": 11,
"silverQuantity": 0 "itemQuantity": 11
}, },
"packType": "GOLD", "unopened": true,
"purchaseCount": 0, "displayGroup": {
"purchaseLimit": 0, "value": "mypacks",
"quantity": 0, "priority": 1
"saleType": "promo", }
"sortPriority": 1,
"state": "active",
"unopened": true
} }
], ],
"timestamp": 1596326400 "timestamp": 1596326400
@@ -2,171 +2,201 @@
"purchase": [ "purchase": [
{ {
"assetId": 1, "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": [ "currencies": [
{ {
"finalFunds": 400, "name": "coins",
"funds": 400, "funds": 400,
"name": "coins" "finalFunds": 400
} }
], ],
"description": "Bronze Pack", "packContentInfo": {
"bronzeQuantity": 10,
"silverQuantity": 2,
"goldQuantity": 0,
"rareQuantity": 1,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": { "displayGroup": {
"value": "bronze" "value": "bronze"
},
"extPrice": {
"finalPrice": {
"amount": 4,
"currency": "mtx"
},
"originalPrice": {
"amount": 4,
"currency": "mtx"
} }
}, },
"id": 1, {
"isPremium": false, "assetId": 2,
"limitType": "NONE", "id": 2,
"packContentInfo": {
"bronzeQuantity": 5,
"goldQuantity": 0,
"itemQuantity": 5,
"rareQuantity": 0,
"silverQuantity": 0
},
"packType": "BRONZE", "packType": "BRONZE",
"purchaseCount": 0, "description": "Premium Bronze Pack",
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"state": "active", "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, "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": [ "currencies": [
{ {
"finalFunds": 5000, "name": "coins",
"funds": 5000, "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": { "packContentInfo": {
"bronzeQuantity": 0, "bronzeQuantity": 0,
"goldQuantity": 7, "silverQuantity": 2,
"itemQuantity": 7, "goldQuantity": 10,
"rareQuantity": 7, "rareQuantity": 1,
"silverQuantity": 0 "itemQuantity": 12
}, },
"packType": "GOLD", "unopened": false,
"purchaseCount": 0, "displayGroup": {
"purchaseLimit": 0, "value": "gold"
"quantity": 0, }
"saleType": "promo",
"sortPriority": 2,
"state": "active",
"unopened": false
}, },
{ {
"assetId": 6, "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": [ "currencies": [
{ {
"finalFunds": 15000, "name": "coins",
"funds": 15000, "funds": 7500,
"name": "coins" "finalFunds": 7500
} }
], ],
"description": "Premium Gold", "packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 2,
"goldQuantity": 10,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": { "displayGroup": {
"value": "gold" "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 "timestamp": 1596326400
@@ -2,197 +2,228 @@
"purchase": [ "purchase": [
{ {
"assetId": 1, "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": [ "currencies": [
{ {
"finalFunds": 400, "name": "coins",
"funds": 400, "funds": 400,
"name": "coins" "finalFunds": 400
} }
], ],
"description": "Bronze Pack", "packContentInfo": {
"bronzeQuantity": 10,
"silverQuantity": 2,
"goldQuantity": 0,
"rareQuantity": 1,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": { "displayGroup": {
"value": "bronze" "value": "bronze"
},
"extPrice": {
"finalPrice": {
"amount": 4,
"currency": "mtx"
},
"originalPrice": {
"amount": 4,
"currency": "mtx"
} }
}, },
"id": 1, {
"isPremium": false, "assetId": 2,
"limitType": "NONE", "id": 2,
"packContentInfo": {
"bronzeQuantity": 5,
"goldQuantity": 0,
"itemQuantity": 5,
"rareQuantity": 0,
"silverQuantity": 0
},
"packType": "BRONZE", "packType": "BRONZE",
"purchaseCount": 0, "description": "Premium Bronze Pack",
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"state": "active", "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, "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": [ "currencies": [
{ {
"finalFunds": 5000, "name": "coins",
"funds": 5000, "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": { "packContentInfo": {
"bronzeQuantity": 0, "bronzeQuantity": 0,
"goldQuantity": 7, "silverQuantity": 2,
"itemQuantity": 7, "goldQuantity": 10,
"rareQuantity": 7, "rareQuantity": 1,
"silverQuantity": 0 "itemQuantity": 12
}, },
"packType": "GOLD", "unopened": false,
"purchaseCount": 0, "displayGroup": {
"purchaseLimit": 0, "value": "gold"
"quantity": 0, }
"saleType": "promo",
"sortPriority": 2,
"state": "active",
"unopened": false
}, },
{ {
"assetId": 6, "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": [ "currencies": [
{ {
"finalFunds": 15000, "name": "coins",
"funds": 15000, "funds": 7500,
"name": "coins" "finalFunds": 7500
} }
], ],
"description": "Premium Gold", "packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 2,
"goldQuantity": 10,
"rareQuantity": 3,
"itemQuantity": 12
},
"unopened": false,
"displayGroup": { "displayGroup": {
"value": "gold" "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, "assetId": 65534,
"description": "",
"displayGroup": {
"priority": 1,
"value": "mypacks"
},
"id": 65534, "id": 65534,
"isPremium": false, "packType": "GOLD",
"description": "",
"state": "active",
"saleType": "promo",
"limitType": "NONE", "limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 1,
"displayGroupAssetId": 3,
"packContentInfo": { "packContentInfo": {
"bronzeQuantity": 0, "bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 0, "goldQuantity": 0,
"itemQuantity": 0,
"rareQuantity": 0, "rareQuantity": 0,
"silverQuantity": 0 "itemQuantity": 0
}, },
"packType": "GOLD", "unopened": false,
"purchaseCount": 0, "displayGroup": {
"purchaseLimit": 0, "value": "mypacks",
"quantity": 0, "priority": 1
"saleType": "promo", }
"sortPriority": 1,
"state": "active",
"unopened": false
} }
], ],
"timestamp": 1596326400 "timestamp": 1596326400
+45 -13
View File
@@ -31,7 +31,9 @@ use openfut_adapter_fifa17::fut::pack_content::{
generate_pack_contents, GeneratedCandidate, GeneratedCard, generate_pack_contents, GeneratedCandidate, GeneratedCard,
}; };
use openfut_adapter_fifa17::fut::squad::SquadWireResolver; 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::{ use crate::{
error_response, json_response, json_status, CoreAccess, CoreEconomy, CoreError, error_response, json_response, json_status, CoreAccess, CoreEconomy, CoreError,
@@ -244,7 +246,11 @@ fn pack_open_body(pid: u64, pack: &PackDef) -> WireResponse {
"firstPartyStoreId": 0, "firstPartyStoreId": 0,
"groupName": "fifa17", "groupName": "fifa17",
"productId": pid.to_string(), "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, Ok(e) => e,
Err(_) => return error_response(503, "core_unavailable"), Err(_) => return error_response(503, "core_unavailable"),
}; };
// The unopened pack instance is an entitlement whose definition id is the // The unopened pack instance is an entitlement whose definition id resolves
// pack id. Absent → already consumed / never granted: honest empty reveal. // 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 let ent = match ents
.into_iter() .into_iter()
.find(|e| e.definition_id.parse::<u64>().ok() == Some(pid)) .find(|e| owned_pack_id_for_definition(&e.definition_id) == Some(pid))
{ {
Some(e) => e, Some(e) => e,
None => return json_response(&json!({ "itemData": [] })), None => return json_response(&json!({ "itemData": [] })),
@@ -695,7 +702,7 @@ mod tests {
// ── Store BUY ────────────────────────────────────────────────────────── // ── Store BUY ──────────────────────────────────────────────────────────
#[test] #[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 econ = RecEcon::new(10_000);
let pool = pool(); let pool = pool();
let assets = FakeAssets::for_pool(&pool); let assets = FakeAssets::for_pool(&pool);
@@ -705,23 +712,23 @@ mod tests {
assert_eq!(resp.status, 200); assert_eq!(resp.status, 200);
let b: Value = serde_json::from_slice(&resp.body).unwrap(); let b: Value = serde_json::from_slice(&resp.body).unwrap();
let cpr = &b["createPackResponse"]; let cpr = &b["createPackResponse"];
assert_eq!(cpr["numberItems"], 5); // pack 1 count assert_eq!(cpr["numberItems"], 12); // pack 1 count (10 bronze + 2 silver)
assert_eq!(cpr["itemList"].as_array().unwrap().len(), 5); assert_eq!(cpr["itemList"].as_array().unwrap().len(), 12);
assert_eq!(cpr["purchasedPackId"], 1); assert_eq!(cpr["purchasedPackId"], 1);
assert_eq!(cpr["duplicateItemIdList"], json!([])); 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); assert_eq!(econ.coins(), 10_000 - 400);
let purchased = econ.purchased.lock(); let purchased = econ.purchased.lock();
assert_eq!(purchased.len(), 1); assert_eq!(purchased.len(), 1);
assert_eq!(purchased[0].0, 400); 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 { for g in &purchased[0].1 {
assert!(pool.iter().any(|c| c.card_id == g.card_id)); assert!(pool.iter().any(|c| c.card_id == g.card_id));
} }
} }
#[test] #[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 econ = RecEcon::new(10_000);
let pool = pool(); let pool = pool();
let assets = FakeAssets::for_pool(&pool); let assets = FakeAssets::for_pool(&pool);
@@ -729,7 +736,7 @@ mod tests {
let deps = store_deps(&econ, &assets, &ent, &pool); let deps = store_deps(&econ, &assets, &ent, &pool);
let resp = handle_store_buy(&body(json!({ "packId": 5 })), &deps, &mut rng(2)); let resp = handle_store_buy(&body(json!({ "packId": 5 })), &deps, &mut rng(2));
let b: Value = serde_json::from_slice(&resp.body).unwrap(); 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); assert_eq!(econ.coins(), 10_000 - 5000);
} }
@@ -860,7 +867,7 @@ mod tests {
assert_eq!(b["firstPartyStoreId"], 0); assert_eq!(b["firstPartyStoreId"], 0);
assert_eq!(b["purchasePackType"], "GOLD"); // pack 5 is gold assert_eq!(b["purchasePackType"], "GOLD"); // pack 5 is gold
assert_eq!(econ.coins(), 10_000 - 5000); 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] #[test]
@@ -886,6 +893,31 @@ mod tests {
assert_eq!(redeemed[0].1.len(), 11); // pack 70 count 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] #[test]
fn open_owned_70_twice_is_consume_once() { fn open_owned_70_twice_is_consume_once() {
let econ = RecEcon::with_entitlements(4600, &["70"]); let econ = RecEcon::with_entitlements(4600, &["70"]);
+98 -15
View File
@@ -73,7 +73,9 @@ use openfut_adapter_fifa17::fut::squad_projection::{
project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput, project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
SquadProjection, SquadProjectionInput, 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::{ use openfut_adapter_fifa17::fut::store_session::{
validate_capability, SessionStore, StoreMode, SENTINEL_PACK_ID, validate_capability, SessionStore, StoreMode, SENTINEL_PACK_ID,
}; };
@@ -663,6 +665,17 @@ pub trait CoreAccess: Send + Sync {
Ok(self.query_owned(&[])?.items) 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<Vec<CoreOwnedItem>, CoreError> {
Err(CoreError::Parse(
"Core content enumeration is not implemented".into(),
))
}
/// Read the active squad + its opaque extension for `namespace`. /// Read the active squad + its opaque extension for `namespace`.
fn read_squad_ext(&self, namespace: &str) -> Result<CoreSquadRead, CoreError>; fn read_squad_ext(&self, namespace: &str) -> Result<CoreSquadRead, CoreError>;
@@ -746,6 +759,22 @@ impl CoreAccess for HttpCoreClient {
let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?; let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?;
parse_core_page(&v) parse_core_page(&v)
} }
fn all_definitions(&self) -> Result<Vec<CoreOwnedItem>, 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<CoreSquadRead, CoreError> { fn read_squad_ext(&self, namespace: &str) -> Result<CoreSquadRead, CoreError> {
let url = format!("{}/squad/ext", self.base_url); let url = format!("{}/squad/ext", self.base_url);
let resp = self let resp = self
@@ -1361,6 +1390,38 @@ fn core_item_from_json(e: &Value) -> Option<CoreOwnedItem> {
}) })
} }
/// 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<Vec<CoreOwnedItem>, 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<CoreOwnedItem> {
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 ─────────────────────── // ───────────────────────────── Item identity resolver ───────────────────────
/// The single production [`ItemIdentityResolver`]: it composes the two distinct /// The single production [`ItemIdentityResolver`]: it composes the two distinct
@@ -1419,6 +1480,15 @@ impl Fifa17IdentityResolver {
.map(|c| c.rareflag) .map(|c| c.rareflag)
.unwrap_or(0) .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 { 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 /// Map Core entitlements to FIFA 17 unopened pack ids. `definition_id` is either
/// numeric pack id; unparseable entries are skipped, never faked). /// 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<u64> { fn entitlement_pack_ids(ents: &[EconomyEntitlement]) -> Vec<u64> {
ents.iter() ents.iter()
.filter_map(|e| e.definition_id.parse::<u64>().ok()) .filter_map(|e| owned_pack_id_for_definition(&e.definition_id))
.collect() .collect()
} }
@@ -2372,30 +2445,40 @@ pub struct EconomyServices {
pub sold_experiment: crate::sold_experiment::SoldExperiment, pub sold_experiment: crate::sold_experiment::SoldExperiment,
} }
/// Build the pack-content candidate pool from Core's current content, evidenced /// Build the pack-content candidate pool. Prefers the FULL card universe (Core
/// by the owned inventory: every distinct owned card definition that resolves to /// content, `GET /cards` via [`CoreAccess::all_definitions`]) so a pack can award
/// a real FIFA asset id is a candidate (`gold` = rating ≥ 75; `special` from the /// any card in the game, not only cards the profile already owns; falls back to
/// catalog `rareflag > 1`). This is the resolvable FIFA∩Core card universe the /// owned inventory when content enumeration is unavailable (older Core / tests).
/// cards a pack can award and the shared shaper can render. An empty pool (no /// Each candidate resolves in the FIFA catalog (unmapped or non-player cards are
/// content, or Core unreachable) is fail-closed by construction: the generator /// dropped, never faked); `gold` = rating ≥ 75, `special` = catalog `rareflag > 1`.
/// returns no cards, so the Store neither mints nor debits. /// 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( pub fn build_content_pool(
core: &dyn CoreAccess, core: &dyn CoreAccess,
resolver: &Fifa17IdentityResolver, resolver: &Fifa17IdentityResolver,
) -> Vec<GeneratedCandidate> { ) -> Vec<GeneratedCandidate> {
let owned = match core.all_owned() { // 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, Ok(v) => v,
Err(_) => return Vec::new(), Err(_) => return Vec::new(),
},
}; };
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();
let mut pool = Vec::new(); let mut pool = Vec::new();
for item in &owned { for item in &items {
if !seen.insert(item.card_id.clone()) { if !seen.insert(item.card_id.clone()) {
continue; 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; continue;
}; };
if kind != ContentKind::Player {
continue;
}
pool.push(GeneratedCandidate { pool.push(GeneratedCandidate {
card_id: item.card_id.clone(), card_id: item.card_id.clone(),
rating: item.rating, rating: item.rating,
@@ -2405,7 +2488,7 @@ pub fn build_content_pool(
club: item.club.clone(), club: item.club.clone(),
attributes: item.attributes, attributes: item.attributes,
gold: item.rating >= 75, gold: item.rating >= 75,
special: id.rareflag > 1, special: rareflag > 1,
}); });
} }
pool pool
@@ -311,7 +311,7 @@ fn case_a_two_buys(h: &Harness) -> String {
refs, 1, refs, 1,
"exactly one BUY refused 461 (statuses {statuses:?})" "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 { for r in &rs {
if r.status == 200 { if r.status == 200 {
assert_eq!( assert_eq!(
@@ -319,7 +319,7 @@ fn case_a_two_buys(h: &Harness) -> String {
.as_array() .as_array()
.unwrap() .unwrap()
.len(), .len(),
5 12
); );
} }
} }
+51 -26
View File
@@ -26,15 +26,15 @@
//! | userMassInfo economy | PARITY | `userInfo.currencies[coins].funds == credits coins`; both | //! | userMassInfo economy | PARITY | `userInfo.currencies[coins].funds == credits coins`; both |
//! | | | carry `unopenedPacks.recoveredPacks==1`. Coins consistent | //! | | | carry `unopenedPacks.recoveredPacks==1`. Coins consistent |
//! | | | across the credits & massinfo surfaces on each side. | //! | | | 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 pack70 | DIFFERENT-BY-DESIGN| STORE DIVERGED: Rust serves the real 6-pack catalogue |
//! | purchasegroup sentinel | PARITY | empty My Packs + unverified session -> {1,5,6,7,65534}, | //! | | | {1,2,3,4,5,6,70}; oracle (rollback) keeps {1,5,6,7,70}. |
//! | | | sentinel `state:"active"`. | //! | purchasegroup sentinel | DIFFERENT-BY-DESIGN| empty My Packs + unverified -> rust {1..6,65534} vs oracle |
//! | purchasegroup clean-v1 | PARITY | empty My Packs + verified capability session -> {1,5,6,7}, | //! | | | {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 | //! | | | sentinel stripped. Rust drives the REAL `SessionStore` state |
//! | | | machine (register_capability+open_session+freeze) exactly as | //! | | | machine (register_capability+open_session+freeze). |
//! | | | the oracle's launcher/auth handshake does. | //! | Store BUY (pack 1) | DIFFERENT-BY-DESIGN| 200; rust real Bronze Pack -> itemList(12),numberItems:12; |
//! | Store BUY (pack 1) | PARITY | 200; `createPackResponse{itemList(5),numberItems:5, | //! | | | oracle placeholder -> 5; both debit 400 + purchasedPackId 1. |
//! | | | purchasedPackId,duplicateItemIdList}`; coin delta -400. |
//! | POST /purchased open70 | PARITY | 200; envelope `{packId:70,firstPartyStoreId,productId:"70", | //! | POST /purchased open70 | PARITY | 200; envelope `{packId:70,firstPartyStoreId,productId:"70", |
//! | | | purchasePackType:"GOLD"}`; coin delta 0; entitlement -1. | //! | | | purchasePackType:"GOLD"}`; coin delta 0; entitlement -1. |
//! | GET /purchased reveal | PARITY | Durable single-profile purchased pile on BOTH (Rust reveal | //! | 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, None,
) )
.1; .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!( assert_eq!(
pack_ids(&opg), pack_ids(&opg),
vec![1, 5, 6, 7, 70], vec![1, 5, 6, 7, 70],
"oracle owned pack70 id set" "oracle (rollback) placeholder id set"
); );
assert_eq!( assert_eq!(
pack_ids(&rpg), pack_ids(&rpg),
vec![1, 5, 6, 7, 70], vec![1, 2, 3, 4, 5, 6, 70],
"rust owned pack70 id set" "rust authoritative real-catalogue id set"
); );
for b in [&opg, &rpg] { for b in [&opg, &rpg] {
assert!( assert!(
!pack_ids(b).contains(&SENTINEL_PACK_ID), !pack_ids(b).contains(&SENTINEL_PACK_ID),
"no 65534 sentinel while a pack is owned" "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() { // 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 id = p["id"].as_u64().unwrap();
let want = if id == 1 { "BRONZE" } else { "GOLD" }; let want = if id == 1 { "BRONZE" } else { "GOLD" };
assert_eq!(p["packType"], want, "packType parity for pack {id}"); 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", "PARITY")); matrix.push(("purchasegroup pack70", "DIFFERENT-BY-DESIGN"));
// ── OP 4: Store BUY (pack 1 Bronze, price 400, 5 cards) ──────────────── // ── OP 4: Store BUY (pack 1 Bronze, price 400, 5 cards) ────────────────
let o_bal0 = oracle.coins(); let o_bal0 = oracle.coins();
@@ -600,11 +614,22 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
.as_array() .as_array()
.expect("rust itemList") .expect("rust itemList")
.clone(); .clone();
assert_eq!(o_items.len(), 5, "oracle pack1 -> 5 cards"); // STORE DIVERGED: rust serves the real Bronze Pack (10+2 = 12 cards); the
assert_eq!(r_items.len(), 5, "rust pack1 -> 5 cards"); // 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] { for b in [&o_buy, &r_buy] {
let cpr = &b["createPackResponse"]; let cpr = &b["createPackResponse"];
assert_eq!(cpr["numberItems"].as_i64().unwrap(), 5, "numberItems==5");
assert_eq!( assert_eq!(
cpr["purchasedPackId"].as_i64().unwrap(), cpr["purchasedPackId"].as_i64().unwrap(),
1, 1,
@@ -619,9 +644,9 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
assert_eq!( assert_eq!(
client.balance().unwrap() - r_bal0, client.balance().unwrap() - r_bal0,
-400, -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 // Minted wire ids for the item ops that follow (both sides put them in the
// pending purchased pile). // pending purchased pile).
let o_wire: Vec<i64> = o_items.iter().map(|i| i["id"].as_i64().unwrap()).collect(); let o_wire: Vec<i64> = 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!( assert_eq!(
pack_ids(&r_pg_s), pack_ids(&r_pg_s),
vec![1, 5, 6, 7, SENTINEL_PACK_ID], vec![1, 2, 3, 4, 5, 6, SENTINEL_PACK_ID],
"rust empty (unknown SID) -> sentinel" "rust empty (unknown SID) -> real catalogue + sentinel"
); );
for b in [&o_pg_s, &r_pg_s] { for b in [&o_pg_s, &r_pg_s] {
let s = b["purchase"] let s = b["purchase"]
@@ -1188,7 +1213,7 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
.unwrap(); .unwrap();
assert_eq!(s["state"], "active", "sentinel state active"); 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) ─ // ── OP 3c: purchasegroup — clean-v1 (empty + verified capability session) ─
// Oracle: register the launcher capability, open a session (auth), present the // 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(); let r_pg_c: Value = serde_json::from_slice(&r_pg_c_resp.body).unwrap();
assert_eq!( assert_eq!(
pack_ids(&r_pg_c), pack_ids(&r_pg_c),
vec![1, 5, 6, 7], vec![1, 2, 3, 4, 5, 6],
"rust clean-v1 strips the sentinel" "rust clean-v1 real catalogue, sentinel stripped"
); );
assert!(!pack_ids(&r_pg_c).contains(&SENTINEL_PACK_ID)); 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. ──────────────────────────────────── // ── Emit the matrix for the run log. ────────────────────────────────────
eprintln!("\n===== economy_differential PARITY / DIFFERENT-BY-DESIGN matrix ====="); eprintln!("\n===== economy_differential PARITY / DIFFERENT-BY-DESIGN matrix =====");
@@ -373,8 +373,8 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
.as_array() .as_array()
.expect("itemList") .expect("itemList")
.clone(); .clone();
assert_eq!(items.len(), 5, "pack 1 awards 5 cards"); assert_eq!(items.len(), 12, "pack 1 (real Bronze Pack) awards 12 cards");
assert_eq!(bv["createPackResponse"]["numberItems"], 5); assert_eq!(bv["createPackResponse"]["numberItems"], 12);
assert!( assert!(
items[0]["id"].as_i64().unwrap() >= 100_000_000, items[0]["id"].as_i64().unwrap() >= 100_000_000,
"minted wire id above the FIFA floor" "minted wire id above the FIFA floor"