//! Cash game and tournament series simulation runners. //! //! Provides: //! - [`run_cash_game`] — runs a cash game simulation //! - [`run_sng_series`] — runs multiple SNG tournaments and aggregates results use std::collections::{HashMap, HashSet}; use rand::seq::SliceRandom; use rand::{Rng, SeedableRng}; use super::bot_factory; use super::config::GameConfig; use super::stats::{SimulationStats, SngSeriesResult}; use super::table::Table; use super::tournament::Tournament; use super::types::{ChipCount, GameError, PlayerId}; /// Runs a cash game simulation based on the given configuration. /// /// # Workflow /// /// 1. Creates a [`Table`] from `config.to_table_config()` /// 2. Creates bots from `config.bots` via [`bot_factory::create_bot`] /// 3. Seats bots at the table (using `BotConfig.seat` if specified, otherwise auto-assigns) /// 4. Runs `config.num_hands` hands /// 5. Collects statistics via [`SimulationStats`] /// 6. Returns `(SimulationStats, Table)` with final state /// /// # Errors /// /// Returns [`GameError`] if: /// - A bot type is unknown /// - The table is full /// - There are not enough players /// - A hand fails to execute pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), GameError> { // Validate that we have at least 2 bots. if config.bots.len() < 2 { return Err(GameError::NotEnoughPlayers(config.bots.len())); } // 1. Create the table. let table_config = config.to_table_config(); let mut table = Table::new(table_config); // Apply seed if configured. if let Some(seed) = config.seed { table = table.with_seed(seed); } // 2. Create bots and track player names. let mut player_names: HashMap = HashMap::new(); let mut starting_stacks: HashMap = HashMap::new(); for (i, bot_config) in config.bots.iter().enumerate() { let player_id = PlayerId(i as u64); let bot = bot_factory::create_bot(bot_config, player_id).map_err(GameError::Custom)?; // Store the name before seating (seating consumes the bot). let name = bot.name().to_string(); player_names.insert(player_id, name); starting_stacks.insert(player_id, config.starting_chips); // 3. Seat the bot. let seat_index = bot_config .seat .unwrap_or_else(|| table.find_empty_seat().unwrap_or(i as u8)); table.seat_player(seat_index, bot, config.starting_chips)?; } // 4. Run hands and collect stats. let mut stats = SimulationStats::new("Cash"); for _ in 0..config.num_hands { match table.run_hand() { Ok(result) => { stats.record_hand_result(&result, &player_names); // Auto-rebuy: top up players whose stack dropped below the // configured percentage of starting chips. // Also reactivates busted players (stack=0, SittingOut) so // they can continue playing all hands. if config.auto_rebuy { // NOTE: Integer division truncates — e.g. 99 * 25 / 100 = 24 (not 24.75). // This is acceptable for simulation purposes; the threshold is slightly // lower than the exact percentage, which is conservative (rebuys trigger // slightly earlier). Uses saturating_mul to prevent overflow with extreme // chip values. let threshold = config.starting_chips .saturating_mul(config.rebuy_threshold_percentage as ChipCount) / 100; for slot in table.seats_mut().iter_mut() { if let Some(seat) = slot { if seat.stack < threshold { let can_rebuy = config.max_rebuys == 0 || seat.rebuy_count < config.max_rebuys; if can_rebuy { let rebuy_amount = config.starting_chips.saturating_sub(seat.stack); log::info!( "Auto-rebuy: {} topped up by {} (was {}, rebuy #{})", seat.player.name(), rebuy_amount, seat.stack, seat.rebuy_count + 1 ); seat.bankroll -= rebuy_amount as i64; seat.stack = config.starting_chips; seat.rebuy_count += 1; // Reset per-hand state that wasn't cleared when the // player busted (complete_hand skips // reset_for_new_hand for stack=0 players). // Without this, wagered_this_hand carries over // from the previous hand, breaking chip_movements. seat.wagered_this_hand = 0; seat.wagered_this_round = 0; seat.has_acted_this_round = false; seat.early_fold_pending = false; seat.hole_cards = None; // Reactivate busted players so they can play // the next hand. seat.state = super::player_info::PlayerState::Active; } } } } } } Err(GameError::NotEnoughPlayers(_)) => { // Not enough active players to continue. break; } Err(e) => { return Err(e); } } } // 5. Record final stacks and bankrolls for accurate profit calculation. let final_stacks: HashMap = table .seats() .iter() .filter_map(|slot| { slot.as_ref() .map(|seat| (seat.player_id, seat.stack)) }) .collect(); let bankrolls: HashMap = table .seats() .iter() .filter_map(|slot| { slot.as_ref() .map(|seat| (seat.player_id, seat.bankroll)) }) .collect(); let bankroll_mode = config.reset_bankroll_each_hand || config.bankroll_refill_threshold > 0; stats.record_final_stacks(&final_stacks, &starting_stacks, &bankrolls, bankroll_mode, config.auto_rebuy); Ok((stats, table)) } /// Runs a series of tournaments (SNG or MTT) and returns aggregate results. /// /// For each tournament: /// 1. Creates a new [`Tournament`] with the given config's schedule, prizes, /// starting chips, and buy-in. /// 2. Creates fresh bot instances via [`bot_factory::create_bot`] for each /// bot config. /// 3. Shuffles the registration order (if `config.randomize_seats`) to /// randomize seat assignments. /// 4. Registers all bots with the tournament. /// 5. Runs the tournament to completion. /// 6. Records each player's placement and prize money. /// /// Players are tracked **by name** across tournaments (since each tournament /// creates fresh bot instances with different [`PlayerId`]s). /// /// Works for both `SitAndGo` and `MultiTableTournament` game types. /// For MTT, uses `config.num_tables` to create the appropriate number of tables. /// /// # Errors /// /// Returns [`GameError`] if: /// - Fewer than 2 bots are configured /// - Bot names are not unique (would cause result aggregation to merge players) /// - A bot type is unknown /// - The table is full /// - A tournament fails to complete pub fn run_sng_series( config: &GameConfig, num_tournaments: u32, ) -> Result { // Validate that we have at least 2 bots. if config.bots.len() < 2 { return Err(GameError::NotEnoughPlayers(config.bots.len())); } // Validate unique bot names — players are tracked by name across tournaments, // so duplicate names would silently merge their results. // Bots without explicit names use their bot_type as a fallback identity. let unique_names: HashSet = config .bots .iter() .map(|b| b.name.clone().unwrap_or_else(|| b.bot_type.clone())) .collect(); if unique_names.len() != config.bots.len() { return Err(GameError::Custom( "Duplicate bot names in config: each bot must have a unique name for series tracking".to_string(), )); } let buy_in = if config.buy_in > 0 { config.buy_in } else { config.starting_chips }; // Build a deterministic RNG for shuffling registration order. // Use the config seed if provided, otherwise generate a random one // and log it so the run can be reproduced. let base_seed = config.seed.unwrap_or_else(|| { let seed = rand::rng().random::(); log::info!("Generated random seed for tournament series: {}", seed); seed }); let mut result = SngSeriesResult::new(num_tournaments, buy_in, base_seed); for tournament_index in 0..num_tournaments { let table_config = config.to_table_config(); let blind_schedule = config.to_blind_schedule(); let prize_structure = config.to_prize_structure(); // Create the appropriate tournament type based on game_type. let mut tournament = match config.game_type { super::tournament::GameType::MultiTableTournament => Tournament::new_mtt( config.num_tables, table_config, blind_schedule, prize_structure, config.starting_chips, buy_in, ), _ => Tournament::new_sng( table_config, blind_schedule, prize_structure, config.starting_chips, buy_in, ), }; // Build the registration order. If randomize_seats is enabled, // shuffle the bot order using a seed derived from the base seed + // tournament index for reproducibility. let mut order: Vec = (0..config.bots.len()).collect(); if config.randomize_seats { let tournament_seed = base_seed.wrapping_add(tournament_index as u64); let mut rng = rand::rngs::StdRng::seed_from_u64(tournament_seed); order.shuffle(&mut rng); } // Create fresh bots and register them in the (possibly shuffled) order. // We need to track names by PlayerId for this tournament. let mut player_names: HashMap = HashMap::new(); for (seat_seq, &bot_idx) in order.iter().enumerate() { let bot_config = &config.bots[bot_idx]; let player_id = PlayerId(seat_seq as u64); let bot = bot_factory::create_bot(bot_config, player_id) .map_err(GameError::Custom)?; let name = bot.name().to_string(); player_names.insert(player_id, name); tournament.register_player(bot)?; } // Run the tournament to completion. log::info!("Running tournament {}/{}...", tournament_index + 1, num_tournaments); tournament.run_to_completion()?; // Accumulate total hands. result.total_hands_played += tournament.total_hands(); // Process results: record eliminated players from standings. let standings = tournament.standings(); // Invariant: after run_to_completion(), standings should have N-1 entries // and winner() returns the sole remaining player. Log a warning if violated. let expected_standings = player_names.len().saturating_sub(1); if standings.len() != expected_standings { log::warn!( "Tournament {} ended with {} standings (expected {})", tournament_index + 1, standings.len(), expected_standings ); } // Track which player IDs have been recorded to prevent double-counting. let mut recorded: HashSet = HashSet::new(); // Standings contain eliminated players with their finish position. // The placement value is the correct finish position (2 = runner-up, // N = first eliminated). Players eliminated in the same hand share // the same position. for &(player_id, finish_position) in standings { let name = player_names .get(&player_id) .cloned() .unwrap_or_else(|| format!("Player({})", player_id.0)); let prize = tournament .prize_structure() .prize_for_placement(finish_position) .unwrap_or(0); log::debug!( "Tournament {}: {} finish={} prize={}", tournament_index + 1, name, finish_position, prize ); result.record_player_finish(&name, finish_position, prize); recorded.insert(player_id); } // Record the winner (last active player) if not already in standings. // The winner is the sole remaining player who was never eliminated. if let Some(winner_id) = tournament.winner() { if !recorded.contains(&winner_id) { let name = player_names .get(&winner_id) .cloned() .unwrap_or_else(|| format!("Player({})", winner_id.0)); let prize = tournament .prize_structure() .prize_for_placement(1) .unwrap_or(0); result.record_player_finish(&name, 1, prize); } } } Ok(result) } // =========================================================================== // Tests // =========================================================================== #[cfg(test)] mod tests { use super::*; use crate::game::config::{BetLimitConfig, BotConfig}; use crate::game::tournament::GameType; use std::collections::HashMap; fn make_cash_config(num_hands: u64, seed: Option) -> GameConfig { GameConfig { game_type: GameType::Cash, num_seats: 6, bet_limit: BetLimitConfig::NoLimit { min_raise: 20 }, small_blind: 10, big_blind: 20, ante: 0, starting_chips: 10_000, buy_in: 0, num_tables: 1, hands_per_level: 10, num_blind_levels: 15, num_hands, seed, rake_percentage: 0.0, rake_cap: 0, reset_bankroll_each_hand: false, bankroll_refill_threshold: 0, randomize_seats: false, auto_rebuy: false, rebuy_threshold_percentage: 25, max_rebuys: 0, blind_levels: vec![], prizes: vec![], tournament_rake_percentage: 0, num_tournaments: 1, bots: vec![ BotConfig { bot_type: "call_bot".to_string(), name: Some("Alice".to_string()), seat: Some(0), parameters: HashMap::new(), }, BotConfig { bot_type: "call_bot".to_string(), name: Some("Bob".to_string()), seat: Some(1), parameters: HashMap::new(), }, BotConfig { bot_type: "fold_bot".to_string(), name: Some("Charlie".to_string()), seat: Some(2), parameters: HashMap::new(), }, ], } } #[test] fn run_cash_game_basic() { let config = make_cash_config(10, Some(42)); let result = run_cash_game(&config); assert!(result.is_ok()); let (stats, _table) = result.unwrap(); assert_eq!(stats.total_hands, 10); assert_eq!(stats.players.len(), 3); assert_eq!(stats.game_type, "Cash"); // All players should have played 10 hands. for stats in stats.players.values() { assert_eq!(stats.hands_played, 10); } // Total profit across all players should sum to zero (zero-sum game). let total_profit: i64 = stats.players.values().map(|p| p.total_profit).sum(); assert_eq!(total_profit, 0); } #[test] fn run_cash_game_reproducible_with_seed() { let config1 = make_cash_config(50, Some(12345)); let config2 = make_cash_config(50, Some(12345)); let (stats1, _) = run_cash_game(&config1).unwrap(); let (stats2, _) = run_cash_game(&config2).unwrap(); // Same seed should produce identical results. for pid in stats1.players.keys() { let p1 = stats1.players.get(pid).unwrap(); let p2 = stats2.players.get(pid).unwrap(); assert_eq!(p1.total_profit, p2.total_profit); assert_eq!(p1.hands_won, p2.hands_won); assert_eq!(p1.final_stack, p2.final_stack); } } #[test] fn run_cash_game_different_seeds_differ() { let config1 = make_cash_config(50, Some(111)); let config2 = make_cash_config(50, Some(222)); let (stats1, _) = run_cash_game(&config1).unwrap(); let (stats2, _) = run_cash_game(&config2).unwrap(); // Different seeds should (almost certainly) produce different results. let profits1: Vec = stats1 .players .values() .map(|p| p.total_profit) .collect(); let profits2: Vec = stats2 .players .values() .map(|p| p.total_profit) .collect(); assert_ne!(profits1, profits2); } #[test] fn run_cash_game_not_enough_bots() { let config = GameConfig { game_type: GameType::Cash, num_seats: 6, bet_limit: BetLimitConfig::NoLimit { min_raise: 20 }, small_blind: 10, big_blind: 20, ante: 0, starting_chips: 10_000, buy_in: 0, num_tables: 1, hands_per_level: 10, num_blind_levels: 15, num_hands: 10, seed: None, rake_percentage: 0.0, rake_cap: 0, reset_bankroll_each_hand: false, bankroll_refill_threshold: 0, randomize_seats: false, auto_rebuy: false, rebuy_threshold_percentage: 25, max_rebuys: 0, blind_levels: vec![], prizes: vec![], tournament_rake_percentage: 0, num_tournaments: 1, bots: vec![BotConfig { bot_type: "call_bot".to_string(), name: Some("Lonely".to_string()), seat: None, parameters: HashMap::new(), }], }; let result = run_cash_game(&config); assert!(result.is_err()); } #[test] fn run_cash_game_unknown_bot_type() { let config = GameConfig { game_type: GameType::Cash, num_seats: 6, bet_limit: BetLimitConfig::NoLimit { min_raise: 20 }, small_blind: 10, big_blind: 20, ante: 0, starting_chips: 10_000, buy_in: 0, num_tables: 1, hands_per_level: 10, num_blind_levels: 15, num_hands: 10, seed: None, rake_percentage: 0.0, rake_cap: 0, reset_bankroll_each_hand: false, bankroll_refill_threshold: 0, randomize_seats: false, auto_rebuy: false, rebuy_threshold_percentage: 25, max_rebuys: 0, blind_levels: vec![], prizes: vec![], tournament_rake_percentage: 0, num_tournaments: 1, bots: vec![ BotConfig { bot_type: "call_bot".to_string(), name: Some("Alice".to_string()), seat: Some(0), parameters: HashMap::new(), }, BotConfig { bot_type: "super_ai".to_string(), name: Some("Bob".to_string()), seat: Some(1), parameters: HashMap::new(), }, ], }; let result = run_cash_game(&config); assert!(result.is_err()); } #[test] fn run_cash_game_auto_seat_assignment() { let config = GameConfig { game_type: GameType::Cash, num_seats: 6, bet_limit: BetLimitConfig::NoLimit { min_raise: 20 }, small_blind: 10, big_blind: 20, ante: 0, starting_chips: 10_000, buy_in: 0, num_tables: 1, hands_per_level: 10, num_blind_levels: 15, num_hands: 5, seed: Some(99), rake_percentage: 0.0, rake_cap: 0, reset_bankroll_each_hand: false, bankroll_refill_threshold: 0, randomize_seats: false, auto_rebuy: false, rebuy_threshold_percentage: 25, max_rebuys: 0, blind_levels: vec![], prizes: vec![], tournament_rake_percentage: 0, num_tournaments: 1, bots: vec![ BotConfig { bot_type: "call_bot".to_string(), name: Some("A".to_string()), seat: None, // auto-assign parameters: HashMap::new(), }, BotConfig { bot_type: "fold_bot".to_string(), name: Some("B".to_string()), seat: None, // auto-assign parameters: HashMap::new(), }, ], }; let result = run_cash_game(&config); assert!(result.is_ok()); let (stats, table) = result.unwrap(); assert_eq!(stats.total_hands, 5); // Both players should be seated. assert_eq!(table.seated_player_count(), 2); } #[test] fn run_cash_game_summary() { let config = make_cash_config(20, Some(42)); let (stats, _) = run_cash_game(&config).unwrap(); let summary = stats.summary(10_000); assert!(summary.contains("=== Simulation Results ===")); assert!(summary.contains("Cash")); assert!(summary.contains("20")); assert!(summary.contains("Alice")); assert!(summary.contains("Bob")); assert!(summary.contains("Charlie")); } #[test] fn test_cash_game_auto_rebuy() { let config = GameConfig { game_type: GameType::Cash, num_seats: 6, bet_limit: BetLimitConfig::NoLimit { min_raise: 10 }, small_blind: 5, big_blind: 10, ante: 0, starting_chips: 100, buy_in: 0, num_tables: 1, hands_per_level: 10, num_blind_levels: 15, num_hands: 5, seed: Some(42), rake_percentage: 0.0, rake_cap: 0, reset_bankroll_each_hand: false, bankroll_refill_threshold: 0, randomize_seats: false, auto_rebuy: true, rebuy_threshold_percentage: 25, max_rebuys: 0, // unlimited blind_levels: vec![], prizes: vec![], tournament_rake_percentage: 0, num_tournaments: 1, bots: vec![ BotConfig { bot_type: "call_bot".to_string(), name: Some("Alice".to_string()), seat: Some(0), parameters: HashMap::new(), }, BotConfig { bot_type: "call_bot".to_string(), name: Some("Bob".to_string()), seat: Some(1), parameters: HashMap::new(), }, ], }; let result = run_cash_game(&config); assert!(result.is_ok(), "run_cash_game should succeed"); let (_stats, table) = result.unwrap(); // With auto_rebuy enabled, players who drop below 25% of starting chips // (threshold = 100/4 = 25) should be topped back up to starting_chips. // Players above the threshold should remain unchanged. // Busted players (stack=0, SittingOut) should be reactivated. for slot in table.seats().iter() { if let Some(seat) = slot { // After rebuy, stack should either be at starting_chips (was rebought) // or above the threshold of 25 (was not eligible for rebuy). assert!( seat.stack >= 25 || seat.stack == 100, "Player {} should have chips >= 25 or be rebought to 100 after auto-rebuy, but has {}", seat.player.name(), seat.stack ); // Verify that bankroll reflects rebuy deductions. // Players start with bankroll 0 (no buy-in set). Each rebuy deducts // from bankroll, so any player who was rebought must have a negative // bankroll. Players who were never rebought should still have bankroll 0. if seat.stack == 100 { // Player was rebought: bankroll must be negative. assert!( seat.bankroll < 0, "Rebought player {} should have negative bankroll, but has {}", seat.player.name(), seat.bankroll ); } } } } #[test] fn test_cash_game_auto_rebuy_cap() { // With max_rebuys = 1, each player can only rebuy once. // After that, they sit out when their stack drops below threshold. let config = GameConfig { game_type: GameType::Cash, num_seats: 6, bet_limit: BetLimitConfig::NoLimit { min_raise: 10 }, small_blind: 5, big_blind: 10, ante: 0, starting_chips: 100, buy_in: 0, num_tables: 1, hands_per_level: 10, num_blind_levels: 15, num_hands: 50, seed: Some(42), rake_percentage: 0.0, rake_cap: 0, reset_bankroll_each_hand: false, bankroll_refill_threshold: 0, randomize_seats: false, auto_rebuy: true, rebuy_threshold_percentage: 25, max_rebuys: 1, blind_levels: vec![], prizes: vec![], tournament_rake_percentage: 0, num_tournaments: 1, bots: vec![ BotConfig { bot_type: "call_bot".to_string(), name: Some("Alice".to_string()), seat: Some(0), parameters: HashMap::new(), }, BotConfig { bot_type: "fold_bot".to_string(), name: Some("Bob".to_string()), seat: Some(1), parameters: HashMap::new(), }, ], }; let result = run_cash_game(&config); assert!(result.is_ok(), "run_cash_game should succeed"); let (_stats, table) = result.unwrap(); // Verify that no player has rebought more than max_rebuys (1) times. for slot in table.seats().iter() { if let Some(seat) = slot { assert!( seat.rebuy_count <= 1, "Player {} should have rebuy_count <= 1, but has {}", seat.player.name(), seat.rebuy_count ); } } } #[test] fn test_cash_game_auto_rebuy_unlimited() { // With max_rebuys = 0 (unlimited), rebuys should work without limit. // Run enough hands to ensure multiple rebuys happen. let config = GameConfig { game_type: GameType::Cash, num_seats: 6, bet_limit: BetLimitConfig::NoLimit { min_raise: 10 }, small_blind: 5, big_blind: 10, ante: 0, starting_chips: 100, buy_in: 0, num_tables: 1, hands_per_level: 10, num_blind_levels: 15, num_hands: 50, seed: Some(42), rake_percentage: 0.0, rake_cap: 0, reset_bankroll_each_hand: false, bankroll_refill_threshold: 0, randomize_seats: false, auto_rebuy: true, rebuy_threshold_percentage: 50, max_rebuys: 0, // unlimited blind_levels: vec![], prizes: vec![], tournament_rake_percentage: 0, num_tournaments: 1, bots: vec![ BotConfig { bot_type: "call_bot".to_string(), name: Some("Alice".to_string()), seat: Some(0), parameters: HashMap::new(), }, BotConfig { bot_type: "fold_bot".to_string(), name: Some("Bob".to_string()), seat: Some(1), parameters: HashMap::new(), }, ], }; let result = run_cash_game(&config); assert!(result.is_ok(), "run_cash_game should succeed"); let (_stats, table) = result.unwrap(); // With unlimited rebuys over 50 hands, at least one player should have // rebought multiple times (the fold_bot loses quickly). let total_rebuys: u32 = table .seats() .iter() .filter_map(|slot| slot.as_ref().map(|s| s.rebuy_count)) .sum(); assert!( total_rebuys > 1, "With unlimited rebuys over 50 hands, expected multiple rebuys, got {}", total_rebuys ); } }