41494bd18f
Adds openfut-adapter-fifa17 fut::store_catalog — a pure, faithful Rust port of the
Python oracle's PACK_CATALOG + _pack_body + store_catalog assembly at production
flag defaults (FUT_STORE_DISPLAYGROUP=1, GROUPID=0, PRICE_PROBE=0):
- PackDef + PACK_CATALOG (ids 1/5/6/7/70; economy numbers are OpenFUT PLACEHOLDER,
wire shape is oracle-verified; 65534 deliberately absent).
- pack_body() (_pack_body port), sentinel_body() (id-65534 compatibility shim),
build_purchasegroup(unopened_ids, StoreMode) mirroring store_catalog(3627).
- Differential parity: fixtures generated from the Python oracle
(tests/fixtures/purchasegroup_{zero_sentinel,zero_clean,pack70}.json); Rust output
matches semantically (6 tests). Adapter 140 tests, host 24, fmt/clippy clean,
Python A-R oracle green.
PURE wire shaping — NOT wired into the live host. Serving purchasegroup from Rust
requires an authoritative Rust owner of unopenedPackIds, which is blocked on the
economy-authority prerequisite (R3): coins are one shared balance written by many
Python-oracle routes (BUY spend, quick-sell credit, SBC/match/objective rewards)
persisted to fut_profile.json, so no single coin-touching route can move without a
whole-cluster migration. No dual-write introduced; no production deployment.
269 lines
9.3 KiB
Rust
269 lines
9.3 KiB
Rust
//! 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<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 {
|
|
//! 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<u64> = 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");
|
|
}
|
|
}
|