//! FIFA 17 Store pack catalogue + `/store/purchasegroup` wire shaping. //! //! A faithful Rust port of the Python oracle's `PACK_CATALOG` + `_pack_body` + //! `store_catalog` assembly (`fifa17-recon/tools/{fut_store,utas_server}.py`) at the //! **production flag defaults** (`FUT_STORE_DISPLAYGROUP=1` on, `FUT_STORE_GROUPID=0` //! off, `FUT_PRICE_PROBE=0` off). Parity is pinned by differential fixtures generated //! from the Python oracle (`tests/fixtures/purchasegroup_*.json`). //! //! ## Scope / split-brain safety //! //! This is **pure wire shaping** — no economy state, no IO. [`build_purchasegroup`] //! is a function of `(owned unopened pack ids, empty-My-Packs StoreMode)`. It is //! deliberately **not yet wired** into the live host: serving purchasegroup from Rust //! requires an authoritative Rust owner of `unopenedPackIds`, and today Python is the //! single writer of coins + unopened packs (BUY, quick-sell, rewards). Wiring this //! before that economy authority exists would create a dual-write/split-brain. See //! the R3 economy-authority prerequisite in the vault (`Rust UTAS Migration`). //! //! ## Economy-parameter provenance //! //! Prices, counts and odds are the current OpenFUT **PLACEHOLDER** economy, NOT //! EA-authentic (the overnight audit established the store economy is invented). The //! wire *shape* is EA-observed/oracle-verified; the *numbers* are placeholders. use serde_json::{json, Value}; use crate::fut::store_session::{StoreMode, SENTINEL_PACK_ID}; /// A FIFA 17 Store pack definition. Wire shape is oracle-verified; the economy /// numbers (`price`/`count`/`special_chance`) are OpenFUT PLACEHOLDER, not EA-authentic. #[derive(Debug, Clone, Copy, PartialEq)] pub struct PackDef { pub id: u64, pub name: &'static str, pub price: u64, pub count: u64, pub gold: bool, pub special_chance: f64, /// Reward-only pack (no purchase path): excluded from the normal catalogue, /// rendered only when owned (in `unopenedPackIds`). pub owned_only: bool, } /// The current supported FIFA 17 pack catalogue (`fut_store.py:820`). Only observed/ /// currently-supported ids. The 65534 sentinel is deliberately ABSENT — it is a /// compatibility shim, never a catalogue pack (never purchasable/openable). pub const PACK_CATALOG: &[PackDef] = &[ PackDef { id: 1, name: "Bronze Pack", price: 400, count: 5, gold: false, special_chance: 0.005, owned_only: false, }, PackDef { id: 5, name: "Gold Pack", price: 5000, count: 7, gold: true, special_chance: 0.03, owned_only: false, }, PackDef { id: 6, name: "Premium Gold", price: 15000, count: 11, gold: true, special_chance: 0.08, owned_only: false, }, PackDef { id: 7, name: "Special Players Pack", price: 25000, count: 11, gold: true, special_chance: 1.0, owned_only: false, }, PackDef { id: 70, name: "Reward Special Players Pack", price: 0, count: 11, gold: true, special_chance: 1.0, owned_only: true, }, ]; /// 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) } /// The FIFA17 StoreFront category token for a NORMAL pack tile (`utas_server.py:3579`): /// one of the six hard-coded tokens the client resolves. fn category(p: &PackDef) -> &'static str { if p.special_chance >= 1.0 { "special" } else if p.gold { "gold" } else { "bronze" } } /// One `purchase[]` entry — the faithful `_pack_body` port (`utas_server.py:3474`) at /// production flag defaults. `owned` packs (My Packs / reward / sentinel) drop the /// purchase fields and take the `mypacks` display group. pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value { let mtx = std::cmp::max(1, p.price / 100); let mut body = json!({ "assetId": p.id, "id": p.id, "packType": if p.gold { "GOLD" } else { "BRONZE" }, "description": p.name, "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": idx, "currencies": [{ "name": "coins", "funds": p.price, "finalFunds": p.price }], "extPrice": { "finalPrice": { "amount": mtx, "currency": "mtx" }, "originalPrice": { "amount": mtx, "currency": "mtx" }, }, "packContentInfo": { "bronzeQuantity": if p.gold { 0 } else { p.count }, "silverQuantity": 0, "goldQuantity": if p.gold { p.count } else { 0 }, "rareQuantity": if p.gold { p.count } else { 0 }, "itemQuantity": p.count, }, "unopened": owned, }); let obj = body.as_object_mut().expect("pack body is a JSON object"); if owned { // Reward/My-Packs tiles have no purchase path; leaving zero-value coin/mtx // objects makes the client render the price label as literal "undefined". obj.remove("currencies"); obj.remove("extPrice"); 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, count: 0, gold: true, 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)); body } /// Build the full `/store/purchasegroup` body from the authoritative unopened-pack ids /// and the frozen empty-My-Packs mode. Pure — mirrors `store_catalog` (`3627`): /// normal packs (1,5,6,7) first, then any owned packs, then the empty-My-Packs shim /// (sentinel for [`StoreMode::Sentinel`], nothing for [`StoreMode::CleanV1`]). 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 { //! Differential parity against the Python oracle. The fixtures under //! `tests/fixtures/purchasegroup_*.json` are generated by calling the oracle's //! `_pack_body`/`store_catalog` at production flag defaults; Rust must match //! them semantically (object key order is irrelevant to `serde_json::Value` eq). use super::*; fn parse(s: &str) -> Value { serde_json::from_str(s).expect("fixture parses") } #[test] fn purchasegroup_zero_sentinel_matches_oracle() { 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_oracle() { 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_oracle() { // 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 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, 5, 6, 7]); } #[test] fn category_tokens_are_canonical() { assert_eq!(category(pack_by_id(1).unwrap()), "bronze"); assert_eq!(category(pack_by_id(5).unwrap()), "gold"); assert_eq!(category(pack_by_id(7).unwrap()), "special"); } }