fifa17 store: real 6-pack economy + full-DB pool; drop extPrice

- store_catalog: replace the invented catalogue with the real always-available
  FUT17 regular packs (Bronze/Prem Bronze/Silver/Prem Silver/Gold/Prem Gold) at
  real prices + tier composition; PackDef now carries per-tier quantities.
- pack_body: drop extPrice (its mtx side-effect switched on the broken "or %1s"
  FIFA-Points tile line; plan-2026-08-05-store-subsystem.md section 3.4).
- pack_content: tier-aware generator draws each pack bronze/silver/gold
  composition with special_chance bias + empty-tier fallback.
- host: CoreAccess::all_definitions (GET /cards); build_content_pool draws the
  FULL card universe via non-minting catalog lookup, owned-inventory fallback.
- economy_differential: store ops reclassified DIFFERENT-BY-DESIGN (Rust is the
  authoritative store; Python oracle stays the untouched rollback baseline).
- fixtures/tests updated to the real catalogue.

Odds are DESIGNED placeholders (FUT17 pack probabilities were never published);
club items remain excluded (cardtype-9 mapping unknown). Full regression green;
real prices + tier-correct draws verified server-side on staging.
This commit is contained in:
funman300
2026-08-18 23:26:55 +00:00
parent 7116046195
commit c68c10cf04
10 changed files with 863 additions and 650 deletions
+71 -49
View File
@@ -7,20 +7,16 @@
//! (only card ids that resolve in BOTH the FIFA catalogue and Core content),
//! mints the drawn cards into Core, and shapes them onto the wire.
//!
//! ## Parity note — Python `open_pack` / `_pack_body`
//! (`fifa17-recon/tools/fut_store.py:689`, `utas_server.py:3474`)
//! 1. `open_pack(price, count, gold, tiers, special_chance)` deducts coins then
//! draws `count` items (mostly players); the reveal body wraps them verbatim.
//! 2. Non-tiered draws split the pool at rating 75 by `gold` (`p[1] >= 75 == gold`)
//! and fall back to the whole pool when that tier is empty (`... or PACK_POOL`).
//! 3. Each drawn player becomes a special with probability `special_chance`
//! (`random.random() < special_chance`).
//! 4. `FUT_PACK_MIX` swaps ~`count // 4` players for consumables/staff extras;
//! we deliberately OMIT that mix (Core candidates are player defs — players-only).
//! 5. Prices/counts/odds are the OpenFUT **PLACEHOLDER** economy (the audit found
//! them invented); only the wire *shape* is EA-observed/oracle-verified.
//! 6. This port reproduces the count + gold-tier split + `special_chance` gate as
//! that same PLACEHOLDER policy, drawing with replacement from the pool.
//! ## Policy (real FUT 17 composition, DESIGNED odds)
//!
//! A pack draws [`PackDef::count`] cards split across rating tiers by the pack's
//! `n_bronze`/`n_silver`/`n_gold` composition (the same numbers the tile shows in
//! `packContentInfo`), drawing with replacement from the candidate pool. Each pick
//! is biased toward a special version with probability `special_chance` (a DESIGNED
//! placeholder — FUT 17 pack odds are unrecoverable). An empty tier falls back to
//! the whole pool so a draw is always possible even when the pool lacks that tier.
//! `FUT_PACK_MIX` (consumable/staff extras) is deliberately OMITTED — Core
//! candidates are player defs.
use rand::Rng;
@@ -77,50 +73,67 @@ pub struct GeneratedCard {
pub attributes: [u8; 6],
}
/// Draw `pack.count` cards from `pool` with the injected RNG. Pure and
/// deterministic under a seeded RNG. Returns an empty `Vec` (fail-closed) when
/// the pool is empty or the pack awards no cards.
/// Draw a pack's cards from `pool` with the injected RNG. Pure and deterministic
/// under a seeded RNG. Returns an empty `Vec` (fail-closed) when the pool is empty
/// or the pack awards no cards.
///
/// Policy (PLACEHOLDER — see the module parity note): draw with replacement from
/// the pack's tier (`gold`), biasing each draw toward a special card with
/// probability `special_chance`. An empty tier or partition falls back to the
/// next-wider set so a draw is always possible when the pool is non-empty.
/// Draws the pack's per-tier composition (`n_gold` gold-tier, `n_silver` silver,
/// `n_bronze` bronze), biasing each pick toward a special with `special_chance`.
/// An empty tier falls back to the whole pool (so a draw is always possible).
pub fn generate_pack_contents(
pack: &PackDef,
rng: &mut impl Rng,
pool: &[GeneratedCandidate],
) -> Vec<GeneratedCard> {
if pool.is_empty() || pack.count == 0 {
if pool.is_empty() || pack.count() == 0 {
return Vec::new();
}
// Tier split: a gold pack draws gold-tier candidates, a non-gold pack draws
// non-gold; an empty tier falls back to the whole pool (oracle `... or POOL`).
let tier: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.gold == pack.gold).collect();
let tier: Vec<&GeneratedCandidate> = if tier.is_empty() {
pool.iter().collect()
} else {
tier
};
// Partition the tier by special so `special_chance` can bias a draw; either
// partition falls back to the whole tier when empty.
let special: Vec<&GeneratedCandidate> = tier.iter().copied().filter(|c| c.special).collect();
let normal: Vec<&GeneratedCandidate> = tier.iter().copied().filter(|c| !c.special).collect();
let chance = pack.special_chance.clamp(0.0, 1.0);
let all: Vec<&GeneratedCandidate> = pool.iter().collect();
let gold: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating >= 75).collect();
let silver: Vec<&GeneratedCandidate> =
pool.iter().filter(|c| (65..75).contains(&c.rating)).collect();
let bronze: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating < 65).collect();
let mut out = Vec::with_capacity(pack.count as usize);
for _ in 0..pack.count {
let mut out = Vec::with_capacity(pack.count() as usize);
draw_tier(&mut out, &gold, &all, pack.n_gold, pack.special_chance, rng);
draw_tier(&mut out, &silver, &all, pack.n_silver, pack.special_chance, rng);
draw_tier(&mut out, &bronze, &all, pack.n_bronze, pack.special_chance, rng);
out
}
/// Draw `n` cards from `tier` — or the whole-pool `fallback` when `tier` is empty —
/// biasing each pick toward a special version with probability `chance`. Either the
/// special or normal partition falls back to the tier when empty.
fn draw_tier(
out: &mut Vec<GeneratedCard>,
tier: &[&GeneratedCandidate],
fallback: &[&GeneratedCandidate],
n: u64,
chance: f64,
rng: &mut impl Rng,
) {
if n == 0 {
return;
}
let src: &[&GeneratedCandidate] = if tier.is_empty() { fallback } else { tier };
if src.is_empty() {
return;
}
let special: Vec<&GeneratedCandidate> = src.iter().copied().filter(|c| c.special).collect();
let normal: Vec<&GeneratedCandidate> = src.iter().copied().filter(|c| !c.special).collect();
let chance = chance.clamp(0.0, 1.0);
for _ in 0..n {
let want_special = chance > 0.0 && rng.gen_bool(chance);
let sub: &[&GeneratedCandidate] = if want_special && !special.is_empty() {
&special
} else if !want_special && !normal.is_empty() {
&normal
} else {
&tier
src
};
let pick = sub[rng.gen_range(0..sub.len())];
out.push(pick.to_card());
}
out
}
#[cfg(test)]
@@ -156,13 +169,22 @@ mod tests {
]
}
fn pack(id: u64, count: u64, gold: bool, special_chance: f64) -> PackDef {
fn pack(id: u64, n_bronze: u64, n_silver: u64, n_gold: u64, special_chance: f64) -> PackDef {
PackDef {
id,
name: "Test Pack",
price: 1000,
count,
gold,
n_bronze,
n_silver,
n_gold,
rares: 0,
category: if n_gold > 0 {
"gold"
} else if n_silver > 0 {
"silver"
} else {
"bronze"
},
special_chance,
owned_only: false,
}
@@ -171,7 +193,7 @@ mod tests {
#[test]
fn same_seed_same_output() {
let pool = pool();
let p = pack(5, 7, true, 0.3);
let p = pack(5, 0, 0, 7, 0.3);
let mut a = StdRng::seed_from_u64(42);
let mut b = StdRng::seed_from_u64(42);
assert_eq!(
@@ -183,7 +205,7 @@ mod tests {
#[test]
fn different_seeds_can_diverge() {
let pool = pool();
let p = pack(5, 7, true, 0.3);
let p = pack(5, 0, 0, 7, 0.3);
let a = generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &pool);
let b = generate_pack_contents(&p, &mut StdRng::seed_from_u64(999), &pool);
// Not a hard guarantee, but with this pool/count the two seeds differ.
@@ -196,7 +218,7 @@ mod tests {
let ids: std::collections::HashSet<&str> =
pool.iter().map(|c| c.card_id.as_str()).collect();
for &n in &[1u64, 5, 7, 11] {
let p = pack(6, n, true, 0.08);
let p = pack(6, 0, 0, n, 0.08);
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(n), &pool);
assert_eq!(cards.len() as u64, n);
for c in &cards {
@@ -212,7 +234,7 @@ mod tests {
#[test]
fn gold_pack_draws_only_gold_tier() {
let pool = pool();
let p = pack(5, 20, true, 0.03);
let p = pack(5, 0, 0, 20, 0.03);
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
assert!(
cards.iter().all(|c| c.rating >= 75),
@@ -223,7 +245,7 @@ mod tests {
#[test]
fn bronze_pack_draws_only_bronze_tier() {
let pool = pool();
let p = pack(1, 20, false, 0.005);
let p = pack(1, 20, 0, 0, 0.005);
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
assert!(
cards.iter().all(|c| c.rating < 75),
@@ -239,7 +261,7 @@ mod tests {
.filter(|c| c.special)
.map(|c| c.card_id.as_str())
.collect();
let p = pack(7, 11, true, 1.0);
let p = pack(7, 0, 0, 11, 1.0);
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(3), &pool);
assert!(cards
.iter()
@@ -248,7 +270,7 @@ mod tests {
#[test]
fn empty_pool_fails_closed() {
let p = pack(5, 7, true, 0.03);
let p = pack(5, 0, 0, 7, 0.03);
assert!(generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &[]).is_empty());
}
}