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:
parent
dd605e8a66
commit
17d0440c86
43 changed files with 12329 additions and 494 deletions
329
holdem_core/src/game/simulation.rs
Normal file
329
holdem_core/src/game/simulation.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue