//! 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 { 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, 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 { 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()); } }