11c17f3039
Pure rustfmt reflow; git diff -w confirms logic byte-identical (PACK_CATALOG values, pack tiers, item_def stubs unchanged). Builds clean. Retained WIP.
293 lines
9.8 KiB
Rust
293 lines
9.8 KiB
Rust
//! FIFA 17 Store pack-content generator (pure, seeded).
|
|
//!
|
|
//! Draws the cards a Store pack awards. It is a **pure function** of
|
|
//! `(pack definition, RNG, candidate pool)` — no IO, no Core, no catalogue
|
|
//! lookup — so it is deterministic under a seeded [`rand::Rng`] and trivially
|
|
//! unit-tested. The host owns the impure parts: it builds the candidate pool
|
|
//! (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.
|
|
//!
|
|
//! ## 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;
|
|
|
|
use crate::fut::store_catalog::PackDef;
|
|
|
|
/// A candidate the host has already verified resolves in BOTH the FIFA catalogue
|
|
/// and Core content. Carries the full Core definition the shaper needs plus the
|
|
/// two draw-policy annotations (`gold` tier, `special` version) the host derives
|
|
/// from the catalogue (keeping this generator pure — it never reads a catalogue).
|
|
#[derive(Debug, Clone)]
|
|
pub struct GeneratedCandidate {
|
|
/// Core card-definition id (resolves in the FIFA catalogue and Core content).
|
|
pub card_id: String,
|
|
pub rating: u8,
|
|
pub position: String,
|
|
pub nation: String,
|
|
pub league: String,
|
|
pub club: String,
|
|
/// [pace, shooting, passing, dribbling, defending, physical].
|
|
pub attributes: [u8; 6],
|
|
/// Gold tier (host derives this as `rating >= 75`, the oracle's split point).
|
|
pub gold: bool,
|
|
/// Special version available (host derives this from the catalogue rareflag
|
|
/// `> 1`); gated by [`PackDef::special_chance`].
|
|
pub special: bool,
|
|
}
|
|
|
|
impl GeneratedCandidate {
|
|
fn to_card(&self) -> GeneratedCard {
|
|
GeneratedCard {
|
|
card_id: self.card_id.clone(),
|
|
rating: self.rating,
|
|
position: self.position.clone(),
|
|
nation: self.nation.clone(),
|
|
league: self.league.clone(),
|
|
club: self.club.clone(),
|
|
attributes: self.attributes,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One card a pack awarded. Carries `card_id` (the Core definition the host mints
|
|
/// and shapes) plus the definition fields the shared item shaper needs. It is
|
|
/// NOT an owned instance yet — the host mints the Core instance id and allocates
|
|
/// the numeric wire id.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct GeneratedCard {
|
|
pub card_id: String,
|
|
pub rating: u8,
|
|
pub position: String,
|
|
pub nation: String,
|
|
pub league: String,
|
|
pub club: String,
|
|
pub attributes: [u8; 6],
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// 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 {
|
|
return Vec::new();
|
|
}
|
|
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);
|
|
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 {
|
|
src
|
|
};
|
|
let pick = sub[rng.gen_range(0..sub.len())];
|
|
out.push(pick.to_card());
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use rand::rngs::StdRng;
|
|
use rand::SeedableRng;
|
|
|
|
fn cand(card: &str, rating: u8, gold: bool, special: bool) -> GeneratedCandidate {
|
|
GeneratedCandidate {
|
|
card_id: card.into(),
|
|
rating,
|
|
position: "ST".into(),
|
|
nation: "Brazil".into(),
|
|
league: "Premier League".into(),
|
|
club: "Arsenal".into(),
|
|
attributes: [rating; 6],
|
|
gold,
|
|
special,
|
|
}
|
|
}
|
|
|
|
/// A mixed pool: gold specials, gold normals, and a bronze tier.
|
|
fn pool() -> Vec<GeneratedCandidate> {
|
|
vec![
|
|
cand("g-sp-1", 90, true, true),
|
|
cand("g-sp-2", 88, true, true),
|
|
cand("g-1", 84, true, false),
|
|
cand("g-2", 82, true, false),
|
|
cand("g-3", 79, true, false),
|
|
cand("b-1", 64, false, false),
|
|
cand("b-2", 62, false, false),
|
|
]
|
|
}
|
|
|
|
fn pack(id: u64, n_bronze: u64, n_silver: u64, n_gold: u64, special_chance: f64) -> PackDef {
|
|
PackDef {
|
|
id,
|
|
name: "Test Pack",
|
|
price: 1000,
|
|
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,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn same_seed_same_output() {
|
|
let pool = pool();
|
|
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!(
|
|
generate_pack_contents(&p, &mut a, &pool),
|
|
generate_pack_contents(&p, &mut b, &pool)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn different_seeds_can_diverge() {
|
|
let pool = pool();
|
|
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.
|
|
assert_ne!(a, b);
|
|
}
|
|
|
|
#[test]
|
|
fn count_is_exact_and_all_cards_from_pool() {
|
|
let pool = pool();
|
|
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, 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 {
|
|
assert!(
|
|
ids.contains(c.card_id.as_str()),
|
|
"drew unknown card {}",
|
|
c.card_id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn gold_pack_draws_only_gold_tier() {
|
|
let pool = pool();
|
|
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),
|
|
"gold pack drew a bronze card"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bronze_pack_draws_only_bronze_tier() {
|
|
let pool = pool();
|
|
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),
|
|
"bronze pack drew a gold card"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn special_chance_one_draws_only_specials() {
|
|
let pool = pool();
|
|
let special_ids: std::collections::HashSet<&str> = pool
|
|
.iter()
|
|
.filter(|c| c.special)
|
|
.map(|c| c.card_id.as_str())
|
|
.collect();
|
|
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()
|
|
.all(|c| special_ids.contains(c.card_id.as_str())));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_pool_fails_closed() {
|
|
let p = pack(5, 0, 0, 7, 0.03);
|
|
assert!(generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &[]).is_empty());
|
|
}
|
|
}
|