//! 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. //! //! ## 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. 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 `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. /// /// 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. pub fn generate_pack_contents( pack: &PackDef, rng: &mut impl Rng, pool: &[GeneratedCandidate], ) -> Vec { 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 mut out = Vec::with_capacity(pack.count as usize); for _ in 0..pack.count { 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 }; let pick = sub[rng.gen_range(0..sub.len())]; out.push(pick.to_card()); } out } #[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, count: u64, gold: bool, special_chance: f64) -> PackDef { PackDef { id, name: "Test Pack", price: 1000, count, gold, special_chance, owned_only: false, } } #[test] fn same_seed_same_output() { let pool = pool(); let p = pack(5, 7, true, 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, 7, true, 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, n, true, 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, 20, true, 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, false, 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, 11, true, 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, 7, true, 0.03); assert!(generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &[]).is_empty()); } }