feat: refactor AlgorithmicEvaluator to stateless, add game engine module and holdem_sim CLI

- Refactor AlgorithmicEvaluator from stateful Arc<Evaluator> wrapper to
  pure stateless evaluator with zero-allocation construction
- Extract shared evaluation logic into pub(crate) methods, eliminating
  ~220 lines of duplicated code from Evaluator
- Add game module: table, hand runner, tournament, bots, config, stats
- Add holdem_sim CLI binary crate for running simulations
- Convert MathEvaluator to thread-safe singleton with Arc<Inner> pattern
- Replace heap-allocated combination generation with const-generics
  stack-based approach in AlgorithmicEvaluator::evaluate_n_card_hand
- Add thiserror 2 dependency for GameError derive
- Update docs: README, ACHIEVEMENTS, ROADMAP
This commit is contained in:
Jan-Peter von Hunnius 2026-05-18 15:12:34 +02:00
parent dd605e8a66
commit 17d0440c86
43 changed files with 12329 additions and 494 deletions

View file

@ -0,0 +1,329 @@
//! Cash game simulation runner.
//!
//! Provides [`run_cash_game`] which creates a table, seats bots from
//! configuration, runs the specified number of hands, and collects
//! statistics.
use std::collections::HashMap;
use super::bot_factory;
use super::config::GameConfig;
use super::stats::SimulationStats;
use super::table::Table;
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);
}
Err(GameError::NotEnoughPlayers(_)) => {
// Not enough active players to continue.
break;
}
Err(e) => {
return Err(e);
}
}
}
// 5. Record final stacks 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();
stats.record_final_stacks(&final_stacks, &starting_stacks);
Ok((stats, table))
}
// ===========================================================================
// 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,
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,
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,
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),
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"));
}
}