From c041cde3fe7c7fc8136e4f97af10f5b4869e224d Mon Sep 17 00:00:00 2001 From: "Jan-Peter v. Hunnius" Date: Wed, 20 May 2026 21:05:22 +0200 Subject: [PATCH] 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 --- ...RE_HAND_LOGGING_TOURNAMENT_IMPROVEMENTS.md | 862 ++++++++++++++++++ holdem_core/examples/configs/cash_game.toml | 8 +- .../examples/configs/mtt_10_tables.toml | 527 +++++++++++ .../examples/configs/mtt_tournament.toml | 18 - .../examples/configs/sng_tournament.toml | 104 ++- holdem_core/src/game/README.md | 69 +- holdem_core/src/game/action.rs | 287 +++++- holdem_core/src/game/betting.rs | 54 +- holdem_core/src/game/config.rs | 254 +++++- holdem_core/src/game/game_info.rs | 93 ++ holdem_core/src/game/hand_history.rs | 813 +++++++++++++++++ holdem_core/src/game/hand_runner.rs | 552 ++++++++++- holdem_core/src/game/mod.rs | 13 +- holdem_core/src/game/simulation.rs | 330 ++++++- holdem_core/src/game/stats.rs | 175 ++++ holdem_core/src/game/tournament.rs | 412 ++++++++- holdem_sim/src/main.rs | 45 +- plans/meerkat-alignment-fixes.md | 193 ++++ 18 files changed, 4601 insertions(+), 208 deletions(-) create mode 100644 docs/ARCHITECTURE_HAND_LOGGING_TOURNAMENT_IMPROVEMENTS.md create mode 100644 holdem_core/examples/configs/mtt_10_tables.toml delete mode 100644 holdem_core/examples/configs/mtt_tournament.toml create mode 100644 holdem_core/src/game/hand_history.rs create mode 100644 plans/meerkat-alignment-fixes.md diff --git a/docs/ARCHITECTURE_HAND_LOGGING_TOURNAMENT_IMPROVEMENTS.md b/docs/ARCHITECTURE_HAND_LOGGING_TOURNAMENT_IMPROVEMENTS.md new file mode 100644 index 0000000..5bca10a --- /dev/null +++ b/docs/ARCHITECTURE_HAND_LOGGING_TOURNAMENT_IMPROVEMENTS.md @@ -0,0 +1,862 @@ +# Architecture: Hand History Logging & Tournament Improvements + +**Document Version:** 1.0 +**Date:** 2026-05-19 +**Scope:** Two major features for the rusty-marvin poker simulation engine + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Feature 1: Hand History Logging in iPoker Format](#feature-1-hand-history-logging) +3. [Feature 2: Tournament Improvements](#feature-2-tournament-improvements) +4. [Implementation Plan](#implementation-plan) +5. [Data Structures](#data-structures) +6. [Integration Points](#integration-points) + +--- + +## Overview + +This document describes the architecture for two major features: + +1. **Hand History Logging** — Log every hand played in PokerTracker 3 / iPoker format for later review, analysis, and a future hand tester function. +2. **Tournament Improvements** — Explicit blind arrays, SNG configuration with all 6 bot types, prize structure configuration, multi-tournament execution with balance tracking. + +--- + +## Feature 1: Hand History Logging + +### 1.1 Module Structure + +A new module `holdem_core/src/game/hand_history.rs` will be the sole location for all hand history formatting and file I/O. It will be registered in [`holdem_core/src/game/mod.rs`](holdem_core/src/game/mod.rs:21). + +``` +holdem_core/src/game/ +├── hand_history.rs ← NEW: iPoker formatter + file writer +├── hand_runner.rs ← MODIFY: expose action history in HandResult +├── table.rs ← MODIFY: pass HandHistoryContext to logger +├── simulation.rs ← MODIFY: integrate logging into cash game loop +├── tournament.rs ← MODIFY: integrate logging into tournament loop +└── config.rs ← MODIFY: extend OutputConfig with log directory +``` + +### 1.2 Data Flow + +The hand history logger needs data from multiple points in the hand lifecycle. The key challenge is that [`HandResult`](holdem_core/src/game/hand_runner.rs:87) currently does NOT include action history or player names — only pot winners, hand values, board, chip movements, and rake. + +```mermaid +flowchart TD + A[Table::run_hand] --> B[HandRunner executes hand] + B --> C[HandRunner collects actions_this_hand] + B --> D[HandRunner produces HandResult] + D --> E[Table::complete_hand restores seats] + E --> F{log_hands enabled?} + F -->|Yes| G[Build HandHistoryRecord] + F -->|No| H[Skip logging] + G --> I[HandHistoryLogger::log_hand] + I --> J[Format iPoker text] + J --> K[Write to logs/hand_N_YYYY-MM-DD_HH-MM-SS.txt] + + subgraph Data Sources + L[HandResult: pot_winners, hand_values, board, chip_movements, rake] + M[HandHistoryRecord: actions, player_names, hole_cards, blind_positions, timestamp] + end + + L --> G + M --> G +``` + +### 1.3 Design Decision: Extend HandResult with Action History + +**Decision:** Add action history and supplementary data to [`HandResult`](holdem_core/src/game/hand_runner.rs:87) rather than exposing it separately from `HandRunner`. + +**Rationale:** +- [`HandRunner::actions_this_hand`](holdem_core/src/game/hand_runner.rs:146) is a private field that already collects `Vec>` (4 elements: preflop, flop, turn, river). +- [`HandRunner::into_seats()`](holdem_core/src/game/hand_runner.rs:838) consumes the runner, so actions must be extracted before or during that call. +- Adding actions to `HandResult` keeps the API clean — callers get everything from one struct. +- The `HandRunner::run_to_completion()` method already builds `HandResult` internally; we extend it to include actions. + +### 1.4 New Data Structures + +#### `HandHistoryRecord` + +A self-contained record with everything needed to produce an iPoker hand history file. This is NOT stored in `HandResult` — it is assembled by the caller (Table or simulation loop) from `HandResult` plus external context. + +```rust +/// Complete record of a hand for history logging. +pub struct HandHistoryRecord { + /// Hand number within the session. + pub hand_number: u64, + /// Timestamp when the hand started. + pub timestamp: chrono::DateTime, + /// Table name (e.g., "Tournament 12345, Table 1" or "Cash Game Table 1"). + pub table_name: String, + /// Maximum seats at the table. + pub max_seats: u8, + /// Dealer button seat index (1-based for iPoker format). + pub button_seat: u8, + /// Player entries: seat number, name, starting stack. + pub players: Vec, + /// Blind postings: player name, amount, type (SB/BB/Ante). + pub blind_postings: Vec, + /// Actions per street: preflop, flop, turn, river. + /// Each street is a list of (player_name, action_description). + pub street_actions: Vec>, + /// Board cards per street. + pub board_cards: Vec>, + /// Showdown entries: player name, hole cards, hand description. + pub showdown_entries: Vec, + /// Pot collection entries: player name, amount. + pub collections: Vec, + /// Total pot and rake. + pub total_pot: ChipCount, + pub total_rake: ChipCount, + /// Summary information. + pub summary: HandSummary, +} +``` + +#### Supporting types: + +```rust +pub struct HandHistoryPlayer { + pub seat: u8, // 1-based + pub name: String, + pub stack: ChipCount, +} + +pub struct BlindPosting { + pub player_name: String, + pub amount: ChipCount, + pub blind_type: BlindType, // SmallBlind, BigBlind, Ante +} + +pub struct StreetAction { + pub player_name: String, + pub action_type: ActionDescription, + pub amount: ChipCount, // 0 for fold/check +} + +pub enum ActionDescription { + Fold, + Check, + Call, + Raise { total: ChipCount }, + Bet { total: ChipCount }, + AllIn { total: ChipCount }, +} + +pub struct ShowdownEntry { + pub player_name: String, + pub seat: u8, + pub hole_cards: Option, + pub hand_description: String, // e.g., "a pair of Aces" + pub result: ShowdownResult, // Won, Lost +} + +pub struct PotCollection { + pub player_name: String, + pub amount: ChipCount, +} + +pub struct HandSummary { + pub total_pot: ChipCount, + pub rake: ChipCount, + pub board: Vec, + pub player_summaries: Vec, +} + +pub struct PlayerSummary { + pub seat: u8, + pub name: String, + pub action: String, // e.g., "collected 800", "showed [As Kh] and lost" +} +``` + +### 1.5 Changes to HandResult + +Extend [`HandResult`](holdem_core/src/game/hand_runner.rs:87) to include action history: + +```rust +pub struct HandResult { + // ... existing fields ... + pub pot_winners: Vec>, + pub hand_values: HashMap, + pub board: Board, + pub chip_movements: HashMap, + pub total_rake: ChipCount, + pub rake_per_pot: Vec, + + // NEW FIELDS: + /// Actions taken during the hand, grouped by street. + /// Index 0 = preflop, 1 = flop, 2 = turn, 3 = river. + pub actions: Vec>, + + /// Hole cards for each player who was dealt cards. + /// Keyed by PlayerId. Available for all players, not just showdown. + pub hole_cards: HashMap, + + /// Seat assignments: PlayerId -> seat index (0-based). + pub seat_assignments: HashMap, + + /// Blind positions: SB and BB seat indices. + pub sb_seat: SeatIndex, + pub bb_seat: SeatIndex, + + /// Dealer seat index. + pub dealer_seat: SeatIndex, + + /// Small blind and big blind amounts used. + pub small_blind: ChipCount, + pub big_blind: ChipCount, + pub ante: ChipCount, +} +``` + +**Where to populate:** In [`HandRunner::run_to_completion()`](holdem_core/src/game/hand_runner.rs), after the hand finishes and before returning the result. The data is already available in `HandRunner`'s private fields: +- `actions_this_hand` → `result.actions` +- `seats[].hole_cards` → `result.hole_cards` +- `seats[].index` → `result.seat_assignments` +- `sb_seat_idx`, `bb_seat_idx` → `result.sb_seat`, `result.bb_seat` +- `dealer_seat` → `result.dealer_seat` +- `small_blind`, `big_blind`, `ante` → already available + +### 1.6 HandHistoryLogger + +The logger is a struct that manages file output: + +```rust +/// Logs hand histories in iPoker format. +pub struct HandHistoryLogger { + /// Directory for log files. + log_dir: PathBuf, + /// Whether logging is enabled. + enabled: bool, +} + +impl HandHistoryLogger { + pub fn new(log_dir: PathBuf, enabled: bool) -> Self; + + /// Ensures the log directory exists (creates if needed). + pub fn ensure_dir(&self) -> std::io::Result<()>; + + /// Logs a hand to a file. + pub fn log_hand(&self, record: &HandHistoryRecord) -> std::io::Result<()>; + + /// Generates the filename for a hand. + fn filename(&self, record: &HandHistoryRecord) -> String { + format!( + "hand_{}_{}.txt", + record.hand_number, + record.timestamp.format("%Y-%m-%d_%H-%M-%S") + ) + } +} +``` + +### 1.7 iPoker Format Specification + +The formatter produces text matching the PokerTracker 3 / iPoker format. The format sections are: + +``` +Game # starts on +Table "" (-max) Seat # is the button +Seat : ( in chips) +: posts small blind +: posts big blind +: posts ante +*** HOLE CARDS *** +Dealt to [ ] +: folds +: calls +: raises to +: checks +*** FLOP *** [ ] +... actions ... +*** TURN *** [ ] [] +... actions ... +*** RIVER *** [ ] [] +... actions ... +*** SHOW DOWN *** +: shows [ ] () + collects from pot +*** SUMMARY *** +Total pot | Rake +Board: [ ] +Seat : collected (showed [ ] and won with ) +Seat : showed [ ] and lost with +Seat : folded before flop (did not show) +``` + +**Card formatting:** Cards use the notation from [`Card::Display`](holdem_core/src/card.rs) — rank char + suit char, e.g., `As`, `Kh`, `Td`, `2c`. Rank mapping: `2-9, T, J, Q, K, A`. Suit mapping: `h, d, c, s`. + +**Hand description mapping** from [`HandRank`](holdem_core/src/evaluator/evaluator.rs:14): + +| HandRank | iPoker Description | +|----------|-------------------| +| HighCard | "high card Ace" / "high card King" etc. | +| Pair | "a pair of Aces" | +| TwoPair | "two pairs, Kings over Queens" | +| ThreeOfAKind | "three of a kind, Aces" | +| Straight | "a straight, Ace to Five" | +| Flush | "a flush, Ace high" | +| FullHouse | "a full house, Aces over Kings" | +| FourOfAKind | "four of a kind, Aces" | +| StraightFlush | "a straight flush, Ace to Five" | +| RoyalFlush | "a royal flush" | + +### 1.8 Integration Points + +#### Cash Game Integration + +In [`run_cash_game()`](holdem_core/src/game/simulation.rs:33), after each `table.run_hand()`: + +```rust +// Create logger from config +let logger = HandHistoryLogger::new( + PathBuf::from("logs"), + config_output.log_hands, +); +logger.ensure_dir()?; + +// In the hand loop: +match table.run_hand() { + Ok(result) => { + stats.record_hand_result(&result, &player_names); + + if logger.is_enabled() { + let record = build_hand_history_record( + &result, &player_names, &table, "Cash Game Table 1" + ); + logger.log_hand(&record)?; + } + } + // ... +} +``` + +#### Tournament Integration + +In [`Tournament::run_next_hands()`](holdem_core/src/game/tournament.rs:362), after each hand result: + +```rust +// Tournament accepts an optional HandHistoryLogger +if let Some(ref logger) = self.hand_logger { + let record = build_hand_history_record( + &hand_result, &self.player_names, &table, &table_name + ); + logger.log_hand(&record)?; +} +``` + +### 1.9 Building HandHistoryRecord + +A helper function constructs `HandHistoryRecord` from the components: + +```rust +/// Builds a HandHistoryRecord from a completed hand result. +/// +/// Requires: +/// - HandResult (with extended action/seat data) +/// - Player name mapping (PlayerId -> name) +/// - Table reference (for table name, max seats) +pub fn build_hand_history_record( + result: &HandResult, + player_names: &HashMap, + table: &Table, + table_name: &str, +) -> HandHistoryRecord { + // 1. Map seat_assignments to HandHistoryPlayer list + // 2. Extract blind postings from actions[0] (preflop) where action is Raise for SB/BB + // 3. Map actions per street to StreetAction with player names + // 4. Map board cards from result.board + // 5. Build showdown entries from result.pot_winners + result.hand_values + result.hole_cards + // 6. Build collections from result.pot_winners + // 7. Build summary +} +``` + +### 1.10 Config Changes + +Extend [`OutputConfig`](holdem_core/src/game/config.rs:442): + +```rust +pub struct OutputConfig { + pub directory: Option, + pub log_hands: bool, + pub log_actions: bool, + // NEW: + /// Directory for hand history log files. Defaults to "logs". + #[serde(default = "default_log_dir")] + pub log_dir: Option, +} + +fn default_log_dir() -> String { + "logs".to_string() +} +``` + +### 1.11 Dependency Addition + +Add `chrono` to [`holdem_core/Cargo.toml`](holdem_core/Cargo.toml) for timestamp support: + +```toml +[dependencies] +chrono = { version = "0.4", features = ["serde"] } +``` + +--- + +## Feature 2: Tournament Improvements + +### 2.1 Explicit Blind Levels in TOML + +#### Config Changes + +Add optional `blind_levels` field to [`GameConfig`](holdem_core/src/game/config.rs:235): + +```rust +pub struct GameConfig { + // ... existing fields ... + + /// Explicit blind levels. If present, overrides num_blind_levels and hands_per_level. + #[serde(default)] + pub blind_levels: Vec, +} + +/// Serde-compatible blind level for TOML configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlindLevelConfig { + pub small_blind: ChipCount, + pub big_blind: ChipCount, + #[serde(default)] + pub ante: ChipCount, + #[serde(default = "default_hands_per_level")] + pub hands: u32, +} +``` + +TOML format: + +```toml +[[game.blind_levels]] +small_blind = 10 +big_blind = 20 +ante = 0 +hands = 10 + +[[game.blind_levels]] +small_blind = 15 +big_blind = 30 +ante = 0 +hands = 10 +``` + +#### Updated `to_blind_schedule()` + +Modify [`GameConfig::to_blind_schedule()`](holdem_core/src/game/config.rs:391) to check for explicit levels first: + +```rust +pub fn to_blind_schedule(&self) -> BlindSchedule { + if !self.blind_levels.is_empty() { + // Use explicit levels from config + let levels: Vec = self.blind_levels + .iter() + .enumerate() + .map(|(i, lc)| BlindLevel { + level: (i + 1) as u32, + small_blind: lc.small_blind, + big_blind: lc.big_blind, + ante: lc.ante, + duration_hands: lc.hands, + }) + .collect(); + BlindSchedule::custom(levels) + } else { + // Existing standard doubling schedule + // ... (current code) + } +} +``` + +### 2.2 Custom Prize Structure + +#### Config Changes + +Add optional `prizes` field to [`GameConfig`](holdem_core/src/game/config.rs:235): + +```rust +pub struct GameConfig { + // ... existing fields ... + + /// Custom prize structure as absolute amounts. If present, overrides + /// standard_sng / standard_mtt calculations. + /// Example: [5, 3, 2] means 1st=5, 2nd=3, 3rd=2. + #[serde(default)] + pub prizes: Vec, +} +``` + +TOML format: + +```toml +[game] +prizes = [5, 3, 2] +``` + +#### Updated `to_prize_structure()` + +Modify [`GameConfig::to_prize_structure()`](holdem_core/src/game/config.rs:423): + +```rust +pub fn to_prize_structure(&self) -> PrizeStructure { + if !self.prizes.is_empty() { + PrizeStructure { prizes: self.prizes.clone() } + } else { + match self.game_type { + GameType::Cash => PrizeStructure::standard_sng(self.buy_in, num_players), + GameType::SitAndGo => PrizeStructure::standard_sng(self.buy_in, num_players), + GameType::MultiTableTournament => { + PrizeStructure::standard_mtt(self.buy_in, total_entries) + } + } + } +} +``` + +### 2.3 Seat Switching Between Tournaments + +#### Current Behavior + +- [`TableConfig.randomize_seats`](holdem_core/src/game/table.rs:51) shuffles seats before each hand in cash games. +- [`Table::shuffle_seats()`](holdem_core/src/game/table.rs) already exists and works. +- In tournaments, [`Table::start_hand()`](holdem_core/src/game/table.rs:327) calls `shuffle_seats()` if `randomize_seats` is true, but this is per-hand, not per-tournament. + +#### Design + +For multi-tournament SNG, we need seat randomization **between tournaments**, not between hands within a tournament. The approach: + +1. **Keep `randomize_seats` on `GameConfig`** with its current meaning for cash games. +2. **In the multi-tournament runner** (new function), call `table.shuffle_seats()` once before each tournament starts, but set `randomize_seats = false` on the `TableConfig` so it does not shuffle per-hand. + +```rust +/// Runs multiple SNG tournaments and aggregates results. +pub fn run_sng_series( + config: &GameConfig, + num_tournaments: u32, +) -> Result { + let mut aggregate_stats = SimulationStats::new("SitAndGo Series"); + + for tournament_num in 0..num_tournaments { + let mut table_config = config.to_table_config(); + table_config.randomize_seats = false; // don't shuffle per-hand + + let blind_schedule = config.to_blind_schedule(); + let prize_structure = config.to_prize_structure(); + + let mut tournament = Tournament::new_sng( + table_config, + blind_schedule, + prize_structure, + config.starting_chips, + config.buy_in, + ); + + // Register bots (fresh instances each tournament) + for (i, bot_config) in config.bots.iter().enumerate() { + let player_id = PlayerId(i as u64); + let bot = create_bot(bot_config, player_id)?; + tournament.register_player(bot)?; + } + + // Shuffle seats between tournaments + if config.randomize_seats { + tournament.shuffle_seats()?; // NEW method on Tournament + } + + // Run tournament + tournament.run_to_completion()?; + + // Record results + aggregate_stats.record_tournament_result( + &tournament, &player_names, config.buy_in + ); + } + + // ... compute aggregate stats +} +``` + +#### New `Tournament::shuffle_seats()` + +Add a method to [`Tournament`](holdem_core/src/game/tournament.rs:228) that shuffles seats on all tables: + +```rust +/// Shuffles seat assignments on all tables. +/// Used between tournaments in a series to randomize positions. +pub fn shuffle_seats(&mut self) { + for table in &mut self.tables { + table.shuffle_seats(); + } +} +``` + +### 2.4 Multi-Tournament Execution + +#### Config Changes + +Add `num_tournaments` to [`OutputConfig`](holdem_core/src/game/config.rs:442) (or `GameConfig`): + +```rust +pub struct OutputConfig { + // ... existing fields ... + + /// Number of tournaments to run in a series. Default: 1. + #[serde(default = "default_one")] + pub num_tournaments: u32, +} +``` + +TOML: + +```toml +[output] +log_hands = true +num_tournaments = 100 +``` + +#### New: SngSeriesResult + +Aggregate results across multiple tournaments: + +```rust +/// Results from a series of SNG tournaments. +pub struct SngSeriesResult { + /// Number of tournaments played. + pub tournaments_played: u32, + /// Per-player aggregate statistics. + pub player_results: HashMap, + /// Total hands played across all tournaments. + pub total_hands: u64, +} + +/// Aggregate results for one player across a tournament series. +pub struct SngPlayerSeriesResult { + pub player_id: PlayerId, + pub name: String, + /// Number of tournaments this player won (1st place). + pub wins: u32, + /// Number of times this player finished in each paid position. + pub finishes: HashMap, // position -> count + /// Total entry fees paid (num_tournaments * buy_in). + pub total_entry_fees: ChipCount, + /// Total prize money won. + pub total_prize_money: ChipCount, + /// Net profit = total_prize_money - total_entry_fees. + pub net_profit: i64, + /// ROI as percentage = (net_profit / total_entry_fees) * 100. + pub roi_percent: f64, + /// Average finishing position. + pub avg_finish: f64, +} +``` + +### 2.5 Balance Tracking (Money, Not Chips) + +The balance tracking is about tracking **real money** (entry fees vs. prize money) across multiple tournaments, not chip balances within a single tournament. + +**Key insight:** Within a single tournament, chips are play money. The financial outcome is: +- **Cost:** `buy_in` per tournament +- **Revenue:** `prize` based on finishing position +- **Profit:** `prize - buy_in` + +This is already partially implemented in [`SimulationStats::record_tournament_result()`](holdem_core/src/game/stats.rs:224) which computes `profit = prize - buy_in`. The multi-tournament aggregation adds: + +1. Accumulating profits across N tournaments +2. Computing ROI across the series +3. Tracking position distribution (how often each player finishes 1st, 2nd, 3rd, etc.) + +### 2.6 Data Flow for Multi-Tournament + +```mermaid +flowchart TD + A[Load SimulationConfig] --> B[Parse blind_levels, prizes, num_tournaments] + B --> C{For each tournament 1..N} + C --> D[Create fresh Tournament instance] + D --> E[Create fresh bot instances] + E --> F{randomize_seats?} + F -->|Yes| G[Tournament.shuffle_seats] + F -->|No| H[Skip] + G --> I[Register bots] + H --> I + I --> J[Tournament.run_to_completion] + J --> K[Record tournament result] + K --> L[Accumulate SngPlayerSeriesResult] + L --> C + C -->|Done| M[Compute aggregate stats] + M --> N[Print summary table] + M --> O[Optionally log individual tournament results] +``` + +### 2.7 Main.rs Changes + +Update [`holdem_sim/src/main.rs`](holdem_sim/src/main.rs) to handle multi-tournament: + +```rust +fn run_tournament_simulation(config: &GameConfig, output_config: &OutputConfig) { + let num_tournaments = output_config.num_tournaments.max(1); + + if num_tournaments > 1 { + // Run SNG series + run_sng_series(config, num_tournaments); + } else { + // Single tournament (existing behavior) + // ... current code ... + } +} +``` + +The `SimulationConfig` needs to be threaded through to the tournament runner so `output.num_tournaments` is accessible. + +--- + +## Implementation Plan + +### Phase 1: Hand History Logging + +| Step | File | Action | +|------|------|--------| +| 1.1 | `holdem_core/Cargo.toml` | Add `chrono` dependency | +| 1.2 | `holdem_core/src/game/hand_runner.rs` | Extend `HandResult` with `actions`, `hole_cards`, `seat_assignments`, `sb_seat`, `bb_seat`, `dealer_seat`, `small_blind`, `big_blind`, `ante` fields. Populate them in `run_to_completion()` before returning. | +| 1.3 | `holdem_core/src/game/hand_history.rs` | Create new module with `HandHistoryRecord`, supporting types, `HandHistoryLogger`, `build_hand_history_record()`, and iPoker formatter | +| 1.4 | `holdem_core/src/game/mod.rs` | Add `pub mod hand_history;` and re-export key types | +| 1.5 | `holdem_core/src/game/config.rs` | Add `log_dir` field to `OutputConfig` | +| 1.6 | `holdem_core/src/game/simulation.rs` | Integrate `HandHistoryLogger` into `run_cash_game()` | +| 1.7 | `holdem_core/src/game/table.rs` | Expose `hand_number()` and `config()` publicly if not already | +| 1.8 | Tests | Unit tests for iPoker formatter, integration test for full hand logging | + +### Phase 2: Tournament Config Improvements + +| Step | File | Action | +|------|------|--------| +| 2.1 | `holdem_core/src/game/config.rs` | Add `BlindLevelConfig` struct, `blind_levels` field to `GameConfig`, `prizes` field to `GameConfig` | +| 2.2 | `holdem_core/src/game/config.rs` | Update `to_blind_schedule()` to use explicit levels when present | +| 2.3 | `holdem_core/src/game/config.rs` | Update `to_prize_structure()` to use custom prizes when present | +| 2.4 | `holdem_core/examples/configs/sng_tournament.toml` | Update example config with explicit blind levels and prize structure | +| 2.5 | Tests | Unit tests for TOML parsing of explicit blind levels and custom prizes | + +### Phase 3: Multi-Tournament Execution + +| Step | File | Action | +|------|------|--------| +| 3.1 | `holdem_core/src/game/config.rs` | Add `num_tournaments` to `OutputConfig` | +| 3.2 | `holdem_core/src/game/tournament.rs` | Add `Tournament::shuffle_seats()` method | +| 3.3 | `holdem_core/src/game/simulation.rs` | Create `run_sng_series()` function with `SngSeriesResult` | +| 3.4 | `holdem_core/src/game/stats.rs` | Add `SngPlayerSeriesResult` and aggregation logic | +| 3.5 | `holdem_sim/src/main.rs` | Update to pass `OutputConfig` to tournament runner, handle `num_tournaments > 1` | +| 3.6 | Tests | Integration test for multi-tournament series | + +### Phase 4: Tournament Hand Logging Integration + +| Step | File | Action | +|------|------|--------| +| 4.1 | `holdem_core/src/game/tournament.rs` | Add optional `HandHistoryLogger` to `Tournament`, integrate into `run_next_hands()` | +| 4.2 | `holdem_core/src/game/tournament.rs` | Track player names mapping inside Tournament for logging | +| 4.3 | Tests | Integration test verifying hand logs from tournament play | + +--- + +## Data Structures + +### Summary of All New Types + +| Type | Location | Purpose | +|------|----------|---------| +| `HandHistoryRecord` | `hand_history.rs` | Complete hand record for logging | +| `HandHistoryPlayer` | `hand_history.rs` | Player info in hand history | +| `BlindPosting` | `hand_history.rs` | Blind/ante posting record | +| `StreetAction` | `hand_history.rs` | Player action on a street | +| `ActionDescription` | `hand_history.rs` | Action type for logging | +| `ShowdownEntry` | `hand_history.rs` | Showdown participant | +| `PotCollection` | `hand_history.rs` | Pot collection record | +| `HandSummary` | `hand_history.rs` | Hand summary section | +| `PlayerSummary` | `hand_history.rs` | Per-player summary | +| `HandHistoryLogger` | `hand_history.rs` | File writer for hand histories | +| `BlindLevelConfig` | `config.rs` | TOML-deserializable blind level | +| `SngSeriesResult` | `simulation.rs` | Multi-tournament aggregate | +| `SngPlayerSeriesResult` | `simulation.rs` | Per-player series statistics | + +### Summary of Modified Types + +| Type | Location | Changes | +|------|----------|---------| +| `HandResult` | `hand_runner.rs` | Add `actions`, `hole_cards`, `seat_assignments`, blind position fields | +| `GameConfig` | `config.rs` | Add `blind_levels: Vec`, `prizes: Vec` | +| `OutputConfig` | `config.rs` | Add `log_dir: Option`, `num_tournaments: u32` | +| `Tournament` | `tournament.rs` | Add `shuffle_seats()` method, optional `HandHistoryLogger` | + +--- + +## Integration Points + +### How New Code Connects to Existing Code + +```mermaid +flowchart LR + subgraph Config Layer + GC[GameConfig] + OC[OutputConfig] + end + + subgraph Engine Layer + HR[HandRunner] + T[Table] + TN[Tournament] + end + + subgraph New Modules + HH[hand_history.rs] + SS[run_sng_series] + end + + subgraph Output + LOG[logs/ directory] + STATS[SimulationStats] + SNG[SngSeriesResult] + end + + GC -->|blind_levels| TN + GC -->|prizes| TN + OC -->|log_hands, log_dir| HH + OC -->|num_tournaments| SS + + HR -->|Extended HandResult| HH + HR -->|Extended HandResult| STATS + T -->|run_hand result| HH + TN -->|per-hand results| HH + TN -->|standings| STATS + SS -->|aggregate| SNG + + HH -->|writes| LOG +``` + +### Key API Surfaces + +1. **`HandResult` extensions** — The extended struct is backward-compatible. New fields are populated automatically by `HandRunner::run_to_completion()`. Existing callers that destructure `HandResult` will get compiler warnings for unused fields but no breakage. + +2. **`HandHistoryLogger`** — Standalone struct with no trait dependencies. Created by the simulation runner and optionally passed to `Table`/`Tournament`. + +3. **`BlindLevelConfig`** — Pure config struct. Only affects `GameConfig::to_blind_schedule()`. + +4. **`run_sng_series()`** — New function in `simulation.rs`. Does not modify existing `run_cash_game()` or single-tournament flow. + +5. **`Tournament::shuffle_seats()`** — Delegates to existing `Table::shuffle_seats()`. No new behavior, just an access method. + +### Backward Compatibility + +- All new `GameConfig` fields use `#[serde(default)]`, so existing TOML configs continue to work. +- `HandResult` extensions are additive — existing code that ignores new fields is unaffected. +- `OutputConfig` extensions are additive with sensible defaults. +- The single-tournament path in `main.rs` is preserved; multi-tournament is triggered only when `num_tournaments > 1`. diff --git a/holdem_core/examples/configs/cash_game.toml b/holdem_core/examples/configs/cash_game.toml index da9fc29..23300f1 100644 --- a/holdem_core/examples/configs/cash_game.toml +++ b/holdem_core/examples/configs/cash_game.toml @@ -4,7 +4,7 @@ num_seats = 10 small_blind = 10 big_blind = 20 ante = 0 -starting_chips = 5000 +starting_chips = 1500 num_hands = 10000 # Rake configuration (optional, defaults to no rake). @@ -15,8 +15,12 @@ num_hands = 10000 # reset_bankroll_each_hand = false # bankroll_refill_threshold = 1000 # Refill when stack <= 1000 +# Auto-rebuy: when enabled, players whose stack drops below half the +# starting_chips are automatically topped back up to starting_chips. +auto_rebuy = true + # Randomize seat assignments before each hand (optional, defaults to false). -# randomize_seats = true +randomize_seats = true [game.bet_limit] type = "no_limit" diff --git a/holdem_core/examples/configs/mtt_10_tables.toml b/holdem_core/examples/configs/mtt_10_tables.toml new file mode 100644 index 0000000..fda4598 --- /dev/null +++ b/holdem_core/examples/configs/mtt_10_tables.toml @@ -0,0 +1,527 @@ +# MTT Tournament Series — 10 tables, 100 players, 100 tournaments +# +# Each of the 6 bot types appears multiple times to fill 100 seats. +# Tables have 10 seats each; players are distributed round-robin +# across tables by the tournament's register_player() logic. +# As players bust out, tables will eventually consolidate +# (last player standing wins). +# +# 100 tournaments are run with aggregate statistics tracked by player name. +# Entry fee: 5 per tournament. Final table prizes: 200, 100, 50, 30, 20, 10, 10, 10, 10, 10. + +[game] +game_type = "MultiTableTournament" +num_seats = 10 +num_tables = 10 +small_blind = 10 +big_blind = 20 +starting_chips = 1500 +buy_in = 5 +hands_per_level = 10 +num_blind_levels = 20 +randomize_seats = true +num_tournaments = 100 + +[game.bet_limit] +type = "no_limit" +min_raise = 20 + +# --------------------------------------------------------------------------- +# Prize structure (100 players × $5 buy-in = $500 prize pool, top 10 paid) +# --------------------------------------------------------------------------- +prizes = [200, 100, 50, 30, 20, 10, 10, 10, 10, 10] + +# --------------------------------------------------------------------------- +# Blind levels (extended to 12 levels) +# --------------------------------------------------------------------------- + +[[game.blind_levels]] +small_blind = 10 +big_blind = 20 +ante = 0 +hands = 10 + +[[game.blind_levels]] +small_blind = 15 +big_blind = 30 +ante = 0 +hands = 10 + +[[game.blind_levels]] +small_blind = 25 +big_blind = 50 +ante = 5 +hands = 10 + +[[game.blind_levels]] +small_blind = 50 +big_blind = 100 +ante = 10 +hands = 10 + +[[game.blind_levels]] +small_blind = 75 +big_blind = 150 +ante = 15 +hands = 10 + +[[game.blind_levels]] +small_blind = 100 +big_blind = 200 +ante = 20 +hands = 10 + +[[game.blind_levels]] +small_blind = 150 +big_blind = 300 +ante = 25 +hands = 10 + +[[game.blind_levels]] +small_blind = 200 +big_blind = 400 +ante = 50 +hands = 10 + +[[game.blind_levels]] +small_blind = 300 +big_blind = 600 +ante = 75 +hands = 10 + +[[game.blind_levels]] +small_blind = 400 +big_blind = 800 +ante = 100 +hands = 10 + +[[game.blind_levels]] +small_blind = 600 +big_blind = 1200 +ante = 150 +hands = 10 + +[[game.blind_levels]] +small_blind = 800 +big_blind = 1600 +ante = 200 +hands = 10 + +# --------------------------------------------------------------------------- +# Bots — 100 total (10 tables × 10 seats) +# 17 FlockBot, 17 ChumpBot, 17 CallBot, 17 RaiseBot, 16 RandomBot, 16 FoldBot +# --------------------------------------------------------------------------- + +# FlockBot copies (17) +[[game.bots]] +bot_type = "flock_bot" +name = "FlockBot-1" + +[[game.bots]] +bot_type = "flock_bot" +name = "FlockBot-2" + +[[game.bots]] +bot_type = "flock_bot" +name = "FlockBot-3" + +[[game.bots]] +bot_type = "flock_bot" +name = "FlockBot-4" + +[[game.bots]] +bot_type = "flock_bot" +name = "FlockBot-5" + +[[game.bots]] +bot_type = "flock_bot" +name = "FlockBot-6" + +[[game.bots]] +bot_type = "flock_bot" +name = "FlockBot-7" + +[[game.bots]] +bot_type = "flock_bot" +name = "FlockBot-8" + +[[game.bots]] +bot_type = "flock_bot" +name = "FlockBot-9" + +[[game.bots]] +bot_type = "flock_bot" +name = "FlockBot-10" + +[[game.bots]] +bot_type = "flock_bot" +name = "FlockBot-11" + +[[game.bots]] +bot_type = "flock_bot" +name = "FlockBot-12" + +[[game.bots]] +bot_type = "flock_bot" +name = "FlockBot-13" + +[[game.bots]] +bot_type = "flock_bot" +name = "FlockBot-14" + +[[game.bots]] +bot_type = "flock_bot" +name = "FlockBot-15" + +[[game.bots]] +bot_type = "flock_bot" +name = "FlockBot-16" + +[[game.bots]] +bot_type = "flock_bot" +name = "FlockBot-17" + +# ChumpBot copies (17) +[[game.bots]] +bot_type = "chump_bot" +name = "ChumpBot-1" + +[[game.bots]] +bot_type = "chump_bot" +name = "ChumpBot-2" + +[[game.bots]] +bot_type = "chump_bot" +name = "ChumpBot-3" + +[[game.bots]] +bot_type = "chump_bot" +name = "ChumpBot-4" + +[[game.bots]] +bot_type = "chump_bot" +name = "ChumpBot-5" + +[[game.bots]] +bot_type = "chump_bot" +name = "ChumpBot-6" + +[[game.bots]] +bot_type = "chump_bot" +name = "ChumpBot-7" + +[[game.bots]] +bot_type = "chump_bot" +name = "ChumpBot-8" + +[[game.bots]] +bot_type = "chump_bot" +name = "ChumpBot-9" + +[[game.bots]] +bot_type = "chump_bot" +name = "ChumpBot-10" + +[[game.bots]] +bot_type = "chump_bot" +name = "ChumpBot-11" + +[[game.bots]] +bot_type = "chump_bot" +name = "ChumpBot-12" + +[[game.bots]] +bot_type = "chump_bot" +name = "ChumpBot-13" + +[[game.bots]] +bot_type = "chump_bot" +name = "ChumpBot-14" + +[[game.bots]] +bot_type = "chump_bot" +name = "ChumpBot-15" + +[[game.bots]] +bot_type = "chump_bot" +name = "ChumpBot-16" + +[[game.bots]] +bot_type = "chump_bot" +name = "ChumpBot-17" + +# CallBot copies (17) +[[game.bots]] +bot_type = "call_bot" +name = "CallBot-1" + +[[game.bots]] +bot_type = "call_bot" +name = "CallBot-2" + +[[game.bots]] +bot_type = "call_bot" +name = "CallBot-3" + +[[game.bots]] +bot_type = "call_bot" +name = "CallBot-4" + +[[game.bots]] +bot_type = "call_bot" +name = "CallBot-5" + +[[game.bots]] +bot_type = "call_bot" +name = "CallBot-6" + +[[game.bots]] +bot_type = "call_bot" +name = "CallBot-7" + +[[game.bots]] +bot_type = "call_bot" +name = "CallBot-8" + +[[game.bots]] +bot_type = "call_bot" +name = "CallBot-9" + +[[game.bots]] +bot_type = "call_bot" +name = "CallBot-10" + +[[game.bots]] +bot_type = "call_bot" +name = "CallBot-11" + +[[game.bots]] +bot_type = "call_bot" +name = "CallBot-12" + +[[game.bots]] +bot_type = "call_bot" +name = "CallBot-13" + +[[game.bots]] +bot_type = "call_bot" +name = "CallBot-14" + +[[game.bots]] +bot_type = "call_bot" +name = "CallBot-15" + +[[game.bots]] +bot_type = "call_bot" +name = "CallBot-16" + +[[game.bots]] +bot_type = "call_bot" +name = "CallBot-17" + +# RaiseBot copies (17) +[[game.bots]] +bot_type = "raise_bot" +name = "RaiseBot-1" + +[[game.bots]] +bot_type = "raise_bot" +name = "RaiseBot-2" + +[[game.bots]] +bot_type = "raise_bot" +name = "RaiseBot-3" + +[[game.bots]] +bot_type = "raise_bot" +name = "RaiseBot-4" + +[[game.bots]] +bot_type = "raise_bot" +name = "RaiseBot-5" + +[[game.bots]] +bot_type = "raise_bot" +name = "RaiseBot-6" + +[[game.bots]] +bot_type = "raise_bot" +name = "RaiseBot-7" + +[[game.bots]] +bot_type = "raise_bot" +name = "RaiseBot-8" + +[[game.bots]] +bot_type = "raise_bot" +name = "RaiseBot-9" + +[[game.bots]] +bot_type = "raise_bot" +name = "RaiseBot-10" + +[[game.bots]] +bot_type = "raise_bot" +name = "RaiseBot-11" + +[[game.bots]] +bot_type = "raise_bot" +name = "RaiseBot-12" + +[[game.bots]] +bot_type = "raise_bot" +name = "RaiseBot-13" + +[[game.bots]] +bot_type = "raise_bot" +name = "RaiseBot-14" + +[[game.bots]] +bot_type = "raise_bot" +name = "RaiseBot-15" + +[[game.bots]] +bot_type = "raise_bot" +name = "RaiseBot-16" + +[[game.bots]] +bot_type = "raise_bot" +name = "RaiseBot-17" + +# RandomBot copies (16) +[[game.bots]] +bot_type = "random_bot" +name = "RandomBot-1" + +[[game.bots]] +bot_type = "random_bot" +name = "RandomBot-2" + +[[game.bots]] +bot_type = "random_bot" +name = "RandomBot-3" + +[[game.bots]] +bot_type = "random_bot" +name = "RandomBot-4" + +[[game.bots]] +bot_type = "random_bot" +name = "RandomBot-5" + +[[game.bots]] +bot_type = "random_bot" +name = "RandomBot-6" + +[[game.bots]] +bot_type = "random_bot" +name = "RandomBot-7" + +[[game.bots]] +bot_type = "random_bot" +name = "RandomBot-8" + +[[game.bots]] +bot_type = "random_bot" +name = "RandomBot-9" + +[[game.bots]] +bot_type = "random_bot" +name = "RandomBot-10" + +[[game.bots]] +bot_type = "random_bot" +name = "RandomBot-11" + +[[game.bots]] +bot_type = "random_bot" +name = "RandomBot-12" + +[[game.bots]] +bot_type = "random_bot" +name = "RandomBot-13" + +[[game.bots]] +bot_type = "random_bot" +name = "RandomBot-14" + +[[game.bots]] +bot_type = "random_bot" +name = "RandomBot-15" + +[[game.bots]] +bot_type = "random_bot" +name = "RandomBot-16" + +# FoldBot copies (16) +[[game.bots]] +bot_type = "fold_bot" +name = "FoldBot-1" + +[[game.bots]] +bot_type = "fold_bot" +name = "FoldBot-2" + +[[game.bots]] +bot_type = "fold_bot" +name = "FoldBot-3" + +[[game.bots]] +bot_type = "fold_bot" +name = "FoldBot-4" + +[[game.bots]] +bot_type = "fold_bot" +name = "FoldBot-5" + +[[game.bots]] +bot_type = "fold_bot" +name = "FoldBot-6" + +[[game.bots]] +bot_type = "fold_bot" +name = "FoldBot-7" + +[[game.bots]] +bot_type = "fold_bot" +name = "FoldBot-8" + +[[game.bots]] +bot_type = "fold_bot" +name = "FoldBot-9" + +[[game.bots]] +bot_type = "fold_bot" +name = "FoldBot-10" + +[[game.bots]] +bot_type = "fold_bot" +name = "FoldBot-11" + +[[game.bots]] +bot_type = "fold_bot" +name = "FoldBot-12" + +[[game.bots]] +bot_type = "fold_bot" +name = "FoldBot-13" + +[[game.bots]] +bot_type = "fold_bot" +name = "FoldBot-14" + +[[game.bots]] +bot_type = "fold_bot" +name = "FoldBot-15" + +[[game.bots]] +bot_type = "fold_bot" +name = "FoldBot-16" + +# --------------------------------------------------------------------------- +# Output settings +# --------------------------------------------------------------------------- + +[output] +log_hands = false +log_actions = false diff --git a/holdem_core/examples/configs/mtt_tournament.toml b/holdem_core/examples/configs/mtt_tournament.toml deleted file mode 100644 index a9efbf0..0000000 --- a/holdem_core/examples/configs/mtt_tournament.toml +++ /dev/null @@ -1,18 +0,0 @@ -[game] -game_type = "MultiTableTournament" -num_seats = 9 -num_tables = 3 -small_blind = 10 -big_blind = 20 -starting_chips = 10000 -buy_in = 5000 -hands_per_level = 10 -num_blind_levels = 20 - -[game.bet_limit] -type = "no_limit" -min_raise = 20 - -[output] -log_hands = true -log_actions = true diff --git a/holdem_core/examples/configs/sng_tournament.toml b/holdem_core/examples/configs/sng_tournament.toml index 4d25618..225ae88 100644 --- a/holdem_core/examples/configs/sng_tournament.toml +++ b/holdem_core/examples/configs/sng_tournament.toml @@ -1,21 +1,107 @@ [game] game_type = "SitAndGo" -num_seats = 9 -small_blind = 10 -big_blind = 20 -starting_chips = 5000 -buy_in = 1000 -hands_per_level = 10 -num_blind_levels = 15 +num_seats = 10 +starting_chips = 1500 +buy_in = 11 +randomize_seats = true +num_tournaments = 100 + +# Custom prize structure in chip amounts: 1st=5, 2nd=3, 3rd=2 +prizes = [50, 30, 20] [game.bet_limit] type = "no_limit" min_raise = 20 +# Explicit blind levels +[[game.blind_levels]] +small_blind = 10 +big_blind = 20 +ante = 0 +hands = 10 + +[[game.blind_levels]] +small_blind = 15 +big_blind = 30 +ante = 0 +hands = 10 + +[[game.blind_levels]] +small_blind = 25 +big_blind = 50 +ante = 5 +hands = 10 + +[[game.blind_levels]] +small_blind = 50 +big_blind = 100 +ante = 10 +hands = 10 + +[[game.blind_levels]] +small_blind = 75 +big_blind = 150 +ante = 15 +hands = 10 + +[[game.blind_levels]] +small_blind = 100 +big_blind = 200 +ante = 20 +hands = 10 + +[[game.blind_levels]] +small_blind = 150 +big_blind = 300 +ante = 25 +hands = 10 + +[[game.blind_levels]] +small_blind = 200 +big_blind = 400 +ante = 50 +hands = 10 + +# 10 bots (all 6 bot types, with duplicates to fill the table) +[[game.bots]] +bot_type = "flock_bot" +name = "FlockBot-1" + +[[game.bots]] +bot_type = "chump_bot" +name = "ChumpBot-1" + [[game.bots]] bot_type = "call_bot" -name = "Bot 1" +name = "CallBot-1" + +[[game.bots]] +bot_type = "raise_bot" +name = "RaiseBot-1" + +[[game.bots]] +bot_type = "random_bot" +name = "RandomBot-1" [[game.bots]] bot_type = "fold_bot" -name = "Bot 2" +name = "FoldBot-1" + +[[game.bots]] +bot_type = "flock_bot" +name = "FlockBot-2" + +[[game.bots]] +bot_type = "call_bot" +name = "CallBot-2" + +[[game.bots]] +bot_type = "raise_bot" +name = "RaiseBot-2" + +[[game.bots]] +bot_type = "random_bot" +name = "RandomBot-2" + +[output] +log_hands = true diff --git a/holdem_core/src/game/README.md b/holdem_core/src/game/README.md index 85402c1..3529026 100644 --- a/holdem_core/src/game/README.md +++ b/holdem_core/src/game/README.md @@ -162,15 +162,47 @@ The `action` module provides a pure validation function: /// Returns `Ok(())` or a `GameError` describing why the action is illegal. pub fn validate_action( action: &Action, + player_id: PlayerId, player_chips: ChipCount, player_wagered_this_round: ChipCount, current_bet: ChipCount, min_raise: ChipCount, bet_limit: &BetLimit, - has_acted_this_round: bool, + street: Street, + raises_this_round: usize, ) -> Result<(), GameError> ``` +The `street` and `raises_this_round` parameters enable correct Fixed-Limit +validation: the fixed bet size depends on the street (small bet on preflop/flop, +big bet on turn/river), and the number of raises is capped at 3 per round. + +Pot-limit max-raise enforcement is handled separately in `HandRunner::step()`, +which has access to the pot size via the `PotManager`. + +### Action Factory Methods + +The `Action` enum provides Meerkat-compatible convenience methods: + +```rust +impl Action { + /// Check if no bet to call, otherwise fold. + pub fn check_or_fold(current_bet: ChipCount, player_wagered: ChipCount) -> Self; + + /// Call the current bet (or go all-in if short-stacked). + pub fn call_action(current_bet: ChipCount, player_wagered: ChipCount, player_chips: ChipCount) -> Self; + + /// Fold. + pub fn fold_action() -> Self; + + /// Open with a bet (first aggressive action in a round). + pub fn bet_action(amount: ChipCount) -> Self; + + /// Raise to a specific total. + pub fn raise_action(total: ChipCount) -> Self; +} +``` + --- ## 4. Game Info — `game_info.rs` @@ -265,6 +297,41 @@ impl GameInfo { pub fn is_postflop(&self) -> bool { self.street >= Street::Flop } + + // --- Meerkat-compatible convenience methods --- + + /// Whether the current stage is preflop. + pub fn is_preflop(&self) -> bool { ... } + /// Whether the current stage is the flop. + pub fn is_flop(&self) -> bool { ... } + /// Whether the current stage is the turn. + pub fn is_turn(&self) -> bool { ... } + /// Whether the current stage is the river. + pub fn is_river(&self) -> bool { ... } + + /// Amount a player needs to put in to match the current bet. + pub fn get_amount_to_call(&self, player_id: PlayerId) -> ChipCount { ... } + + /// Street index: 0=preflop, 1=flop, 2=turn, 3=river. + pub fn stage_index(&self) -> usize { ... } + + /// Number of players still active (not folded or eliminated). + pub fn get_num_active_players(&self) -> usize { ... } + + /// Number of seats at the table. + pub fn get_num_seats(&self) -> usize { ... } + + /// Big blind size. + pub fn get_big_blind_size(&self) -> ChipCount { ... } + + /// Small blind size. + pub fn get_small_blind_size(&self) -> ChipCount { ... } + + /// Total pot size. + pub fn get_pot_size(&self) -> ChipCount { ... } + + /// Number of raises made in the current betting round. + pub fn get_num_raises(&self) -> usize { ... } } ``` diff --git a/holdem_core/src/game/action.rs b/holdem_core/src/game/action.rs index 51ceef1..111a69f 100644 --- a/holdem_core/src/game/action.rs +++ b/holdem_core/src/game/action.rs @@ -6,8 +6,9 @@ use serde::{Deserialize, Serialize}; -use crate::game::betting::BetLimit; +use crate::game::betting::{BetLimit, BettingRules}; use crate::game::types::{ChipCount, GameError, PlayerId}; +use crate::Street; /// Actions a player can take when it is their turn. /// @@ -86,6 +87,92 @@ impl Action { pub fn is_active(&self) -> bool { !matches!(self, Action::Fold) } + + /// Convenience factory matching Meerkat's `Action.checkOrFoldAction(toCall)`. + /// + /// Returns [`Action::Check`] when `amount_to_call` is 0 (nothing to call), + /// otherwise returns [`Action::Fold`]. + /// + /// ``` + /// use holdem_core::game::action::Action; + /// + /// assert_eq!(Action::check_or_fold(0), Action::Check); + /// assert_eq!(Action::check_or_fold(50), Action::Fold); + /// ``` + pub fn check_or_fold(amount_to_call: ChipCount) -> Action { + if amount_to_call == 0 { + Action::Check + } else { + Action::Fold + } + } + + /// Convenience factory matching Meerkat's `Action.callAction(toCall)`. + /// + /// Returns [`Action::Call`]. The `amount_to_call` parameter is accepted for + /// API compatibility with Meerkat but is **not used** — the engine computes + /// the actual call amount from the game state. Prefer using `Action::Call` + /// directly in non-Meerkat code. + /// + /// ``` + /// use holdem_core::game::action::Action; + /// + /// assert_eq!(Action::call_action(50), Action::Call); + /// assert_eq!(Action::call_action(0), Action::Call); + /// ``` + pub fn call_action(_amount_to_call: ChipCount) -> Action { + Action::Call + } + + /// Convenience factory matching Meerkat's `Action.foldAction(toCall)`. + /// + /// Returns [`Action::Fold`]. The `amount_to_call` parameter is accepted for + /// API compatibility with Meerkat but is **not used**. Prefer using + /// `Action::Fold` directly in non-Meerkat code. + /// + /// ``` + /// use holdem_core::game::action::Action; + /// + /// assert_eq!(Action::fold_action(50), Action::Fold); + /// ``` + pub fn fold_action(_amount_to_call: ChipCount) -> Action { + Action::Fold + } + + /// Convenience factory matching Meerkat's `Action.betAction(amountToRaise)`. + /// + /// Creates a raise action with the given total amount. In Meerkat, + /// `betAction(amount)` makes the first bet in a round (when current_bet is 0), + /// so `amount` is both the total and the increment. + /// + /// ``` + /// use holdem_core::game::action::Action; + /// + /// assert_eq!(Action::bet_action(100), Action::Raise { total: 100 }); + /// ``` + pub fn bet_action(amount_to_raise: ChipCount) -> Action { + Action::Raise { + total: amount_to_raise, + } + } + + /// Convenience factory matching Meerkat's `Action.raiseAction(toCall, amountToRaise)`. + /// + /// Creates a raise to the given total. The Meerkat API takes `toCall` and + /// `amountToRaise` separately, where the total raise is `toCall + amountToRaise`. + /// This helper computes the total for you. + /// + /// ``` + /// use holdem_core::game::action::Action; + /// + /// // To call 50 and raise by 100 more → total raise to 150 + /// assert_eq!(Action::raise_action(50, 100), Action::Raise { total: 150 }); + /// ``` + pub fn raise_action(to_call: ChipCount, amount_to_raise: ChipCount) -> Action { + Action::Raise { + total: to_call + amount_to_raise, + } + } } /// Validate that an action is legal given the current game state. @@ -102,7 +189,8 @@ impl Action { /// * `current_bet` — The current outstanding bet to call. /// * `min_raise` — The minimum raise increment. /// * `bet_limit` — The betting structure in use. -/// * `has_acted_this_round` — Whether the player has already acted this round. +/// * `street` — The current betting street (used for Fixed-Limit bet sizing). +/// * `raises_this_round` — Number of raises already made this round (for Fixed-Limit cap). #[allow(clippy::too_many_arguments)] pub fn validate_action( action: &Action, @@ -112,7 +200,8 @@ pub fn validate_action( current_bet: ChipCount, min_raise: ChipCount, bet_limit: &BetLimit, - _has_acted_this_round: bool, + street: Street, + raises_this_round: usize, ) -> Result<(), GameError> { match action { Action::Fold => Ok(()), @@ -163,17 +252,24 @@ pub fn validate_action( match bet_limit { BetLimit::NoLimit { .. } => Ok(()), BetLimit::PotLimit { min_raise: _ } => { - // Pot-limit validation requires knowing the pot size. - // The engine layer handles this; we pass here. + // Pot-limit max raise is enforced by the HandRunner which + // has access to the pot size. The validator passes here. Ok(()) } - BetLimit::FixedLimit { - small_bet, - big_bet: _, - } => { - // In fixed limit, the raise must equal the fixed bet size. - if raise_increment != *small_bet { - return Err(GameError::RaiseAboveMaximum(raise_increment, *small_bet)); + BetLimit::FixedLimit { .. } => { + // In fixed limit, the raise must equal the fixed bet size for the street. + let fixed_size = bet_limit + .fixed_bet_size(street) + .expect("FixedLimit always returns a fixed_bet_size"); + if raise_increment != fixed_size { + return Err(GameError::RaiseAboveMaximum(raise_increment, fixed_size)); + } + // Check the raise count cap (typically 3 raises per round). + if !bet_limit.can_raise(street, raises_this_round) { + return Err(GameError::InvalidAction(format!( + "raise cap reached: {} raises already made this round", + raises_this_round + ))); } Ok(()) } @@ -209,7 +305,8 @@ mod tests { 50, 10, &bet_limit, - false, + Street::Preflop, + 0, ) .is_ok()); } @@ -225,7 +322,8 @@ mod tests { 0, 10, &bet_limit, - false, + Street::Preflop, + 0, ) .is_ok()); } @@ -241,7 +339,8 @@ mod tests { 50, 10, &bet_limit, - false, + Street::Preflop, + 0, ); assert!(matches!(result, Err(GameError::CheckNotAllowed(50)))); } @@ -257,7 +356,8 @@ mod tests { 50, 10, &bet_limit, - false, + Street::Preflop, + 0, ) .is_ok()); } @@ -273,7 +373,8 @@ mod tests { 20, 20, &bet_limit, - false, + Street::Preflop, + 0, ); assert!(matches!( result, @@ -292,7 +393,8 @@ mod tests { 20, 10, &bet_limit, - false, + Street::Preflop, + 0, ); assert!(matches!( result, @@ -311,7 +413,8 @@ mod tests { 20, 10, &bet_limit, - false, + Street::Preflop, + 0, ); assert!(matches!( result, @@ -333,4 +436,150 @@ mod tests { assert!(Action::Raise { total: 50 }.is_active()); assert!(Action::AllIn.is_active()); } + + // ---- Fixed-Limit street-aware validation ---- + + #[test] + fn fixed_limit_preflop_uses_small_bet() { + let bet_limit = BetLimit::FixedLimit { + small_bet: 10, + big_bet: 20, + }; + // Preflop: raise increment must equal small_bet (10) + assert!(validate_action( + &Action::Raise { total: 20 }, // current_bet=10, increment=10 ✓ + pid(1), + 200, + 0, + 10, + 10, + &bet_limit, + Street::Preflop, + 0, + ) + .is_ok()); + + // Preflop: raise increment of 20 (big_bet) should be rejected + let result = validate_action( + &Action::Raise { total: 30 }, // current_bet=10, increment=20 ✗ + pid(1), + 200, + 0, + 10, + 10, + &bet_limit, + Street::Preflop, + 0, + ); + assert!(matches!( + result, + Err(GameError::RaiseAboveMaximum(20, 10)) + )); + } + + #[test] + fn fixed_limit_turn_uses_big_bet() { + let bet_limit = BetLimit::FixedLimit { + small_bet: 10, + big_bet: 20, + }; + // Turn: raise increment must equal big_bet (20) + assert!(validate_action( + &Action::Raise { total: 40 }, // current_bet=20, increment=20 ✓ + pid(1), + 200, + 0, + 20, + 20, + &bet_limit, + Street::Turn, + 0, + ) + .is_ok()); + + // Turn: raise increment of 10 (small_bet) should be rejected. + // min_raise=20, so raise_increment=10 < 20 → RaiseBelowMinimum. + let result = validate_action( + &Action::Raise { total: 30 }, // current_bet=20, increment=10 ✗ + pid(1), + 200, + 0, + 20, + 20, + &bet_limit, + Street::Turn, + 0, + ); + assert!(matches!( + result, + Err(GameError::RaiseBelowMinimum(10, 20)) + )); + } + + #[test] + fn fixed_limit_raise_cap_enforced() { + let bet_limit = BetLimit::FixedLimit { + small_bet: 10, + big_bet: 20, + }; + // 3 raises already → 4th should be rejected + let result = validate_action( + &Action::Raise { total: 50 }, // increment=10 on preflop + pid(1), + 200, + 0, + 40, + 10, + &bet_limit, + Street::Preflop, + 3, + ); + assert!(matches!(result, Err(GameError::InvalidAction(_)))); + + // 2 raises → 3rd should be allowed + assert!(validate_action( + &Action::Raise { total: 40 }, // increment=10 on preflop + pid(1), + 200, + 0, + 30, + 10, + &bet_limit, + Street::Preflop, + 2, + ) + .is_ok()); + } + + // ---- Convenience factory tests ---- + + #[test] + fn check_or_fold_factory() { + assert_eq!(Action::check_or_fold(0), Action::Check); + assert_eq!(Action::check_or_fold(50), Action::Fold); + } + + #[test] + fn call_action_factory() { + assert_eq!(Action::call_action(50), Action::Call); + assert_eq!(Action::call_action(0), Action::Call); + } + + #[test] + fn fold_action_factory() { + assert_eq!(Action::fold_action(50), Action::Fold); + } + + #[test] + fn bet_action_factory() { + assert_eq!(Action::bet_action(100), Action::Raise { total: 100 }); + } + + #[test] + fn raise_action_factory() { + assert_eq!( + Action::raise_action(50, 100), + Action::Raise { total: 150 } + ); + } } diff --git a/holdem_core/src/game/betting.rs b/holdem_core/src/game/betting.rs index 537ca4f..4cef8be 100644 --- a/holdem_core/src/game/betting.rs +++ b/holdem_core/src/game/betting.rs @@ -85,12 +85,13 @@ impl BettingRules for BetLimit { BetLimit::FixedLimit { small_bet, big_bet: _, - } => *small_bet, // min_raise_amount is overridden per-street below isn't needed — fixed - // Actually, for FixedLimit the min raise *is* the bet size for the street. - // But we only have `current_bet` and `previous_raise` here, not `street`. - // The caller should use `fixed_bet_size` for FixedLimit instead. - // We return small_bet as a safe default; FixedLimit users should prefer - // `fixed_bet_size(street)`. + } => { + // For FixedLimit, the min raise is the fixed bet size for the street. + // Since this trait method doesn't receive `street`, we return `small_bet` + // as a safe default (correct for preflop/flop, conservative for turn/river). + // Callers needing street-aware sizing should use `fixed_bet_size(street)`. + *small_bet + } } } @@ -98,31 +99,28 @@ impl BettingRules for BetLimit { &self, pot: ChipCount, call_amount: ChipCount, - _player_stack: ChipCount, + player_stack: ChipCount, ) -> Option { match self { BetLimit::NoLimit { .. } => None, BetLimit::PotLimit { .. } => { - // Standard pot-limit formula: pot + call_amount + previous_raise - // Here we return the max *total* raise above the call amount. - // The classic formula: max raise = pot + call_amount (where call_amount - // is what the player needs to put in to call). - Some(pot + call_amount) + // Standard pot-limit formula: + // The maximum *raise increment* a player can make is: + // pot_size + amount_to_call + // where pot_size is the current pot and amount_to_call is what the + // player needs to put in to match the current bet. + // This means the maximum *total* wager for the player is: + // current_bet + call_amount + pot_size + // We return the max raise *increment* here (pot + call_amount), + // capped by the player's stack (a short-stacked player cannot + // raise more than they can afford). + Some((pot + call_amount).min(player_stack)) } BetLimit::FixedLimit { .. } => { - // Max raise equals min raise (fixed increments). - // The caller should use `fixed_bet_size` to determine the amount. - // Returning `Some(0)` isn't useful; FixedLimit max is the same as min. - // We return None to indicate "use fixed_bet_size" — but the trait - // contract says Some means there's a cap. For FixedLimit, the cap - // IS the fixed bet size. Let's return it via fixed_bet_size instead. - // Actually, to keep the trait useful, we return `Some` equal to the - // fixed bet size. But we don't have `street` here... - // The simplest correct thing: FixedLimit max raise == min raise, - // and the caller should use fixed_bet_size(street) for the actual value. - // We return Some(0) as a sentinel — no, that's bad. - // Best approach: the HandRunner will use fixed_bet_size for FixedLimit. - // For the trait, we return None and rely on can_raise for capping. + // For FixedLimit, the max raise equals the fixed bet size for the street. + // Since this method doesn't receive `street`, we return None. + // Callers should use `fixed_bet_size(street)` instead, which is what + // `validate_action` now does. None } } @@ -141,6 +139,12 @@ impl BettingRules for BetLimit { } } + /// Returns `true` if another raise is allowed given the current raise count. + /// + /// For Fixed-Limit, the cap is **3 voluntary raises per round** (uniform across + /// all streets). The `street` parameter is accepted for potential future + /// per-street customization (e.g., heads-up 4-raise cap) but is currently + /// unused — the cap is intentionally the same on every street. fn can_raise(&self, _street: Street, raises_this_round: usize) -> bool { match self { BetLimit::NoLimit { .. } | BetLimit::PotLimit { .. } => true, diff --git a/holdem_core/src/game/config.rs b/holdem_core/src/game/config.rs index 61b36ca..19b8033 100644 --- a/holdem_core/src/game/config.rs +++ b/holdem_core/src/game/config.rs @@ -92,6 +92,30 @@ impl RakeConfig { } } +// --------------------------------------------------------------------------- +// BlindLevelConfig +// --------------------------------------------------------------------------- + +/// Configuration for a single blind level in a tournament. +/// +/// When specified as part of `[[game.blind_levels]]` in TOML, these override +/// the default doubling-blind schedule generated by `num_blind_levels` and +/// `hands_per_level`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BlindLevelConfig { + /// Small blind amount. + pub small_blind: ChipCount, + /// Big blind amount. + pub big_blind: ChipCount, + /// Ante amount (0 = no ante). + #[serde(default)] + pub ante: ChipCount, + /// Number of hands at this level before advancing. + #[serde(default = "default_hands_per_level")] + pub hands: u32, +} + // --------------------------------------------------------------------------- // Default helper functions // --------------------------------------------------------------------------- @@ -101,11 +125,26 @@ fn default_num_seats() -> SeatIndex { 9 } +/// Default small blind amount. +fn default_small_blind() -> ChipCount { + 10 +} + +/// Default big blind amount. +fn default_big_blind() -> ChipCount { + 20 +} + /// Default value of 1. fn default_one() -> usize { 1 } +/// Default starting chips. +fn default_starting_chips() -> ChipCount { + 10_000 +} + /// Default number of hands per blind level. fn default_hands_per_level() -> u32 { 10 @@ -116,6 +155,16 @@ fn default_num_levels() -> u32 { 15 } +/// Default value of 1 for u32 fields. +fn default_one_u32() -> u32 { + 1 +} + +/// Default auto-rebuy threshold percentage (25%). +fn default_rebuy_threshold() -> u8 { + 25 +} + // --------------------------------------------------------------------------- // BetLimitConfig // --------------------------------------------------------------------------- @@ -243,10 +292,12 @@ pub struct GameConfig { /// Betting structure. pub bet_limit: BetLimitConfig, - /// Small blind amount. + /// Small blind amount. Defaults to 10 when omitted from config. + #[serde(default = "default_small_blind")] pub small_blind: ChipCount, - /// Big blind amount. + /// Big blind amount. Defaults to 20 when omitted from config. + #[serde(default = "default_big_blind")] pub big_blind: ChipCount, /// Ante amount (0 = no ante). @@ -254,6 +305,7 @@ pub struct GameConfig { pub ante: ChipCount, /// Starting chip count for each player. + #[serde(default = "default_starting_chips")] pub starting_chips: ChipCount, /// Buy-in amount (for tournaments). @@ -304,6 +356,33 @@ pub struct GameConfig { #[serde(default)] pub randomize_seats: bool, + /// Whether to auto-rebuy players when their stack drops below a threshold. + /// The rebuy tops up to the full starting_chips amount. + #[serde(default)] + pub auto_rebuy: bool, + + /// Percentage of starting chips below which auto-rebuy triggers (1–100). + /// For example, `25` means rebuy when stack < 25% of starting_chips. + /// Defaults to 25. Only relevant when `auto_rebuy` is `true`. + #[serde(default = "default_rebuy_threshold")] + pub rebuy_threshold_percentage: u8, + + /// Explicit blind levels. When present, overrides `num_blind_levels` and + /// `hands_per_level`. Each entry specifies SB, BB, ante, and hands duration. + #[serde(default)] + pub blind_levels: Vec, + + /// Custom prize structure as absolute amounts (e.g., `[5, 3, 2]`). + /// When present, overrides the standard SNG/MTT prize calculation. + /// Entries are ordered: 1st place, 2nd place, 3rd place, etc. + #[serde(default)] + pub prizes: Vec, + + /// Number of tournaments to run in a series (default: 1). + /// Applies to both SitAndGo and MultiTableTournament game types. + #[serde(default = "default_one_u32")] + pub num_tournaments: u32, + /// Bot configurations. #[serde(default)] pub bots: Vec, @@ -317,8 +396,8 @@ impl Default for GameConfig { bet_limit: BetLimitConfig::NoLimit { min_raise: 20, }, - small_blind: 10, - big_blind: 20, + small_blind: default_small_blind(), + big_blind: default_big_blind(), ante: 0, starting_chips: 10_000, buy_in: 0, @@ -332,6 +411,11 @@ impl Default for GameConfig { reset_bankroll_each_hand: false, bankroll_refill_threshold: 0, randomize_seats: false, + auto_rebuy: false, + rebuy_threshold_percentage: default_rebuy_threshold(), + blind_levels: Vec::new(), + prizes: Vec::new(), + num_tournaments: 1, bots: Vec::new(), } } @@ -386,51 +470,119 @@ impl GameConfig { /// Convert to a [`BlindSchedule`] (for tournaments). /// - /// Generates a standard blind schedule using the configured starting blinds, - /// number of blind levels, and hands per level. + /// When `blind_levels` is non-empty, builds a custom schedule from those + /// explicit levels. Otherwise, generates a standard doubling-blind schedule + /// using the configured starting blinds, `num_blind_levels`, and + /// `hands_per_level`. pub fn to_blind_schedule(&self) -> BlindSchedule { - let mut levels = Vec::with_capacity(self.num_blind_levels as usize); - let base_sb = self.small_blind; - let base_bb = self.big_blind; - let ratio = base_bb.checked_div(base_sb).unwrap_or(2); + if !self.blind_levels.is_empty() { + let levels: Vec = self + .blind_levels + .iter() + .enumerate() + .map(|(i, lc)| super::tournament::BlindLevel { + level: (i + 1) as u32, + small_blind: lc.small_blind, + big_blind: lc.big_blind, + ante: lc.ante, + duration_hands: lc.hands, + }) + .collect(); + BlindSchedule::custom(levels) + } else { + let mut levels = Vec::with_capacity(self.num_blind_levels as usize); + let base_sb = self.small_blind; + let base_bb = self.big_blind; + let ratio = base_bb.checked_div(base_sb).unwrap_or(2); - for i in 0..self.num_blind_levels { - let level_num = i + 1; - let sb = base_sb * (1u64 << i); - let bb = sb * ratio; - let ante = if level_num >= 3 { - let a = bb / 10; - if a == 0 { 1 } else { a } - } else { - 0 - }; - levels.push(super::tournament::BlindLevel { - level: level_num, - small_blind: sb, - big_blind: bb, - ante, - duration_hands: self.hands_per_level, - }); + for i in 0..self.num_blind_levels { + let level_num = i + 1; + let sb = base_sb * (1u64 << i); + let bb = sb * ratio; + let ante = if level_num >= 3 { + let a = bb / 10; + if a == 0 { 1 } else { a } + } else { + 0 + }; + levels.push(super::tournament::BlindLevel { + level: level_num, + small_blind: sb, + big_blind: bb, + ante, + duration_hands: self.hands_per_level, + }); + } + BlindSchedule::custom(levels) } - BlindSchedule::custom(levels) } /// Convert to a [`PrizeStructure`] (for tournaments). /// - /// Uses the standard prize structures based on game type: + /// When `prizes` is non-empty, uses the custom prize amounts directly. + /// Otherwise, uses the standard prize structures based on game type: /// - Sit-and-Go: 50/30/20 for top 3 /// - MTT: top-heavy distribution for top ~15% + /// - Cash: empty prize structure pub fn to_prize_structure(&self) -> PrizeStructure { - let num_players = self.bots.len().max(2); - match self.game_type { - GameType::Cash => PrizeStructure::standard_sng(self.buy_in, num_players), - GameType::SitAndGo => PrizeStructure::standard_sng(self.buy_in, num_players), - GameType::MultiTableTournament => { - let total_entries = num_players * self.num_tables; - PrizeStructure::standard_mtt(self.buy_in, total_entries) + if !self.prizes.is_empty() { + PrizeStructure::custom(self.prizes.clone()) + } else { + let num_players = self.bots.len().max(2); + match self.game_type { + GameType::Cash => PrizeStructure::custom(vec![]), + GameType::SitAndGo => PrizeStructure::standard_sng(self.buy_in, num_players), + GameType::MultiTableTournament => { + let total_entries = num_players * self.num_tables; + PrizeStructure::standard_mtt(self.buy_in, total_entries) + } } } } + + /// Validates the configuration and returns a list of advisory warnings. + /// + /// Warnings are non-fatal issues that may indicate user confusion (e.g., + /// providing both explicit `blind_levels` and `num_blind_levels`). + pub fn validate(&self) -> Vec { + let mut warnings = Vec::new(); + + if !self.blind_levels.is_empty() + && self.num_blind_levels != default_num_levels() + { + warnings.push(format!( + "Both `blind_levels` ({} levels) and `num_blind_levels` ({}) are set. \ + The explicit `blind_levels` take precedence; `num_blind_levels` is ignored.", + self.blind_levels.len(), + self.num_blind_levels + )); + } + + if !self.prizes.is_empty() && self.game_type == GameType::Cash { + warnings.push( + "Custom `prizes` are set but game type is Cash. Prize structures are typically \ + only used in tournament play.".to_string() + ); + } + + if matches!(self.game_type, GameType::SitAndGo | GameType::MultiTableTournament) + && self.blind_levels.is_empty() + && self.num_blind_levels == 0 + { + warnings.push( + "Tournament game type requires either `blind_levels` or `num_blind_levels > 0`. \ + An empty blind schedule will prevent the tournament from posting blinds.".to_string() + ); + } + + if self.small_blind == 0 || self.big_blind == 0 { + warnings.push( + "small_blind and big_blind should be > 0. A blind of 0 means no blinds will be posted.".to_string() + ); + } + + warnings + } } // --------------------------------------------------------------------------- @@ -783,6 +935,11 @@ min_raise = 50 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 { @@ -865,6 +1022,11 @@ min_raise = 50 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![], }; @@ -897,6 +1059,11 @@ min_raise = 50 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![], }; @@ -945,6 +1112,11 @@ min_raise = 50 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 { @@ -995,6 +1167,11 @@ min_raise = 50 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 { @@ -1087,6 +1264,11 @@ min_raise = 20 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![], }, output: OutputConfig { diff --git a/holdem_core/src/game/game_info.rs b/holdem_core/src/game/game_info.rs index ee4aff3..fcfcb4c 100644 --- a/holdem_core/src/game/game_info.rs +++ b/holdem_core/src/game/game_info.rs @@ -113,4 +113,97 @@ impl GameInfo { pub fn is_postflop(&self) -> bool { self.street >= Street::Flop } + + /// Whether the hand is in the preflop stage. + /// + /// Corresponds to Meerkat's `GameInfo.isPreFlop()`. + pub fn is_preflop(&self) -> bool { + self.street == Street::Preflop + } + + /// Whether the hand is on the flop. + pub fn is_flop(&self) -> bool { + self.street == Street::Flop + } + + /// Whether the hand is on the turn. + pub fn is_turn(&self) -> bool { + self.street == Street::Turn + } + + /// Whether the hand is on the river. + pub fn is_river(&self) -> bool { + self.street == Street::River + } + + /// Get the amount a player must call to stay in the hand. + /// + /// Corresponds to Meerkat's `GameInfo.getAmountToCall(seat)`. + /// Returns `current_bet - player_wagered_this_round`, or 0 if the player + /// has already matched or exceeded the current bet. + pub fn get_amount_to_call(&self, player_id: PlayerId) -> ChipCount { + self.player_by_id(player_id) + .map(|p| self.current_bet.saturating_sub(p.wagered_this_round)) + .unwrap_or(0) + } + + /// Get the current stage as a numeric index (Meerkat convention). + /// + /// Returns `0` for Preflop, `1` for Flop, `2` for Turn, `3` for River. + /// Corresponds to Meerkat's `Holdem.PREFLOP`, `Holdem.FLOP`, etc. + pub fn stage_index(&self) -> usize { + match self.street { + Street::Preflop => 0, + Street::Flop => 1, + Street::Turn => 2, + Street::River => 3, + } + } + + /// Returns the total number of active players still in the hand + /// (not folded, not sitting out — includes all-in players). + /// + /// Corresponds to Meerkat's `GameInfo.getNumActivePlayers()`. + pub fn get_num_active_players(&self) -> usize { + self.players + .iter() + .flatten() + .filter(|p| p.is_in_hand()) + .count() + } + + /// Returns the total number of seats at the table. + /// + /// Corresponds to Meerkat's `GameInfo.getNumSeats()`. + pub fn get_num_seats(&self) -> SeatIndex { + self.num_seats + } + + /// Returns the big blind size. + /// + /// Corresponds to Meerkat's `GameInfo.getBigBlindSize()`. + pub fn get_big_blind_size(&self) -> ChipCount { + self.big_blind + } + + /// Returns the small blind size. + /// + /// Corresponds to Meerkat's `GameInfo.getSmallBlindSize()`. + pub fn get_small_blind_size(&self) -> ChipCount { + self.small_blind + } + + /// Returns the total chips in the pot. + /// + /// Corresponds to Meerkat's `GameInfo.getPotSize()`. + pub fn get_pot_size(&self) -> ChipCount { + self.pot + } + + /// Returns the number of voluntary raises on the current street. + /// + /// Corresponds to Meerkat's `GameInfo.getNumRaises()`. + pub fn get_num_raises(&self) -> usize { + self.num_raises + } } diff --git a/holdem_core/src/game/hand_history.rs b/holdem_core/src/game/hand_history.rs new file mode 100644 index 0000000..ce7df20 --- /dev/null +++ b/holdem_core/src/game/hand_history.rs @@ -0,0 +1,813 @@ +//! Hand history logging module for recording and persisting hand results. +//! +//! This module provides structures for capturing complete hand history data and +//! formatting it in the iPoker/PokerTracker 3 text format. It supports logging +//! hand histories to files for later analysis. +//! +//! # Overview +//! +//! - [`HandHistoryRecord`] — The main data structure capturing everything about a hand. +//! - [`HandHistoryLogger`] — Writes hand history records to files. +//! - [`format_ipoker`] — Formats a record into iPoker text format. +//! +//! # Example +//! +//! ```rust,ignore +//! use holdem_core::game::hand_history::{HandHistoryLogger, HandHistoryRecord}; +//! +//! let logger = HandHistoryLogger::new("./logs", true); +//! logger.log_hand(&record).expect("Failed to log hand"); +//! ``` + +use std::collections::HashMap; +use std::fs::{self, OpenOptions}; +use std::io::{self, Write}; +use std::path::PathBuf; + +use chrono::Local; + +use crate::Board; +use crate::Card; +use crate::HoleCards; +use crate::evaluator::evaluator::{HandRank, HandValue}; + +use super::action::Action; +use super::pot::PotWinner; +use super::types::{ChipCount, PlayerId, SeatIndex}; + +// --------------------------------------------------------------------------- +// BlindType enum +// --------------------------------------------------------------------------- + +/// Type of blind posting. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BlindType { + /// Small blind. + SmallBlind, + /// Big blind. + BigBlind, + /// Ante. + Ante, +} + +// --------------------------------------------------------------------------- +// HandHistoryPlayer struct +// --------------------------------------------------------------------------- + +/// Player information captured at the start of a hand for history logging. +#[derive(Debug, Clone)] +pub struct HandHistoryPlayer { + /// Unique player identifier. + pub player_id: PlayerId, + /// Display name. + pub name: String, + /// Seat position at the table (0-based). + pub seat_index: SeatIndex, + /// Chip stack at the start of the hand. + pub starting_stack: ChipCount, + /// Hole cards dealt, if available. + pub hole_cards: Option, + /// Whether this player is the dealer. + pub is_dealer: bool, + /// Whether this player is the small blind. + pub is_small_blind: bool, + /// Whether this player is the big blind. + pub is_big_blind: bool, +} + +// --------------------------------------------------------------------------- +// BlindPosting struct +// --------------------------------------------------------------------------- + +/// A blind or ante posting event. +#[derive(Debug, Clone)] +pub struct BlindPosting { + /// The player who posted. + pub player_id: PlayerId, + /// Type of blind. + pub blind_type: BlindType, + /// Amount posted. + pub amount: ChipCount, +} + +// --------------------------------------------------------------------------- +// StreetAction struct +// --------------------------------------------------------------------------- + +/// A player action on a specific street. +#[derive(Debug, Clone)] +pub struct StreetAction { + /// The player who acted. + pub player_id: PlayerId, + /// The action taken. + pub action: Action, +} + +// --------------------------------------------------------------------------- +// ShowdownEntry struct +// --------------------------------------------------------------------------- + +/// A showdown result entry for a single player. +#[derive(Debug, Clone)] +pub struct ShowdownEntry { + /// The player at showdown. + pub player_id: PlayerId, + /// The player's hole cards. + pub hole_cards: HoleCards, + /// Evaluated hand value, if available. + pub hand_value: Option, + /// Whether this player won. + pub won: bool, + /// Amount won (0 if lost). + pub amount_won: ChipCount, +} + +// --------------------------------------------------------------------------- +// HandHistoryRecord struct +// --------------------------------------------------------------------------- + +/// Complete record of a single hand, capturing all data for history logging. +#[derive(Debug, Clone)] +pub struct HandHistoryRecord { + /// Hand sequence number. + pub hand_number: u64, + /// Timestamp in ISO 8601 format. + pub timestamp: String, + /// Game type description (e.g., "Cash", "SitAndGo"). + pub game_type: String, + /// Table name. + pub table_name: String, + /// Number of seats at the table. + pub num_seats: SeatIndex, + /// Seat index of the dealer button. + pub button_seat: SeatIndex, + /// Player information for all participants. + pub players: Vec, + /// Blind and ante postings. + pub blind_postings: Vec, + /// Hole cards dealt to each player. + pub hole_cards_dealt: Vec<(PlayerId, HoleCards)>, + /// Preflop actions. + pub preflop_actions: Vec, + /// Flop community cards. + pub flop_cards: Vec, + /// Flop actions. + pub flop_actions: Vec, + /// Turn community card. + pub turn_card: Option, + /// Turn actions. + pub turn_actions: Vec, + /// River community card. + pub river_card: Option, + /// River actions. + pub river_actions: Vec, + /// Showdown entries (empty if hand ended before showdown). + pub showdown_entries: Vec, + /// Final community board. + pub board: Board, + /// Total pot size. + pub total_pot: ChipCount, + /// Rake deducted. + pub rake: ChipCount, + /// Pot winners per pot. + pub pot_winners: Vec>, +} + +// --------------------------------------------------------------------------- +// HandHistoryLogger struct +// --------------------------------------------------------------------------- + +/// Logs hand history records to files in iPoker text format. +pub struct HandHistoryLogger { + /// Directory where log files are written. + output_directory: PathBuf, + /// Whether logging is enabled. + enabled: bool, +} + +impl HandHistoryLogger { + /// Creates a new hand history logger. + /// + /// # Arguments + /// + /// * `output_directory` — Directory where hand history files are written. + /// * `enabled` — Whether logging is enabled. When `false`, [`log_hand`](Self::log_hand) is a no-op. + pub fn new(output_directory: impl Into, enabled: bool) -> Self { + Self { + output_directory: output_directory.into(), + enabled, + } + } + + /// Logs a hand history record to a file. + /// + /// Creates the output directory if it doesn't exist. The file is named + /// `hand_{hand_number}_{YYYYMMDD_HHMMSS}.txt` using the local timestamp. + /// If a file with the same name already exists (e.g., two hands completing + /// within the same second), a numeric suffix is appended to avoid collision. + /// + /// This is a no-op when `enabled` is `false`. + pub fn log_hand(&self, record: &HandHistoryRecord) -> io::Result<()> { + if !self.enabled { + return Ok(()); + } + + // Create output directory if it doesn't exist. + fs::create_dir_all(&self.output_directory)?; + + // Generate filename from hand number and local timestamp. + let now = Local::now(); + let base_filename = format!( + "hand_{}_{}.txt", + record.hand_number, + now.format("%Y%m%d_%H%M%S") + ); + let filepath = self.output_directory.join(&base_filename); + + // Format and write. Use create_new to prevent accidental overwrites + // of existing hand history files. If the file already exists (collision + // from two hands completing in the same second), append a counter suffix. + let content = format_ipoker(record); + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&filepath) + { + Ok(mut file) => file.write_all(content.as_bytes()), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { + // Collision — try with numeric suffixes until we find a free name. + for i in 1..100 { + let alt_filename = format!( + "hand_{}_{}_{}.txt", + record.hand_number, + now.format("%Y%m%d_%H%M%S"), + i + ); + let alt_path = self.output_directory.join(&alt_filename); + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&alt_path) + { + Ok(mut file) => return file.write_all(content.as_bytes()), + Err(e2) if e2.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e2) => return Err(e2), + } + } + // All 100 attempts failed — fall back to a UUID-like suffix. + let fallback = format!( + "hand_{}_{}_overflow.txt", + record.hand_number, + now.format("%Y%m%d_%H%M%S") + ); + let fallback_path = self.output_directory.join(&fallback); + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(fallback_path)?; + file.write_all(content.as_bytes()) + } + Err(e) => Err(e), + } + } +} + +// --------------------------------------------------------------------------- +// iPoker format formatter +// --------------------------------------------------------------------------- + +/// Formats a [`HandHistoryRecord`] into iPoker/PokerTracker 3 text format. +/// +/// The format follows the standard iPoker hand history convention used by +/// PokerTracker 3 and similar tracking software. +pub fn format_ipoker(record: &HandHistoryRecord) -> String { + let mut lines: Vec = Vec::new(); + + // Build a player name lookup. + let name_map: HashMap = record + .players + .iter() + .map(|p| (p.player_id, p.name.as_str())) + .collect(); + + // Header. + lines.push(format!( + "Game #{} starts on {}", + record.hand_number, record.timestamp + )); + lines.push(format!( + "Table \"{}\" ({}-max) Seat #{} is the button", + record.table_name, record.num_seats, record.button_seat + )); + + // Seat listings. + for player in &record.players { + lines.push(format!( + "Seat {}: {} ({} in chips)", + player.seat_index, player.name, player.starting_stack + )); + } + + // Blind postings. + for posting in &record.blind_postings { + let name = name_map + .get(&posting.player_id) + .unwrap_or(&"Unknown"); + let label = match posting.blind_type { + BlindType::SmallBlind => "small blind", + BlindType::BigBlind => "big blind", + BlindType::Ante => "ante", + }; + lines.push(format!("{}: posts {} {}", name, label, posting.amount)); + } + + // Hole cards. + lines.push("*** HOLE CARDS ***".to_string()); + for (player_id, hole_cards) in &record.hole_cards_dealt { + let name = name_map.get(player_id).unwrap_or(&"Unknown"); + let cards_str = format_hole_cards(*hole_cards); + lines.push(format!("Dealt to {} [{}]", name, cards_str)); + } + + // Preflop actions. + for sa in &record.preflop_actions { + let name = name_map.get(&sa.player_id).unwrap_or(&"Unknown"); + lines.push(format_action(name, &sa.action)); + } + + // Flop. + if !record.flop_cards.is_empty() { + let flop_str = format_cards(&record.flop_cards); + lines.push(format!("*** FLOP *** [{}]", flop_str)); + + for sa in &record.flop_actions { + let name = name_map.get(&sa.player_id).unwrap_or(&"Unknown"); + lines.push(format_action(name, &sa.action)); + } + } + + // Turn. + if let Some(turn_card) = record.turn_card { + let flop_str = format_cards(&record.flop_cards); + let turn_str = format_card(turn_card); + lines.push(format!("*** TURN *** [{}] [{}]", flop_str, turn_str)); + + for sa in &record.turn_actions { + let name = name_map.get(&sa.player_id).unwrap_or(&"Unknown"); + lines.push(format_action(name, &sa.action)); + } + } + + // River. + if let Some(river_card) = record.river_card { + let flop_str = format_cards(&record.flop_cards); + let turn_str = record + .turn_card + .map(format_card) + .unwrap_or_default(); + let river_str = format_card(river_card); + lines.push(format!( + "*** RIVER *** [{}] [{}] [{}]", + flop_str, turn_str, river_str + )); + + for sa in &record.river_actions { + let name = name_map.get(&sa.player_id).unwrap_or(&"Unknown"); + lines.push(format_action(name, &sa.action)); + } + } + + // Showdown. + if !record.showdown_entries.is_empty() { + lines.push("*** SHOW DOWN ***".to_string()); + + for entry in &record.showdown_entries { + let name = name_map + .get(&entry.player_id) + .unwrap_or(&"Unknown"); + let cards_str = format_hole_cards(entry.hole_cards); + + if let Some(hv) = entry.hand_value { + let hand_desc = hand_rank_description(hv.rank); + lines.push(format!( + "{}: shows [{}] ({})", + name, cards_str, hand_desc + )); + } else { + lines.push(format!("{}: shows [{}]", name, cards_str)); + } + } + + // Pot collection lines. + for pot_result in &record.pot_winners { + for winner in pot_result { + let name = name_map + .get(&winner.player_id) + .unwrap_or(&"Unknown"); + lines.push(format!("{} collects {} from pot", name, winner.amount)); + } + } + } else { + // No showdown — winner collects. + for pot_result in &record.pot_winners { + for winner in pot_result { + let name = name_map + .get(&winner.player_id) + .unwrap_or(&"Unknown"); + lines.push(format!("{} collects {} from pot", name, winner.amount)); + } + } + } + + // Summary. + lines.push("*** SUMMARY ***".to_string()); + lines.push(format!( + "Total pot {} | Rake {}", + record.total_pot, record.rake + )); + + // Board cards in summary. + let visible = record.board.visible_cards(); + if !visible.is_empty() { + let board_str = format_cards(visible); + lines.push(format!("Board: [{}]", board_str)); + } + + // Per-seat summary. + // Build lookup for total amount won per player. + let mut total_won: HashMap = HashMap::new(); + for pot_result in &record.pot_winners { + for winner in pot_result { + *total_won.entry(winner.player_id).or_insert(0) += winner.amount; + } + } + + // Build lookup for showdown entries by player_id. + let showdown_map: HashMap = record + .showdown_entries + .iter() + .map(|e| (e.player_id, e)) + .collect(); + + // Determine which players folded preflop (they have a Fold in preflop_actions + // and no actions on later streets). + let folded_preflop: HashMap = record + .players + .iter() + .map(|p| { + let folded_pf = record + .preflop_actions + .iter() + .any(|sa| sa.player_id == p.player_id && matches!(sa.action, Action::Fold)); + // Check they didn't act on later streets (they wouldn't if they folded preflop). + let acted_later = record + .flop_actions + .iter() + .chain(record.turn_actions.iter()) + .chain(record.river_actions.iter()) + .any(|sa| sa.player_id == p.player_id); + (p.player_id, folded_pf && !acted_later) + }) + .collect(); + + for player in &record.players { + let won_amount = total_won.get(&player.player_id).copied().unwrap_or(0); + + if let Some(entry) = showdown_map.get(&player.player_id) { + let cards_str = format_hole_cards(entry.hole_cards); + if entry.won && won_amount > 0 { + if let Some(hv) = entry.hand_value { + let hand_desc = hand_rank_description(hv.rank); + lines.push(format!( + "Seat {}: {} collected {} (showed [{}] and won with {})", + player.seat_index, player.name, won_amount, cards_str, hand_desc + )); + } else { + lines.push(format!( + "Seat {}: {} collected {} (showed [{}])", + player.seat_index, player.name, won_amount, cards_str + )); + } + } else if let Some(hv) = entry.hand_value { + let hand_desc = hand_rank_description(hv.rank); + lines.push(format!( + "Seat {}: {} showed [{}] and lost with {}", + player.seat_index, player.name, cards_str, hand_desc + )); + } else { + lines.push(format!( + "Seat {}: {} showed [{}] and lost", + player.seat_index, player.name, cards_str + )); + } + } else if *folded_preflop.get(&player.player_id).unwrap_or(&false) { + lines.push(format!( + "Seat {}: {} folded before flop", + player.seat_index, player.name + )); + } else if won_amount > 0 { + // Won without showdown (everyone else folded). + lines.push(format!( + "Seat {}: {} collected {}", + player.seat_index, player.name, won_amount + )); + } else { + // Folded on a later street or some other case. + lines.push(format!( + "Seat {}: {} folded", + player.seat_index, player.name + )); + } + } + + lines.join("\n") + "\n" +} + +// --------------------------------------------------------------------------- +// Formatting helpers +// --------------------------------------------------------------------------- + +/// Formats a single card as `{rank}{suit}` (e.g., "As", "Th"). +fn format_card(card: Card) -> String { + let rank = card.rank_char(); + let suit = card.suit_char(); + format!("{}{}", rank, suit) +} + +/// Formats a slice of cards separated by spaces. +fn format_cards(cards: &[Card]) -> String { + cards.iter().map(|c| format_card(*c)).collect::>().join(" ") +} + +/// Formats hole cards as `{card1} {card2}`. +fn format_hole_cards(hc: HoleCards) -> String { + format!( + "{} {}", + format_card(hc.first_card()), + format_card(hc.second_card()) + ) +} + +/// Formats an action in iPoker text format. +fn format_action(name: &str, action: &Action) -> String { + match action { + Action::Fold => format!("{}: folds", name), + Action::Check => format!("{}: checks", name), + Action::Call => format!("{}: calls", name), + Action::Raise { total } => format!("{}: raises to {}", name, total), + Action::AllIn => format!("{}: all-in", name), + } +} + +/// Returns a human-readable description for a [`HandRank`]. +fn hand_rank_description(rank: HandRank) -> &'static str { + match rank { + HandRank::HighCard => "High Card", + HandRank::Pair => "One Pair", + HandRank::TwoPair => "Two Pair", + HandRank::ThreeOfAKind => "Three of a Kind", + HandRank::Straight => "Straight", + HandRank::Flush => "Flush", + HandRank::FullHouse => "Full House", + HandRank::FourOfAKind => "Four of a Kind", + HandRank::StraightFlush => "Straight Flush", + HandRank::RoyalFlush => "Royal Flush", + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::action::Action; + use crate::game::pot::PotWinner; + use crate::game::types::PlayerId; + use crate::{Board, Card, HoleCards}; + + fn make_card(rank: u8, suit: u8) -> Card { + Card::new(rank, suit).unwrap() + } + + #[test] + fn test_format_card() { + // Ace of spades. + let card = make_card(12, 3); + assert_eq!(format_card(card), "As"); + + // Ten of hearts. + let card = make_card(8, 0); + assert_eq!(format_card(card), "Th"); + + // Two of clubs. + let card = make_card(0, 2); + assert_eq!(format_card(card), "2c"); + } + + #[test] + fn test_format_cards() { + let cards = vec![make_card(12, 3), make_card(11, 2)]; + assert_eq!(format_cards(&cards), "As Kc"); + } + + #[test] + fn test_format_hole_cards() { + let hc = HoleCards::new(make_card(12, 3), make_card(11, 2)).unwrap(); + assert_eq!(format_hole_cards(hc), "As Kc"); + } + + #[test] + fn test_hand_rank_description() { + assert_eq!(hand_rank_description(HandRank::HighCard), "High Card"); + assert_eq!(hand_rank_description(HandRank::Pair), "One Pair"); + assert_eq!(hand_rank_description(HandRank::RoyalFlush), "Royal Flush"); + } + + #[test] + fn test_format_action_fold() { + assert_eq!(format_action("Alice", &Action::Fold), "Alice: folds"); + } + + #[test] + fn test_format_action_raise() { + assert_eq!( + format_action("Bob", &Action::Raise { total: 100 }), + "Bob: raises to 100" + ); + } + + #[test] + fn test_format_ipoker_minimal() { + // Build a minimal hand history record. + let hc_alice = HoleCards::new(make_card(12, 3), make_card(11, 2)).unwrap(); // As Kc + let hc_bob = HoleCards::new(make_card(8, 1), make_card(5, 0)).unwrap(); // Td 7h + + let players = vec![ + HandHistoryPlayer { + player_id: PlayerId(1), + name: "Alice".to_string(), + seat_index: 0, + starting_stack: 1000, + hole_cards: Some(hc_alice), + is_dealer: true, + is_small_blind: true, + is_big_blind: false, + }, + HandHistoryPlayer { + player_id: PlayerId(2), + name: "Bob".to_string(), + seat_index: 1, + starting_stack: 1000, + hole_cards: Some(hc_bob), + is_dealer: false, + is_small_blind: false, + is_big_blind: true, + }, + ]; + + let record = HandHistoryRecord { + hand_number: 42, + timestamp: "2026-05-19 12:00:00".to_string(), + game_type: "Cash".to_string(), + table_name: "Table1".to_string(), + num_seats: 6, + button_seat: 0, + players, + blind_postings: vec![ + BlindPosting { + player_id: PlayerId(1), + blind_type: BlindType::SmallBlind, + amount: 10, + }, + BlindPosting { + player_id: PlayerId(2), + blind_type: BlindType::BigBlind, + amount: 20, + }, + ], + hole_cards_dealt: vec![ + (PlayerId(1), hc_alice), + (PlayerId(2), hc_bob), + ], + preflop_actions: vec![ + StreetAction { + player_id: PlayerId(2), + action: Action::Fold, + }, + ], + flop_cards: vec![], + flop_actions: vec![], + turn_card: None, + turn_actions: vec![], + river_card: None, + river_actions: vec![], + showdown_entries: vec![], + board: Board::new(), + total_pot: 30, + rake: 0, + pot_winners: vec![vec![PotWinner { + player_id: PlayerId(1), + amount: 30, + hand_value: HandValue::new(HandRank::HighCard, 0), + }]], + }; + + let output = format_ipoker(&record); + + // Verify key sections appear. + assert!(output.contains("Game #42 starts on 2026-05-19 12:00:00")); + assert!(output.contains("Table \"Table1\" (6-max) Seat #0 is the button")); + assert!(output.contains("Seat 0: Alice (1000 in chips)")); + assert!(output.contains("Seat 1: Bob (1000 in chips)")); + assert!(output.contains("Alice: posts small blind 10")); + assert!(output.contains("Bob: posts big blind 20")); + assert!(output.contains("*** HOLE CARDS ***")); + assert!(output.contains("Dealt to Alice [As Kc]")); + assert!(output.contains("Dealt to Bob [Td 7h]")); + assert!(output.contains("Bob: folds")); + assert!(output.contains("Alice collects 30 from pot")); + assert!(output.contains("*** SUMMARY ***")); + assert!(output.contains("Total pot 30 | Rake 0")); + assert!(output.contains("Seat 0: Alice collected 30")); + assert!(output.contains("Seat 1: Bob folded before flop")); + } + + #[test] + fn test_logger_disabled_is_noop() { + let logger = HandHistoryLogger::new("/tmp/hand_history_test_disabled", false); + let record = HandHistoryRecord { + hand_number: 1, + timestamp: "2026-01-01 00:00:00".to_string(), + game_type: "Cash".to_string(), + table_name: "Test".to_string(), + num_seats: 2, + button_seat: 0, + players: vec![], + blind_postings: vec![], + hole_cards_dealt: vec![], + preflop_actions: vec![], + flop_cards: vec![], + flop_actions: vec![], + turn_card: None, + turn_actions: vec![], + river_card: None, + river_actions: vec![], + showdown_entries: vec![], + board: Board::new(), + total_pot: 0, + rake: 0, + pot_winners: vec![], + }; + // Should succeed without creating any files. + assert!(logger.log_hand(&record).is_ok()); + } + + #[test] + fn test_logger_creates_directory_and_file() { + let dir = tempfile::tempdir().unwrap(); + let log_dir = dir.path().join("hand_logs"); + let logger = HandHistoryLogger::new(&log_dir, true); + + let record = HandHistoryRecord { + hand_number: 99, + timestamp: "2026-05-19 12:00:00".to_string(), + game_type: "Cash".to_string(), + table_name: "TestTable".to_string(), + num_seats: 2, + button_seat: 0, + players: vec![], + blind_postings: vec![], + hole_cards_dealt: vec![], + preflop_actions: vec![], + flop_cards: vec![], + flop_actions: vec![], + turn_card: None, + turn_actions: vec![], + river_card: None, + river_actions: vec![], + showdown_entries: vec![], + board: Board::new(), + total_pot: 0, + rake: 0, + pot_winners: vec![], + }; + + logger.log_hand(&record).unwrap(); + + // Directory should exist. + assert!(log_dir.exists()); + + // A file matching the pattern should exist. + let files: Vec<_> = fs::read_dir(&log_dir) + .unwrap() + .filter_map(|e| e.ok()) + .collect(); + assert_eq!(files.len(), 1); + let filename = files[0].file_name().to_string_lossy().to_string(); + assert!(filename.starts_with("hand_99_")); + assert!(filename.ends_with(".txt")); + } +} diff --git a/holdem_core/src/game/hand_runner.rs b/holdem_core/src/game/hand_runner.rs index d783749..589ba95 100644 --- a/holdem_core/src/game/hand_runner.rs +++ b/holdem_core/src/game/hand_runner.rs @@ -16,9 +16,10 @@ use crate::evaluator::evaluator::{HandRank, HandValue}; use crate::{Board, Deck, Hand, HoleCards}; use super::action::{validate_action, Action}; -use super::betting::{BetLimit, BettingRound}; +use super::betting::{BetLimit, BettingRound, BettingRules}; use super::config::RakeConfig; use super::game_info::GameInfo; +use super::hand_history::BlindType; use super::player::Seat; use super::player_info::PlayerState; use super::pot::{PotManager, PotWinner}; @@ -97,6 +98,54 @@ pub struct HandResult { pub total_rake: ChipCount, /// Rake deducted per pot, parallel to `pot_winners`. pub rake_per_pot: Vec, + + /// Hand history data, populated when the hand completes. + /// `None` only for test-constructed results; real hands always populate this. + pub hand_history: Option, +} + +/// Hand history data captured during a hand for logging and analysis. +/// +/// Extracted into its own struct so that `HandResult` stays focused and +/// test construction doesn't require five extra empty fields. +#[derive(Debug, Clone)] +pub struct HandHistoryData { + /// Actions per street (index 0=preflop, 1=flop, 2=turn, 3=river). + /// Each entry is `(PlayerId, Action)`. + pub actions: Vec>, + + /// Hole cards for all players dealt into the hand. + pub hole_cards: HashMap, + + /// Seat assignments: `(player_id, seat_index, starting_stack, is_dealer, is_sb, is_bb)`. + pub seat_assignments: Vec<(PlayerId, SeatIndex, ChipCount, bool, bool, bool)>, + + /// Blind postings: `(player_id, amount, blind_type)`. + pub blind_postings: Vec<(PlayerId, ChipCount, BlindType)>, + + /// Starting stacks at the beginning of the hand. + pub starting_stacks: HashMap, +} + +impl HandHistoryData { + /// Returns actions with blind postings filtered out, consuming the data. + /// + /// On preflop, the first two entries in `actions[0]` are SB and BB blind + /// postings recorded as `Action::Raise` (Meerkat convention). This method + /// strips those entries so consumers see only voluntary player actions. + /// + /// For postflop streets, returns the actions unchanged. + /// + /// Takes `self` by ownership to avoid cloning the entire action vector. + /// If you need to preserve `self`, clone it before calling. + pub fn voluntary_actions(mut self) -> Vec> { + let blind_count = self.blind_postings.len(); + if blind_count > 0 && !self.actions.is_empty() { + let skip = blind_count.min(self.actions[0].len()); + self.actions[0] = self.actions[0].drain(skip..).collect(); + } + self.actions + } } // --------------------------------------------------------------------------- @@ -143,11 +192,37 @@ pub struct HandRunner { bb_seat_idx: usize, /// Actions taken this hand, grouped by street. /// Index 0 = preflop, 1 = flop, 2 = turn, 3 = river. + /// + /// **⚠ Meerkat convention:** On preflop, the SB and BB blind postings are + /// recorded as `Action::Raise` entries (the first two entries). Code that + /// counts voluntary raises from this field must subtract 2 on preflop. + /// Use `blind_postings_snapshot` for accurate blind data in hand history + /// consumers. The `BettingRound.num_raises` field tracks only voluntary + /// raises and is unaffected by this convention. actions_this_hand: Vec>, /// Optional RNG seed for reproducible shuffles. seed: Option, /// Rake configuration for this hand. rake_config: RakeConfig, + /// Captured at start of hand for hand history. + /// **Consumed** by [`collect_hand_history_data()`](HandRunner::collect_hand_history_data) + /// on hand completion — will be empty afterward. + seat_snapshot: Vec<(PlayerId, SeatIndex, ChipCount, bool, bool, bool)>, + /// Blind postings captured during `start()`. + /// **Consumed** by [`collect_hand_history_data()`](HandRunner::collect_hand_history_data) + /// on hand completion — will be empty afterward. + blind_postings_snapshot: Vec<(PlayerId, ChipCount, BlindType)>, + /// Starting stacks captured during `start()`. + /// **Consumed** by [`collect_hand_history_data()`](HandRunner::collect_hand_history_data) + /// on hand completion — will be empty afterward. + starting_stacks_snapshot: HashMap, + /// Cached HandResult from `finalize_result()`, used when `run_to_completion()` + /// encounters a `Complete` state that was set by `step()` → `resolve_last_player_wins()`. + /// `step()` returns `HandState` and discards the result, so we cache it here. + pending_result: Option, + /// Debug guard: set to `true` after `collect_hand_history_data()` is called. + /// In debug builds, triggers an assertion on double-consumption. + history_collected: bool, } impl HandRunner { @@ -196,6 +271,11 @@ impl HandRunner { ], seed: None, rake_config: RakeConfig::no_rake(), + seat_snapshot: Vec::new(), + blind_postings_snapshot: Vec::new(), + starting_stacks_snapshot: HashMap::new(), + pending_result: None, + history_collected: false, } } @@ -214,6 +294,18 @@ impl HandRunner { self } + // ----------------------------------------------------------------------- + // Public accessors + // ----------------------------------------------------------------------- + + /// Returns the actions taken this hand, grouped by street. + /// + /// Index 0 = preflop, 1 = flop, 2 = turn, 3 = river. + /// Each entry is `(PlayerId, Action)`. + pub fn actions_this_hand(&self) -> &[Vec<(PlayerId, Action)>] { + &self.actions_this_hand + } + // ----------------------------------------------------------------------- // Start the hand // ----------------------------------------------------------------------- @@ -234,6 +326,7 @@ impl HandRunner { self.ante = ante; self.state = HandState::PreflopDeal; + self.history_collected = false; // 1. Shuffle the deck. if let Some(seed) = self.seed { @@ -250,17 +343,7 @@ impl HandRunner { seat.player.game_start_event(&game_info); } - // 3. Post antes (all active players). - if ante > 0 { - for seat in &mut self.seats { - let actual = seat.post_ante(ante); - if actual > 0 { - self.pot_manager.place_bet(seat.player_id, actual); - } - } - } - - // 4. Determine blind positions. + // 3. Determine blind positions BEFORE posting blinds. // In heads-up (2 players): dealer/SB = index 0, BB = index 1. // In 3+ players: SB = dealer+1, BB = dealer+2. if self.seats.len() == 2 { @@ -287,6 +370,43 @@ impl HandRunner { self.bb_seat_idx = (dealer_idx + 2) % self.seats.len(); } + // 4. Capture seat snapshot for hand history (before blinds/antes modify stacks). + self.seat_snapshot = self + .seats + .iter() + .map(|s| { + ( + s.player_id, + s.index, + s.stack, + s.index == self.dealer_seat, + s.index == self.seats[self.sb_seat_idx].index, + s.index == self.seats[self.bb_seat_idx].index, + ) + }) + .collect(); + self.starting_stacks_snapshot = self + .seats + .iter() + .map(|s| (s.player_id, s.stack)) + .collect(); + self.blind_postings_snapshot.clear(); + + // 5. Post antes (all active players). + if ante > 0 { + for seat in &mut self.seats { + let actual = seat.post_ante(ante); + if actual > 0 { + self.pot_manager.place_bet(seat.player_id, actual); + self.blind_postings_snapshot.push(( + seat.player_id, + actual, + BlindType::Ante, + )); + } + } + } + // Post small blind. { let actual_sb = self.seats[self.sb_seat_idx].post_blind(small_blind); @@ -294,10 +414,18 @@ impl HandRunner { self.pot_manager .place_bet(self.seats[self.sb_seat_idx].player_id, actual_sb); // Record SB as a raise (Meerkat convention: blinds count as raises). + // NOTE: Blind postings appear as `Action::Raise` in `actions_this_hand` + // for Meerkat compatibility. Use `blind_postings_snapshot` for accurate + // blind data in hand history consumers. self.actions_this_hand[0].push(( self.seats[self.sb_seat_idx].player_id, Action::Raise { total: actual_sb }, )); + self.blind_postings_snapshot.push(( + self.seats[self.sb_seat_idx].player_id, + actual_sb, + BlindType::SmallBlind, + )); } } @@ -308,14 +436,20 @@ impl HandRunner { self.pot_manager .place_bet(self.seats[self.bb_seat_idx].player_id, actual_bb); // Record BB as a raise (Meerkat convention: blinds count as raises). + // See SB posting above for the note about blind_postings_snapshot. self.actions_this_hand[0].push(( self.seats[self.bb_seat_idx].player_id, Action::Raise { total: actual_bb }, )); + self.blind_postings_snapshot.push(( + self.seats[self.bb_seat_idx].player_id, + actual_bb, + BlindType::BigBlind, + )); } } - // 5. Deal hole cards (2 per player). + // 6. Deal hole cards (2 per player). for seat in &mut self.seats { let c1 = self.deck.deal_one().expect("deck should have enough cards"); let c2 = self.deck.deal_one().expect("deck should have enough cards"); @@ -323,7 +457,7 @@ impl HandRunner { seat.hole_cards = Some(hc); } - // 6. Call hole_cards_dealt on each player. + // 7. Call hole_cards_dealt on each player. for i in 0..self.seats.len() { let hc = self.seats[i].hole_cards; if let Some(hole_cards) = hc { @@ -332,7 +466,7 @@ impl HandRunner { } } - // 7. Call stage_event for Preflop on all players. + // 8. Call stage_event for Preflop on all players. { let game_info = self.build_game_info(); for seat in &mut self.seats { @@ -340,12 +474,12 @@ impl HandRunner { } } - // 8. Create preflop BettingRound. + // 9. Create preflop BettingRound. // First to act is UTG (after big blind). let utg_idx = (self.bb_seat_idx + 1) % self.seats.len(); self.betting_round = Some(BettingRound::new(utg_idx, big_blind)); - // 9. Set state to PreflopBetting. + // 10. Set state to PreflopBetting. self.state = HandState::PreflopBetting; } @@ -416,6 +550,12 @@ impl HandRunner { .as_ref() .map(|br| br.last_raise_size) .unwrap_or(self.big_blind); + let raises_this_round = self + .betting_round + .as_ref() + .map(|br| br.num_raises) + .unwrap_or(0); + let street = self.current_street(); validate_action( &action, @@ -425,9 +565,28 @@ impl HandRunner { current_bet, min_raise, &self.bet_limit, - seat.has_acted_this_round, + street, + raises_this_round, )?; + // Pot-limit max raise enforcement. + // The generic validator cannot check this because it lacks pot-size context. + if let Action::Raise { total } = action { + if let Some(max_raise_increment) = self.bet_limit.max_raise_amount( + self.pot_manager.total_pot(), + current_bet.saturating_sub(seat.wagered_this_round), + seat.stack, + ) { + let raise_increment = total.saturating_sub(current_bet); + if raise_increment > max_raise_increment { + return Err(GameError::RaiseAboveMaximum( + raise_increment, + max_raise_increment, + )); + } + } + } + // Apply the action. self.apply_action(next_id, action.clone())?; @@ -877,33 +1036,41 @@ impl HandRunner { loop { match self.state { HandState::Showdown => { - let result = self.showdown(); + let mut result = self.showdown(); + // Populate hand history (showdown() leaves it None to avoid + // double-calling collect_hand_history_data). + result.hand_history = Some(self.collect_hand_history_data()); return Ok(result); } HandState::Complete => { - // Hand was already resolved (e.g., by resolve_last_player_wins via step). - // Stacks were already credited there — only reconstruct the HandResult. - let hand_values: HashMap = self - .seats - .iter() - .filter(|s| s.is_active()) - .map(|s| (s.player_id, HandValue::new(HandRank::HighCard, 0))) - .collect(); - - let pot_winners = self.pot_manager.distribute(&hand_values); - let (raked_winners, rake_per_pot, total_rake) = self.apply_rake(pot_winners); - - // Compute chip movements for the result (no stack crediting — already done). - let chip_movements = self.compute_chip_movements(&raked_winners); - - return Ok(HandResult { - pot_winners: raked_winners, - hand_values, - board: self.board.clone(), - chip_movements, - total_rake, - rake_per_pot, + // Hand was already resolved by step() → resolve_last_player_wins() + // → finalize_result(), which cached the result in pending_result. + // Use unwrap_or_else with a fallback reconstruction in case the + // state was set without going through finalize_result(). + let mut result = self.pending_result.take().unwrap_or_else(|| { + // Fallback: reconstruct result from current state. + let hand_values: HashMap = self + .seats + .iter() + .filter(|s| s.is_active()) + .map(|s| (s.player_id, HandValue::new(HandRank::HighCard, 0))) + .collect(); + let pot_winners = self.pot_manager.distribute(&hand_values); + let (raked_winners, rake_per_pot, total_rake) = self.apply_rake(pot_winners); + let chip_movements = self.compute_chip_movements(&raked_winners); + HandResult { + pot_winners: raked_winners, + hand_values, + board: self.board.clone(), + chip_movements, + total_rake, + rake_per_pot, + hand_history: None, + } }); + // Populate hand history (finalize_result leaves it None). + result.hand_history = Some(self.collect_hand_history_data()); + return Ok(result); } HandState::Idle => { return Err(GameError::NoHandInProgress); @@ -983,13 +1150,87 @@ impl HandRunner { // 5. Set state to Complete. self.state = HandState::Complete; - HandResult { + // NOTE: hand_history is set to None here; the caller (run_to_completion) + // is responsible for calling collect_hand_history_data() exactly once + // to avoid double-consumption of the runner's snapshot fields. + + let result = HandResult { pot_winners: raked_winners, hand_values, board: self.board.clone(), chip_movements, total_rake, rake_per_pot, + hand_history: None, + }; + + // Cache the result so run_to_completion can return it when it sees + // the Complete state (step() discards the HandResult). + self.pending_result = Some(result.clone()); + + result + } + + // ----------------------------------------------------------------------- + // Hand history helpers + // ----------------------------------------------------------------------- + + /// Collects hand history data from the current hand state. + /// + /// Returns a [`HandHistoryData`] struct with all data needed for hand history logging. + /// Uses `std::mem::replace` for `Vec` fields to avoid cloning when the data + /// is only consumed once (the runner is done with these fields after collection). + /// + /// **One-shot semantics:** This method is intended to be called exactly once per + /// hand, at hand completion. After calling it, the runner's snapshot fields are + /// emptied and `actions_this_hand` is reset to an empty 4-element structure. + /// Calling it again will produce empty data. + fn collect_hand_history_data(&mut self) -> HandHistoryData { + // Debug assertion: catch double-consumption at development time. + debug_assert!( + !self.history_collected, + "collect_hand_history_data called twice on the same hand — \ + snapshot data was already consumed" + ); + self.history_collected = true; + + // Guard: if snapshot data is already consumed (empty), return empty data. + // This can happen if finalize_result() was called but collect_hand_history_data + // was not (e.g., step() → resolve_last_player_wins() → finalize_result() path). + if self.seat_snapshot.is_empty() && self.actions_this_hand.iter().all(|v| v.is_empty()) { + log::warn!( + "collect_hand_history_data called on already-consumed runner — returning empty data. \ + This indicates a double-consumption bug in the caller." + ); + return HandHistoryData { + actions: vec![vec![], vec![], vec![], vec![]], + hole_cards: HashMap::new(), + seat_assignments: Vec::new(), + blind_postings: Vec::new(), + starting_stacks: HashMap::new(), + }; + } + // Replace with a properly-sized 4-element Vec to preserve the street-indexed + // structure (index 0=preflop, 1=flop, 2=turn, 3=river) in case the runner + // is ever reused after hand completion. + let actions = std::mem::replace( + &mut self.actions_this_hand, + vec![vec![], vec![], vec![], vec![]], + ); + let hole_cards: HashMap = self + .seats + .iter() + .filter_map(|s| s.hole_cards.map(|hc| (s.player_id, hc))) + .collect(); + let seat_assignments = std::mem::take(&mut self.seat_snapshot); + let blind_postings = std::mem::take(&mut self.blind_postings_snapshot); + let starting_stacks = std::mem::take(&mut self.starting_stacks_snapshot); + HandHistoryData { + actions, + hole_cards, + seat_assignments, + blind_postings, + starting_stacks, } } @@ -1444,8 +1685,9 @@ mod tests { #[test] fn test_fold_before_showdown() { // Two players: one folds preflop. - // In heads-up, BB (seat 1) acts first preflop. - // Make Bob (seat 1) the FoldBot so he folds first. + // In heads-up, dealer/SB (seat 0) acts first preflop. + // Alice (seat 0, CheckCallBot) acts first and calls. + // Then Bob (seat 1, FoldBot) folds. let seat0 = make_check_call_seat(1, "Alice", 1000, 0); let seat1 = make_fold_seat(2, "Bob", 1000, 1); @@ -1456,8 +1698,8 @@ mod tests { let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1); runner.start(10, 20, 0); - // In heads-up, BB (seat 1 = Bob) acts first preflop. - // Bob (FoldBot) will fold. Then only Alice remains. + // In heads-up, dealer/SB (seat 0 = Alice) acts first preflop. + // Alice calls, then Bob (FoldBot) folds. Only Alice remains. let result = runner.run_to_completion().unwrap(); assert_eq!(runner.state(), HandState::Complete); @@ -1518,8 +1760,8 @@ mod tests { let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1); runner.start(10, 20, 0); - // In heads-up, dealer (seat 0) is SB. First to act preflop is BB (seat 1). - // Set early fold for Alice (SB, seat 0) who will act second. + // In heads-up, dealer (seat 0) is SB. First to act preflop is dealer/SB (seat 0). + // Set early fold for Alice (SB, seat 0) who will act first. runner.set_early_fold(PlayerId(1)).unwrap(); // Step once — BB (Bob) acts (calls), then Alice's early fold triggers. @@ -1564,4 +1806,218 @@ mod tests { let result = runner.step(); assert!(result.is_err()); } + + // ----------------------------------------------------------------------- + // Heads-up blind positions and acting order + // ----------------------------------------------------------------------- + + #[test] + fn test_heads_up_dealer_is_sb_and_acts_first_preflop() { + // In heads-up, the dealer is the SB and acts first preflop. + // Post-flop, the dealer/SB acts last (BB acts first). + let seat0 = make_check_call_seat(1, "Alice", 1000, 0); + let seat1 = make_check_call_seat(2, "Bob", 1000, 1); + + let seats = vec![seat0, seat1]; + let deck = Deck::new(); + let bet_limit = BetLimit::NoLimit { min_raise: 10 }; + + let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1); + runner.start(10, 20, 0); + + // Verify blind positions: seat 0 (dealer) is SB, seat 1 is BB. + assert_eq!(runner.sb_seat_idx, 0); + assert_eq!(runner.bb_seat_idx, 1); + + // Verify SB and BB wagers. + assert_eq!(runner.seats[0].wagered_this_round, 10); // SB = 10 + assert_eq!(runner.seats[1].wagered_this_round, 20); // BB = 20 + + // First to act preflop should be seat 0 (dealer/SB). + let next = runner.next_to_act(); + assert_eq!(next, Some(PlayerId(1)), "In heads-up, dealer/SB should act first preflop"); + + // Run to completion. + let result = runner.run_to_completion().unwrap(); + assert_eq!(runner.state(), HandState::Complete); + + // Conservation of chips. + let total: i64 = result.chip_movements.values().sum(); + assert_eq!(total, 0); + } + + #[test] + fn test_three_player_acting_order_preflop() { + // In 3-player, SB = dealer+1, BB = dealer+2, UTG = BB+1 = dealer. + // With dealer at seat 0: SB=1, BB=2, UTG=0. + let seat0 = make_check_call_seat(1, "Alice", 1000, 0); + let seat1 = make_check_call_seat(2, "Bob", 1000, 1); + let seat2 = make_check_call_seat(3, "Charlie", 1000, 2); + + let seats = vec![seat0, seat1, seat2]; + let deck = Deck::new(); + let bet_limit = BetLimit::NoLimit { min_raise: 10 }; + + let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1); + runner.start(10, 20, 0); + + // Verify blind positions: SB = seat 1, BB = seat 2. + assert_eq!(runner.sb_seat_idx, 1); + assert_eq!(runner.bb_seat_idx, 2); + + // First to act preflop should be UTG = seat 0 (Alice). + let next = runner.next_to_act(); + assert_eq!(next, Some(PlayerId(1)), "UTG should act first in 3-player preflop"); + + let result = runner.run_to_completion().unwrap(); + assert_eq!(runner.state(), HandState::Complete); + + // Conservation of chips. + let total: i64 = result.chip_movements.values().sum(); + assert_eq!(total, 0); + } + + // ----------------------------------------------------------------------- + // Pot-limit enforcement + // ----------------------------------------------------------------------- + + /// A bot that always tries to raise to a specific total. + struct RaiseToBot { + id: PlayerId, + name: String, + total: ChipCount, + } + + impl RaiseToBot { + fn new(id: u64, name: &str, total: ChipCount) -> Self { + Self { id: PlayerId(id), name: name.to_string(), total } + } + } + + impl crate::game::player::HoldemPlayer for RaiseToBot { + fn id(&self) -> PlayerId { self.id } + fn name(&self) -> &str { &self.name } + fn init(&mut self, _game_info: &GameInfo) {} + fn game_start_event(&mut self, _game_info: &GameInfo) {} + fn hole_cards_dealt(&mut self, _hole_cards: &HoleCards, _game_info: &GameInfo) {} + fn stage_event(&mut self, _game_info: &GameInfo) {} + fn act(&mut self, _game_info: &GameInfo) -> Action { + Action::Raise { total: self.total } + } + fn action_event(&mut self, _player_id: PlayerId, _action: &Action, _game_info: &GameInfo) {} + fn showdown_event(&mut self, _player_id: PlayerId, _hole_cards: Option<&HoleCards>, _hand_value: Option<&HandValue>, _game_info: &GameInfo) {} + fn game_over_event(&mut self, _game_info: &GameInfo) {} + fn on_eliminated(&self, _placement: u32) {} + } + + #[test] + fn test_pot_limit_rejects_raise_above_pot() { + // Pot-limit: max raise increment = pot + call_amount. + // Setup: 3 players, pot = 30 (SB=10 + BB=20), first to act is UTG. + // UTG faces current_bet=20, call_amount=20, pot=30. + // Max raise increment = 30 + 20 = 50. Max total = 20 + 50 = 70. + // Raise to 100 should be rejected. + let seat0 = Seat::new(0, Box::new(RaiseToBot::new(1, "Alice", 100)), 1000); + let seat1 = make_check_call_seat(2, "Bob", 1000, 1); + let seat2 = make_check_call_seat(3, "Charlie", 1000, 2); + + let seats = vec![seat0, seat1, seat2]; + let deck = Deck::new(); + let bet_limit = BetLimit::PotLimit { min_raise: 10 }; + + let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1); + runner.start(10, 20, 0); + + // Alice tries to raise to 100, which exceeds the pot limit. + let result = runner.step(); + assert!(result.is_err(), "Pot-limit raise above pot+call should be rejected"); + } + + #[test] + fn test_pot_limit_allows_pot_sized_raise() { + // Pot-limit: max raise increment = pot + call_amount. + // Setup: 3 players, pot = 30 (SB=10 + BB=20), first to act is UTG. + // UTG faces current_bet=20, call_amount=20, pot=30. + // Max raise increment = 30 + 20 = 50. Max total = 20 + 50 = 70. + // Raise to 70 should be allowed. + let seat0 = Seat::new(0, Box::new(RaiseToBot::new(1, "Alice", 70)), 1000); + let seat1 = make_check_call_seat(2, "Bob", 1000, 1); + let seat2 = make_check_call_seat(3, "Charlie", 1000, 2); + + let seats = vec![seat0, seat1, seat2]; + let deck = Deck::new(); + let bet_limit = BetLimit::PotLimit { min_raise: 10 }; + + let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1); + runner.start(10, 20, 0); + + // Alice raises to exactly the pot limit — should succeed. + let state = runner.step(); + assert!(state.is_ok(), "Pot-sized raise should be allowed"); + assert_eq!(state.unwrap(), HandState::PreflopBetting); + } + + // ----------------------------------------------------------------------- + // Side pot edge cases + // ----------------------------------------------------------------------- + + #[test] + fn test_side_pot_three_players_different_stacks() { + // Three players with different stacks all check/call to showdown. + // Alice: 100, Bob: 200, Charlie: 300. + // With CallBot, everyone just calls the BB preflop and checks postflop. + let seat0 = make_seat(1, "Alice", 100, 0); + let seat1 = make_seat(2, "Bob", 200, 1); + let seat2 = make_seat(3, "Charlie", 300, 2); + + let seats = vec![seat0, seat1, seat2]; + let deck = Deck::new(); + let bet_limit = BetLimit::NoLimit { min_raise: 10 }; + + let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1); + runner.start(10, 20, 0); + + let result = runner.run_to_completion().unwrap(); + assert_eq!(runner.state(), HandState::Complete); + + // Conservation of chips. + let total: i64 = result.chip_movements.values().sum(); + assert_eq!(total, 0); + + // All three should have hand values (went to showdown). + assert_eq!(result.hand_values.len(), 3); + } + + #[test] + fn test_heads_up_bb_gets_option_when_sb_calls_preflop() { + // In heads-up, when SB calls/limps preflop, BB gets the option to raise. + // The BB should see a check/call situation (current_bet matches their wagered). + // Both players check/call through to showdown. + let seat0 = make_check_call_seat(1, "Alice", 1000, 0); + let seat1 = make_check_call_seat(2, "Bob", 1000, 1); + + let seats = vec![seat0, seat1]; + let deck = Deck::new(); + let bet_limit = BetLimit::NoLimit { min_raise: 10 }; + + let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1); + runner.start(10, 20, 0); + + // SB (Alice, seat 0) acts first preflop — she calls (matches BB). + let state = runner.step().unwrap(); + assert_eq!(state, HandState::PreflopBetting); + + // Now BB (Bob, seat 1) gets the option — he checks. + let state = runner.step().unwrap(); + // After BB checks, preflop betting is done → advance to flop. + assert!(matches!(state, HandState::FlopBetting | HandState::DealFlop | HandState::Complete)); + + // Run to completion. + let result = runner.run_to_completion().unwrap(); + assert_eq!(runner.state(), HandState::Complete); + + // Conservation of chips. + let total: i64 = result.chip_movements.values().sum(); + assert_eq!(total, 0); + } } diff --git a/holdem_core/src/game/mod.rs b/holdem_core/src/game/mod.rs index 43bf762..1657819 100644 --- a/holdem_core/src/game/mod.rs +++ b/holdem_core/src/game/mod.rs @@ -24,6 +24,7 @@ pub mod bot_factory; pub mod bots; pub mod config; pub mod game_info; +pub mod hand_history; pub mod hand_runner; pub mod player; pub mod player_info; @@ -39,14 +40,18 @@ pub use action::{Action, PlayerAction}; pub use betting::BetLimit; pub use bot_factory::create_bot; pub use bots::{CallBot, FoldBot, RandomBot, RaiseBot}; -pub use config::{BetLimitConfig, BotConfig, GameConfig, OutputConfig, RakeConfig, SimulationConfig}; +pub use config::{BetLimitConfig, BlindLevelConfig, BotConfig, GameConfig, OutputConfig, RakeConfig, SimulationConfig}; pub use game_info::GameInfo; -pub use hand_runner::{HandResult, HandRunner, HandState}; +pub use hand_history::{ + BlindPosting, BlindType, HandHistoryLogger, HandHistoryPlayer, HandHistoryRecord, ShowdownEntry, + StreetAction, format_ipoker, +}; +pub use hand_runner::{HandHistoryData, HandResult, HandRunner, HandState}; pub use player::{HoldemPlayer, Seat}; pub use player_info::{PlayerInfo, PlayerState}; pub use pot::{Pot, PotManager, PotWinner}; -pub use simulation::run_cash_game; -pub use stats::{PlayerStats, SimulationStats}; +pub use simulation::{run_cash_game, run_sng_series}; +pub use stats::{PlayerStats, SimulationStats, SngPlayerSeriesResult, SngSeriesResult}; pub use table::{Table, TableConfig}; pub use tournament::{BlindLevel, BlindSchedule, GameType, PrizeStructure, Tournament}; pub use types::{ChipCount, GameError, PlayerId, SeatIndex, MAX_SEATS, MIN_PLAYERS}; diff --git a/holdem_core/src/game/simulation.rs b/holdem_core/src/game/simulation.rs index d1246ed..c22b3f3 100644 --- a/holdem_core/src/game/simulation.rs +++ b/holdem_core/src/game/simulation.rs @@ -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 { + // 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 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 + ); + } + } + } } diff --git a/holdem_core/src/game/stats.rs b/holdem_core/src/game/stats.rs index 5b265bc..51eaf7b 100644 --- a/holdem_core/src/game/stats.rs +++ b/holdem_core/src/game/stats.rs @@ -403,6 +403,177 @@ impl SimulationStats { } } +// --------------------------------------------------------------------------- +// SngPlayerSeriesResult +// --------------------------------------------------------------------------- + +/// Per-player results across an SNG tournament series. +/// +/// Tracks aggregate performance for a single bot identity (matched by name) +/// across multiple SNG tournaments. +#[derive(Debug, Clone)] +pub struct SngPlayerSeriesResult { + /// Display name (used as the identity key across tournaments). + pub name: String, + /// Number of tournaments this player participated in. + pub tournaments_played: u32, + /// Total entry fees paid (`buy_in × tournaments_played`). + pub total_entry_fees: i64, + /// Total prize money won across all tournaments. + pub total_prize_money: i64, + /// Net profit: `total_prize_money - total_entry_fees`. + pub net_profit: i64, + /// Return on investment: `(net_profit / total_entry_fees) × 100`. + /// Returns `0.0` if `total_entry_fees` is zero. + pub roi_percent: f64, + /// Number of 1st-place finishes. + pub wins: u32, + /// Placement (1-indexed) for each tournament. Index corresponds to + /// tournament number in the series. + pub finishes: Vec, +} + +impl SngPlayerSeriesResult { + /// Creates a new, zeroed result for a player with the given name. + pub fn new(name: String) -> Self { + Self { + name, + tournaments_played: 0, + total_entry_fees: 0, + total_prize_money: 0, + net_profit: 0, + roi_percent: 0.0, + wins: 0, + finishes: Vec::new(), + } + } + + /// Records a single tournament result for this player. + pub fn record_finish(&mut self, placement: u32, prize: ChipCount, buy_in: ChipCount) { + self.tournaments_played += 1; + self.total_entry_fees += buy_in as i64; + self.total_prize_money += prize as i64; + if placement == 1 { + self.wins += 1; + } + self.finishes.push(placement); + + // Recompute derived fields. + self.net_profit = self.total_prize_money - self.total_entry_fees; + self.roi_percent = if self.total_entry_fees > 0 { + (self.net_profit as f64 / self.total_entry_fees as f64) * 100.0 + } else { + 0.0 + }; + } +} + +// --------------------------------------------------------------------------- +// SngSeriesResult +// --------------------------------------------------------------------------- + +/// Aggregate results of an SNG tournament series. +#[derive(Debug, Clone)] +pub struct SngSeriesResult { + /// Number of tournaments run. + pub num_tournaments: u32, + /// Buy-in per tournament. + pub buy_in: ChipCount, + /// Per-player results, keyed by player name. + pub players: HashMap, + /// Total hands played across all tournaments. + pub total_hands_played: u64, + /// The seed used for this series (either from config or auto-generated). + /// Store this to allow reproducing a specific run. + pub seed_used: u64, +} + +impl SngSeriesResult { + /// Creates a new, empty series result. + pub fn new(num_tournaments: u32, buy_in: ChipCount, seed_used: u64) -> Self { + Self { + num_tournaments, + buy_in, + players: HashMap::new(), + total_hands_played: 0, + seed_used, + } + } + + /// Records a single player's tournament finish. + pub fn record_player_finish( + &mut self, + name: &str, + placement: u32, + prize: ChipCount, + ) { + let result = self + .players + .entry(name.to_string()) + .or_insert_with(|| SngPlayerSeriesResult::new(name.to_string())); + result.record_finish(placement, prize, self.buy_in); + } + + /// Formats a human-readable summary table. + /// + /// ```text + /// SNG Series Results: 100 tournaments, buy-in: 11 + /// ================================================ + /// Player Tournaments Wins Prize Money Entry Fees Net Profit ROI% + /// FlockBot-1 100 23 253 1100 -847 -77.0% + /// ChumpBot-1 100 18 198 1100 -902 -82.0% + /// ================================================ + /// ``` + pub fn summary(&self) -> String { + let mut lines = Vec::new(); + + lines.push(format!( + "SNG Series Results: {} tournaments, buy-in: {}, seed: {}", + self.num_tournaments, self.buy_in, self.seed_used + )); + lines.push("=".repeat(80)); + + // Header + lines.push(format!( + "{:<16} {:>12} {:>4} {:>12} {:>10} {:>10} {:>8}", + "Player", "Tournaments", "Wins", "Prize Money", "Entry Fees", "Net Profit", "ROI%" + )); + + // Sort players by net_profit descending. + let mut players: Vec<&SngPlayerSeriesResult> = + self.players.values().collect(); + players.sort_by(|a, b| b.net_profit.cmp(&a.net_profit)); + + for p in &players { + let profit_str = if p.net_profit >= 0 { + format!("+{}", p.net_profit) + } else { + format!("{}", p.net_profit) + }; + + let roi_str = if p.roi_percent >= 0.0 { + format!("+{:.1}%", p.roi_percent) + } else { + format!("{:.1}%", p.roi_percent) + }; + + lines.push(format!( + "{:<16} {:>12} {:>4} {:>12} {:>10} {:>10} {:>8}", + p.name, + p.tournaments_played, + p.wins, + p.total_prize_money, + p.total_entry_fees, + profit_str, + roi_str + )); + } + + lines.push("=".repeat(80)); + lines.join("\n") + } +} + // =========================================================================== // Tests // =========================================================================== @@ -497,6 +668,7 @@ mod tests { chip_movements, total_rake: 0, rake_per_pot: vec![], + hand_history: None, }; sim_stats.record_hand_result(&result, &player_names); @@ -534,6 +706,7 @@ mod tests { }, total_rake: 0, rake_per_pot: vec![], + hand_history: None, }; // Hand 2: Bob wins 200 @@ -549,6 +722,7 @@ mod tests { }, total_rake: 0, rake_per_pot: vec![], + hand_history: None, }; sim_stats.record_hand_result(&result1, &player_names); @@ -587,6 +761,7 @@ mod tests { }, total_rake: 0, rake_per_pot: vec![], + hand_history: None, }; sim_stats.record_hand_result(&result, &player_names); diff --git a/holdem_core/src/game/tournament.rs b/holdem_core/src/game/tournament.rs index 0cddd9a..0adb28c 100644 --- a/holdem_core/src/game/tournament.rs +++ b/holdem_core/src/game/tournament.rs @@ -8,8 +8,9 @@ use serde::{Deserialize, Serialize}; use super::player_info::PlayerState; +use super::player::Seat; use super::table::{Table, TableConfig}; -use super::types::{ChipCount, GameError, PlayerId}; +use super::types::{ChipCount, GameError, PlayerId, MIN_PLAYERS}; // --------------------------------------------------------------------------- // GameType Enum @@ -109,6 +110,36 @@ impl BlindSchedule { pub fn is_final_level(&self, level_index: usize) -> bool { level_index >= self.levels.len().saturating_sub(1) } + + /// Adds a new level that doubles the blinds/ante of the last level. + /// + /// Used to extend the blind schedule past the final defined level, + /// forcing the tournament to conclude by escalating blinds. + pub fn add_escalated_level(&mut self) { + if let Some(last) = self.levels.last() { + let new_level = last.level.saturating_add(1); + let new_sb = last.small_blind.saturating_mul(2); + let new_bb = last.big_blind.saturating_mul(2); + if new_sb == u64::MAX || new_bb == u64::MAX { + log::warn!( + "Blind level {} saturated at u64::MAX — tournament may not conclude through blind escalation", + new_level + ); + } + let new_ante = if last.ante > 0 { + last.ante.saturating_mul(2) + } else { + last.big_blind / 5 + }; + self.levels.push(BlindLevel { + level: new_level, + small_blind: new_sb, + big_blind: new_bb, + ante: new_ante, + duration_hands: last.duration_hands, + }); + } + } } // --------------------------------------------------------------------------- @@ -123,6 +154,13 @@ pub struct PrizeStructure { } impl PrizeStructure { + /// Creates a custom prize structure from the given prize amounts. + /// + /// Entries are ordered: 1st place, 2nd place, 3rd place, etc. + pub fn custom(prizes: Vec) -> Self { + Self { prizes } + } + /// Standard Sit-and-Go prize structure. /// /// - 3+ players: 50/30/20 for top 3 @@ -248,7 +286,6 @@ pub struct Tournament { /// Whether the tournament is complete. is_complete: bool, /// Buy-in amount per player. - #[allow(dead_code)] // Used for prize pool calculations in future extensions buy_in: ChipCount, } @@ -266,7 +303,7 @@ impl Tournament { buy_in: ChipCount, ) -> Self { let table = Table::new(config); - Self { + let mut tournament = Self { game_type: GameType::SitAndGo, tables: vec![table], blind_schedule, @@ -278,7 +315,10 @@ impl Tournament { total_hands: 0, is_complete: false, buy_in, - } + }; + // Apply the first blind level to the table. + tournament.apply_blinds_to_tables(); + tournament } /// Creates a new Multi-Table Tournament with the specified number of tables. @@ -293,7 +333,7 @@ impl Tournament { let tables = (0..num_tables) .map(|_| Table::new(config.clone())) .collect(); - Self { + let mut tournament = Self { game_type: GameType::MultiTableTournament, tables, blind_schedule, @@ -305,7 +345,10 @@ impl Tournament { total_hands: 0, is_complete: false, buy_in, - } + }; + // Apply the first blind level to all tables. + tournament.apply_blinds_to_tables(); + tournament } // ----------------------------------------------------------------------- @@ -387,6 +430,15 @@ impl Tournament { self.total_hands += hands_run as u64; } + // Rebalance tables after bustouts — move players from full tables + // to short-handed ones so that all tables can continue running hands. + // This prevents the deadlock where 2+ players survive but are isolated + // on different tables with < MIN_PLAYERS each. + self.rebalance_tables(); + + // Remove tables that have no active players. + self.tables.retain(|t| t.active_player_count() > 0); + // Check blind advancement self.check_blind_advance(); @@ -441,11 +493,18 @@ impl Tournament { // ----------------------------------------------------------------------- /// Checks if the blind level should advance and advances if so. + /// + /// When the final defined blind level is exhausted, a new level is + /// automatically generated by doubling the previous level's blinds and + /// antes. This ensures the tournament always concludes, even with + /// passive players or very deep stacks. pub fn check_blind_advance(&mut self) { if let Some(level) = self.blind_schedule.current_level(self.current_level) { - if self.hands_at_current_level >= level.duration_hands - && !self.blind_schedule.is_final_level(self.current_level) - { + if self.hands_at_current_level >= level.duration_hands { + if self.blind_schedule.is_final_level(self.current_level) { + // Past the last defined level — generate an escalated one. + self.blind_schedule.add_escalated_level(); + } self.current_level += 1; self.hands_at_current_level = 0; self.apply_blinds_to_tables(); @@ -531,7 +590,87 @@ impl Tournament { return; } + // Safety counter: each iteration should either consolidate tables or move + // a player, so the upper bound is O(total_players * tables). We use a + // generous limit to prevent infinite loops if consolidation logic has a bug. + let max_iterations = self.tables.len() * self.tables.iter().map(|t| t.active_player_count()).max().unwrap_or(1) + self.tables.len(); + let mut iterations = 0; + loop { + iterations += 1; + if iterations > max_iterations { + log::warn!( + "rebalance_tables: safety limit ({}) reached after {} iterations — stopping", + max_iterations, iterations + ); + break; + } + + // Remove empty tables first (tables with 0 active players) + self.tables.retain(|t| t.active_player_count() > 0); + + if self.tables.len() <= 1 { + break; + } + + // Last-resort consolidation: if total active players across all tables + // is <= MIN_PLAYERS * (tables - 1), we can fit everyone on fewer tables. + // Move all players from smaller tables to the largest table. + let total_active: usize = self.tables.iter().map(|t| t.active_player_count()).sum(); + let max_capacity = MIN_PLAYERS as usize * (self.tables.len() - 1); + if total_active <= max_capacity { + // Consolidate all players onto the table with the most players. + // Take players from all other tables. + let mut max_idx = 0; + let mut max_count = 0usize; + for (i, table) in self.tables.iter().enumerate() { + let count = table.active_player_count(); + if count > max_count { + max_count = count; + max_idx = i; + } + } + + // Collect all players from non-max tables. + let mut players_to_move: Vec = Vec::new(); + for (i, table) in self.tables.iter_mut().enumerate() { + if i != max_idx { + for slot in table.seats_mut().iter_mut() { + if let Some(seat) = slot.take() { + if seat.state != PlayerState::SittingOut { + players_to_move.push(seat); + } + } + } + } + } + + // Place collected players onto the max table. + for mut seat in players_to_move { + if let Some(empty_idx) = self.tables[max_idx].find_empty_seat() { + seat.index = empty_idx; + self.tables[max_idx].seats_mut()[empty_idx as usize] = Some(seat); + } else { + log::warn!( + "Consolidation failed: no empty seat on table {} for player {}", + max_idx, seat.player_id + ); + // Put the player back where they came from — find any table with room + for table in self.tables.iter_mut() { + if let Some(empty_idx) = table.find_empty_seat() { + seat.index = empty_idx; + table.seats_mut()[empty_idx as usize] = Some(seat); + break; + } + } + } + } + + // Remove now-empty tables and re-check. + self.tables.retain(|t| t.active_player_count() > 0); + continue; // Restart the loop to re-evaluate. + } + // Find table with most and fewest active players let mut max_count = 0usize; let mut max_idx = 0usize; @@ -550,14 +689,28 @@ impl Tournament { } } - // If difference < 2 or tables are the same, we're balanced - if max_count.saturating_sub(min_count) < 2 || max_idx == min_idx { + // Consolidation: move players from short tables (below MIN_PLAYERS) + // to larger tables so they can continue playing. + // Balance: when tables differ by >= 2, move from largest to smallest. + let needs_consolidation = min_count < MIN_PLAYERS as usize && max_count >= MIN_PLAYERS as usize; + let needs_balance = min_count >= MIN_PLAYERS as usize && max_count.saturating_sub(min_count) >= 2; + + if (!needs_consolidation && !needs_balance) || max_idx == min_idx { break; } + // Determine source and destination: + // - Consolidation: move FROM the short table (min) TO the big table (max) + // - Balance: move FROM the big table (max) TO the short table (min) + let (source_idx, dest_idx) = if needs_consolidation { + (min_idx, max_idx) + } else { + (max_idx, min_idx) + }; + // Take a player from the source table let seat_taken = { - let source_seats = self.tables[max_idx].seats_mut(); + let source_seats = self.tables[source_idx].seats_mut(); let pos = source_seats.iter().position(|slot| { slot.as_ref().is_some_and(|s| s.state != PlayerState::SittingOut) }); @@ -566,32 +719,37 @@ impl Tournament { if let Some(mut seat) = seat_taken { // Find empty seat on destination table - let dest_table = &mut self.tables[min_idx]; + let dest_table = &mut self.tables[dest_idx]; if let Some(new_seat_idx) = dest_table.find_empty_seat() { seat.index = new_seat_idx; dest_table.seats_mut()[new_seat_idx as usize] = Some(seat); } else { // No room — put the seat back on the source table - // Find the original position or any empty slot - let source_seats = self.tables[max_idx].seats_mut(); + let source_seats = self.tables[source_idx].seats_mut(); if let Some(empty_pos) = source_seats.iter().position(|s| s.is_none()) { source_seats[empty_pos] = Some(seat); } else { - // Should not happen since we just took from this table - source_seats.push(Some(seat)); + // This should never happen since we just took a seat + // from this table, so there must be an empty slot. + // Log the error and attempt recovery on any table with room. + log::error!( + "Rebalance bug: no empty seat on source table {} for player {} \ + (table capacity exceeded — seat-index invariant broken)", + source_idx, seat.player_id + ); + for table in self.tables.iter_mut() { + if let Some(empty_idx) = table.find_empty_seat() { + seat.index = empty_idx; + table.seats_mut()[empty_idx as usize] = Some(seat); + break; + } + } } break; // Can't move, stop balancing } } else { break; // No movable player found } - - // Remove empty tables (tables with 0 active players) - self.tables.retain(|t| t.active_player_count() > 0); - - if self.tables.len() <= 1 { - break; - } } } @@ -614,6 +772,11 @@ impl Tournament { &self.tables } + /// Returns a mutable reference to the tournament tables. + pub fn tables_mut(&mut self) -> &mut [Table] { + &mut self.tables + } + /// Returns the total number of hands played. pub fn total_hands(&self) -> u64 { self.total_hands @@ -623,6 +786,49 @@ impl Tournament { pub fn prize_structure(&self) -> &PrizeStructure { &self.prize_structure } + + /// Returns the buy-in amount per player. + pub fn buy_in(&self) -> ChipCount { + self.buy_in + } + + /// Returns the winner's [`PlayerId`] if the tournament is complete. + /// + /// The winner is the sole active (non-eliminated, non-sitting-out) player + /// remaining across all tables. Returns `None` if the tournament is not + /// yet complete or if the number of active players is not exactly 1 + /// (should not happen in normal operation after completion). + pub fn winner(&self) -> Option { + if !self.is_complete { + return None; + } + let active: Vec = self + .tables + .iter() + .flat_map(|t| t.seats().iter().flatten()) + .filter(|s| s.state != PlayerState::SittingOut) + .map(|s| s.player_id) + .collect(); + if active.len() == 1 { + Some(active[0]) + } else { + log::warn!( + "Tournament completed with {} active players (expected 1)", + active.len() + ); + active.first().copied() + } + } + + /// Shuffles seat assignments on all tables. + /// + /// Should be called after registering all players but before + /// [`run_to_completion()`](Tournament::run_to_completion). + pub fn shuffle_seats(&mut self) { + for table in &mut self.tables { + table.shuffle_seats(); + } + } } // --------------------------------------------------------------------------- @@ -992,19 +1198,14 @@ mod tests { assert_eq!(tournament.tables()[0].seated_player_count(), 5); assert_eq!(tournament.tables()[1].seated_player_count(), 1); - // Rebalance — should move players to even out + // Rebalance — table 1 has only 1 player (< MIN_PLAYERS=2), + // so consolidation moves that player to table 0, then removes the empty table. tournament.rebalance_tables(); - // After balancing, the difference should be < 2 - let count0 = tournament.tables()[0].seated_player_count(); - let count1 = tournament.tables()[1].seated_player_count(); - assert!( - count0.abs_diff(count1) < 2, - "Tables should be balanced: table0={}, table1={}", - count0, - count1 - ); - assert_eq!(count0 + count1, 6); // All players still present + // After consolidation: 1 table with all 6 players + assert_eq!(tournament.tables().len(), 1, "Empty table should be removed"); + let total_players: usize = tournament.tables().iter().map(|t| t.seated_player_count()).sum(); + assert_eq!(total_players, 6, "All players should be preserved"); } // ----------------------------------------------------------------------- @@ -1072,4 +1273,145 @@ mod tests { let deserialized: GameType = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized, gt); } + + // ----------------------------------------------------------------------- + // Winner tracking tests + // ----------------------------------------------------------------------- + + #[test] + fn test_winner_returns_none_before_completion() { + let config = TableConfig::default(); + let schedule = BlindSchedule::custom(vec![BlindLevel { + level: 1, + small_blind: 10, + big_blind: 20, + ante: 0, + duration_hands: 10, + }]); + let prizes = PrizeStructure::standard_sng(100, 3); + let mut tournament = Tournament::new_sng(config, schedule, prizes, 1000, 100); + + tournament + .register_player(Box::new(CallBot::new(1, "Alice"))) + .unwrap(); + tournament + .register_player(Box::new(CallBot::new(2, "Bob"))) + .unwrap(); + + assert!(!tournament.is_complete()); + assert_eq!(tournament.winner(), None); + } + + #[test] + fn test_sng_run_to_completion_records_winner() { + let config = TableConfig::default(); + let schedule = BlindSchedule::custom(vec![BlindLevel { + level: 1, + small_blind: 10, + big_blind: 20, + ante: 0, + duration_hands: 5, + }]); + let prizes = PrizeStructure::standard_sng(100, 3); + let mut tournament = Tournament::new_sng(config, schedule, prizes, 1000, 100); + + tournament + .register_player(Box::new(CallBot::new(1, "Alice"))) + .unwrap(); + tournament + .register_player(Box::new(CallBot::new(2, "Bob"))) + .unwrap(); + tournament + .register_player(Box::new(CallBot::new(3, "Charlie"))) + .unwrap(); + + tournament.run_to_completion().unwrap(); + + assert!(tournament.is_complete()); + + // The winner should be identifiable. + let winner = tournament.winner(); + assert!(winner.is_some(), "Tournament should have a winner after completion"); + + // All players should be accounted for: N-1 in standings + 1 winner. + let standings = tournament.standings(); + let total_accounted = standings.len() + if winner.is_some() { 1 } else { 0 }; + assert_eq!( + total_accounted, 3, + "All 3 players should be accounted for (standings + winner), got {} in standings + winner", + total_accounted + ); + } + + #[test] + fn test_mtt_consolidation_prevents_deadlock() { + // 2 tables, 1 active player each — should consolidate to 1 table. + let config = TableConfig { + num_seats: 6, + ..Default::default() + }; + let schedule = BlindSchedule::custom(vec![BlindLevel { + level: 1, + small_blind: 10, + big_blind: 20, + ante: 0, + duration_hands: 5, + }]); + let prizes = PrizeStructure::standard_sng(100, 4); + let mut tournament = + Tournament::new_mtt(2, config, schedule, prizes, 1000, 100); + + // Register 4 players. + for i in 1..=4 { + tournament + .register_player(Box::new(CallBot::new(i, &format!("Bot{}", i)))) + .unwrap(); + } + + // Manually bust out 2 players to leave 1 per table. + // Registration distributes: table 0 gets Bot1(id=1) and Bot3(id=3), + // table 1 gets Bot2(id=2) and Bot4(id=4). + // Keep player 1 active on table 0, player 2 active on table 1. + { + let seats = tournament.tables_mut()[0].seats_mut(); + for slot in seats.iter_mut() { + if let Some(seat) = slot.as_mut() { + if seat.player_id != PlayerId(1) { + seat.stack = 0; + seat.state = PlayerState::SittingOut; + } else { + seat.stack = 4000; // Give all chips to player 1 + } + } + } + } + { + let seats = tournament.tables_mut()[1].seats_mut(); + for slot in seats.iter_mut() { + if let Some(seat) = slot.as_mut() { + if seat.player_id != PlayerId(2) { + seat.stack = 0; + seat.state = PlayerState::SittingOut; + } else { + seat.stack = 4000; // Give all chips to player 2 + } + } + } + } + + // Before fix: rebalance would leave 2 tables with 1 player each (deadlock). + // After fix: rebalance consolidates to 1 table. + tournament.rebalance_tables(); + + assert_eq!( + tournament.tables().len(), + 1, + "Should consolidate to 1 table" + ); + assert_eq!( + tournament.tables()[0].active_player_count(), + 2, + "Should have 2 active players" + ); + } } diff --git a/holdem_sim/src/main.rs b/holdem_sim/src/main.rs index 092111c..dfa0575 100644 --- a/holdem_sim/src/main.rs +++ b/holdem_sim/src/main.rs @@ -17,7 +17,7 @@ use clap::Parser; use holdem_core::game::bot_factory::create_bot; use holdem_core::game::config::SimulationConfig; -use holdem_core::game::simulation::run_cash_game; +use holdem_core::game::simulation::{run_cash_game, run_sng_series}; use holdem_core::game::stats::SimulationStats; use holdem_core::game::tournament::{GameType, Tournament}; use holdem_core::game::types::PlayerId; @@ -75,11 +75,16 @@ fn main() { game_config.num_hands = hands; } - // Run simulation based on game type + // Run simulation based on game type and number of tournaments match game_config.game_type { GameType::Cash => run_cash_simulation(&game_config), - GameType::SitAndGo => run_tournament_simulation(&game_config), - GameType::MultiTableTournament => run_tournament_simulation(&game_config), + GameType::SitAndGo | GameType::MultiTableTournament => { + if game_config.num_tournaments > 1 { + run_tournament_series_simulation(&game_config, game_config.num_tournaments); + } else { + run_tournament_simulation(&game_config); + } + } } } @@ -197,3 +202,35 @@ fn run_tournament_simulation(config: &holdem_core::game::config::GameConfig) { println!("{}", stats.summary(config.buy_in)); } + +// --------------------------------------------------------------------------- +// Tournament Series (SNG or MTT) +// --------------------------------------------------------------------------- + +fn run_tournament_series_simulation(config: &holdem_core::game::config::GameConfig, num_tournaments: u32) { + let game_type_name = match config.game_type { + GameType::SitAndGo => "SNG", + GameType::MultiTableTournament => "MTT", + GameType::Cash => "Cash", + }; + println!("=== {} Tournament Series ===", game_type_name); + println!("Tournaments: {}", num_tournaments); + println!("Buy-in: {}", config.buy_in); + println!("Starting chips: {}", config.starting_chips); + println!("Blinds: {}/{}", config.small_blind, config.big_blind); + println!("Players: {}", config.bots.len()); + if config.game_type == GameType::MultiTableTournament { + println!("Tables: {}", config.num_tables); + } + if let Some(seed) = config.seed { + println!("Seed: {}", seed); + } + println!(); + + let result = run_sng_series(config, num_tournaments).unwrap_or_else(|e| { + eprintln!("Tournament series error: {}", e); + std::process::exit(1); + }); + + println!("{}", result.summary()); +} diff --git a/plans/meerkat-alignment-fixes.md b/plans/meerkat-alignment-fixes.md new file mode 100644 index 0000000..c2e64f6 --- /dev/null +++ b/plans/meerkat-alignment-fixes.md @@ -0,0 +1,193 @@ +# Meerkat API Alignment — Bug Fixes and Improvements Plan + +## Executive Summary + +After a thorough comparison of the Rust `holdem_core` implementation against the Java Meerkat API reference (`Meerkat-API`), I identified several bugs, behavioral discrepancies, and missing features. The core architecture is solid and well-structured, but there are concrete issues that need fixing for correct poker gameplay. + +--- + +## Bug Fixes + +### BUG-1: FixedLimit raise validation uses `small_bet` for all streets + +**File:** [`action.rs`](holdem_core/src/game/action.rs:173) + +**Problem:** In `validate_action()`, the `FixedLimit` branch always compares `raise_increment` against `small_bet`, even on turn/river streets where `big_bet` should be used. The validator doesn't know the current street. + +```rust +// Current (WRONG for turn/river): +BetLimit::FixedLimit { small_bet, big_bet: _ } => { + if raise_increment != *small_bet { ... } +} +``` + +**Fix:** Pass the current `Street` to `validate_action()` so it can use the correct bet size. On preflop/flop use `small_bet`, on turn/river use `big_bet`. + +**Impact:** Fixed-limit games on turn/river incorrectly reject valid raises or accept invalid ones. + +--- + +### BUG-2: Pot-limit `max_raise_amount` calculation is wrong + +**File:** [`betting.rs`](holdem_core/src/game/betting.rs:106) + +**Problem:** The pot-limit max raise formula returns `pot + call_amount`, but the standard pot-limit formula should be: + +``` +max_raise_total = pot + call_amount + current_bet +``` + +Wait, let me re-examine. The trait says it returns the max raise *amount*, but the semantics are unclear. In standard pot-limit: +- A player can raise up to: `pot_size + amount_to_call` +- Where `pot_size` is the pot BEFORE the player's call +- So the max *total* they can put in is: `current_bet + call_amount + pot_size` + +The current code returns `pot + call_amount` which is ambiguous. The caller needs to know whether this is the raise increment or the total. This needs clarification and the actual pot-limit cap needs to be enforced in `HandRunner`. + +**Fix:** Clarify the semantics of `max_raise_amount` — it should return the maximum *total* wager for the player (not just the increment). Then enforce it in `validate_action` or `apply_action`. + +--- + +### BUG-3: FixedLimit `max_raise_amount` returns `None` + +**File:** [`betting.rs`](holdem_core/src/game/betting.rs:112) + +**Problem:** For `FixedLimit`, `max_raise_amount()` returns `None`, meaning "no cap." But Fixed-Limit definitely has a cap — the fixed bet size. The comment even acknowledges this confusion. + +**Fix:** Return `Some(fixed_bet_size)` for the current street. This requires passing `street` to the method, or restructuring the trait. + +--- + +### BUG-4: All-in that doesn't exceed current bet doesn't reopen action + +**File:** [`hand_runner.rs`](holdem_core/src/game/hand_runner.rs:597) + +**Problem:** When a player goes all-in for less than the current bet, the code calls `br.apply_call()` and does NOT reset `has_acted_this_round` for other players. However, consider this scenario: + +- Player A bets 100 +- Player B raises to 200 +- Player C goes all-in for 150 (doesn't exceed 200) +- Player D has already acted and called the 200 + +In this case, Player D doesn't need to act again, which is correct. BUT if Player C's all-in somehow changes the action flow, there could be issues. Actually, this behavior is correct for standard poker rules — an all-in that doesn't exceed the current bet does NOT reopen the betting. So this is actually fine. + +**Revised:** This is NOT a bug. The current behavior is correct. + +--- + +### BUG-5: `run_to_completion` may double-credit winnings + +**File:** [`hand_runner.rs`](holdem_core/src/game/hand_runner.rs:968) + +**Problem:** When `step()` resolves the hand via `resolve_last_player_wins()`, it calls `finalize_result()` which credits winnings to stacks and sets state to `Complete`. Then `run_to_completion()` enters the `Complete` branch and calls `distribute()` again, computing chip movements from a second distribution. However, `distribute()` only reads pot state (doesn't mutate), and `credit_winnings()` is NOT called again in the `Complete` branch. So the stacks are only credited once. + +Wait, let me re-read more carefully... + +In `resolve_last_player_wins()`: +1. Calls `finalize_result()` which credits winnings, sets state to Complete +2. Returns `Ok(HandResult)` but the result is discarded by `step()` which only returns `HandState` + +Then in `run_to_completion()`: +- The `Complete` branch reconstructs a `HandResult` but does NOT call `credit_winnings()` again +- It only computes `chip_movements` for the result struct + +So actually the stacks are only credited once (in `finalize_result`), but the `HandResult` constructed in the `Complete` branch of `run_to_completion` calls `distribute()` again, which returns a fresh result from the pot manager. This is fine because `distribute` is read-only. + +**Revised:** This is NOT a double-credit bug. The code is correct but slightly confusing. The `Complete` branch in `run_to_completion` should ideally reuse the result from `finalize_result` rather than reconstructing it, but functionally it's correct. + +--- + +### BUG-6: Missing `game_state_changed` callback + +**File:** [`player.rs`](holdem_core/src/game/player.rs) + +**Problem:** The Meerkat API has a `gameStateChanged()` callback that is called after an action event has been fully processed. The Rust implementation does not have this callback. In Meerkat, the sequence is: + +1. `actionEvent(pos, act)` — the action is observed +2. `gameStateChanged()` — the game info state has been updated + +This is important because in Meerkat, `actionEvent` is called BEFORE the game state is updated, and `gameStateChanged` is called AFTER. In the Rust implementation, `action_event` is called with an already-updated `GameInfo`, which is actually better. But the `gameStateChanged` callback is missing. + +**Fix:** This is a minor API gap. The current Rust design is arguably better (providing updated state in `action_event`), so this is low priority. Could add a `game_state_changed` method with a default no-op implementation. + +--- + +### BUG-7: `validate_action` doesn't validate FixedLimit raise count cap + +**File:** [`action.rs`](holdem_core/src/game/action.rs) + +**Problem:** In Meerkat's Fixed-Limit, there's a cap of 3 raises per round (or 4 total bets). The `can_raise` method in `BettingRules` checks this, but `validate_action` doesn't check it. The validation only checks the raise amount, not the number of raises. + +**Fix:** Pass `raises_this_round` to `validate_action` and check `bet_limit.can_raise(street, raises_this_round)` for FixedLimit. + +--- + +## Meerkat API Alignment + +### ALIGN-1: Add `get_amount_to_call` convenience method to `GameInfo` + +**File:** [`game_info.rs`](holdem_core/src/game/game_info.rs) + +**Problem:** Meerkat's `GameInfo.getAmountToCall(seat)` is used extensively by bots. The Rust `GameInfo` has `current_bet` and players have `wagered_this_round`, but there's no convenience method to compute `amount_to_call = current_bet - wagered_this_round`. + +**Fix:** Add: +```rust +pub fn get_amount_to_call(&self, player_id: PlayerId) -> ChipCount +pub fn is_preflop(&self) -> bool +``` + +--- + +### ALIGN-2: Add `check_or_fold` Action helper + +**Problem:** Meerkat has `Action.checkOrFoldAction(toCall)` which returns CHECK if toCall is 0, FOLD otherwise. The Rust implementation doesn't have this convenience. + +**Fix:** Add a method to `Action`: +```rust +pub fn check_or_fold(amount_to_call: ChipCount) -> Action +``` + +--- + +### ALIGN-3: Add `bet_action` helper + +**Problem:** Meerkat has `Action.betAction(gi)` which creates a raise/bet of the minimum amount. The Rust implementation requires manually computing the raise total. + +**Fix:** Add a constructor or helper that computes the correct raise total from game state. + +--- + +## Test Coverage Gaps + +### TEST-1: Fixed-limit raise validation on turn/river + +No tests verify that Fixed-Limit raises on turn/river use `big_bet` instead of `small_bet`. + +### TEST-2: Heads-up blind positions + +No tests verify that in heads-up, the dealer is the small blind and acts first preflop. + +### TEST-3: Side pot edge cases + +No tests for: +- Three players all-in with different amounts +- Folded player with money in the pot creating side pots +- Split pots with odd chips + +### TEST-4: All-in that reopens betting + +No tests verify that a full all-in raise correctly resets `has_acted_this_round` for other players. + +--- + +## Priority Order + +1. **BUG-1** — FixedLimit validation uses wrong bet size (gameplay-breaking for fixed-limit) +2. **BUG-7** — FixedLimit raise count not validated (gameplay-breaking for fixed-limit) +3. **BUG-2** — Pot-limit max raise calculation needs enforcement (gameplay-breaking for pot-limit) +4. **BUG-3** — FixedLimit max_raise_amount returns None (related to BUG-2) +5. **ALIGN-1** — Missing GameInfo convenience methods (bot compatibility) +6. **ALIGN-2** — Missing check_or_fold helper (bot compatibility) +7. **ALIGN-3** — Missing bet_action helper (bot compatibility) +8. **TEST-1 through TEST-4** — Comprehensive test coverage +9. **BUG-6** — Missing game_state_changed callback (low priority)