feat: hand history logging, tournament series, MTT consolidation, Meerkat API

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
This commit is contained in:
Jan-Peter von Hunnius 2026-05-20 21:05:22 +02:00
parent 9dfd9c3ee3
commit c041cde3fe
18 changed files with 4601 additions and 208 deletions

View file

@ -1,15 +1,19 @@
//! Cash game simulation runner.
//! Cash game and tournament series simulation runners.
//!
//! Provides [`run_cash_game`] which creates a table, seats bots from
//! configuration, runs the specified number of hands, and collects
//! statistics.
//! Provides:
//! - [`run_cash_game`] — runs a cash game simulation
//! - [`run_sng_series`] — runs multiple SNG tournaments and aggregates results
use std::collections::HashMap;
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;
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.
@ -73,6 +77,41 @@ pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), Ga
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.
@ -107,6 +146,200 @@ pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), Ga
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
// ===========================================================================
@ -138,6 +371,11 @@ mod tests {
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(),
@ -244,6 +482,11 @@ mod tests {
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(),
@ -278,7 +521,11 @@ mod tests {
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(),
@ -320,6 +567,11 @@ mod tests {
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 {
@ -358,4 +610,68 @@ mod tests {
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
);
}
}
}
}