Files
OpenFUT/openfut-adapter-fifa17/src/fut/store_catalog.rs
T
funman300 11c17f3039 style(adapter-fifa17): apply cargo fmt to fut store/pack/non_economy/club_stats
Pure rustfmt reflow; git diff -w confirms logic byte-identical (PACK_CATALOG
values, pack tiers, item_def stubs unchanged). Builds clean. Retained WIP.
2026-08-20 16:06:29 +00:00

538 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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");
}
}