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

Major changes:
- Add hand history module (iPoker format) with HandHistoryLogger
- Add run_sng_series() for multi-tournament aggregate statistics
- Fix MTT table consolidation deadlock (sub-minimum-player tables)
- Add Meerkat-compatible convenience methods on Action and GameInfo
- Add custom blind levels, prize structures, auto-rebuy to GameConfig
- Add SngSeriesResult/SngPlayerSeriesResult for series tracking
- Add Tournament::winner(), tables_mut(), shuffle_seats()

Review fixes:
- Fix TOML prizes field ordering (must precede [[game.blind_levels]])
- Add sensible serde defaults for small_blind (10) and big_blind (20)
- Fix prize assignment in run_sng_series (use raw_placement directly)
- Handle hand history filename collisions with suffixed retry
- Add debug_assert guard on collect_hand_history_data double-consumption
This commit is contained in:
Jan-Peter von Hunnius 2026-05-20 21:05:22 +02:00
parent 9dfd9c3ee3
commit c041cde3fe
18 changed files with 4601 additions and 208 deletions

View file

@ -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<Vec<(PlayerId, Action)>>` (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<chrono::Utc>,
/// 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<HandHistoryPlayer>,
/// Blind postings: player name, amount, type (SB/BB/Ante).
pub blind_postings: Vec<BlindPosting>,
/// Actions per street: preflop, flop, turn, river.
/// Each street is a list of (player_name, action_description).
pub street_actions: Vec<Vec<StreetAction>>,
/// Board cards per street.
pub board_cards: Vec<Vec<Card>>,
/// Showdown entries: player name, hole cards, hand description.
pub showdown_entries: Vec<ShowdownEntry>,
/// Pot collection entries: player name, amount.
pub collections: Vec<PotCollection>,
/// 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<HoleCards>,
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<Card>,
pub player_summaries: Vec<PlayerSummary>,
}
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<Vec<PotWinner>>,
pub hand_values: HashMap<PlayerId, HandValue>,
pub board: Board,
pub chip_movements: HashMap<PlayerId, i64>,
pub total_rake: ChipCount,
pub rake_per_pot: Vec<ChipCount>,
// NEW FIELDS:
/// Actions taken during the hand, grouped by street.
/// Index 0 = preflop, 1 = flop, 2 = turn, 3 = river.
pub actions: Vec<Vec<(PlayerId, Action)>>,
/// Hole cards for each player who was dealt cards.
/// Keyed by PlayerId. Available for all players, not just showdown.
pub hole_cards: HashMap<PlayerId, HoleCards>,
/// Seat assignments: PlayerId -> seat index (0-based).
pub seat_assignments: HashMap<PlayerId, SeatIndex>,
/// 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 #<hand_number> starts on <YYYY/MM/DD HH:MM:SS>
Table "<table_name>" (<max_seats>-max) Seat #<button_seat_1based> is the button
Seat <N>: <name> (<stack> in chips)
<name>: posts small blind <amount>
<name>: posts big blind <amount>
<name>: posts ante <amount>
*** HOLE CARDS ***
Dealt to <name> [<card1> <card2>]
<name>: folds
<name>: calls <amount>
<name>: raises <raise_amount> to <total>
<name>: checks
*** FLOP *** [<c1> <c2> <c3>]
... actions ...
*** TURN *** [<c1> <c2> <c3>] [<c4>]
... actions ...
*** RIVER *** [<c1> <c2> <c3> <c4>] [<c5>]
... actions ...
*** SHOW DOWN ***
<name>: shows [<c1> <c2>] (<hand_description>)
<name> collects <amount> from pot
*** SUMMARY ***
Total pot <amount> | Rake <amount>
Board: [<c1> <c2> <c3> <c4> <c5>]
Seat <N>: <name> collected <amount> (showed [<c1> <c2>] and won with <hand_desc>)
Seat <N>: <name> showed [<c1> <c2>] and lost with <hand_desc>
Seat <N>: <name> 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<PlayerId, String>,
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<String>,
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<String>,
}
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<BlindLevelConfig>,
}
/// 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<BlindLevel> = 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<ChipCount>,
}
```
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<SngSeriesResult, GameError> {
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<PlayerId, SngPlayerSeriesResult>,
/// 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<u32, u32>, // 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<BlindLevelConfig>`, `prizes: Vec<ChipCount>` |
| `OutputConfig` | `config.rs` | Add `log_dir: Option<String>`, `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`.

View file

@ -4,7 +4,7 @@ num_seats = 10
small_blind = 10 small_blind = 10
big_blind = 20 big_blind = 20
ante = 0 ante = 0
starting_chips = 5000 starting_chips = 1500
num_hands = 10000 num_hands = 10000
# Rake configuration (optional, defaults to no rake). # Rake configuration (optional, defaults to no rake).
@ -15,8 +15,12 @@ num_hands = 10000
# reset_bankroll_each_hand = false # reset_bankroll_each_hand = false
# bankroll_refill_threshold = 1000 # Refill when stack <= 1000 # 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 seat assignments before each hand (optional, defaults to false).
# randomize_seats = true randomize_seats = true
[game.bet_limit] [game.bet_limit]
type = "no_limit" type = "no_limit"

View file

@ -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

View file

@ -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

View file

@ -1,21 +1,107 @@
[game] [game]
game_type = "SitAndGo" game_type = "SitAndGo"
num_seats = 9 num_seats = 10
small_blind = 10 starting_chips = 1500
big_blind = 20 buy_in = 11
starting_chips = 5000 randomize_seats = true
buy_in = 1000 num_tournaments = 100
hands_per_level = 10
num_blind_levels = 15 # Custom prize structure in chip amounts: 1st=5, 2nd=3, 3rd=2
prizes = [50, 30, 20]
[game.bet_limit] [game.bet_limit]
type = "no_limit" type = "no_limit"
min_raise = 20 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]] [[game.bots]]
bot_type = "call_bot" 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]] [[game.bots]]
bot_type = "fold_bot" 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

View file

