//! FIFA 17 Store pack catalogue + `/store/purchasegroup` wire shaping. //! //! 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. //! //! ## Relationship to the Python oracle //! //! 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 //! //! 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. 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, /// 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, } 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] = &[ // ── 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). pub fn pack_by_id(id: u64) -> Option<&'static PackDef> { PACK_CATALOG.iter().find(|p| p.id == id) } /// 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 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": art_asset, "id": p.id, "packType": pack_type, "description": p.name, "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "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 }], "packContentInfo": { "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 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 }), ); } else { obj.insert("displayGroup".into(), json!({ "value": category(p) })); } body } /// The synthetic empty-My-Packs sentinel `purchase[]` entry (id 65534): an owned-style /// body forced to `state:"active"`, `unopened:false`. Compatibility shim ONLY — 65534 /// is absent from [`PACK_CATALOG`], so it can never be bought/opened/granted. pub fn sentinel_body(idx: u64) -> Value { let sentinel = PackDef { id: SENTINEL_PACK_ID, name: "", price: 0, n_bronze: 0, n_silver: 0, n_gold: 0, rares: 0, category: "gold", special_chance: 0.0, owned_only: true, }; let mut body = pack_body(&sentinel, idx, true); let obj = body .as_object_mut() .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: 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() .filter(|p| !p.owned_only) .enumerate() .map(|(i, p)| pack_body(p, i as u64 + 1, false)) .collect(); for (i, &pid) in unopened_ids.iter().enumerate() { if let Some(owned) = pack_by_id(pid) { packs.push(pack_body(owned, i as u64 + 1, true)); } } if unopened_ids.is_empty() && mode == StoreMode::Sentinel { packs.push(sentinel_body(1)); } json!({ "purchase": packs, "timestamp": 1596326400i64 }) } #[cfg(test)] mod tests { //! 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 { serde_json::from_str(s).expect("fixture parses") } #[test] fn purchasegroup_zero_sentinel_matches_golden() { let got = build_purchasegroup(&[], StoreMode::Sentinel); let want = parse(include_str!( "../../tests/fixtures/purchasegroup_zero_sentinel.json" )); assert_eq!(got, want); } #[test] fn purchasegroup_zero_clean_matches_golden() { let got = build_purchasegroup(&[], StoreMode::CleanV1); let want = parse(include_str!( "../../tests/fixtures/purchasegroup_zero_clean.json" )); assert_eq!(got, want); } #[test] 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!( "../../tests/fixtures/purchasegroup_pack70.json" )); assert_eq!(got, want); } #[test] fn sentinel_absent_from_catalog() { assert!(pack_by_id(SENTINEL_PACK_ID).is_none()); 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); let ids: Vec = got["purchase"] .as_array() .unwrap() .iter() .map(|e| e["id"].as_u64().unwrap()) .collect(); 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"); } }