Major changes: - Add hand history module (iPoker format) with HandHistoryLogger - Add run_sng_series() for multi-tournament aggregate statistics - Fix MTT table consolidation deadlock (sub-minimum-player tables) - Add Meerkat-compatible convenience methods on Action and GameInfo - Add custom blind levels, prize structures, auto-rebuy to GameConfig - Add SngSeriesResult/SngPlayerSeriesResult for series tracking - Add Tournament::winner(), tables_mut(), shuffle_seats() Review fixes: - Fix TOML prizes field ordering (must precede [[game.blind_levels]]) - Add sensible serde defaults for small_blind (10) and big_blind (20) - Fix prize assignment in run_sng_series (use raw_placement directly) - Handle hand history filename collisions with suffixed retry - Add debug_assert guard on collect_hand_history_data double-consumption
677 lines
24 KiB
Rust
677 lines
24 KiB
Rust
//! 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<PlayerId, String> = HashMap::new();
|
|
let mut starting_stacks: HashMap<PlayerId, ChipCount> = 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 rebuy_amount =
|
|
config.starting_chips.saturating_sub(seat.stack);
|
|
log::info!(
|
|
"Auto-rebuy: {} topped up by {} (was {})",
|
|
seat.player.name(),
|
|
rebuy_amount,
|
|
seat.stack
|
|
);
|
|
seat.stack = config.starting_chips;
|
|
// Reactivate busted players so they can play
|
|
// the next hand.
|
|
if seat.state == super::player_info::PlayerState::SittingOut {
|
|
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<PlayerId, ChipCount> = table
|
|
.seats()
|
|
.iter()
|
|
.filter_map(|slot| {
|
|
slot.as_ref()
|
|
.map(|seat| (seat.player_id, seat.stack))
|
|
})
|
|
.collect();
|
|
let bankrolls: HashMap<PlayerId, i64> = 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);
|
|
|
|
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<SngSeriesResult, GameError> {
|
|
// 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<String> = 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::<u64>();
|
|
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<usize> = (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<PlayerId, String> = 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<PlayerId> = HashSet::new();
|
|
|
|
// Standings contain eliminated players with raw placement numbers.
|
|
// In the tournament, placement = N means the player was the Nth-to-last
|
|
// to be eliminated (i.e., placement=N is the worst finish).
|
|
// For a 10-player tournament: first player out gets placement=10,
|
|
// last player eliminated gets placement=2. The winner (placement=1)
|
|
// is NOT in standings — they are tracked separately via winner().
|
|
//
|
|
// The raw placement IS the finish position, so we use it directly
|
|
// for prize lookup (prize_for_placement is 1-indexed).
|
|
for &(player_id, raw_placement) in standings.iter() {
|
|
let finish_position = raw_placement;
|
|
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 {}: {} raw_placement={} finish={} prize={}",
|
|
tournament_index + 1, name, raw_placement, 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<u64>) -> 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,
|
|
blind_levels: vec![],
|
|
prizes: vec![],
|
|
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<i64> = stats1
|
|
.players
|
|
.values()
|
|
.map(|p| p.total_profit)
|
|
.collect();
|
|
let profits2: Vec<i64> = 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,
|
|
blind_levels: vec![],
|
|
prizes: vec![],
|
|
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,
|
|
blind_levels: vec![],
|
|
prizes: vec![],
|
|
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,
|
|
blind_levels: vec![],
|
|
prizes: vec![],
|
|
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,
|
|
blind_levels: vec![],
|
|
prizes: vec![],
|
|
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
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|