@ -162,15 +162,47 @@ The `action` module provides a pure validation function:
/// Returns `Ok(())` or a `GameError` describing why the action is illegal. /// Returns `Ok(())` or a `GameError` describing why the action is illegal.
pub fn validate_action( pub fn validate_action(
action: &Action, action: &Action,
player_id: PlayerId,
player_chips: ChipCount, player_chips: ChipCount,
player_wagered_this_round: ChipCount, player_wagered_this_round: ChipCount,
current_bet: ChipCount, current_bet: ChipCount,
min_raise: ChipCount, min_raise: ChipCount,
bet_limit: &BetLimit, bet_limit: &BetLimit,
has_acted_this_round: bool, street: Street,
raises_this_round: usize,
) -> Result<(), GameError> ) -> 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` ## 4. Game Info — `game_info.rs`
@ -265,6 +297,41 @@ impl GameInfo {
pub fn is_postflop(&self) -> bool { pub fn is_postflop(&self) -> bool {
self.street >= Street::Flop 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 { ... }
} }
``` ```

View file

@ -6,8 +6,9 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::game::betting::BetLimit; use crate::game::betting::{BetLimit, BettingRules};
use crate::game::types::{ChipCount, GameError, PlayerId}; use crate::game::types::{ChipCount, GameError, PlayerId};
use crate::Street;
/// Actions a player can take when it is their turn. /// Actions a player can take when it is their turn.
/// ///
@ -86,6 +87,92 @@ impl Action {
pub fn is_active(&self) -> bool { pub fn is_active(&self) -> bool {
!matches!(self, Action::Fold) !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. /// 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. /// * `current_bet` — The current outstanding bet to call.
/// * `min_raise` — The minimum raise increment. /// * `min_raise` — The minimum raise increment.
/// * `bet_limit` — The betting structure in use. /// * `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)] #[allow(clippy::too_many_arguments)]
pub fn validate_action( pub fn validate_action(
action: &Action, action: &Action,
@ -112,7 +200,8 @@ pub fn validate_action(
current_bet: ChipCount, current_bet: ChipCount,
min_raise: ChipCount, min_raise: ChipCount,
bet_limit: &BetLimit, bet_limit: &BetLimit,
_has_acted_this_round: bool, street: Street,
raises_this_round: usize,
) -> Result<(), GameError> { ) -> Result<(), GameError> {
match action { match action {
Action::Fold => Ok(()), Action::Fold => Ok(()),
@ -163,17 +252,24 @@ pub fn validate_action(
match bet_limit { match bet_limit {
BetLimit::NoLimit { .. } => Ok(()), BetLimit::NoLimit { .. } => Ok(()),
BetLimit::PotLimit { min_raise: _ } => { BetLimit::PotLimit { min_raise: _ } => {
// Pot-limit validation requires knowing the pot size. // Pot-limit max raise is enforced by the HandRunner which
// The engine layer handles this; we pass here. // has access to the pot size. The validator passes here.
Ok(()) Ok(())
} }
BetLimit::FixedLimit { BetLimit::FixedLimit { .. } => {
small_bet, // In fixed limit, the raise must equal the fixed bet size for the street.
big_bet: _, let fixed_size = bet_limit
} => { .fixed_bet_size(street)
// In fixed limit, the raise must equal the fixed bet size. .expect("FixedLimit always returns a fixed_bet_size");
if raise_increment != *small_bet { if raise_increment != fixed_size {
return Err(GameError::RaiseAboveMaximum(raise_increment, *small_bet)); 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(()) Ok(())
} }
@ -209,7 +305,8 @@ mod tests {
50, 50,
10, 10,
&bet_limit, &bet_limit,
false, Street::Preflop,
0,
) )
.is_ok()); .is_ok());
} }
@ -225,7 +322,8 @@ mod tests {
0, 0,
10, 10,
&bet_limit, &bet_limit,
false, Street::Preflop,
0,
) )
.is_ok()); .is_ok());
} }
@ -241,7 +339,8 @@ mod tests {
50, 50,
10, 10,
&bet_limit, &bet_limit,
false, Street::Preflop,
0,
); );
assert!(matches!(result, Err(GameError::CheckNotAllowed(50)))); assert!(matches!(result, Err(GameError::CheckNotAllowed(50))));
} }
@ -257,7 +356,8 @@ mod tests {
50, 50,
10, 10,
&bet_limit, &bet_limit,
false, Street::Preflop,
0,
) )
.is_ok()); .is_ok());
} }
@ -273,7 +373,8 @@ mod tests {
20, 20,
20, 20,
&bet_limit, &bet_limit,
false, Street::Preflop,
0,
); );
assert!(matches!( assert!(matches!(
result, result,
@ -292,7 +393,8 @@ mod tests {
20, 20,
10, 10,
&bet_limit, &bet_limit,
false, Street::Preflop,
0,
); );
assert!(matches!( assert!(matches!(
result, result,
@ -311,7 +413,8 @@ mod tests {
20, 20,
10, 10,
&bet_limit, &bet_limit,
false, Street::Preflop,
0,
); );
assert!(matches!( assert!(matches!(
result, result,
@ -333,4 +436,150 @@ mod tests {
assert!(Action::Raise { total: 50 }.is_active()); assert!(Action::Raise { total: 50 }.is_active());
assert!(Action::AllIn.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 }
);
}
} }

View file

@ -85,12 +85,13 @@ impl BettingRules for BetLimit {
BetLimit::FixedLimit { BetLimit::FixedLimit {
small_bet, small_bet,
big_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. // For FixedLimit, the min raise is the fixed bet size for the street.
// But we only have `current_bet` and `previous_raise` here, not `street`. // Since this trait method doesn't receive `street`, we return `small_bet`
// The caller should use `fixed_bet_size` for FixedLimit instead. // as a safe default (correct for preflop/flop, conservative for turn/river).
// We return small_bet as a safe default; FixedLimit users should prefer // Callers needing street-aware sizing should use `fixed_bet_size(street)`.
// `fixed_bet_size(street)`. *small_bet
}
} }
} }
@ -98,31 +99,28 @@ impl BettingRules for BetLimit {
&self, &self,
pot: ChipCount, pot: ChipCount,
call_amount: ChipCount, call_amount: ChipCount,
_player_stack: ChipCount, player_stack: ChipCount,
) -> Option<ChipCount> { ) -> Option<ChipCount> {
match self { match self {
BetLimit::NoLimit { .. } => None, BetLimit::NoLimit { .. } => None,
BetLimit::PotLimit { .. } => { BetLimit::PotLimit { .. } => {
// Standard pot-limit formula: pot + call_amount + previous_raise // Standard pot-limit formula:
// Here we return the max *total* raise above the call amount. // The maximum *raise increment* a player can make is:
// The classic formula: max raise = pot + call_amount (where call_amount // pot_size + amount_to_call
// is what the player needs to put in to call). // where pot_size is the current pot and amount_to_call is what the
Some(pot + call_amount) // 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 { .. } => { BetLimit::FixedLimit { .. } => {
// Max raise equals min raise (fixed increments). // For FixedLimit, the max raise equals the fixed bet size for the street.
// The caller should use `fixed_bet_size` to determine the amount. // Since this method doesn't receive `street`, we return None.
// Returning `Some(0)` isn't useful; FixedLimit max is the same as min. // Callers should use `fixed_bet_size(street)` instead, which is what
// We return None to indicate "use fixed_bet_size" — but the trait // `validate_action` now does.
// 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.
None 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 { fn can_raise(&self, _street: Street, raises_this_round: usize) -> bool {
match self { match self {
BetLimit::NoLimit { .. } | BetLimit::PotLimit { .. } => true, BetLimit::NoLimit { .. } | BetLimit::PotLimit { .. } => true,

View file

@ -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 // Default helper functions
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -101,11 +125,26 @@ fn default_num_seats() -> SeatIndex {
9 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. /// Default value of 1.
fn default_one() -> usize { fn default_one() -> usize {
1 1
} }
/// Default starting chips.
fn default_starting_chips() -> ChipCount {
10_000
}
/// Default number of hands per blind level. /// Default number of hands per blind level.
fn default_hands_per_level() -> u32 { fn default_hands_per_level() -> u32 {
10 10
@ -116,6 +155,16 @@ fn default_num_levels() -> u32 {
15 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 // BetLimitConfig
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -243,10 +292,12 @@ pub struct GameConfig {
/// Betting structure. /// Betting structure.
pub bet_limit: BetLimitConfig, 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, 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, pub big_blind: ChipCount,
/// Ante amount (0 = no ante). /// Ante amount (0 = no ante).
@ -254,6 +305,7 @@ pub struct GameConfig {
pub ante: ChipCount, pub ante: ChipCount,
/// Starting chip count for each player. /// Starting chip count for each player.
#[serde(default = "default_starting_chips")]
pub starting_chips: ChipCount, pub starting_chips: ChipCount,
/// Buy-in amount (for tournaments). /// Buy-in amount (for tournaments).
@ -304,6 +356,33 @@ pub struct GameConfig {
#[serde(default)] #[serde(default)]
pub randomize_seats: bool, 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<BlindLevelConfig>,
/// 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<ChipCount>,
/// 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. /// Bot configurations.
#[serde(default)] #[serde(default)]
pub bots: Vec<BotConfig>, pub bots: Vec<BotConfig>,
@ -317,8 +396,8 @@ impl Default for GameConfig {
bet_limit: BetLimitConfig::NoLimit { bet_limit: BetLimitConfig::NoLimit {
min_raise: 20, min_raise: 20,
}, },
small_blind: 10, small_blind: default_small_blind(),
big_blind: 20, big_blind: default_big_blind(),
ante: 0, ante: 0,
starting_chips: 10_000, starting_chips: 10_000,
buy_in: 0, buy_in: 0,
@ -332,6 +411,11 @@ impl Default for GameConfig {
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
randomize_seats: false, 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(), bots: Vec::new(),
} }
} }
@ -386,9 +470,26 @@ impl GameConfig {
/// Convert to a [`BlindSchedule`] (for tournaments). /// Convert to a [`BlindSchedule`] (for tournaments).
/// ///
/// Generates a standard blind schedule using the configured starting blinds, /// When `blind_levels` is non-empty, builds a custom schedule from those
/// number of blind levels, and hands per level. /// 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 { pub fn to_blind_schedule(&self) -> BlindSchedule {
if !self.blind_levels.is_empty() {
let levels: Vec<super::tournament::BlindLevel> = 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 mut levels = Vec::with_capacity(self.num_blind_levels as usize);
let base_sb = self.small_blind; let base_sb = self.small_blind;
let base_bb = self.big_blind; let base_bb = self.big_blind;
@ -414,16 +515,22 @@ impl GameConfig {
} }
BlindSchedule::custom(levels) BlindSchedule::custom(levels)
} }
}
/// Convert to a [`PrizeStructure`] (for tournaments). /// 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 /// - Sit-and-Go: 50/30/20 for top 3
/// - MTT: top-heavy distribution for top ~15% /// - MTT: top-heavy distribution for top ~15%
/// - Cash: empty prize structure
pub fn to_prize_structure(&self) -> PrizeStructure { pub fn to_prize_structure(&self) -> PrizeStructure {
if !self.prizes.is_empty() {
PrizeStructure::custom(self.prizes.clone())
} else {
let num_players = self.bots.len().max(2); let num_players = self.bots.len().max(2);
match self.game_type { match self.game_type {
GameType::Cash => PrizeStructure::standard_sng(self.buy_in, num_players), GameType::Cash => PrizeStructure::custom(vec![]),
GameType::SitAndGo => PrizeStructure::standard_sng(self.buy_in, num_players), GameType::SitAndGo => PrizeStructure::standard_sng(self.buy_in, num_players),
GameType::MultiTableTournament => { GameType::MultiTableTournament => {
let total_entries = num_players * self.num_tables; let total_entries = num_players * self.num_tables;
@ -431,6 +538,51 @@ impl GameConfig {
} }
} }
} }
}
/// 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<String> {
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, reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false,
rebuy_threshold_percentage: 25,
blind_levels: vec![],
prizes: vec![],
num_tournaments: 1,
bots: vec![ bots: vec![
BotConfig { BotConfig {
@ -865,6 +1022,11 @@ min_raise = 50
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false,
rebuy_threshold_percentage: 25,
blind_levels: vec![],
prizes: vec![],
num_tournaments: 1,
bots: vec![], bots: vec![],
}; };
@ -897,6 +1059,11 @@ min_raise = 50
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false,
rebuy_threshold_percentage: 25,
blind_levels: vec![],
prizes: vec![],
num_tournaments: 1,
bots: vec![], bots: vec![],
}; };
@ -945,6 +1112,11 @@ min_raise = 50
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false,
rebuy_threshold_percentage: 25,
blind_levels: vec![],
prizes: vec![],
num_tournaments: 1,
bots: vec![ bots: vec![
BotConfig { BotConfig {
@ -995,6 +1167,11 @@ min_raise = 50
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false,
rebuy_threshold_percentage: 25,
blind_levels: vec![],
prizes: vec![],
num_tournaments: 1,
bots: vec![ bots: vec![
BotConfig { BotConfig {
@ -1087,6 +1264,11 @@ min_raise = 20
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false,
rebuy_threshold_percentage: 25,
blind_levels: vec![],
prizes: vec![],
num_tournaments: 1,
bots: vec![], bots: vec![],
}, },
output: OutputConfig { output: OutputConfig {

View file

@ -113,4 +113,97 @@ impl GameInfo {
pub fn is_postflop(&self) -> bool { pub fn is_postflop(&self) -> bool {
self.street >= Street::Flop 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
}
} }

View file

@ -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<HoleCards>,
/// 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<HandValue>,
/// 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<HandHistoryPlayer>,
/// Blind and ante postings.
pub blind_postings: Vec<BlindPosting>,
/// Hole cards dealt to each player.
pub hole_cards_dealt: Vec<(PlayerId, HoleCards)>,
/// Preflop actions.
pub preflop_actions: Vec<StreetAction>,
/// Flop community cards.
pub flop_cards: Vec<Card>,
/// Flop actions.
pub flop_actions: Vec<StreetAction>,
/// Turn community card.
pub turn_card: Option<Card>,
/// Turn actions.
pub turn_actions: Vec<StreetAction>,
/// River community card.
pub river_card: Option<Card>,
/// River actions.
pub river_actions: Vec<StreetAction>,
/// Showdown entries (empty if hand ended before showdown).
pub showdown_entries: Vec<ShowdownEntry>,
/// 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<Vec<PotWinner>>,
}
// ---------------------------------------------------------------------------
// 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<PathBuf>, 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<String> = Vec::new();
// Build a player name lookup.
let name_map: HashMap<PlayerId, &str> = 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<PlayerId, ChipCount> = 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<PlayerId, &ShowdownEntry> = 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<PlayerId, bool> = 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::<Vec<_>>().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"));
}
}

View file

@ -16,9 +16,10 @@ use crate::evaluator::evaluator::{HandRank, HandValue};
use crate::{Board, Deck, Hand, HoleCards}; use crate::{Board, Deck, Hand, HoleCards};
use super::action::{validate_action, Action}; use super::action::{validate_action, Action};
use super::betting::{BetLimit, BettingRound}; use super::betting::{BetLimit, BettingRound, BettingRules};
use super::config::RakeConfig; use super::config::RakeConfig;
use super::game_info::GameInfo; use super::game_info::GameInfo;
use super::hand_history::BlindType;
use super::player::Seat; use super::player::Seat;
use super::player_info::PlayerState; use super::player_info::PlayerState;
use super::pot::{PotManager, PotWinner}; use super::pot::{PotManager, PotWinner};
@ -97,6 +98,54 @@ pub struct HandResult {
pub total_rake: ChipCount, pub total_rake: ChipCount,
/// Rake deducted per pot, parallel to `pot_winners`. /// Rake deducted per pot, parallel to `pot_winners`.
pub rake_per_pot: Vec<ChipCount>, pub rake_per_pot: Vec<ChipCount>,
/// Hand history data, populated when the hand completes.
/// `None` only for test-constructed results; real hands always populate this.
pub hand_history: Option<HandHistoryData>,
}
/// 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<Vec<(PlayerId, Action)>>,
/// Hole cards for all players dealt into the hand.
pub hole_cards: HashMap<PlayerId, HoleCards>,
/// 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<PlayerId, ChipCount>,
}
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<Vec<(PlayerId, Action)>> {
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, bb_seat_idx: usize,
/// Actions taken this hand, grouped by street. /// Actions taken this hand, grouped by street.
/// Index 0 = preflop, 1 = flop, 2 = turn, 3 = river. /// 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<Vec<(PlayerId, Action)>>, actions_this_hand: Vec<Vec<(PlayerId, Action)>>,
/// Optional RNG seed for reproducible shuffles. /// Optional RNG seed for reproducible shuffles.
seed: Option<u64>, seed: Option<u64>,
/// Rake configuration for this hand. /// Rake configuration for this hand.
rake_config: RakeConfig, 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<PlayerId, ChipCount>,
/// 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<HandResult>,
/// 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 { impl HandRunner {
@ -196,6 +271,11 @@ impl HandRunner {
], ],
seed: None, seed: None,
rake_config: RakeConfig::no_rake(), 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 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 // Start the hand
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@ -234,6 +326,7 @@ impl HandRunner {
self.ante = ante; self.ante = ante;
self.state = HandState::PreflopDeal; self.state = HandState::PreflopDeal;
self.history_collected = false;
// 1. Shuffle the deck. // 1. Shuffle the deck.
if let Some(seed) = self.seed { if let Some(seed) = self.seed {
@ -250,17 +343,7 @@ impl HandRunner {
seat.player.game_start_event(&game_info); seat.player.game_start_event(&game_info);
} }
// 3. Post antes (all active players). // 3. Determine blind positions BEFORE posting blinds.
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.
// In heads-up (2 players): dealer/SB = index 0, BB = index 1. // In heads-up (2 players): dealer/SB = index 0, BB = index 1.
// In 3+ players: SB = dealer+1, BB = dealer+2. // In 3+ players: SB = dealer+1, BB = dealer+2.
if self.seats.len() == 2 { if self.seats.len() == 2 {
@ -287,6 +370,43 @@ impl HandRunner {
self.bb_seat_idx = (dealer_idx + 2) % self.seats.len(); 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. // Post small blind.
{ {
let actual_sb = self.seats[self.sb_seat_idx].post_blind(small_blind); let actual_sb = self.seats[self.sb_seat_idx].post_blind(small_blind);
@ -294,10 +414,18 @@ impl HandRunner {
self.pot_manager self.pot_manager
.place_bet(self.seats[self.sb_seat_idx].player_id, actual_sb); .place_bet(self.seats[self.sb_seat_idx].player_id, actual_sb);
// Record SB as a raise (Meerkat convention: blinds count as raises). // 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.actions_this_hand[0].push((
self.seats[self.sb_seat_idx].player_id, self.seats[self.sb_seat_idx].player_id,
Action::Raise { total: actual_sb }, 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 self.pot_manager
.place_bet(self.seats[self.bb_seat_idx].player_id, actual_bb); .place_bet(self.seats[self.bb_seat_idx].player_id, actual_bb);
// Record BB as a raise (Meerkat convention: blinds count as raises). // 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.actions_this_hand[0].push((
self.seats[self.bb_seat_idx].player_id, self.seats[self.bb_seat_idx].player_id,
Action::Raise { total: actual_bb }, 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 { for seat in &mut self.seats {
let c1 = self.deck.deal_one().expect("deck should have enough cards"); let c1 = self.deck.deal_one().expect("deck should have enough cards");
let c2 = 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); 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() { for i in 0..self.seats.len() {
let hc = self.seats[i].hole_cards; let hc = self.seats[i].hole_cards;
if let Some(hole_cards) = hc { 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(); let game_info = self.build_game_info();
for seat in &mut self.seats { 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). // First to act is UTG (after big blind).
let utg_idx = (self.bb_seat_idx + 1) % self.seats.len(); let utg_idx = (self.bb_seat_idx + 1) % self.seats.len();
self.betting_round = Some(BettingRound::new(utg_idx, big_blind)); self.betting_round = Some(BettingRound::new(utg_idx, big_blind));
// 9. Set state to PreflopBetting. // 10. Set state to PreflopBetting.
self.state = HandState::PreflopBetting; self.state = HandState::PreflopBetting;
} }
@ -416,6 +550,12 @@ impl HandRunner {
.as_ref() .as_ref()
.map(|br| br.last_raise_size) .map(|br| br.last_raise_size)
.unwrap_or(self.big_blind); .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( validate_action(
&action, &action,
@ -425,9 +565,28 @@ impl HandRunner {
current_bet, current_bet,
min_raise, min_raise,
&self.bet_limit, &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. // Apply the action.
self.apply_action(next_id, action.clone())?; self.apply_action(next_id, action.clone())?;
@ -877,33 +1036,41 @@ impl HandRunner {
loop { loop {
match self.state { match self.state {
HandState::Showdown => { 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); return Ok(result);
} }
HandState::Complete => { HandState::Complete => {
// Hand was already resolved (e.g., by resolve_last_player_wins via step). // Hand was already resolved by step() → resolve_last_player_wins()
// Stacks were already credited there — only reconstruct the HandResult. // → 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<PlayerId, HandValue> = self let hand_values: HashMap<PlayerId, HandValue> = self
.seats .seats
.iter() .iter()
.filter(|s| s.is_active()) .filter(|s| s.is_active())
.map(|s| (s.player_id, HandValue::new(HandRank::HighCard, 0))) .map(|s| (s.player_id, HandValue::new(HandRank::HighCard, 0)))
.collect(); .collect();
let pot_winners = self.pot_manager.distribute(&hand_values); let pot_winners = self.pot_manager.distribute(&hand_values);
let (raked_winners, rake_per_pot, total_rake) = self.apply_rake(pot_winners); 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); let chip_movements = self.compute_chip_movements(&raked_winners);
HandResult {
return Ok(HandResult {
pot_winners: raked_winners, pot_winners: raked_winners,
hand_values, hand_values,
board: self.board.clone(), board: self.board.clone(),
chip_movements, chip_movements,
total_rake, total_rake,
rake_per_pot, 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 => { HandState::Idle => {
return Err(GameError::NoHandInProgress); return Err(GameError::NoHandInProgress);
@ -983,13 +1150,87 @@ impl HandRunner {
// 5. Set state to Complete. // 5. Set state to Complete.
self.state = HandState::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, pot_winners: raked_winners,
hand_values, hand_values,
board: self.board.clone(), board: self.board.clone(),
chip_movements, chip_movements,
total_rake, total_rake,
rake_per_pot, 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<PlayerId, HoleCards> = 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] #[test]
fn test_fold_before_showdown() { fn test_fold_before_showdown() {
// Two players: one folds preflop. // Two players: one folds preflop.
// In heads-up, BB (seat 1) acts first preflop. // In heads-up, dealer/SB (seat 0) acts first preflop.
// Make Bob (seat 1) the FoldBot so he folds first. // 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 seat0 = make_check_call_seat(1, "Alice", 1000, 0);
let seat1 = make_fold_seat(2, "Bob", 1000, 1); 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); let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
runner.start(10, 20, 0); runner.start(10, 20, 0);
// In heads-up, BB (seat 1 = Bob) acts first preflop. // In heads-up, dealer/SB (seat 0 = Alice) acts first preflop.
// Bob (FoldBot) will fold. Then only Alice remains. // Alice calls, then Bob (FoldBot) folds. Only Alice remains.
let result = runner.run_to_completion().unwrap(); let result = runner.run_to_completion().unwrap();
assert_eq!(runner.state(), HandState::Complete); assert_eq!(runner.state(), HandState::Complete);
@ -1518,8 +1760,8 @@ mod tests {
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1); let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
runner.start(10, 20, 0); runner.start(10, 20, 0);
// In heads-up, dealer (seat 0) is SB. First to act preflop is BB (seat 1). // 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 second. // Set early fold for Alice (SB, seat 0) who will act first.
runner.set_early_fold(PlayerId(1)).unwrap(); runner.set_early_fold(PlayerId(1)).unwrap();
// Step once — BB (Bob) acts (calls), then Alice's early fold triggers. // Step once — BB (Bob) acts (calls), then Alice's early fold triggers.
@ -1564,4 +1806,218 @@ mod tests {
let result = runner.step(); let result = runner.step();
assert!(result.is_err()); 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);
}
} }

View file

@ -24,6 +24,7 @@ pub mod bot_factory;
pub mod bots; pub mod bots;
pub mod config; pub mod config;
pub mod game_info; pub mod game_info;
pub mod hand_history;
pub mod hand_runner; pub mod hand_runner;
pub mod player; pub mod player;
pub mod player_info; pub mod player_info;
@ -39,14 +40,18 @@ pub use action::{Action, PlayerAction};
pub use betting::BetLimit; pub use betting::BetLimit;
pub use bot_factory::create_bot; pub use bot_factory::create_bot;
pub use bots::{CallBot, FoldBot, RandomBot, RaiseBot}; 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 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::{HoldemPlayer, Seat};
pub use player_info::{PlayerInfo, PlayerState}; pub use player_info::{PlayerInfo, PlayerState};
pub use pot::{Pot, PotManager, PotWinner}; pub use pot::{Pot, PotManager, PotWinner};
pub use simulation::run_cash_game; pub use simulation::{run_cash_game, run_sng_series};
pub use stats::{PlayerStats, SimulationStats}; pub use stats::{PlayerStats, SimulationStats, SngPlayerSeriesResult, SngSeriesResult};
pub use table::{Table, TableConfig}; pub use table::{Table, TableConfig};
pub use tournament::{BlindLevel, BlindSchedule, GameType, PrizeStructure, Tournament}; pub use tournament::{BlindLevel, BlindSchedule, GameType, PrizeStructure, Tournament};
pub use types::{ChipCount, GameError, PlayerId, SeatIndex, MAX_SEATS, MIN_PLAYERS}; pub use types::{ChipCount, GameError, PlayerId, SeatIndex, MAX_SEATS, MIN_PLAYERS};

View file

@ -1,15 +1,19 @@
//! Cash game simulation runner. //! Cash game and tournament series simulation runners.
//! //!
//! Provides [`run_cash_game`] which creates a table, seats bots from //! Provides:
//! configuration, runs the specified number of hands, and collects //! - [`run_cash_game`] — runs a cash game simulation
//! statistics. //! - [`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::bot_factory;
use super::config::GameConfig; use super::config::GameConfig;
use super::stats::SimulationStats; use super::stats::{SimulationStats, SngSeriesResult};
use super::table::Table; use super::table::Table;
use super::tournament::Tournament;
use super::types::{ChipCount, GameError, PlayerId}; use super::types::{ChipCount, GameError, PlayerId};
/// Runs a cash game simulation based on the given configuration. /// 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() { match table.run_hand() {
Ok(result) => { Ok(result) => {
stats.record_hand_result(&result, &player_names); 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(_)) => { Err(GameError::NotEnoughPlayers(_)) => {
// Not enough active players to continue. // Not enough active players to continue.
@ -107,6 +146,200 @@ pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), Ga
Ok((stats, table)) Ok((stats, table))
} }
/// Runs a series of tournaments (SNG or MTT) and returns aggregate results.
///
/// For each tournament:
/// 1. Creates a new [`Tournament`] with the given config's schedule, prizes,
/// starting chips, and buy-in.
/// 2. Creates fresh bot instances via [`bot_factory::create_bot`] for each
/// bot config.
/// 3. Shuffles the registration order (if `config.randomize_seats`) to
/// randomize seat assignments.
/// 4. Registers all bots with the tournament.
/// 5. Runs the tournament to completion.
/// 6. Records each player's placement and prize money.
///
/// Players are tracked **by name** across tournaments (since each tournament
/// creates fresh bot instances with different [`PlayerId`]s).
///
/// Works for both `SitAndGo` and `MultiTableTournament` game types.
/// For MTT, uses `config.num_tables` to create the appropriate number of tables.
///
/// # Errors
///
/// Returns [`GameError`] if:
/// - Fewer than 2 bots are configured
/// - Bot names are not unique (would cause result aggregation to merge players)
/// - A bot type is unknown
/// - The table is full
/// - A tournament fails to complete
pub fn run_sng_series(
config: &GameConfig,
num_tournaments: u32,
) -> Result<SngSeriesResult, GameError> {
// Validate that we have at least 2 bots.
if config.bots.len() < 2 {
return Err(GameError::NotEnoughPlayers(config.bots.len()));
}
// Validate unique bot names — players are tracked by name across tournaments,
// so duplicate names would silently merge their results.
// Bots without explicit names use their bot_type as a fallback identity.
let unique_names: HashSet<String> = config
.bots
.iter()
.map(|b| b.name.clone().unwrap_or_else(|| b.bot_type.clone()))
.collect();
if unique_names.len() != config.bots.len() {
return Err(GameError::Custom(
"Duplicate bot names in config: each bot must have a unique name for series tracking".to_string(),
));
}
let buy_in = if config.buy_in > 0 {
config.buy_in
} else {
config.starting_chips
};
// Build a deterministic RNG for shuffling registration order.
// Use the config seed if provided, otherwise generate a random one
// and log it so the run can be reproduced.
let base_seed = config.seed.unwrap_or_else(|| {
let seed = rand::rng().random::<u64>();
log::info!("Generated random seed for tournament series: {}", seed);
seed
});
let mut result = SngSeriesResult::new(num_tournaments, buy_in, base_seed);
for tournament_index in 0..num_tournaments {
let table_config = config.to_table_config();
let blind_schedule = config.to_blind_schedule();
let prize_structure = config.to_prize_structure();
// Create the appropriate tournament type based on game_type.
let mut tournament = match config.game_type {
super::tournament::GameType::MultiTableTournament => Tournament::new_mtt(
config.num_tables,
table_config,
blind_schedule,
prize_structure,
config.starting_chips,
buy_in,
),
_ => Tournament::new_sng(
table_config,
blind_schedule,
prize_structure,
config.starting_chips,
buy_in,
),
};
// Build the registration order. If randomize_seats is enabled,
// shuffle the bot order using a seed derived from the base seed +
// tournament index for reproducibility.
let mut order: Vec<usize> = (0..config.bots.len()).collect();
if config.randomize_seats {
let tournament_seed = base_seed.wrapping_add(tournament_index as u64);
let mut rng = rand::rngs::StdRng::seed_from_u64(tournament_seed);
order.shuffle(&mut rng);
}
// Create fresh bots and register them in the (possibly shuffled) order.
// We need to track names by PlayerId for this tournament.
let mut player_names: HashMap<PlayerId, String> = HashMap::new();
for (seat_seq, &bot_idx) in order.iter().enumerate() {
let bot_config = &config.bots[bot_idx];
let player_id = PlayerId(seat_seq as u64);
let bot = bot_factory::create_bot(bot_config, player_id)
.map_err(GameError::Custom)?;
let name = bot.name().to_string();
player_names.insert(player_id, name);
tournament.register_player(bot)?;
}
// Run the tournament to completion.
log::info!("Running tournament {}/{}...", tournament_index + 1, num_tournaments);
tournament.run_to_completion()?;
// Accumulate total hands.
result.total_hands_played += tournament.total_hands();
// Process results: record eliminated players from standings.
let standings = tournament.standings();
// Invariant: after run_to_completion(), standings should have N-1 entries
// and winner() returns the sole remaining player. Log a warning if violated.
let expected_standings = player_names.len().saturating_sub(1);
if standings.len() != expected_standings {
log::warn!(
"Tournament {} ended with {} standings (expected {})",
tournament_index + 1,
standings.len(),
expected_standings
);
}
// Track which player IDs have been recorded to prevent double-counting.
let mut recorded: HashSet<PlayerId> = HashSet::new();
// Standings contain eliminated players with raw placement numbers.
// In the tournament, placement = N means the player was the Nth-to-last
// to be eliminated (i.e., placement=N is the worst finish).
// For a 10-player tournament: first player out gets placement=10,
// last player eliminated gets placement=2. The winner (placement=1)
// is NOT in standings — they are tracked separately via winner().
//
// The raw placement IS the finish position, so we use it directly
// for prize lookup (prize_for_placement is 1-indexed).
for &(player_id, raw_placement) in standings.iter() {
let finish_position = raw_placement;
let name = player_names
.get(&player_id)
.cloned()
.unwrap_or_else(|| format!("Player({})", player_id.0));
let prize = tournament
.prize_structure()
.prize_for_placement(finish_position)
.unwrap_or(0);
log::debug!(
"Tournament {}: {} raw_placement={} finish={} prize={}",
tournament_index + 1, name, raw_placement, finish_position, prize
);
result.record_player_finish(&name, finish_position, prize);
recorded.insert(player_id);
}
// Record the winner (last active player) if not already in standings.
// The winner is the sole remaining player who was never eliminated.
if let Some(winner_id) = tournament.winner() {
if !recorded.contains(&winner_id) {
let name = player_names
.get(&winner_id)
.cloned()
.unwrap_or_else(|| format!("Player({})", winner_id.0));
let prize = tournament
.prize_structure()
.prize_for_placement(1)
.unwrap_or(0);
result.record_player_finish(&name, 1, prize);
}
}
}
Ok(result)
}
// =========================================================================== // ===========================================================================
// Tests // Tests
// =========================================================================== // ===========================================================================
@ -138,6 +371,11 @@ mod tests {
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false,
rebuy_threshold_percentage: 25,
blind_levels: vec![],
prizes: vec![],
num_tournaments: 1,
bots: vec![ bots: vec![
BotConfig { BotConfig {
bot_type: "call_bot".to_string(), bot_type: "call_bot".to_string(),
@ -244,6 +482,11 @@ mod tests {
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false,
rebuy_threshold_percentage: 25,
blind_levels: vec![],
prizes: vec![],
num_tournaments: 1,
bots: vec![BotConfig { bots: vec![BotConfig {
bot_type: "call_bot".to_string(), bot_type: "call_bot".to_string(),
@ -278,7 +521,11 @@ mod tests {
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false,
rebuy_threshold_percentage: 25,
blind_levels: vec![],
prizes: vec![],
num_tournaments: 1,
bots: vec![ bots: vec![
BotConfig { BotConfig {
bot_type: "call_bot".to_string(), bot_type: "call_bot".to_string(),
@ -320,6 +567,11 @@ mod tests {
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false,
rebuy_threshold_percentage: 25,
blind_levels: vec![],
prizes: vec![],
num_tournaments: 1,
bots: vec![ bots: vec![
BotConfig { BotConfig {
@ -358,4 +610,68 @@ mod tests {
assert!(summary.contains("Bob")); assert!(summary.contains("Bob"));
assert!(summary.contains("Charlie")); 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
);
}
}
}
} }

View file

@ -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<u32>,
}
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<String, SngPlayerSeriesResult>,
/// 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 // Tests
// =========================================================================== // ===========================================================================
@ -497,6 +668,7 @@ mod tests {
chip_movements, chip_movements,
total_rake: 0, total_rake: 0,
rake_per_pot: vec![], rake_per_pot: vec![],
hand_history: None,
}; };
sim_stats.record_hand_result(&result, &player_names); sim_stats.record_hand_result(&result, &player_names);
@ -534,6 +706,7 @@ mod tests {
}, },
total_rake: 0, total_rake: 0,
rake_per_pot: vec![], rake_per_pot: vec![],
hand_history: None,
}; };
// Hand 2: Bob wins 200 // Hand 2: Bob wins 200
@ -549,6 +722,7 @@ mod tests {
}, },
total_rake: 0, total_rake: 0,
rake_per_pot: vec![], rake_per_pot: vec![],
hand_history: None,
}; };
sim_stats.record_hand_result(&result1, &player_names); sim_stats.record_hand_result(&result1, &player_names);
@ -587,6 +761,7 @@ mod tests {
}, },
total_rake: 0, total_rake: 0,
rake_per_pot: vec![], rake_per_pot: vec![],
hand_history: None,
}; };
sim_stats.record_hand_result(&result, &player_names); sim_stats.record_hand_result(&result, &player_names);

View file

@ -8,8 +8,9 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::player_info::PlayerState; use super::player_info::PlayerState;
use super::player::Seat;
use super::table::{Table, TableConfig}; use super::table::{Table, TableConfig};
use super::types::{ChipCount, GameError, PlayerId}; use super::types::{ChipCount, GameError, PlayerId, MIN_PLAYERS};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GameType Enum // GameType Enum
@ -109,6 +110,36 @@ impl BlindSchedule {
pub fn is_final_level(&self, level_index: usize) -> bool { pub fn is_final_level(&self, level_index: usize) -> bool {
level_index >= self.levels.len().saturating_sub(1) 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 { 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<ChipCount>) -> Self {
Self { prizes }
}
/// Standard Sit-and-Go prize structure. /// Standard Sit-and-Go prize structure.
/// ///
/// - 3+ players: 50/30/20 for top 3 /// - 3+ players: 50/30/20 for top 3
@ -248,7 +286,6 @@ pub struct Tournament {
/// Whether the tournament is complete. /// Whether the tournament is complete.
is_complete: bool, is_complete: bool,
/// Buy-in amount per player. /// Buy-in amount per player.
#[allow(dead_code)] // Used for prize pool calculations in future extensions
buy_in: ChipCount, buy_in: ChipCount,
} }
@ -266,7 +303,7 @@ impl Tournament {
buy_in: ChipCount, buy_in: ChipCount,
) -> Self { ) -> Self {
let table = Table::new(config); let table = Table::new(config);
Self { let mut tournament = Self {
game_type: GameType::SitAndGo, game_type: GameType::SitAndGo,
tables: vec![table], tables: vec![table],
blind_schedule, blind_schedule,
@ -278,7 +315,10 @@ impl Tournament {
total_hands: 0, total_hands: 0,
is_complete: false, is_complete: false,
buy_in, 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. /// Creates a new Multi-Table Tournament with the specified number of tables.
@ -293,7 +333,7 @@ impl Tournament {
let tables = (0..num_tables) let tables = (0..num_tables)
.map(|_| Table::new(config.clone())) .map(|_| Table::new(config.clone()))
.collect(); .collect();
Self { let mut tournament = Self {
game_type: GameType::MultiTableTournament, game_type: GameType::MultiTableTournament,
tables, tables,
blind_schedule, blind_schedule,
@ -305,7 +345,10 @@ impl Tournament {
total_hands: 0, total_hands: 0,
is_complete: false, is_complete: false,
buy_in, 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; 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 // Check blind advancement
self.check_blind_advance(); self.check_blind_advance();
@ -441,11 +493,18 @@ impl Tournament {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// Checks if the blind level should advance and advances if so. /// 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) { pub fn check_blind_advance(&mut self) {
if let Some(level) = self.blind_schedule.current_level(self.current_level) { if let Some(level) = self.blind_schedule.current_level(self.current_level) {
if self.hands_at_current_level >= level.duration_hands if self.hands_at_current_level >= level.duration_hands {
&& !self.blind_schedule.is_final_level(self.current_level) 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.current_level += 1;
self.hands_at_current_level = 0; self.hands_at_current_level = 0;
self.apply_blinds_to_tables(); self.apply_blinds_to_tables();
@ -531,7 +590,87 @@ impl Tournament {
return; 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 { 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<Seat> = 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 // Find table with most and fewest active players
let mut max_count = 0usize; let mut max_count = 0usize;
let mut max_idx = 0usize; let mut max_idx = 0usize;
@ -550,14 +689,28 @@ impl Tournament {
} }
} }
// If difference < 2 or tables are the same, we're balanced // Consolidation: move players from short tables (below MIN_PLAYERS)
if max_count.saturating_sub(min_count) < 2 || max_idx == min_idx { // 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; 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 // Take a player from the source table
let seat_taken = { 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| { let pos = source_seats.iter().position(|slot| {
slot.as_ref().is_some_and(|s| s.state != PlayerState::SittingOut) slot.as_ref().is_some_and(|s| s.state != PlayerState::SittingOut)
}); });
@ -566,32 +719,37 @@ impl Tournament {
if let Some(mut seat) = seat_taken { if let Some(mut seat) = seat_taken {
// Find empty seat on destination table // 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() { if let Some(new_seat_idx) = dest_table.find_empty_seat() {
seat.index = new_seat_idx; seat.index = new_seat_idx;
dest_table.seats_mut()[new_seat_idx as usize] = Some(seat); dest_table.seats_mut()[new_seat_idx as usize] = Some(seat);
} else { } else {
// No room — put the seat back on the source table // No room — put the seat back on the source table
// Find the original position or any empty slot let source_seats = self.tables[source_idx].seats_mut();
let source_seats = self.tables[max_idx].seats_mut();
if let Some(empty_pos) = source_seats.iter().position(|s| s.is_none()) { if let Some(empty_pos) = source_seats.iter().position(|s| s.is_none()) {
source_seats[empty_pos] = Some(seat); source_seats[empty_pos] = Some(seat);
} else { } else {
// Should not happen since we just took from this table // This should never happen since we just took a seat
source_seats.push(Some(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 break; // Can't move, stop balancing
} }
} else { } else {
break; // No movable player found 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 &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. /// Returns the total number of hands played.
pub fn total_hands(&self) -> u64 { pub fn total_hands(&self) -> u64 {
self.total_hands self.total_hands
@ -623,6 +786,49 @@ impl Tournament {
pub fn prize_structure(&self) -> &PrizeStructure { pub fn prize_structure(&self) -> &PrizeStructure {
&self.prize_structure &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<PlayerId> {
if !self.is_complete {
return None;
}
let active: Vec<PlayerId> = 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()[0].seated_player_count(), 5);
assert_eq!(tournament.tables()[1].seated_player_count(), 1); 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(); tournament.rebalance_tables();
// After balancing, the difference should be < 2 // After consolidation: 1 table with all 6 players
let count0 = tournament.tables()[0].seated_player_count(); assert_eq!(tournament.tables().len(), 1, "Empty table should be removed");
let count1 = tournament.tables()[1].seated_player_count(); let total_players: usize = tournament.tables().iter().map(|t| t.seated_player_count()).sum();
assert!( assert_eq!(total_players, 6, "All players should be preserved");
count0.abs_diff(count1) < 2,
"Tables should be balanced: table0={}, table1={}",
count0,
count1
);
assert_eq!(count0 + count1, 6); // All players still present
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@ -1072,4 +1273,145 @@ mod tests {
let deserialized: GameType = serde_json::from_str(&json).unwrap(); let deserialized: GameType = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, gt); 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"
);
}
} }

View file

@ -17,7 +17,7 @@ use clap::Parser;
use holdem_core::game::bot_factory::create_bot; use holdem_core::game::bot_factory::create_bot;
use holdem_core::game::config::SimulationConfig; 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::stats::SimulationStats;
use holdem_core::game::tournament::{GameType, Tournament}; use holdem_core::game::tournament::{GameType, Tournament};
use holdem_core::game::types::PlayerId; use holdem_core::game::types::PlayerId;
@ -75,11 +75,16 @@ fn main() {
game_config.num_hands = hands; 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 { match game_config.game_type {
GameType::Cash => run_cash_simulation(&game_config), GameType::Cash => run_cash_simulation(&game_config),
GameType::SitAndGo => run_tournament_simulation(&game_config), GameType::SitAndGo | GameType::MultiTableTournament => {
GameType::MultiTableTournament => run_tournament_simulation(&game_config), 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)); 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());
}

View file

@ -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)