11c17f3039
Pure rustfmt reflow; git diff -w confirms logic byte-identical (PACK_CATALOG values, pack tiers, item_def stubs unchanged). Builds clean. Retained WIP.
538 lines
20 KiB
Rust
538 lines
20 KiB
Rust
//! 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<u64> {
|
||
if let Ok(numeric) = definition_id.parse::<u64>() {
|
||
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<Value> = 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<u64> = 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");
|
||
}
|
||
}
|