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:
parent
9dfd9c3ee3
commit
c041cde3fe
18 changed files with 4601 additions and 208 deletions
862
docs/ARCHITECTURE_HAND_LOGGING_TOURNAMENT_IMPROVEMENTS.md
Normal file
862
docs/ARCHITECTURE_HAND_LOGGING_TOURNAMENT_IMPROVEMENTS.md
Normal 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`.
|
||||
|
|
@ -4,7 +4,7 @@ num_seats = 10
|
|||
small_blind = 10
|
||||
big_blind = 20
|
||||
ante = 0
|
||||
starting_chips = 5000
|
||||
starting_chips = 1500
|
||||
num_hands = 10000
|
||||
|
||||
# Rake configuration (optional, defaults to no rake).
|
||||
|
|
@ -15,8 +15,12 @@ num_hands = 10000
|
|||
# reset_bankroll_each_hand = false
|
||||
# bankroll_refill_threshold = 1000 # Refill when stack <= 1000
|
||||
|
||||
# Auto-rebuy: when enabled, players whose stack drops below half the
|
||||
# starting_chips are automatically topped back up to starting_chips.
|
||||
auto_rebuy = true
|
||||
|
||||
# Randomize seat assignments before each hand (optional, defaults to false).
|
||||
# randomize_seats = true
|
||||
randomize_seats = true
|
||||
|
||||
[game.bet_limit]
|
||||
type = "no_limit"
|
||||
|
|
|
|||
527
holdem_core/examples/configs/mtt_10_tables.toml
Normal file
527
holdem_core/examples/configs/mtt_10_tables.toml
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -1,21 +1,107 @@
|
|||
[game]
|
||||
game_type = "SitAndGo"
|
||||
num_seats = 9
|
||||
small_blind = 10
|
||||
big_blind = 20
|
||||
starting_chips = 5000
|
||||
buy_in = 1000
|
||||
hands_per_level = 10
|
||||
num_blind_levels = 15
|
||||
num_seats = 10
|
||||
starting_chips = 1500
|
||||
buy_in = 11
|
||||
randomize_seats = true
|
||||
num_tournaments = 100
|
||||
|
||||
# Custom prize structure in chip amounts: 1st=5, 2nd=3, 3rd=2
|
||||
prizes = [50, 30, 20]
|
||||
|
||||
[game.bet_limit]
|
||||
type = "no_limit"
|
||||
min_raise = 20
|
||||
|
||||
# Explicit blind levels
|
||||
[[game.blind_levels]]
|
||||
small_blind = 10
|
||||
big_blind = 20
|
||||
ante = 0
|
||||
hands = 10
|
||||
|
||||
[[game.blind_levels]]
|
||||
small_blind = 15
|
||||
big_blind = 30
|
||||
ante = 0
|
||||
hands = 10
|
||||
|
||||
[[game.blind_levels]]
|
||||
small_blind = 25
|
||||
big_blind = 50
|
||||
ante = 5
|
||||
hands = 10
|
||||
|
||||
[[game.blind_levels]]
|
||||
small_blind = 50
|
||||
big_blind = 100
|
||||
ante = 10
|
||||
hands = 10
|
||||
|
||||
[[game.blind_levels]]
|
||||
small_blind = 75
|
||||
big_blind = 150
|
||||
ante = 15
|
||||
hands = 10
|
||||
|
||||
[[game.blind_levels]]
|
||||
small_blind = 100
|
||||
big_blind = 200
|
||||
ante = 20
|
||||
hands = 10
|
||||
|
||||
[[game.blind_levels]]
|
||||
small_blind = 150
|
||||
big_blind = 300
|
||||
ante = 25
|
||||
hands = 10
|
||||
|
||||
[[game.blind_levels]]
|
||||
small_blind = 200
|
||||
big_blind = 400
|
||||
ante = 50
|
||||
hands = 10
|
||||
|
||||
# 10 bots (all 6 bot types, with duplicates to fill the table)
|
||||
[[game.bots]]
|
||||
bot_type = "flock_bot"
|
||||
name = "FlockBot-1"
|
||||
|
||||
[[game.bots]]
|
||||
bot_type = "chump_bot"
|
||||
name = "ChumpBot-1"
|
||||
|
||||
[[game.bots]]
|
||||
bot_type = "call_bot"
|
||||
name = "Bot 1"
|
||||
name = "CallBot-1"
|
||||
|
||||
[[game.bots]]
|
||||
bot_type = "raise_bot"
|
||||
name = "RaiseBot-1"
|
||||
|
||||
[[game.bots]]
|
||||
bot_type = "random_bot"
|
||||
name = "RandomBot-1"
|
||||
|
||||
[[game.bots]]
|
||||
bot_type = "fold_bot"
|
||||
name = "Bot 2"
|
||||
name = "FoldBot-1"
|
||||
|
||||
[[game.bots]]
|
||||
bot_type = "flock_bot"
|
||||
name = "FlockBot-2"
|
||||
|
||||
[[game.bots]]
|
||||
bot_type = "call_bot"
|
||||
name = "CallBot-2"
|
||||
|
||||
[[game.bots]]
|
||||
bot_type = "raise_bot"
|
||||
name = "RaiseBot-2"
|
||||
|
||||
[[game.bots]]
|
||||
bot_type = "random_bot"
|
||||
name = "RandomBot-2"
|
||||
|
||||
[output]
|
||||
log_hands = true
|
||||
|
|
|
|||
|
|
@ -162,15 +162,47 @@ The `action` module provides a pure validation function:
|
|||
/// Returns `Ok(())` or a `GameError` describing why the action is illegal.
|
||||
pub fn validate_action(
|
||||
action: &Action,
|
||||
player_id: PlayerId,
|
||||
player_chips: ChipCount,
|
||||
player_wagered_this_round: ChipCount,
|
||||
current_bet: ChipCount,
|
||||
min_raise: ChipCount,
|
||||
bet_limit: &BetLimit,
|
||||
has_acted_this_round: bool,
|
||||
street: Street,
|
||||
raises_this_round: usize,
|
||||
) -> Result<(), GameError>
|
||||
```
|
||||
|
||||
The `street` and `raises_this_round` parameters enable correct Fixed-Limit
|
||||
validation: the fixed bet size depends on the street (small bet on preflop/flop,
|
||||
big bet on turn/river), and the number of raises is capped at 3 per round.
|
||||
|
||||
Pot-limit max-raise enforcement is handled separately in `HandRunner::step()`,
|
||||
which has access to the pot size via the `PotManager`.
|
||||
|
||||
### Action Factory Methods
|
||||
|
||||
The `Action` enum provides Meerkat-compatible convenience methods:
|
||||
|
||||
```rust
|
||||
impl Action {
|
||||
/// Check if no bet to call, otherwise fold.
|
||||
pub fn check_or_fold(current_bet: ChipCount, player_wagered: ChipCount) -> Self;
|
||||
|
||||
/// Call the current bet (or go all-in if short-stacked).
|
||||
pub fn call_action(current_bet: ChipCount, player_wagered: ChipCount, player_chips: ChipCount) -> Self;
|
||||
|
||||
/// Fold.
|
||||
pub fn fold_action() -> Self;
|
||||
|
||||
/// Open with a bet (first aggressive action in a round).
|
||||
pub fn bet_action(amount: ChipCount) -> Self;
|
||||
|
||||
/// Raise to a specific total.
|
||||
pub fn raise_action(total: ChipCount) -> Self;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Game Info — `game_info.rs`
|
||||
|
|
@ -265,6 +297,41 @@ impl GameInfo {
|
|||
pub fn is_postflop(&self) -> bool {
|
||||
self.street >= Street::Flop
|
||||
}
|
||||
|
||||
// --- Meerkat-compatible convenience methods ---
|
||||
|
||||
/// Whether the current stage is preflop.
|
||||
pub fn is_preflop(&self) -> bool { ... }
|
||||
/// Whether the current stage is the flop.
|
||||
pub fn is_flop(&self) -> bool { ... }
|
||||
/// Whether the current stage is the turn.
|
||||
pub fn is_turn(&self) -> bool { ... }
|
||||
/// Whether the current stage is the river.
|
||||
pub fn is_river(&self) -> bool { ... }
|
||||
|
||||
/// Amount a player needs to put in to match the current bet.
|
||||
pub fn get_amount_to_call(&self, player_id: PlayerId) -> ChipCount { ... }
|
||||
|
||||
/// Street index: 0=preflop, 1=flop, 2=turn, 3=river.
|
||||
pub fn stage_index(&self) -> usize { ... }
|
||||
|
||||
/// Number of players still active (not folded or eliminated).
|
||||
pub fn get_num_active_players(&self) -> usize { ... }
|
||||
|
||||
/// Number of seats at the table.
|
||||
pub fn get_num_seats(&self) -> usize { ... }
|
||||
|
||||
/// Big blind size.
|
||||
pub fn get_big_blind_size(&self) -> ChipCount { ... }
|
||||
|
||||
/// Small blind size.
|
||||
pub fn get_small_blind_size(&self) -> ChipCount { ... }
|
||||
|
||||
/// Total pot size.
|
||||
pub fn get_pot_size(&self) -> ChipCount { ... }
|
||||
|
||||
/// Number of raises made in the current betting round.
|
||||
pub fn get_num_raises(&self) -> usize { ... }
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::game::betting::BetLimit;
|
||||
use crate::game::betting::{BetLimit, BettingRules};
|
||||
use crate::game::types::{ChipCount, GameError, PlayerId};
|
||||
use crate::Street;
|
||||
|
||||
/// Actions a player can take when it is their turn.
|
||||
///
|
||||
|
|
@ -86,6 +87,92 @@ impl Action {
|
|||
pub fn is_active(&self) -> bool {
|
||||
!matches!(self, Action::Fold)
|
||||
}
|
||||
|
||||
/// Convenience factory matching Meerkat's `Action.checkOrFoldAction(toCall)`.
|
||||
///
|
||||
/// Returns [`Action::Check`] when `amount_to_call` is 0 (nothing to call),
|
||||
/// otherwise returns [`Action::Fold`].
|
||||
///
|
||||
/// ```
|
||||
/// use holdem_core::game::action::Action;
|
||||
///
|
||||
/// assert_eq!(Action::check_or_fold(0), Action::Check);
|
||||
/// assert_eq!(Action::check_or_fold(50), Action::Fold);
|
||||
/// ```
|
||||
pub fn check_or_fold(amount_to_call: ChipCount) -> Action {
|
||||
if amount_to_call == 0 {
|
||||
Action::Check
|
||||
} else {
|
||||
Action::Fold
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience factory matching Meerkat's `Action.callAction(toCall)`.
|
||||
///
|
||||
/// Returns [`Action::Call`]. The `amount_to_call` parameter is accepted for
|
||||
/// API compatibility with Meerkat but is **not used** — the engine computes
|
||||
/// the actual call amount from the game state. Prefer using `Action::Call`
|
||||
/// directly in non-Meerkat code.
|
||||
///
|
||||
/// ```
|
||||
/// use holdem_core::game::action::Action;
|
||||
///
|
||||
/// assert_eq!(Action::call_action(50), Action::Call);
|
||||
/// assert_eq!(Action::call_action(0), Action::Call);
|
||||
/// ```
|
||||
pub fn call_action(_amount_to_call: ChipCount) -> Action {
|
||||
Action::Call
|
||||
}
|
||||
|
||||
/// Convenience factory matching Meerkat's `Action.foldAction(toCall)`.
|
||||
///
|
||||
/// Returns [`Action::Fold`]. The `amount_to_call` parameter is accepted for
|
||||
/// API compatibility with Meerkat but is **not used**. Prefer using
|
||||
/// `Action::Fold` directly in non-Meerkat code.
|
||||
///
|
||||
/// ```
|
||||
/// use holdem_core::game::action::Action;
|
||||
///
|
||||
/// assert_eq!(Action::fold_action(50), Action::Fold);
|
||||
/// ```
|
||||
pub fn fold_action(_amount_to_call: ChipCount) -> Action {
|
||||
Action::Fold
|
||||
}
|
||||
|
||||
/// Convenience factory matching Meerkat's `Action.betAction(amountToRaise)`.
|
||||
///
|
||||
/// Creates a raise action with the given total amount. In Meerkat,
|
||||
/// `betAction(amount)` makes the first bet in a round (when current_bet is 0),
|
||||
/// so `amount` is both the total and the increment.
|
||||
///
|
||||
/// ```
|
||||
/// use holdem_core::game::action::Action;
|
||||
///
|
||||
/// assert_eq!(Action::bet_action(100), Action::Raise { total: 100 });
|
||||
/// ```
|
||||
pub fn bet_action(amount_to_raise: ChipCount) -> Action {
|
||||
Action::Raise {
|
||||
total: amount_to_raise,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience factory matching Meerkat's `Action.raiseAction(toCall, amountToRaise)`.
|
||||
///
|
||||
/// Creates a raise to the given total. The Meerkat API takes `toCall` and
|
||||
/// `amountToRaise` separately, where the total raise is `toCall + amountToRaise`.
|
||||
/// This helper computes the total for you.
|
||||
///
|
||||
/// ```
|
||||
/// use holdem_core::game::action::Action;
|
||||
///
|
||||
/// // To call 50 and raise by 100 more → total raise to 150
|
||||
/// assert_eq!(Action::raise_action(50, 100), Action::Raise { total: 150 });
|
||||
/// ```
|
||||
pub fn raise_action(to_call: ChipCount, amount_to_raise: ChipCount) -> Action {
|
||||
Action::Raise {
|
||||
total: to_call + amount_to_raise,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that an action is legal given the current game state.
|
||||
|
|
@ -102,7 +189,8 @@ impl Action {
|
|||
/// * `current_bet` — The current outstanding bet to call.
|
||||
/// * `min_raise` — The minimum raise increment.
|
||||
/// * `bet_limit` — The betting structure in use.
|
||||
/// * `has_acted_this_round` — Whether the player has already acted this round.
|
||||
/// * `street` — The current betting street (used for Fixed-Limit bet sizing).
|
||||
/// * `raises_this_round` — Number of raises already made this round (for Fixed-Limit cap).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn validate_action(
|
||||
action: &Action,
|
||||
|
|
@ -112,7 +200,8 @@ pub fn validate_action(
|
|||
current_bet: ChipCount,
|
||||
min_raise: ChipCount,
|
||||
bet_limit: &BetLimit,
|
||||
_has_acted_this_round: bool,
|
||||
street: Street,
|
||||
raises_this_round: usize,
|
||||
) -> Result<(), GameError> {
|
||||
match action {
|
||||
Action::Fold => Ok(()),
|
||||
|
|
@ -163,17 +252,24 @@ pub fn validate_action(
|
|||
match bet_limit {
|
||||
BetLimit::NoLimit { .. } => Ok(()),
|
||||
BetLimit::PotLimit { min_raise: _ } => {
|
||||
// Pot-limit validation requires knowing the pot size.
|
||||
// The engine layer handles this; we pass here.
|
||||
// Pot-limit max raise is enforced by the HandRunner which
|
||||
// has access to the pot size. The validator passes here.
|
||||
Ok(())
|
||||
}
|
||||
BetLimit::FixedLimit {
|
||||
small_bet,
|
||||
big_bet: _,
|
||||
} => {
|
||||
// In fixed limit, the raise must equal the fixed bet size.
|
||||
if raise_increment != *small_bet {
|
||||
return Err(GameError::RaiseAboveMaximum(raise_increment, *small_bet));
|
||||
BetLimit::FixedLimit { .. } => {
|
||||
// In fixed limit, the raise must equal the fixed bet size for the street.
|
||||
let fixed_size = bet_limit
|
||||
.fixed_bet_size(street)
|
||||
.expect("FixedLimit always returns a fixed_bet_size");
|
||||
if raise_increment != fixed_size {
|
||||
return Err(GameError::RaiseAboveMaximum(raise_increment, fixed_size));
|
||||
}
|
||||
// Check the raise count cap (typically 3 raises per round).
|
||||
if !bet_limit.can_raise(street, raises_this_round) {
|
||||
return Err(GameError::InvalidAction(format!(
|
||||
"raise cap reached: {} raises already made this round",
|
||||
raises_this_round
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -209,7 +305,8 @@ mod tests {
|
|||
50,
|
||||
10,
|
||||
&bet_limit,
|
||||
false,
|
||||
Street::Preflop,
|
||||
0,
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
|
@ -225,7 +322,8 @@ mod tests {
|
|||
0,
|
||||
10,
|
||||
&bet_limit,
|
||||
false,
|
||||
Street::Preflop,
|
||||
0,
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
|
@ -241,7 +339,8 @@ mod tests {
|
|||
50,
|
||||
10,
|
||||
&bet_limit,
|
||||
false,
|
||||
Street::Preflop,
|
||||
0,
|
||||
);
|
||||
assert!(matches!(result, Err(GameError::CheckNotAllowed(50))));
|
||||
}
|
||||
|
|
@ -257,7 +356,8 @@ mod tests {
|
|||
50,
|
||||
10,
|
||||
&bet_limit,
|
||||
false,
|
||||
Street::Preflop,
|
||||
0,
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
|
@ -273,7 +373,8 @@ mod tests {
|
|||
20,
|
||||
20,
|
||||
&bet_limit,
|
||||
false,
|
||||
Street::Preflop,
|
||||
0,
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
|
|
@ -292,7 +393,8 @@ mod tests {
|
|||
20,
|
||||
10,
|
||||
&bet_limit,
|
||||
false,
|
||||
Street::Preflop,
|
||||
0,
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
|
|
@ -311,7 +413,8 @@ mod tests {
|
|||
20,
|
||||
10,
|
||||
&bet_limit,
|
||||
false,
|
||||
Street::Preflop,
|
||||
0,
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
|
|
@ -333,4 +436,150 @@ mod tests {
|
|||
assert!(Action::Raise { total: 50 }.is_active());
|
||||
assert!(Action::AllIn.is_active());
|
||||
}
|
||||
|
||||
// ---- Fixed-Limit street-aware validation ----
|
||||
|
||||
#[test]
|
||||
fn fixed_limit_preflop_uses_small_bet() {
|
||||
let bet_limit = BetLimit::FixedLimit {
|
||||
small_bet: 10,
|
||||
big_bet: 20,
|
||||
};
|
||||
// Preflop: raise increment must equal small_bet (10)
|
||||
assert!(validate_action(
|
||||
&Action::Raise { total: 20 }, // current_bet=10, increment=10 ✓
|
||||
pid(1),
|
||||
200,
|
||||
0,
|
||||
10,
|
||||
10,
|
||||
&bet_limit,
|
||||
Street::Preflop,
|
||||
0,
|
||||
)
|
||||
.is_ok());
|
||||
|
||||
// Preflop: raise increment of 20 (big_bet) should be rejected
|
||||
let result = validate_action(
|
||||
&Action::Raise { total: 30 }, // current_bet=10, increment=20 ✗
|
||||
pid(1),
|
||||
200,
|
||||
0,
|
||||
10,
|
||||
10,
|
||||
&bet_limit,
|
||||
Street::Preflop,
|
||||
0,
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(GameError::RaiseAboveMaximum(20, 10))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_limit_turn_uses_big_bet() {
|
||||
let bet_limit = BetLimit::FixedLimit {
|
||||
small_bet: 10,
|
||||
big_bet: 20,
|
||||
};
|
||||
// Turn: raise increment must equal big_bet (20)
|
||||
assert!(validate_action(
|
||||
&Action::Raise { total: 40 }, // current_bet=20, increment=20 ✓
|
||||
pid(1),
|
||||
200,
|
||||
0,
|
||||
20,
|
||||
20,
|
||||
&bet_limit,
|
||||
Street::Turn,
|
||||
0,
|
||||
)
|
||||
.is_ok());
|
||||
|
||||
// Turn: raise increment of 10 (small_bet) should be rejected.
|
||||
// min_raise=20, so raise_increment=10 < 20 → RaiseBelowMinimum.
|
||||
let result = validate_action(
|
||||
&Action::Raise { total: 30 }, // current_bet=20, increment=10 ✗
|
||||
pid(1),
|
||||
200,
|
||||
0,
|
||||
20,
|
||||
20,
|
||||
&bet_limit,
|
||||
Street::Turn,
|
||||
0,
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(GameError::RaiseBelowMinimum(10, 20))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_limit_raise_cap_enforced() {
|
||||
let bet_limit = BetLimit::FixedLimit {
|
||||
small_bet: 10,
|
||||
big_bet: 20,
|
||||
};
|
||||
// 3 raises already → 4th should be rejected
|
||||
let result = validate_action(
|
||||
&Action::Raise { total: 50 }, // increment=10 on preflop
|
||||
pid(1),
|
||||
200,
|
||||
0,
|
||||
40,
|
||||
10,
|
||||
&bet_limit,
|
||||
Street::Preflop,
|
||||
3,
|
||||
);
|
||||
assert!(matches!(result, Err(GameError::InvalidAction(_))));
|
||||
|
||||
// 2 raises → 3rd should be allowed
|
||||
assert!(validate_action(
|
||||
&Action::Raise { total: 40 }, // increment=10 on preflop
|
||||
pid(1),
|
||||
200,
|
||||
0,
|
||||
30,
|
||||
10,
|
||||
&bet_limit,
|
||||
Street::Preflop,
|
||||
2,
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
// ---- Convenience factory tests ----
|
||||
|
||||
#[test]
|
||||
fn check_or_fold_factory() {
|
||||
assert_eq!(Action::check_or_fold(0), Action::Check);
|
||||
assert_eq!(Action::check_or_fold(50), Action::Fold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_action_factory() {
|
||||
assert_eq!(Action::call_action(50), Action::Call);
|
||||
assert_eq!(Action::call_action(0), Action::Call);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_action_factory() {
|
||||
assert_eq!(Action::fold_action(50), Action::Fold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bet_action_factory() {
|
||||
assert_eq!(Action::bet_action(100), Action::Raise { total: 100 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raise_action_factory() {
|
||||
assert_eq!(
|
||||
Action::raise_action(50, 100),
|
||||
Action::Raise { total: 150 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,12 +85,13 @@ impl BettingRules for BetLimit {
|
|||
BetLimit::FixedLimit {
|
||||
small_bet,
|
||||
big_bet: _,
|
||||
} => *small_bet, // min_raise_amount is overridden per-street below isn't needed — fixed
|
||||
// Actually, for FixedLimit the min raise *is* the bet size for the street.
|
||||
// But we only have `current_bet` and `previous_raise` here, not `street`.
|
||||
// The caller should use `fixed_bet_size` for FixedLimit instead.
|
||||
// We return small_bet as a safe default; FixedLimit users should prefer
|
||||
// `fixed_bet_size(street)`.
|
||||
} => {
|
||||
// For FixedLimit, the min raise is the fixed bet size for the street.
|
||||
// Since this trait method doesn't receive `street`, we return `small_bet`
|
||||
// as a safe default (correct for preflop/flop, conservative for turn/river).
|
||||
// Callers needing street-aware sizing should use `fixed_bet_size(street)`.
|
||||
*small_bet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -98,31 +99,28 @@ impl BettingRules for BetLimit {
|
|||
&self,
|
||||
pot: ChipCount,
|
||||
call_amount: ChipCount,
|
||||
_player_stack: ChipCount,
|
||||
player_stack: ChipCount,
|
||||
) -> Option<ChipCount> {
|
||||
match self {
|
||||
BetLimit::NoLimit { .. } => None,
|
||||
BetLimit::PotLimit { .. } => {
|
||||
// Standard pot-limit formula: pot + call_amount + previous_raise
|
||||
// Here we return the max *total* raise above the call amount.
|
||||
// The classic formula: max raise = pot + call_amount (where call_amount
|
||||
// is what the player needs to put in to call).
|
||||
Some(pot + call_amount)
|
||||
// Standard pot-limit formula:
|
||||
// The maximum *raise increment* a player can make is:
|
||||
// pot_size + amount_to_call
|
||||
// where pot_size is the current pot and amount_to_call is what the
|
||||
// player needs to put in to match the current bet.
|
||||
// This means the maximum *total* wager for the player is:
|
||||
// current_bet + call_amount + pot_size
|
||||
// We return the max raise *increment* here (pot + call_amount),
|
||||
// capped by the player's stack (a short-stacked player cannot
|
||||
// raise more than they can afford).
|
||||
Some((pot + call_amount).min(player_stack))
|
||||
}
|
||||
BetLimit::FixedLimit { .. } => {
|
||||
// Max raise equals min raise (fixed increments).
|
||||
// The caller should use `fixed_bet_size` to determine the amount.
|
||||
// Returning `Some(0)` isn't useful; FixedLimit max is the same as min.
|
||||
// We return None to indicate "use fixed_bet_size" — but the trait
|
||||
// contract says Some means there's a cap. For FixedLimit, the cap
|
||||
// IS the fixed bet size. Let's return it via fixed_bet_size instead.
|
||||
// Actually, to keep the trait useful, we return `Some` equal to the
|
||||
// fixed bet size. But we don't have `street` here...
|
||||
// The simplest correct thing: FixedLimit max raise == min raise,
|
||||
// and the caller should use fixed_bet_size(street) for the actual value.
|
||||
// We return Some(0) as a sentinel — no, that's bad.
|
||||
// Best approach: the HandRunner will use fixed_bet_size for FixedLimit.
|
||||
// For the trait, we return None and rely on can_raise for capping.
|
||||
// For FixedLimit, the max raise equals the fixed bet size for the street.
|
||||
// Since this method doesn't receive `street`, we return None.
|
||||
// Callers should use `fixed_bet_size(street)` instead, which is what
|
||||
// `validate_action` now does.
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -141,6 +139,12 @@ impl BettingRules for BetLimit {
|
|||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if another raise is allowed given the current raise count.
|
||||
///
|
||||
/// For Fixed-Limit, the cap is **3 voluntary raises per round** (uniform across
|
||||
/// all streets). The `street` parameter is accepted for potential future
|
||||
/// per-street customization (e.g., heads-up 4-raise cap) but is currently
|
||||
/// unused — the cap is intentionally the same on every street.
|
||||
fn can_raise(&self, _street: Street, raises_this_round: usize) -> bool {
|
||||
match self {
|
||||
BetLimit::NoLimit { .. } | BetLimit::PotLimit { .. } => true,
|
||||
|
|
|
|||
|
|
@ -92,6 +92,30 @@ impl RakeConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BlindLevelConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Configuration for a single blind level in a tournament.
|
||||
///
|
||||
/// When specified as part of `[[game.blind_levels]]` in TOML, these override
|
||||
/// the default doubling-blind schedule generated by `num_blind_levels` and
|
||||
/// `hands_per_level`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BlindLevelConfig {
|
||||
/// Small blind amount.
|
||||
pub small_blind: ChipCount,
|
||||
/// Big blind amount.
|
||||
pub big_blind: ChipCount,
|
||||
/// Ante amount (0 = no ante).
|
||||
#[serde(default)]
|
||||
pub ante: ChipCount,
|
||||
/// Number of hands at this level before advancing.
|
||||
#[serde(default = "default_hands_per_level")]
|
||||
pub hands: u32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default helper functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -101,11 +125,26 @@ fn default_num_seats() -> SeatIndex {
|
|||
9
|
||||
}
|
||||
|
||||
/// Default small blind amount.
|
||||
fn default_small_blind() -> ChipCount {
|
||||
10
|
||||
}
|
||||
|
||||
/// Default big blind amount.
|
||||
fn default_big_blind() -> ChipCount {
|
||||
20
|
||||
}
|
||||
|
||||
/// Default value of 1.
|
||||
fn default_one() -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
/// Default starting chips.
|
||||
fn default_starting_chips() -> ChipCount {
|
||||
10_000
|
||||
}
|
||||
|
||||
/// Default number of hands per blind level.
|
||||
fn default_hands_per_level() -> u32 {
|
||||
10
|
||||
|
|
@ -116,6 +155,16 @@ fn default_num_levels() -> u32 {
|
|||
15
|
||||
}
|
||||
|
||||
/// Default value of 1 for u32 fields.
|
||||
fn default_one_u32() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
/// Default auto-rebuy threshold percentage (25%).
|
||||
fn default_rebuy_threshold() -> u8 {
|
||||
25
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BetLimitConfig
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -243,10 +292,12 @@ pub struct GameConfig {
|
|||
/// Betting structure.
|
||||
pub bet_limit: BetLimitConfig,
|
||||
|
||||
/// Small blind amount.
|
||||
/// Small blind amount. Defaults to 10 when omitted from config.
|
||||
#[serde(default = "default_small_blind")]
|
||||
pub small_blind: ChipCount,
|
||||
|
||||
/// Big blind amount.
|
||||
/// Big blind amount. Defaults to 20 when omitted from config.
|
||||
#[serde(default = "default_big_blind")]
|
||||
pub big_blind: ChipCount,
|
||||
|
||||
/// Ante amount (0 = no ante).
|
||||
|
|
@ -254,6 +305,7 @@ pub struct GameConfig {
|
|||
pub ante: ChipCount,
|
||||
|
||||
/// Starting chip count for each player.
|
||||
#[serde(default = "default_starting_chips")]
|
||||
pub starting_chips: ChipCount,
|
||||
|
||||
/// Buy-in amount (for tournaments).
|
||||
|
|
@ -304,6 +356,33 @@ pub struct GameConfig {
|
|||
#[serde(default)]
|
||||
pub randomize_seats: bool,
|
||||
|
||||
/// Whether to auto-rebuy players when their stack drops below a threshold.
|
||||
/// The rebuy tops up to the full starting_chips amount.
|
||||
#[serde(default)]
|
||||
pub auto_rebuy: bool,
|
||||
|
||||
/// Percentage of starting chips below which auto-rebuy triggers (1–100).
|
||||
/// For example, `25` means rebuy when stack < 25% of starting_chips.
|
||||
/// Defaults to 25. Only relevant when `auto_rebuy` is `true`.
|
||||
#[serde(default = "default_rebuy_threshold")]
|
||||
pub rebuy_threshold_percentage: u8,
|
||||
|
||||
/// Explicit blind levels. When present, overrides `num_blind_levels` and
|
||||
/// `hands_per_level`. Each entry specifies SB, BB, ante, and hands duration.
|
||||
#[serde(default)]
|
||||
pub blind_levels: Vec<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.
|
||||
#[serde(default)]
|
||||
pub bots: Vec<BotConfig>,
|
||||
|
|
@ -317,8 +396,8 @@ impl Default for GameConfig {
|
|||
bet_limit: BetLimitConfig::NoLimit {
|
||||
min_raise: 20,
|
||||
},
|
||||
small_blind: 10,
|
||||
big_blind: 20,
|
||||
small_blind: default_small_blind(),
|
||||
big_blind: default_big_blind(),
|
||||
ante: 0,
|
||||
starting_chips: 10_000,
|
||||
buy_in: 0,
|
||||
|
|
@ -332,6 +411,11 @@ impl Default for GameConfig {
|
|||
reset_bankroll_each_hand: false,
|
||||
bankroll_refill_threshold: 0,
|
||||
randomize_seats: false,
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: default_rebuy_threshold(),
|
||||
blind_levels: Vec::new(),
|
||||
prizes: Vec::new(),
|
||||
num_tournaments: 1,
|
||||
bots: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -386,9 +470,26 @@ impl GameConfig {
|
|||
|
||||
/// Convert to a [`BlindSchedule`] (for tournaments).
|
||||
///
|
||||
/// Generates a standard blind schedule using the configured starting blinds,
|
||||
/// number of blind levels, and hands per level.
|
||||
/// When `blind_levels` is non-empty, builds a custom schedule from those
|
||||
/// explicit levels. Otherwise, generates a standard doubling-blind schedule
|
||||
/// using the configured starting blinds, `num_blind_levels`, and
|
||||
/// `hands_per_level`.
|
||||
pub fn to_blind_schedule(&self) -> BlindSchedule {
|
||||
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 base_sb = self.small_blind;
|
||||
let base_bb = self.big_blind;
|
||||
|
|
@ -414,16 +515,22 @@ impl GameConfig {
|
|||
}
|
||||
BlindSchedule::custom(levels)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to a [`PrizeStructure`] (for tournaments).
|
||||
///
|
||||
/// Uses the standard prize structures based on game type:
|
||||
/// When `prizes` is non-empty, uses the custom prize amounts directly.
|
||||
/// Otherwise, uses the standard prize structures based on game type:
|
||||
/// - Sit-and-Go: 50/30/20 for top 3
|
||||
/// - MTT: top-heavy distribution for top ~15%
|
||||
/// - Cash: empty prize structure
|
||||
pub fn to_prize_structure(&self) -> PrizeStructure {
|
||||
if !self.prizes.is_empty() {
|
||||
PrizeStructure::custom(self.prizes.clone())
|
||||
} else {
|
||||
let num_players = self.bots.len().max(2);
|
||||
match self.game_type {
|
||||
GameType::Cash => PrizeStructure::standard_sng(self.buy_in, num_players),
|
||||
GameType::Cash => PrizeStructure::custom(vec![]),
|
||||
GameType::SitAndGo => PrizeStructure::standard_sng(self.buy_in, num_players),
|
||||
GameType::MultiTableTournament => {
|
||||
let total_entries = num_players * self.num_tables;
|
||||
|
|
@ -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,
|
||||
bankroll_refill_threshold: 0,
|
||||
randomize_seats: false,
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: 25,
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
num_tournaments: 1,
|
||||
|
||||
bots: vec![
|
||||
BotConfig {
|
||||
|
|
@ -865,6 +1022,11 @@ min_raise = 50
|
|||
reset_bankroll_each_hand: false,
|
||||
bankroll_refill_threshold: 0,
|
||||
randomize_seats: false,
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: 25,
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
num_tournaments: 1,
|
||||
|
||||
bots: vec![],
|
||||
};
|
||||
|
|
@ -897,6 +1059,11 @@ min_raise = 50
|
|||
reset_bankroll_each_hand: false,
|
||||
bankroll_refill_threshold: 0,
|
||||
randomize_seats: false,
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: 25,
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
num_tournaments: 1,
|
||||
|
||||
bots: vec![],
|
||||
};
|
||||
|
|
@ -945,6 +1112,11 @@ min_raise = 50
|
|||
reset_bankroll_each_hand: false,
|
||||
bankroll_refill_threshold: 0,
|
||||
randomize_seats: false,
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: 25,
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
num_tournaments: 1,
|
||||
|
||||
bots: vec![
|
||||
BotConfig {
|
||||
|
|
@ -995,6 +1167,11 @@ min_raise = 50
|
|||
reset_bankroll_each_hand: false,
|
||||
bankroll_refill_threshold: 0,
|
||||
randomize_seats: false,
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: 25,
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
num_tournaments: 1,
|
||||
|
||||
bots: vec![
|
||||
BotConfig {
|
||||
|
|
@ -1087,6 +1264,11 @@ min_raise = 20
|
|||
reset_bankroll_each_hand: false,
|
||||
bankroll_refill_threshold: 0,
|
||||
randomize_seats: false,
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: 25,
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
num_tournaments: 1,
|
||||
bots: vec![],
|
||||
},
|
||||
output: OutputConfig {
|
||||
|
|
|
|||
|
|
@ -113,4 +113,97 @@ impl GameInfo {
|
|||
pub fn is_postflop(&self) -> bool {
|
||||
self.street >= Street::Flop
|
||||
}
|
||||
|
||||
/// Whether the hand is in the preflop stage.
|
||||
///
|
||||
/// Corresponds to Meerkat's `GameInfo.isPreFlop()`.
|
||||
pub fn is_preflop(&self) -> bool {
|
||||
self.street == Street::Preflop
|
||||
}
|
||||
|
||||
/// Whether the hand is on the flop.
|
||||
pub fn is_flop(&self) -> bool {
|
||||
self.street == Street::Flop
|
||||
}
|
||||
|
||||
/// Whether the hand is on the turn.
|
||||
pub fn is_turn(&self) -> bool {
|
||||
self.street == Street::Turn
|
||||
}
|
||||
|
||||
/// Whether the hand is on the river.
|
||||
pub fn is_river(&self) -> bool {
|
||||
self.street == Street::River
|
||||
}
|
||||
|
||||
/// Get the amount a player must call to stay in the hand.
|
||||
///
|
||||
/// Corresponds to Meerkat's `GameInfo.getAmountToCall(seat)`.
|
||||
/// Returns `current_bet - player_wagered_this_round`, or 0 if the player
|
||||
/// has already matched or exceeded the current bet.
|
||||
pub fn get_amount_to_call(&self, player_id: PlayerId) -> ChipCount {
|
||||
self.player_by_id(player_id)
|
||||
.map(|p| self.current_bet.saturating_sub(p.wagered_this_round))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Get the current stage as a numeric index (Meerkat convention).
|
||||
///
|
||||
/// Returns `0` for Preflop, `1` for Flop, `2` for Turn, `3` for River.
|
||||
/// Corresponds to Meerkat's `Holdem.PREFLOP`, `Holdem.FLOP`, etc.
|
||||
pub fn stage_index(&self) -> usize {
|
||||
match self.street {
|
||||
Street::Preflop => 0,
|
||||
Street::Flop => 1,
|
||||
Street::Turn => 2,
|
||||
Street::River => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the total number of active players still in the hand
|
||||
/// (not folded, not sitting out — includes all-in players).
|
||||
///
|
||||
/// Corresponds to Meerkat's `GameInfo.getNumActivePlayers()`.
|
||||
pub fn get_num_active_players(&self) -> usize {
|
||||
self.players
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|p| p.is_in_hand())
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Returns the total number of seats at the table.
|
||||
///
|
||||
/// Corresponds to Meerkat's `GameInfo.getNumSeats()`.
|
||||
pub fn get_num_seats(&self) -> SeatIndex {
|
||||
self.num_seats
|
||||
}
|
||||
|
||||
/// Returns the big blind size.
|
||||
///
|
||||
/// Corresponds to Meerkat's `GameInfo.getBigBlindSize()`.
|
||||
pub fn get_big_blind_size(&self) -> ChipCount {
|
||||
self.big_blind
|
||||
}
|
||||
|
||||
/// Returns the small blind size.
|
||||
///
|
||||
/// Corresponds to Meerkat's `GameInfo.getSmallBlindSize()`.
|
||||
pub fn get_small_blind_size(&self) -> ChipCount {
|
||||
self.small_blind
|
||||
}
|
||||
|
||||
/// Returns the total chips in the pot.
|
||||
///
|
||||
/// Corresponds to Meerkat's `GameInfo.getPotSize()`.
|
||||
pub fn get_pot_size(&self) -> ChipCount {
|
||||
self.pot
|
||||
}
|
||||
|
||||
/// Returns the number of voluntary raises on the current street.
|
||||
///
|
||||
/// Corresponds to Meerkat's `GameInfo.getNumRaises()`.
|
||||
pub fn get_num_raises(&self) -> usize {
|
||||
self.num_raises
|
||||
}
|
||||
}
|
||||
|
|
|
|||
813
holdem_core/src/game/hand_history.rs
Normal file
813
holdem_core/src/game/hand_history.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -16,9 +16,10 @@ use crate::evaluator::evaluator::{HandRank, HandValue};
|
|||
use crate::{Board, Deck, Hand, HoleCards};
|
||||
|
||||
use super::action::{validate_action, Action};
|
||||
use super::betting::{BetLimit, BettingRound};
|
||||
use super::betting::{BetLimit, BettingRound, BettingRules};
|
||||
use super::config::RakeConfig;
|
||||
use super::game_info::GameInfo;
|
||||
use super::hand_history::BlindType;
|
||||
use super::player::Seat;
|
||||
use super::player_info::PlayerState;
|
||||
use super::pot::{PotManager, PotWinner};
|
||||
|
|
@ -97,6 +98,54 @@ pub struct HandResult {
|
|||
pub total_rake: ChipCount,
|
||||
/// Rake deducted per pot, parallel to `pot_winners`.
|
||||
pub rake_per_pot: Vec<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,
|
||||
/// Actions taken this hand, grouped by street.
|
||||
/// Index 0 = preflop, 1 = flop, 2 = turn, 3 = river.
|
||||
///
|
||||
/// **⚠ Meerkat convention:** On preflop, the SB and BB blind postings are
|
||||
/// recorded as `Action::Raise` entries (the first two entries). Code that
|
||||
/// counts voluntary raises from this field must subtract 2 on preflop.
|
||||
/// Use `blind_postings_snapshot` for accurate blind data in hand history
|
||||
/// consumers. The `BettingRound.num_raises` field tracks only voluntary
|
||||
/// raises and is unaffected by this convention.
|
||||
actions_this_hand: Vec<Vec<(PlayerId, Action)>>,
|
||||
/// Optional RNG seed for reproducible shuffles.
|
||||
seed: Option<u64>,
|
||||
/// Rake configuration for this hand.
|
||||
rake_config: RakeConfig,
|
||||
/// Captured at start of hand for hand history.
|
||||
/// **Consumed** by [`collect_hand_history_data()`](HandRunner::collect_hand_history_data)
|
||||
/// on hand completion — will be empty afterward.
|
||||
seat_snapshot: Vec<(PlayerId, SeatIndex, ChipCount, bool, bool, bool)>,
|
||||
/// Blind postings captured during `start()`.
|
||||
/// **Consumed** by [`collect_hand_history_data()`](HandRunner::collect_hand_history_data)
|
||||
/// on hand completion — will be empty afterward.
|
||||
blind_postings_snapshot: Vec<(PlayerId, ChipCount, BlindType)>,
|
||||
/// Starting stacks captured during `start()`.
|
||||
/// **Consumed** by [`collect_hand_history_data()`](HandRunner::collect_hand_history_data)
|
||||
/// on hand completion — will be empty afterward.
|
||||
starting_stacks_snapshot: HashMap<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 {
|
||||
|
|
@ -196,6 +271,11 @@ impl HandRunner {
|
|||
],
|
||||
seed: None,
|
||||
rake_config: RakeConfig::no_rake(),
|
||||
seat_snapshot: Vec::new(),
|
||||
blind_postings_snapshot: Vec::new(),
|
||||
starting_stacks_snapshot: HashMap::new(),
|
||||
pending_result: None,
|
||||
history_collected: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -214,6 +294,18 @@ impl HandRunner {
|
|||
self
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public accessors
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Returns the actions taken this hand, grouped by street.
|
||||
///
|
||||
/// Index 0 = preflop, 1 = flop, 2 = turn, 3 = river.
|
||||
/// Each entry is `(PlayerId, Action)`.
|
||||
pub fn actions_this_hand(&self) -> &[Vec<(PlayerId, Action)>] {
|
||||
&self.actions_this_hand
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Start the hand
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -234,6 +326,7 @@ impl HandRunner {
|
|||
self.ante = ante;
|
||||
|
||||
self.state = HandState::PreflopDeal;
|
||||
self.history_collected = false;
|
||||
|
||||
// 1. Shuffle the deck.
|
||||
if let Some(seed) = self.seed {
|
||||
|
|
@ -250,17 +343,7 @@ impl HandRunner {
|
|||
seat.player.game_start_event(&game_info);
|
||||
}
|
||||
|
||||
// 3. Post antes (all active players).
|
||||
if ante > 0 {
|
||||
for seat in &mut self.seats {
|
||||
let actual = seat.post_ante(ante);
|
||||
if actual > 0 {
|
||||
self.pot_manager.place_bet(seat.player_id, actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Determine blind positions.
|
||||
// 3. Determine blind positions BEFORE posting blinds.
|
||||
// In heads-up (2 players): dealer/SB = index 0, BB = index 1.
|
||||
// In 3+ players: SB = dealer+1, BB = dealer+2.
|
||||
if self.seats.len() == 2 {
|
||||
|
|
@ -287,6 +370,43 @@ impl HandRunner {
|
|||
self.bb_seat_idx = (dealer_idx + 2) % self.seats.len();
|
||||
}
|
||||
|
||||
// 4. Capture seat snapshot for hand history (before blinds/antes modify stacks).
|
||||
self.seat_snapshot = self
|
||||
.seats
|
||||
.iter()
|
||||
.map(|s| {
|
||||
(
|
||||
s.player_id,
|
||||
s.index,
|
||||
s.stack,
|
||||
s.index == self.dealer_seat,
|
||||
s.index == self.seats[self.sb_seat_idx].index,
|
||||
s.index == self.seats[self.bb_seat_idx].index,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
self.starting_stacks_snapshot = self
|
||||
.seats
|
||||
.iter()
|
||||
.map(|s| (s.player_id, s.stack))
|
||||
.collect();
|
||||
self.blind_postings_snapshot.clear();
|
||||
|
||||
// 5. Post antes (all active players).
|
||||
if ante > 0 {
|
||||
for seat in &mut self.seats {
|
||||
let actual = seat.post_ante(ante);
|
||||
if actual > 0 {
|
||||
self.pot_manager.place_bet(seat.player_id, actual);
|
||||
self.blind_postings_snapshot.push((
|
||||
seat.player_id,
|
||||
actual,
|
||||
BlindType::Ante,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post small blind.
|
||||
{
|
||||
let actual_sb = self.seats[self.sb_seat_idx].post_blind(small_blind);
|
||||
|
|
@ -294,10 +414,18 @@ impl HandRunner {
|
|||
self.pot_manager
|
||||
.place_bet(self.seats[self.sb_seat_idx].player_id, actual_sb);
|
||||
// Record SB as a raise (Meerkat convention: blinds count as raises).
|
||||
// NOTE: Blind postings appear as `Action::Raise` in `actions_this_hand`
|
||||
// for Meerkat compatibility. Use `blind_postings_snapshot` for accurate
|
||||
// blind data in hand history consumers.
|
||||
self.actions_this_hand[0].push((
|
||||
self.seats[self.sb_seat_idx].player_id,
|
||||
Action::Raise { total: actual_sb },
|
||||
));
|
||||
self.blind_postings_snapshot.push((
|
||||
self.seats[self.sb_seat_idx].player_id,
|
||||
actual_sb,
|
||||
BlindType::SmallBlind,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -308,14 +436,20 @@ impl HandRunner {
|
|||
self.pot_manager
|
||||
.place_bet(self.seats[self.bb_seat_idx].player_id, actual_bb);
|
||||
// Record BB as a raise (Meerkat convention: blinds count as raises).
|
||||
// See SB posting above for the note about blind_postings_snapshot.
|
||||
self.actions_this_hand[0].push((
|
||||
self.seats[self.bb_seat_idx].player_id,
|
||||
Action::Raise { total: actual_bb },
|
||||
));
|
||||
self.blind_postings_snapshot.push((
|
||||
self.seats[self.bb_seat_idx].player_id,
|
||||
actual_bb,
|
||||
BlindType::BigBlind,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Deal hole cards (2 per player).
|
||||
// 6. Deal hole cards (2 per player).
|
||||
for seat in &mut self.seats {
|
||||
let c1 = self.deck.deal_one().expect("deck should have enough cards");
|
||||
let c2 = self.deck.deal_one().expect("deck should have enough cards");
|
||||
|
|
@ -323,7 +457,7 @@ impl HandRunner {
|
|||
seat.hole_cards = Some(hc);
|
||||
}
|
||||
|
||||
// 6. Call hole_cards_dealt on each player.
|
||||
// 7. Call hole_cards_dealt on each player.
|
||||
for i in 0..self.seats.len() {
|
||||
let hc = self.seats[i].hole_cards;
|
||||
if let Some(hole_cards) = hc {
|
||||
|
|
@ -332,7 +466,7 @@ impl HandRunner {
|
|||
}
|
||||
}
|
||||
|
||||
// 7. Call stage_event for Preflop on all players.
|
||||
// 8. Call stage_event for Preflop on all players.
|
||||
{
|
||||
let game_info = self.build_game_info();
|
||||
for seat in &mut self.seats {
|
||||
|
|
@ -340,12 +474,12 @@ impl HandRunner {
|
|||
}
|
||||
}
|
||||
|
||||
// 8. Create preflop BettingRound.
|
||||
// 9. Create preflop BettingRound.
|
||||
// First to act is UTG (after big blind).
|
||||
let utg_idx = (self.bb_seat_idx + 1) % self.seats.len();
|
||||
self.betting_round = Some(BettingRound::new(utg_idx, big_blind));
|
||||
|
||||
// 9. Set state to PreflopBetting.
|
||||
// 10. Set state to PreflopBetting.
|
||||
self.state = HandState::PreflopBetting;
|
||||
}
|
||||
|
||||
|
|
@ -416,6 +550,12 @@ impl HandRunner {
|
|||
.as_ref()
|
||||
.map(|br| br.last_raise_size)
|
||||
.unwrap_or(self.big_blind);
|
||||
let raises_this_round = self
|
||||
.betting_round
|
||||
.as_ref()
|
||||
.map(|br| br.num_raises)
|
||||
.unwrap_or(0);
|
||||
let street = self.current_street();
|
||||
|
||||
validate_action(
|
||||
&action,
|
||||
|
|
@ -425,9 +565,28 @@ impl HandRunner {
|
|||
current_bet,
|
||||
min_raise,
|
||||
&self.bet_limit,
|
||||
seat.has_acted_this_round,
|
||||
street,
|
||||
raises_this_round,
|
||||
)?;
|
||||
|
||||
// Pot-limit max raise enforcement.
|
||||
// The generic validator cannot check this because it lacks pot-size context.
|
||||
if let Action::Raise { total } = action {
|
||||
if let Some(max_raise_increment) = self.bet_limit.max_raise_amount(
|
||||
self.pot_manager.total_pot(),
|
||||
current_bet.saturating_sub(seat.wagered_this_round),
|
||||
seat.stack,
|
||||
) {
|
||||
let raise_increment = total.saturating_sub(current_bet);
|
||||
if raise_increment > max_raise_increment {
|
||||
return Err(GameError::RaiseAboveMaximum(
|
||||
raise_increment,
|
||||
max_raise_increment,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the action.
|
||||
self.apply_action(next_id, action.clone())?;
|
||||
|
||||
|
|
@ -877,33 +1036,41 @@ impl HandRunner {
|
|||
loop {
|
||||
match self.state {
|
||||
HandState::Showdown => {
|
||||
let result = self.showdown();
|
||||
let mut result = self.showdown();
|
||||
// Populate hand history (showdown() leaves it None to avoid
|
||||
// double-calling collect_hand_history_data).
|
||||
result.hand_history = Some(self.collect_hand_history_data());
|
||||
return Ok(result);
|
||||
}
|
||||
HandState::Complete => {
|
||||
// Hand was already resolved (e.g., by resolve_last_player_wins via step).
|
||||
// Stacks were already credited there — only reconstruct the HandResult.
|
||||
// Hand was already resolved by step() → resolve_last_player_wins()
|
||||
// → finalize_result(), which cached the result in pending_result.
|
||||
// Use unwrap_or_else with a fallback reconstruction in case the
|
||||
// state was set without going through finalize_result().
|
||||
let mut result = self.pending_result.take().unwrap_or_else(|| {
|
||||
// Fallback: reconstruct result from current state.
|
||||
let hand_values: HashMap<PlayerId, HandValue> = self
|
||||
.seats
|
||||
.iter()
|
||||
.filter(|s| s.is_active())
|
||||
.map(|s| (s.player_id, HandValue::new(HandRank::HighCard, 0)))
|
||||
.collect();
|
||||
|
||||
let pot_winners = self.pot_manager.distribute(&hand_values);
|
||||
let (raked_winners, rake_per_pot, total_rake) = self.apply_rake(pot_winners);
|
||||
|
||||
// Compute chip movements for the result (no stack crediting — already done).
|
||||
let chip_movements = self.compute_chip_movements(&raked_winners);
|
||||
|
||||
return Ok(HandResult {
|
||||
HandResult {
|
||||
pot_winners: raked_winners,
|
||||
hand_values,
|
||||
board: self.board.clone(),
|
||||
chip_movements,
|
||||
total_rake,
|
||||
rake_per_pot,
|
||||
hand_history: None,
|
||||
}
|
||||
});
|
||||
// Populate hand history (finalize_result leaves it None).
|
||||
result.hand_history = Some(self.collect_hand_history_data());
|
||||
return Ok(result);
|
||||
}
|
||||
HandState::Idle => {
|
||||
return Err(GameError::NoHandInProgress);
|
||||
|
|
@ -983,13 +1150,87 @@ impl HandRunner {
|
|||
// 5. Set state to Complete.
|
||||
self.state = HandState::Complete;
|
||||
|
||||
HandResult {
|
||||
// NOTE: hand_history is set to None here; the caller (run_to_completion)
|
||||
// is responsible for calling collect_hand_history_data() exactly once
|
||||
// to avoid double-consumption of the runner's snapshot fields.
|
||||
|
||||
let result = HandResult {
|
||||
pot_winners: raked_winners,
|
||||
hand_values,
|
||||
board: self.board.clone(),
|
||||
chip_movements,
|
||||
total_rake,
|
||||
rake_per_pot,
|
||||
hand_history: None,
|
||||
};
|
||||
|
||||
// Cache the result so run_to_completion can return it when it sees
|
||||
// the Complete state (step() discards the HandResult).
|
||||
self.pending_result = Some(result.clone());
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hand history helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Collects hand history data from the current hand state.
|
||||
///
|
||||
/// Returns a [`HandHistoryData`] struct with all data needed for hand history logging.
|
||||
/// Uses `std::mem::replace` for `Vec` fields to avoid cloning when the data
|
||||
/// is only consumed once (the runner is done with these fields after collection).
|
||||
///
|
||||
/// **One-shot semantics:** This method is intended to be called exactly once per
|
||||
/// hand, at hand completion. After calling it, the runner's snapshot fields are
|
||||
/// emptied and `actions_this_hand` is reset to an empty 4-element structure.
|
||||
/// Calling it again will produce empty data.
|
||||
fn collect_hand_history_data(&mut self) -> HandHistoryData {
|
||||
// Debug assertion: catch double-consumption at development time.
|
||||
debug_assert!(
|
||||
!self.history_collected,
|
||||
"collect_hand_history_data called twice on the same hand — \
|
||||
snapshot data was already consumed"
|
||||
);
|
||||
self.history_collected = true;
|
||||
|
||||
// Guard: if snapshot data is already consumed (empty), return empty data.
|
||||
// This can happen if finalize_result() was called but collect_hand_history_data
|
||||
// was not (e.g., step() → resolve_last_player_wins() → finalize_result() path).
|
||||
if self.seat_snapshot.is_empty() && self.actions_this_hand.iter().all(|v| v.is_empty()) {
|
||||
log::warn!(
|
||||
"collect_hand_history_data called on already-consumed runner — returning empty data. \
|
||||
This indicates a double-consumption bug in the caller."
|
||||
);
|
||||
return HandHistoryData {
|
||||
actions: vec![vec![], vec![], vec![], vec![]],
|
||||
hole_cards: HashMap::new(),
|
||||
seat_assignments: Vec::new(),
|
||||
blind_postings: Vec::new(),
|
||||
starting_stacks: HashMap::new(),
|
||||
};
|
||||
}
|
||||
// Replace with a properly-sized 4-element Vec to preserve the street-indexed
|
||||
// structure (index 0=preflop, 1=flop, 2=turn, 3=river) in case the runner
|
||||
// is ever reused after hand completion.
|
||||
let actions = std::mem::replace(
|
||||
&mut self.actions_this_hand,
|
||||
vec![vec![], vec![], vec![], vec![]],
|
||||
);
|
||||
let hole_cards: HashMap<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]
|
||||
fn test_fold_before_showdown() {
|
||||
// Two players: one folds preflop.
|
||||
// In heads-up, BB (seat 1) acts first preflop.
|
||||
// Make Bob (seat 1) the FoldBot so he folds first.
|
||||
// In heads-up, dealer/SB (seat 0) acts first preflop.
|
||||
// Alice (seat 0, CheckCallBot) acts first and calls.
|
||||
// Then Bob (seat 1, FoldBot) folds.
|
||||
let seat0 = make_check_call_seat(1, "Alice", 1000, 0);
|
||||
let seat1 = make_fold_seat(2, "Bob", 1000, 1);
|
||||
|
||||
|
|
@ -1456,8 +1698,8 @@ mod tests {
|
|||
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||
runner.start(10, 20, 0);
|
||||
|
||||
// In heads-up, BB (seat 1 = Bob) acts first preflop.
|
||||
// Bob (FoldBot) will fold. Then only Alice remains.
|
||||
// In heads-up, dealer/SB (seat 0 = Alice) acts first preflop.
|
||||
// Alice calls, then Bob (FoldBot) folds. Only Alice remains.
|
||||
let result = runner.run_to_completion().unwrap();
|
||||
assert_eq!(runner.state(), HandState::Complete);
|
||||
|
||||
|
|
@ -1518,8 +1760,8 @@ mod tests {
|
|||
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||
runner.start(10, 20, 0);
|
||||
|
||||
// In heads-up, dealer (seat 0) is SB. First to act preflop is BB (seat 1).
|
||||
// Set early fold for Alice (SB, seat 0) who will act second.
|
||||
// In heads-up, dealer (seat 0) is SB. First to act preflop is dealer/SB (seat 0).
|
||||
// Set early fold for Alice (SB, seat 0) who will act first.
|
||||
runner.set_early_fold(PlayerId(1)).unwrap();
|
||||
|
||||
// Step once — BB (Bob) acts (calls), then Alice's early fold triggers.
|
||||
|
|
@ -1564,4 +1806,218 @@ mod tests {
|
|||
let result = runner.step();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Heads-up blind positions and acting order
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_heads_up_dealer_is_sb_and_acts_first_preflop() {
|
||||
// In heads-up, the dealer is the SB and acts first preflop.
|
||||
// Post-flop, the dealer/SB acts last (BB acts first).
|
||||
let seat0 = make_check_call_seat(1, "Alice", 1000, 0);
|
||||
let seat1 = make_check_call_seat(2, "Bob", 1000, 1);
|
||||
|
||||
let seats = vec![seat0, seat1];
|
||||
let deck = Deck::new();
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
|
||||
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||
runner.start(10, 20, 0);
|
||||
|
||||
// Verify blind positions: seat 0 (dealer) is SB, seat 1 is BB.
|
||||
assert_eq!(runner.sb_seat_idx, 0);
|
||||
assert_eq!(runner.bb_seat_idx, 1);
|
||||
|
||||
// Verify SB and BB wagers.
|
||||
assert_eq!(runner.seats[0].wagered_this_round, 10); // SB = 10
|
||||
assert_eq!(runner.seats[1].wagered_this_round, 20); // BB = 20
|
||||
|
||||
// First to act preflop should be seat 0 (dealer/SB).
|
||||
let next = runner.next_to_act();
|
||||
assert_eq!(next, Some(PlayerId(1)), "In heads-up, dealer/SB should act first preflop");
|
||||
|
||||
// Run to completion.
|
||||
let result = runner.run_to_completion().unwrap();
|
||||
assert_eq!(runner.state(), HandState::Complete);
|
||||
|
||||
// Conservation of chips.
|
||||
let total: i64 = result.chip_movements.values().sum();
|
||||
assert_eq!(total, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_three_player_acting_order_preflop() {
|
||||
// In 3-player, SB = dealer+1, BB = dealer+2, UTG = BB+1 = dealer.
|
||||
// With dealer at seat 0: SB=1, BB=2, UTG=0.
|
||||
let seat0 = make_check_call_seat(1, "Alice", 1000, 0);
|
||||
let seat1 = make_check_call_seat(2, "Bob", 1000, 1);
|
||||
let seat2 = make_check_call_seat(3, "Charlie", 1000, 2);
|
||||
|
||||
let seats = vec![seat0, seat1, seat2];
|
||||
let deck = Deck::new();
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
|
||||
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||
runner.start(10, 20, 0);
|
||||
|
||||
// Verify blind positions: SB = seat 1, BB = seat 2.
|
||||
assert_eq!(runner.sb_seat_idx, 1);
|
||||
assert_eq!(runner.bb_seat_idx, 2);
|
||||
|
||||
// First to act preflop should be UTG = seat 0 (Alice).
|
||||
let next = runner.next_to_act();
|
||||
assert_eq!(next, Some(PlayerId(1)), "UTG should act first in 3-player preflop");
|
||||
|
||||
let result = runner.run_to_completion().unwrap();
|
||||
assert_eq!(runner.state(), HandState::Complete);
|
||||
|
||||
// Conservation of chips.
|
||||
let total: i64 = result.chip_movements.values().sum();
|
||||
assert_eq!(total, 0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Pot-limit enforcement
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// A bot that always tries to raise to a specific total.
|
||||
struct RaiseToBot {
|
||||
id: PlayerId,
|
||||
name: String,
|
||||
total: ChipCount,
|
||||
}
|
||||
|
||||
impl RaiseToBot {
|
||||
fn new(id: u64, name: &str, total: ChipCount) -> Self {
|
||||
Self { id: PlayerId(id), name: name.to_string(), total }
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::game::player::HoldemPlayer for RaiseToBot {
|
||||
fn id(&self) -> PlayerId { self.id }
|
||||
fn name(&self) -> &str { &self.name }
|
||||
fn init(&mut self, _game_info: &GameInfo) {}
|
||||
fn game_start_event(&mut self, _game_info: &GameInfo) {}
|
||||
fn hole_cards_dealt(&mut self, _hole_cards: &HoleCards, _game_info: &GameInfo) {}
|
||||
fn stage_event(&mut self, _game_info: &GameInfo) {}
|
||||
fn act(&mut self, _game_info: &GameInfo) -> Action {
|
||||
Action::Raise { total: self.total }
|
||||
}
|
||||
fn action_event(&mut self, _player_id: PlayerId, _action: &Action, _game_info: &GameInfo) {}
|
||||
fn showdown_event(&mut self, _player_id: PlayerId, _hole_cards: Option<&HoleCards>, _hand_value: Option<&HandValue>, _game_info: &GameInfo) {}
|
||||
fn game_over_event(&mut self, _game_info: &GameInfo) {}
|
||||
fn on_eliminated(&self, _placement: u32) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pot_limit_rejects_raise_above_pot() {
|
||||
// Pot-limit: max raise increment = pot + call_amount.
|
||||
// Setup: 3 players, pot = 30 (SB=10 + BB=20), first to act is UTG.
|
||||
// UTG faces current_bet=20, call_amount=20, pot=30.
|
||||
// Max raise increment = 30 + 20 = 50. Max total = 20 + 50 = 70.
|
||||
// Raise to 100 should be rejected.
|
||||
let seat0 = Seat::new(0, Box::new(RaiseToBot::new(1, "Alice", 100)), 1000);
|
||||
let seat1 = make_check_call_seat(2, "Bob", 1000, 1);
|
||||
let seat2 = make_check_call_seat(3, "Charlie", 1000, 2);
|
||||
|
||||
let seats = vec![seat0, seat1, seat2];
|
||||
let deck = Deck::new();
|
||||
let bet_limit = BetLimit::PotLimit { min_raise: 10 };
|
||||
|
||||
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||
runner.start(10, 20, 0);
|
||||
|
||||
// Alice tries to raise to 100, which exceeds the pot limit.
|
||||
let result = runner.step();
|
||||
assert!(result.is_err(), "Pot-limit raise above pot+call should be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pot_limit_allows_pot_sized_raise() {
|
||||
// Pot-limit: max raise increment = pot + call_amount.
|
||||
// Setup: 3 players, pot = 30 (SB=10 + BB=20), first to act is UTG.
|
||||
// UTG faces current_bet=20, call_amount=20, pot=30.
|
||||
// Max raise increment = 30 + 20 = 50. Max total = 20 + 50 = 70.
|
||||
// Raise to 70 should be allowed.
|
||||
let seat0 = Seat::new(0, Box::new(RaiseToBot::new(1, "Alice", 70)), 1000);
|
||||
let seat1 = make_check_call_seat(2, "Bob", 1000, 1);
|
||||
let seat2 = make_check_call_seat(3, "Charlie", 1000, 2);
|
||||
|
||||
let seats = vec![seat0, seat1, seat2];
|
||||
let deck = Deck::new();
|
||||
let bet_limit = BetLimit::PotLimit { min_raise: 10 };
|
||||
|
||||
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||
runner.start(10, 20, 0);
|
||||
|
||||
// Alice raises to exactly the pot limit — should succeed.
|
||||
let state = runner.step();
|
||||
assert!(state.is_ok(), "Pot-sized raise should be allowed");
|
||||
assert_eq!(state.unwrap(), HandState::PreflopBetting);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Side pot edge cases
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_side_pot_three_players_different_stacks() {
|
||||
// Three players with different stacks all check/call to showdown.
|
||||
// Alice: 100, Bob: 200, Charlie: 300.
|
||||
// With CallBot, everyone just calls the BB preflop and checks postflop.
|
||||
let seat0 = make_seat(1, "Alice", 100, 0);
|
||||
let seat1 = make_seat(2, "Bob", 200, 1);
|
||||
let seat2 = make_seat(3, "Charlie", 300, 2);
|
||||
|
||||
let seats = vec![seat0, seat1, seat2];
|
||||
let deck = Deck::new();
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
|
||||
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||
runner.start(10, 20, 0);
|
||||
|
||||
let result = runner.run_to_completion().unwrap();
|
||||
assert_eq!(runner.state(), HandState::Complete);
|
||||
|
||||
// Conservation of chips.
|
||||
let total: i64 = result.chip_movements.values().sum();
|
||||
assert_eq!(total, 0);
|
||||
|
||||
// All three should have hand values (went to showdown).
|
||||
assert_eq!(result.hand_values.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heads_up_bb_gets_option_when_sb_calls_preflop() {
|
||||
// In heads-up, when SB calls/limps preflop, BB gets the option to raise.
|
||||
// The BB should see a check/call situation (current_bet matches their wagered).
|
||||
// Both players check/call through to showdown.
|
||||
let seat0 = make_check_call_seat(1, "Alice", 1000, 0);
|
||||
let seat1 = make_check_call_seat(2, "Bob", 1000, 1);
|
||||
|
||||
let seats = vec![seat0, seat1];
|
||||
let deck = Deck::new();
|
||||
let bet_limit = BetLimit::NoLimit { min_raise: 10 };
|
||||
|
||||
let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1);
|
||||
runner.start(10, 20, 0);
|
||||
|
||||
// SB (Alice, seat 0) acts first preflop — she calls (matches BB).
|
||||
let state = runner.step().unwrap();
|
||||
assert_eq!(state, HandState::PreflopBetting);
|
||||
|
||||
// Now BB (Bob, seat 1) gets the option — he checks.
|
||||
let state = runner.step().unwrap();
|
||||
// After BB checks, preflop betting is done → advance to flop.
|
||||
assert!(matches!(state, HandState::FlopBetting | HandState::DealFlop | HandState::Complete));
|
||||
|
||||
// Run to completion.
|
||||
let result = runner.run_to_completion().unwrap();
|
||||
assert_eq!(runner.state(), HandState::Complete);
|
||||
|
||||
// Conservation of chips.
|
||||
let total: i64 = result.chip_movements.values().sum();
|
||||
assert_eq!(total, 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ pub mod bot_factory;
|
|||
pub mod bots;
|
||||
pub mod config;
|
||||
pub mod game_info;
|
||||
pub mod hand_history;
|
||||
pub mod hand_runner;
|
||||
pub mod player;
|
||||
pub mod player_info;
|
||||
|
|
@ -39,14 +40,18 @@ pub use action::{Action, PlayerAction};
|
|||
pub use betting::BetLimit;
|
||||
pub use bot_factory::create_bot;
|
||||
pub use bots::{CallBot, FoldBot, RandomBot, RaiseBot};
|
||||
pub use config::{BetLimitConfig, BotConfig, GameConfig, OutputConfig, RakeConfig, SimulationConfig};
|
||||
pub use config::{BetLimitConfig, BlindLevelConfig, BotConfig, GameConfig, OutputConfig, RakeConfig, SimulationConfig};
|
||||
pub use game_info::GameInfo;
|
||||
pub use hand_runner::{HandResult, HandRunner, HandState};
|
||||
pub use hand_history::{
|
||||
BlindPosting, BlindType, HandHistoryLogger, HandHistoryPlayer, HandHistoryRecord, ShowdownEntry,
|
||||
StreetAction, format_ipoker,
|
||||
};
|
||||
pub use hand_runner::{HandHistoryData, HandResult, HandRunner, HandState};
|
||||
pub use player::{HoldemPlayer, Seat};
|
||||
pub use player_info::{PlayerInfo, PlayerState};
|
||||
pub use pot::{Pot, PotManager, PotWinner};
|
||||
pub use simulation::run_cash_game;
|
||||
pub use stats::{PlayerStats, SimulationStats};
|
||||
pub use simulation::{run_cash_game, run_sng_series};
|
||||
pub use stats::{PlayerStats, SimulationStats, SngPlayerSeriesResult, SngSeriesResult};
|
||||
pub use table::{Table, TableConfig};
|
||||
pub use tournament::{BlindLevel, BlindSchedule, GameType, PrizeStructure, Tournament};
|
||||
pub use types::{ChipCount, GameError, PlayerId, SeatIndex, MAX_SEATS, MIN_PLAYERS};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
//! Cash game simulation runner.
|
||||
//! Cash game and tournament series simulation runners.
|
||||
//!
|
||||
//! Provides [`run_cash_game`] which creates a table, seats bots from
|
||||
//! configuration, runs the specified number of hands, and collects
|
||||
//! statistics.
|
||||
//! Provides:
|
||||
//! - [`run_cash_game`] — runs a cash game simulation
|
||||
//! - [`run_sng_series`] — runs multiple SNG tournaments and aggregates results
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use rand::seq::SliceRandom;
|
||||
use rand::{Rng, SeedableRng};
|
||||
|
||||
use super::bot_factory;
|
||||
use super::config::GameConfig;
|
||||
use super::stats::SimulationStats;
|
||||
use super::stats::{SimulationStats, SngSeriesResult};
|
||||
use super::table::Table;
|
||||
use super::tournament::Tournament;
|
||||
use super::types::{ChipCount, GameError, PlayerId};
|
||||
|
||||
/// Runs a cash game simulation based on the given configuration.
|
||||
|
|
@ -73,6 +77,41 @@ pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), Ga
|
|||
match table.run_hand() {
|
||||
Ok(result) => {
|
||||
stats.record_hand_result(&result, &player_names);
|
||||
|
||||
// Auto-rebuy: top up players whose stack dropped below the
|
||||
// configured percentage of starting chips.
|
||||
// Also reactivates busted players (stack=0, SittingOut) so
|
||||
// they can continue playing all hands.
|
||||
if config.auto_rebuy {
|
||||
// NOTE: Integer division truncates — e.g. 99 * 25 / 100 = 24 (not 24.75).
|
||||
// This is acceptable for simulation purposes; the threshold is slightly
|
||||
// lower than the exact percentage, which is conservative (rebuys trigger
|
||||
// slightly earlier). Uses saturating_mul to prevent overflow with extreme
|
||||
// chip values.
|
||||
let threshold = config.starting_chips
|
||||
.saturating_mul(config.rebuy_threshold_percentage as ChipCount)
|
||||
/ 100;
|
||||
for slot in table.seats_mut().iter_mut() {
|
||||
if let Some(seat) = slot {
|
||||
if seat.stack < threshold {
|
||||
let rebuy_amount =
|
||||
config.starting_chips.saturating_sub(seat.stack);
|
||||
log::info!(
|
||||
"Auto-rebuy: {} topped up by {} (was {})",
|
||||
seat.player.name(),
|
||||
rebuy_amount,
|
||||
seat.stack
|
||||
);
|
||||
seat.stack = config.starting_chips;
|
||||
// Reactivate busted players so they can play
|
||||
// the next hand.
|
||||
if seat.state == super::player_info::PlayerState::SittingOut {
|
||||
seat.state = super::player_info::PlayerState::Active;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(GameError::NotEnoughPlayers(_)) => {
|
||||
// Not enough active players to continue.
|
||||
|
|
@ -107,6 +146,200 @@ pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), Ga
|
|||
Ok((stats, table))
|
||||
}
|
||||
|
||||
/// Runs a series of tournaments (SNG or MTT) and returns aggregate results.
|
||||
///
|
||||
/// For each tournament:
|
||||
/// 1. Creates a new [`Tournament`] with the given config's schedule, prizes,
|
||||
/// starting chips, and buy-in.
|
||||
/// 2. Creates fresh bot instances via [`bot_factory::create_bot`] for each
|
||||
/// bot config.
|
||||
/// 3. Shuffles the registration order (if `config.randomize_seats`) to
|
||||
/// randomize seat assignments.
|
||||
/// 4. Registers all bots with the tournament.
|
||||
/// 5. Runs the tournament to completion.
|
||||
/// 6. Records each player's placement and prize money.
|
||||
///
|
||||
/// Players are tracked **by name** across tournaments (since each tournament
|
||||
/// creates fresh bot instances with different [`PlayerId`]s).
|
||||
///
|
||||
/// Works for both `SitAndGo` and `MultiTableTournament` game types.
|
||||
/// For MTT, uses `config.num_tables` to create the appropriate number of tables.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`GameError`] if:
|
||||
/// - Fewer than 2 bots are configured
|
||||
/// - Bot names are not unique (would cause result aggregation to merge players)
|
||||
/// - A bot type is unknown
|
||||
/// - The table is full
|
||||
/// - A tournament fails to complete
|
||||
pub fn run_sng_series(
|
||||
config: &GameConfig,
|
||||
num_tournaments: u32,
|
||||
) -> Result<SngSeriesResult, GameError> {
|
||||
// Validate that we have at least 2 bots.
|
||||
if config.bots.len() < 2 {
|
||||
return Err(GameError::NotEnoughPlayers(config.bots.len()));
|
||||
}
|
||||
|
||||
// Validate unique bot names — players are tracked by name across tournaments,
|
||||
// so duplicate names would silently merge their results.
|
||||
// Bots without explicit names use their bot_type as a fallback identity.
|
||||
let unique_names: HashSet<String> = config
|
||||
.bots
|
||||
.iter()
|
||||
.map(|b| b.name.clone().unwrap_or_else(|| b.bot_type.clone()))
|
||||
.collect();
|
||||
if unique_names.len() != config.bots.len() {
|
||||
return Err(GameError::Custom(
|
||||
"Duplicate bot names in config: each bot must have a unique name for series tracking".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let buy_in = if config.buy_in > 0 {
|
||||
config.buy_in
|
||||
} else {
|
||||
config.starting_chips
|
||||
};
|
||||
|
||||
// Build a deterministic RNG for shuffling registration order.
|
||||
// Use the config seed if provided, otherwise generate a random one
|
||||
// and log it so the run can be reproduced.
|
||||
let base_seed = config.seed.unwrap_or_else(|| {
|
||||
let seed = rand::rng().random::<u64>();
|
||||
log::info!("Generated random seed for tournament series: {}", seed);
|
||||
seed
|
||||
});
|
||||
|
||||
let mut result = SngSeriesResult::new(num_tournaments, buy_in, base_seed);
|
||||
|
||||
for tournament_index in 0..num_tournaments {
|
||||
let table_config = config.to_table_config();
|
||||
let blind_schedule = config.to_blind_schedule();
|
||||
let prize_structure = config.to_prize_structure();
|
||||
|
||||
// Create the appropriate tournament type based on game_type.
|
||||
let mut tournament = match config.game_type {
|
||||
super::tournament::GameType::MultiTableTournament => Tournament::new_mtt(
|
||||
config.num_tables,
|
||||
table_config,
|
||||
blind_schedule,
|
||||
prize_structure,
|
||||
config.starting_chips,
|
||||
buy_in,
|
||||
),
|
||||
_ => Tournament::new_sng(
|
||||
table_config,
|
||||
blind_schedule,
|
||||
prize_structure,
|
||||
config.starting_chips,
|
||||
buy_in,
|
||||
),
|
||||
};
|
||||
|
||||
// Build the registration order. If randomize_seats is enabled,
|
||||
// shuffle the bot order using a seed derived from the base seed +
|
||||
// tournament index for reproducibility.
|
||||
let mut order: Vec<usize> = (0..config.bots.len()).collect();
|
||||
if config.randomize_seats {
|
||||
let tournament_seed = base_seed.wrapping_add(tournament_index as u64);
|
||||
let mut rng = rand::rngs::StdRng::seed_from_u64(tournament_seed);
|
||||
order.shuffle(&mut rng);
|
||||
}
|
||||
|
||||
// Create fresh bots and register them in the (possibly shuffled) order.
|
||||
// We need to track names by PlayerId for this tournament.
|
||||
let mut player_names: HashMap<PlayerId, String> = HashMap::new();
|
||||
|
||||
for (seat_seq, &bot_idx) in order.iter().enumerate() {
|
||||
let bot_config = &config.bots[bot_idx];
|
||||
let player_id = PlayerId(seat_seq as u64);
|
||||
let bot = bot_factory::create_bot(bot_config, player_id)
|
||||
.map_err(GameError::Custom)?;
|
||||
|
||||
let name = bot.name().to_string();
|
||||
player_names.insert(player_id, name);
|
||||
|
||||
tournament.register_player(bot)?;
|
||||
}
|
||||
|
||||
// Run the tournament to completion.
|
||||
log::info!("Running tournament {}/{}...", tournament_index + 1, num_tournaments);
|
||||
tournament.run_to_completion()?;
|
||||
|
||||
// Accumulate total hands.
|
||||
result.total_hands_played += tournament.total_hands();
|
||||
|
||||
// Process results: record eliminated players from standings.
|
||||
let standings = tournament.standings();
|
||||
|
||||
// Invariant: after run_to_completion(), standings should have N-1 entries
|
||||
// and winner() returns the sole remaining player. Log a warning if violated.
|
||||
let expected_standings = player_names.len().saturating_sub(1);
|
||||
if standings.len() != expected_standings {
|
||||
log::warn!(
|
||||
"Tournament {} ended with {} standings (expected {})",
|
||||
tournament_index + 1,
|
||||
standings.len(),
|
||||
expected_standings
|
||||
);
|
||||
}
|
||||
|
||||
// Track which player IDs have been recorded to prevent double-counting.
|
||||
let mut recorded: HashSet<PlayerId> = HashSet::new();
|
||||
|
||||
// Standings contain eliminated players with raw placement numbers.
|
||||
// In the tournament, placement = N means the player was the Nth-to-last
|
||||
// to be eliminated (i.e., placement=N is the worst finish).
|
||||
// For a 10-player tournament: first player out gets placement=10,
|
||||
// last player eliminated gets placement=2. The winner (placement=1)
|
||||
// is NOT in standings — they are tracked separately via winner().
|
||||
//
|
||||
// The raw placement IS the finish position, so we use it directly
|
||||
// for prize lookup (prize_for_placement is 1-indexed).
|
||||
for &(player_id, raw_placement) in standings.iter() {
|
||||
let finish_position = raw_placement;
|
||||
let name = player_names
|
||||
.get(&player_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("Player({})", player_id.0));
|
||||
|
||||
let prize = tournament
|
||||
.prize_structure()
|
||||
.prize_for_placement(finish_position)
|
||||
.unwrap_or(0);
|
||||
|
||||
log::debug!(
|
||||
"Tournament {}: {} raw_placement={} finish={} prize={}",
|
||||
tournament_index + 1, name, raw_placement, finish_position, prize
|
||||
);
|
||||
|
||||
result.record_player_finish(&name, finish_position, prize);
|
||||
recorded.insert(player_id);
|
||||
}
|
||||
|
||||
// Record the winner (last active player) if not already in standings.
|
||||
// The winner is the sole remaining player who was never eliminated.
|
||||
if let Some(winner_id) = tournament.winner() {
|
||||
if !recorded.contains(&winner_id) {
|
||||
let name = player_names
|
||||
.get(&winner_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("Player({})", winner_id.0));
|
||||
|
||||
let prize = tournament
|
||||
.prize_structure()
|
||||
.prize_for_placement(1)
|
||||
.unwrap_or(0);
|
||||
|
||||
result.record_player_finish(&name, 1, prize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tests
|
||||
// ===========================================================================
|
||||
|
|
@ -138,6 +371,11 @@ mod tests {
|
|||
reset_bankroll_each_hand: false,
|
||||
bankroll_refill_threshold: 0,
|
||||
randomize_seats: false,
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: 25,
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
num_tournaments: 1,
|
||||
bots: vec![
|
||||
BotConfig {
|
||||
bot_type: "call_bot".to_string(),
|
||||
|
|
@ -244,6 +482,11 @@ mod tests {
|
|||
reset_bankroll_each_hand: false,
|
||||
bankroll_refill_threshold: 0,
|
||||
randomize_seats: false,
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: 25,
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
num_tournaments: 1,
|
||||
|
||||
bots: vec![BotConfig {
|
||||
bot_type: "call_bot".to_string(),
|
||||
|
|
@ -278,7 +521,11 @@ mod tests {
|
|||
reset_bankroll_each_hand: false,
|
||||
bankroll_refill_threshold: 0,
|
||||
randomize_seats: false,
|
||||
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: 25,
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
num_tournaments: 1,
|
||||
bots: vec![
|
||||
BotConfig {
|
||||
bot_type: "call_bot".to_string(),
|
||||
|
|
@ -320,6 +567,11 @@ mod tests {
|
|||
reset_bankroll_each_hand: false,
|
||||
bankroll_refill_threshold: 0,
|
||||
randomize_seats: false,
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: 25,
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
num_tournaments: 1,
|
||||
|
||||
bots: vec![
|
||||
BotConfig {
|
||||
|
|
@ -358,4 +610,68 @@ mod tests {
|
|||
assert!(summary.contains("Bob"));
|
||||
assert!(summary.contains("Charlie"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cash_game_auto_rebuy() {
|
||||
let config = GameConfig {
|
||||
game_type: GameType::Cash,
|
||||
num_seats: 6,
|
||||
bet_limit: BetLimitConfig::NoLimit { min_raise: 10 },
|
||||
small_blind: 5,
|
||||
big_blind: 10,
|
||||
ante: 0,
|
||||
starting_chips: 100,
|
||||
buy_in: 0,
|
||||
num_tables: 1,
|
||||
hands_per_level: 10,
|
||||
num_blind_levels: 15,
|
||||
num_hands: 5,
|
||||
seed: Some(42),
|
||||
rake_percentage: 0.0,
|
||||
rake_cap: 0,
|
||||
reset_bankroll_each_hand: false,
|
||||
bankroll_refill_threshold: 0,
|
||||
randomize_seats: false,
|
||||
auto_rebuy: true,
|
||||
rebuy_threshold_percentage: 25,
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
num_tournaments: 1,
|
||||
bots: vec![
|
||||
BotConfig {
|
||||
bot_type: "call_bot".to_string(),
|
||||
name: Some("Alice".to_string()),
|
||||
seat: Some(0),
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
BotConfig {
|
||||
bot_type: "call_bot".to_string(),
|
||||
name: Some("Bob".to_string()),
|
||||
seat: Some(1),
|
||||
parameters: HashMap::new(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let result = run_cash_game(&config);
|
||||
assert!(result.is_ok(), "run_cash_game should succeed");
|
||||
|
||||
let (_stats, table) = result.unwrap();
|
||||
// With auto_rebuy enabled, players who drop below 25% of starting chips
|
||||
// (threshold = 100/4 = 25) should be topped back up to starting_chips.
|
||||
// Players above the threshold should remain unchanged.
|
||||
// Busted players (stack=0, SittingOut) should be reactivated.
|
||||
for slot in table.seats().iter() {
|
||||
if let Some(seat) = slot {
|
||||
// After rebuy, stack should either be at starting_chips (was rebought)
|
||||
// or above the threshold of 25 (was not eligible for rebuy).
|
||||
assert!(
|
||||
seat.stack >= 25 || seat.stack == 100,
|
||||
"Player {} should have chips >= 25 or be rebought to 100 after auto-rebuy, but has {}",
|
||||
seat.player.name(),
|
||||
seat.stack
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ===========================================================================
|
||||
|
|
@ -497,6 +668,7 @@ mod tests {
|
|||
chip_movements,
|
||||
total_rake: 0,
|
||||
rake_per_pot: vec![],
|
||||
hand_history: None,
|
||||
};
|
||||
|
||||
sim_stats.record_hand_result(&result, &player_names);
|
||||
|
|
@ -534,6 +706,7 @@ mod tests {
|
|||
},
|
||||
total_rake: 0,
|
||||
rake_per_pot: vec![],
|
||||
hand_history: None,
|
||||
};
|
||||
|
||||
// Hand 2: Bob wins 200
|
||||
|
|
@ -549,6 +722,7 @@ mod tests {
|
|||
},
|
||||
total_rake: 0,
|
||||
rake_per_pot: vec![],
|
||||
hand_history: None,
|
||||
};
|
||||
|
||||
sim_stats.record_hand_result(&result1, &player_names);
|
||||
|
|
@ -587,6 +761,7 @@ mod tests {
|
|||
},
|
||||
total_rake: 0,
|
||||
rake_per_pot: vec![],
|
||||
hand_history: None,
|
||||
};
|
||||
sim_stats.record_hand_result(&result, &player_names);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::player_info::PlayerState;
|
||||
use super::player::Seat;
|
||||
use super::table::{Table, TableConfig};
|
||||
use super::types::{ChipCount, GameError, PlayerId};
|
||||
use super::types::{ChipCount, GameError, PlayerId, MIN_PLAYERS};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GameType Enum
|
||||
|
|
@ -109,6 +110,36 @@ impl BlindSchedule {
|
|||
pub fn is_final_level(&self, level_index: usize) -> bool {
|
||||
level_index >= self.levels.len().saturating_sub(1)
|
||||
}
|
||||
|
||||
/// Adds a new level that doubles the blinds/ante of the last level.
|
||||
///
|
||||
/// Used to extend the blind schedule past the final defined level,
|
||||
/// forcing the tournament to conclude by escalating blinds.
|
||||
pub fn add_escalated_level(&mut self) {
|
||||
if let Some(last) = self.levels.last() {
|
||||
let new_level = last.level.saturating_add(1);
|
||||
let new_sb = last.small_blind.saturating_mul(2);
|
||||
let new_bb = last.big_blind.saturating_mul(2);
|
||||
if new_sb == u64::MAX || new_bb == u64::MAX {
|
||||
log::warn!(
|
||||
"Blind level {} saturated at u64::MAX — tournament may not conclude through blind escalation",
|
||||
new_level
|
||||
);
|
||||
}
|
||||
let new_ante = if last.ante > 0 {
|
||||
last.ante.saturating_mul(2)
|
||||
} else {
|
||||
last.big_blind / 5
|
||||
};
|
||||
self.levels.push(BlindLevel {
|
||||
level: new_level,
|
||||
small_blind: new_sb,
|
||||
big_blind: new_bb,
|
||||
ante: new_ante,
|
||||
duration_hands: last.duration_hands,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -123,6 +154,13 @@ pub struct PrizeStructure {
|
|||
}
|
||||
|
||||
impl PrizeStructure {
|
||||
/// Creates a custom prize structure from the given prize amounts.
|
||||
///
|
||||
/// Entries are ordered: 1st place, 2nd place, 3rd place, etc.
|
||||
pub fn custom(prizes: Vec<ChipCount>) -> Self {
|
||||
Self { prizes }
|
||||
}
|
||||
|
||||
/// Standard Sit-and-Go prize structure.
|
||||
///
|
||||
/// - 3+ players: 50/30/20 for top 3
|
||||
|
|
@ -248,7 +286,6 @@ pub struct Tournament {
|
|||
/// Whether the tournament is complete.
|
||||
is_complete: bool,
|
||||
/// Buy-in amount per player.
|
||||
#[allow(dead_code)] // Used for prize pool calculations in future extensions
|
||||
buy_in: ChipCount,
|
||||
}
|
||||
|
||||
|
|
@ -266,7 +303,7 @@ impl Tournament {
|
|||
buy_in: ChipCount,
|
||||
) -> Self {
|
||||
let table = Table::new(config);
|
||||
Self {
|
||||
let mut tournament = Self {
|
||||
game_type: GameType::SitAndGo,
|
||||
tables: vec![table],
|
||||
blind_schedule,
|
||||
|
|
@ -278,7 +315,10 @@ impl Tournament {
|
|||
total_hands: 0,
|
||||
is_complete: false,
|
||||
buy_in,
|
||||
}
|
||||
};
|
||||
// Apply the first blind level to the table.
|
||||
tournament.apply_blinds_to_tables();
|
||||
tournament
|
||||
}
|
||||
|
||||
/// Creates a new Multi-Table Tournament with the specified number of tables.
|
||||
|
|
@ -293,7 +333,7 @@ impl Tournament {
|
|||
let tables = (0..num_tables)
|
||||
.map(|_| Table::new(config.clone()))
|
||||
.collect();
|
||||
Self {
|
||||
let mut tournament = Self {
|
||||
game_type: GameType::MultiTableTournament,
|
||||
tables,
|
||||
blind_schedule,
|
||||
|
|
@ -305,7 +345,10 @@ impl Tournament {
|
|||
total_hands: 0,
|
||||
is_complete: false,
|
||||
buy_in,
|
||||
}
|
||||
};
|
||||
// Apply the first blind level to all tables.
|
||||
tournament.apply_blinds_to_tables();
|
||||
tournament
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -387,6 +430,15 @@ impl Tournament {
|
|||
self.total_hands += hands_run as u64;
|
||||
}
|
||||
|
||||
// Rebalance tables after bustouts — move players from full tables
|
||||
// to short-handed ones so that all tables can continue running hands.
|
||||
// This prevents the deadlock where 2+ players survive but are isolated
|
||||
// on different tables with < MIN_PLAYERS each.
|
||||
self.rebalance_tables();
|
||||
|
||||
// Remove tables that have no active players.
|
||||
self.tables.retain(|t| t.active_player_count() > 0);
|
||||
|
||||
// Check blind advancement
|
||||
self.check_blind_advance();
|
||||
|
||||
|
|
@ -441,11 +493,18 @@ impl Tournament {
|
|||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Checks if the blind level should advance and advances if so.
|
||||
///
|
||||
/// When the final defined blind level is exhausted, a new level is
|
||||
/// automatically generated by doubling the previous level's blinds and
|
||||
/// antes. This ensures the tournament always concludes, even with
|
||||
/// passive players or very deep stacks.
|
||||
pub fn check_blind_advance(&mut self) {
|
||||
if let Some(level) = self.blind_schedule.current_level(self.current_level) {
|
||||
if self.hands_at_current_level >= level.duration_hands
|
||||
&& !self.blind_schedule.is_final_level(self.current_level)
|
||||
{
|
||||
if self.hands_at_current_level >= level.duration_hands {
|
||||
if self.blind_schedule.is_final_level(self.current_level) {
|
||||
// Past the last defined level — generate an escalated one.
|
||||
self.blind_schedule.add_escalated_level();
|
||||
}
|
||||
self.current_level += 1;
|
||||
self.hands_at_current_level = 0;
|
||||
self.apply_blinds_to_tables();
|
||||
|
|
@ -531,7 +590,87 @@ impl Tournament {
|
|||
return;
|
||||
}
|
||||
|
||||
// Safety counter: each iteration should either consolidate tables or move
|
||||
// a player, so the upper bound is O(total_players * tables). We use a
|
||||
// generous limit to prevent infinite loops if consolidation logic has a bug.
|
||||
let max_iterations = self.tables.len() * self.tables.iter().map(|t| t.active_player_count()).max().unwrap_or(1) + self.tables.len();
|
||||
let mut iterations = 0;
|
||||
|
||||
loop {
|
||||
iterations += 1;
|
||||
if iterations > max_iterations {
|
||||
log::warn!(
|
||||
"rebalance_tables: safety limit ({}) reached after {} iterations — stopping",
|
||||
max_iterations, iterations
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Remove empty tables first (tables with 0 active players)
|
||||
self.tables.retain(|t| t.active_player_count() > 0);
|
||||
|
||||
if self.tables.len() <= 1 {
|
||||
break;
|
||||
}
|
||||
|
||||
// Last-resort consolidation: if total active players across all tables
|
||||
// is <= MIN_PLAYERS * (tables - 1), we can fit everyone on fewer tables.
|
||||
// Move all players from smaller tables to the largest table.
|
||||
let total_active: usize = self.tables.iter().map(|t| t.active_player_count()).sum();
|
||||
let max_capacity = MIN_PLAYERS as usize * (self.tables.len() - 1);
|
||||
if total_active <= max_capacity {
|
||||
// Consolidate all players onto the table with the most players.
|
||||
// Take players from all other tables.
|
||||
let mut max_idx = 0;
|
||||
let mut max_count = 0usize;
|
||||
for (i, table) in self.tables.iter().enumerate() {
|
||||
let count = table.active_player_count();
|
||||
if count > max_count {
|
||||
max_count = count;
|
||||
max_idx = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all players from non-max tables.
|
||||
let mut players_to_move: Vec<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
|
||||
let mut max_count = 0usize;
|
||||
let mut max_idx = 0usize;
|
||||
|
|
@ -550,14 +689,28 @@ impl Tournament {
|
|||
}
|
||||
}
|
||||
|
||||
// If difference < 2 or tables are the same, we're balanced
|
||||
if max_count.saturating_sub(min_count) < 2 || max_idx == min_idx {
|
||||
// Consolidation: move players from short tables (below MIN_PLAYERS)
|
||||
// to larger tables so they can continue playing.
|
||||
// Balance: when tables differ by >= 2, move from largest to smallest.
|
||||
let needs_consolidation = min_count < MIN_PLAYERS as usize && max_count >= MIN_PLAYERS as usize;
|
||||
let needs_balance = min_count >= MIN_PLAYERS as usize && max_count.saturating_sub(min_count) >= 2;
|
||||
|
||||
if (!needs_consolidation && !needs_balance) || max_idx == min_idx {
|
||||
break;
|
||||
}
|
||||
|
||||
// Determine source and destination:
|
||||
// - Consolidation: move FROM the short table (min) TO the big table (max)
|
||||
// - Balance: move FROM the big table (max) TO the short table (min)
|
||||
let (source_idx, dest_idx) = if needs_consolidation {
|
||||
(min_idx, max_idx)
|
||||
} else {
|
||||
(max_idx, min_idx)
|
||||
};
|
||||
|
||||
// Take a player from the source table
|
||||
let seat_taken = {
|
||||
let source_seats = self.tables[max_idx].seats_mut();
|
||||
let source_seats = self.tables[source_idx].seats_mut();
|
||||
let pos = source_seats.iter().position(|slot| {
|
||||
slot.as_ref().is_some_and(|s| s.state != PlayerState::SittingOut)
|
||||
});
|
||||
|
|
@ -566,32 +719,37 @@ impl Tournament {
|
|||
|
||||
if let Some(mut seat) = seat_taken {
|
||||
// Find empty seat on destination table
|
||||
let dest_table = &mut self.tables[min_idx];
|
||||
let dest_table = &mut self.tables[dest_idx];
|
||||
if let Some(new_seat_idx) = dest_table.find_empty_seat() {
|
||||
seat.index = new_seat_idx;
|
||||
dest_table.seats_mut()[new_seat_idx as usize] = Some(seat);
|
||||
} else {
|
||||
// No room — put the seat back on the source table
|
||||
// Find the original position or any empty slot
|
||||
let source_seats = self.tables[max_idx].seats_mut();
|
||||
let source_seats = self.tables[source_idx].seats_mut();
|
||||
if let Some(empty_pos) = source_seats.iter().position(|s| s.is_none()) {
|
||||
source_seats[empty_pos] = Some(seat);
|
||||
} else {
|
||||
// Should not happen since we just took from this table
|
||||
source_seats.push(Some(seat));
|
||||
// This should never happen since we just took a seat
|
||||
// from this table, so there must be an empty slot.
|
||||
// Log the error and attempt recovery on any table with room.
|
||||
log::error!(
|
||||
"Rebalance bug: no empty seat on source table {} for player {} \
|
||||
(table capacity exceeded — seat-index invariant broken)",
|
||||
source_idx, seat.player_id
|
||||
);
|
||||
for table in self.tables.iter_mut() {
|
||||
if let Some(empty_idx) = table.find_empty_seat() {
|
||||
seat.index = empty_idx;
|
||||
table.seats_mut()[empty_idx as usize] = Some(seat);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
break; // Can't move, stop balancing
|
||||
}
|
||||
} else {
|
||||
break; // No movable player found
|
||||
}
|
||||
|
||||
// Remove empty tables (tables with 0 active players)
|
||||
self.tables.retain(|t| t.active_player_count() > 0);
|
||||
|
||||
if self.tables.len() <= 1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -614,6 +772,11 @@ impl Tournament {
|
|||
&self.tables
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the tournament tables.
|
||||
pub fn tables_mut(&mut self) -> &mut [Table] {
|
||||
&mut self.tables
|
||||
}
|
||||
|
||||
/// Returns the total number of hands played.
|
||||
pub fn total_hands(&self) -> u64 {
|
||||
self.total_hands
|
||||
|
|
@ -623,6 +786,49 @@ impl Tournament {
|
|||
pub fn prize_structure(&self) -> &PrizeStructure {
|
||||
&self.prize_structure
|
||||
}
|
||||
|
||||
/// Returns the buy-in amount per player.
|
||||
pub fn buy_in(&self) -> ChipCount {
|
||||
self.buy_in
|
||||
}
|
||||
|
||||
/// Returns the winner's [`PlayerId`] if the tournament is complete.
|
||||
///
|
||||
/// The winner is the sole active (non-eliminated, non-sitting-out) player
|
||||
/// remaining across all tables. Returns `None` if the tournament is not
|
||||
/// yet complete or if the number of active players is not exactly 1
|
||||
/// (should not happen in normal operation after completion).
|
||||
pub fn winner(&self) -> Option<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()[1].seated_player_count(), 1);
|
||||
|
||||
// Rebalance — should move players to even out
|
||||
// Rebalance — table 1 has only 1 player (< MIN_PLAYERS=2),
|
||||
// so consolidation moves that player to table 0, then removes the empty table.
|
||||
tournament.rebalance_tables();
|
||||
|
||||
// After balancing, the difference should be < 2
|
||||
let count0 = tournament.tables()[0].seated_player_count();
|
||||
let count1 = tournament.tables()[1].seated_player_count();
|
||||
assert!(
|
||||
count0.abs_diff(count1) < 2,
|
||||
"Tables should be balanced: table0={}, table1={}",
|
||||
count0,
|
||||
count1
|
||||
);
|
||||
assert_eq!(count0 + count1, 6); // All players still present
|
||||
// After consolidation: 1 table with all 6 players
|
||||
assert_eq!(tournament.tables().len(), 1, "Empty table should be removed");
|
||||
let total_players: usize = tournament.tables().iter().map(|t| t.seated_player_count()).sum();
|
||||
assert_eq!(total_players, 6, "All players should be preserved");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -1072,4 +1273,145 @@ mod tests {
|
|||
let deserialized: GameType = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized, gt);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Winner tracking tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_winner_returns_none_before_completion() {
|
||||
let config = TableConfig::default();
|
||||
let schedule = BlindSchedule::custom(vec![BlindLevel {
|
||||
level: 1,
|
||||
small_blind: 10,
|
||||
big_blind: 20,
|
||||
ante: 0,
|
||||
duration_hands: 10,
|
||||
}]);
|
||||
let prizes = PrizeStructure::standard_sng(100, 3);
|
||||
let mut tournament = Tournament::new_sng(config, schedule, prizes, 1000, 100);
|
||||
|
||||
tournament
|
||||
.register_player(Box::new(CallBot::new(1, "Alice")))
|
||||
.unwrap();
|
||||
tournament
|
||||
.register_player(Box::new(CallBot::new(2, "Bob")))
|
||||
.unwrap();
|
||||
|
||||
assert!(!tournament.is_complete());
|
||||
assert_eq!(tournament.winner(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sng_run_to_completion_records_winner() {
|
||||
let config = TableConfig::default();
|
||||
let schedule = BlindSchedule::custom(vec![BlindLevel {
|
||||
level: 1,
|
||||
small_blind: 10,
|
||||
big_blind: 20,
|
||||
ante: 0,
|
||||
duration_hands: 5,
|
||||
}]);
|
||||
let prizes = PrizeStructure::standard_sng(100, 3);
|
||||
let mut tournament = Tournament::new_sng(config, schedule, prizes, 1000, 100);
|
||||
|
||||
tournament
|
||||
.register_player(Box::new(CallBot::new(1, "Alice")))
|
||||
.unwrap();
|
||||
tournament
|
||||
.register_player(Box::new(CallBot::new(2, "Bob")))
|
||||
.unwrap();
|
||||
tournament
|
||||
.register_player(Box::new(CallBot::new(3, "Charlie")))
|
||||
.unwrap();
|
||||
|
||||
tournament.run_to_completion().unwrap();
|
||||
|
||||
assert!(tournament.is_complete());
|
||||
|
||||
// The winner should be identifiable.
|
||||
let winner = tournament.winner();
|
||||
assert!(winner.is_some(), "Tournament should have a winner after completion");
|
||||
|
||||
// All players should be accounted for: N-1 in standings + 1 winner.
|
||||
let standings = tournament.standings();
|
||||
let total_accounted = standings.len() + if winner.is_some() { 1 } else { 0 };
|
||||
assert_eq!(
|
||||
total_accounted, 3,
|
||||
"All 3 players should be accounted for (standings + winner), got {} in standings + winner",
|
||||
total_accounted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mtt_consolidation_prevents_deadlock() {
|
||||
// 2 tables, 1 active player each — should consolidate to 1 table.
|
||||
let config = TableConfig {
|
||||
num_seats: 6,
|
||||
..Default::default()
|
||||
};
|
||||
let schedule = BlindSchedule::custom(vec![BlindLevel {
|
||||
level: 1,
|
||||
small_blind: 10,
|
||||
big_blind: 20,
|
||||
ante: 0,
|
||||
duration_hands: 5,
|
||||
}]);
|
||||
let prizes = PrizeStructure::standard_sng(100, 4);
|
||||
let mut tournament =
|
||||
Tournament::new_mtt(2, config, schedule, prizes, 1000, 100);
|
||||
|
||||
// Register 4 players.
|
||||
for i in 1..=4 {
|
||||
tournament
|
||||
.register_player(Box::new(CallBot::new(i, &format!("Bot{}", i))))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Manually bust out 2 players to leave 1 per table.
|
||||
// Registration distributes: table 0 gets Bot1(id=1) and Bot3(id=3),
|
||||
// table 1 gets Bot2(id=2) and Bot4(id=4).
|
||||
// Keep player 1 active on table 0, player 2 active on table 1.
|
||||
{
|
||||
let seats = tournament.tables_mut()[0].seats_mut();
|
||||
for slot in seats.iter_mut() {
|
||||
if let Some(seat) = slot.as_mut() {
|
||||
if seat.player_id != PlayerId(1) {
|
||||
seat.stack = 0;
|
||||
seat.state = PlayerState::SittingOut;
|
||||
} else {
|
||||
seat.stack = 4000; // Give all chips to player 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
let seats = tournament.tables_mut()[1].seats_mut();
|
||||
for slot in seats.iter_mut() {
|
||||
if let Some(seat) = slot.as_mut() {
|
||||
if seat.player_id != PlayerId(2) {
|
||||
seat.stack = 0;
|
||||
seat.state = PlayerState::SittingOut;
|
||||
} else {
|
||||
seat.stack = 4000; // Give all chips to player 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Before fix: rebalance would leave 2 tables with 1 player each (deadlock).
|
||||
// After fix: rebalance consolidates to 1 table.
|
||||
tournament.rebalance_tables();
|
||||
|
||||
assert_eq!(
|
||||
tournament.tables().len(),
|
||||
1,
|
||||
"Should consolidate to 1 table"
|
||||
);
|
||||
assert_eq!(
|
||||
tournament.tables()[0].active_player_count(),
|
||||
2,
|
||||
"Should have 2 active players"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ use clap::Parser;
|
|||
|
||||
use holdem_core::game::bot_factory::create_bot;
|
||||
use holdem_core::game::config::SimulationConfig;
|
||||
use holdem_core::game::simulation::run_cash_game;
|
||||
use holdem_core::game::simulation::{run_cash_game, run_sng_series};
|
||||
use holdem_core::game::stats::SimulationStats;
|
||||
use holdem_core::game::tournament::{GameType, Tournament};
|
||||
use holdem_core::game::types::PlayerId;
|
||||
|
|
@ -75,11 +75,16 @@ fn main() {
|
|||
game_config.num_hands = hands;
|
||||
}
|
||||
|
||||
// Run simulation based on game type
|
||||
// Run simulation based on game type and number of tournaments
|
||||
match game_config.game_type {
|
||||
GameType::Cash => run_cash_simulation(&game_config),
|
||||
GameType::SitAndGo => run_tournament_simulation(&game_config),
|
||||
GameType::MultiTableTournament => run_tournament_simulation(&game_config),
|
||||
GameType::SitAndGo | GameType::MultiTableTournament => {
|
||||
if game_config.num_tournaments > 1 {
|
||||
run_tournament_series_simulation(&game_config, game_config.num_tournaments);
|
||||
} else {
|
||||
run_tournament_simulation(&game_config);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -197,3 +202,35 @@ fn run_tournament_simulation(config: &holdem_core::game::config::GameConfig) {
|
|||
|
||||
println!("{}", stats.summary(config.buy_in));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tournament Series (SNG or MTT)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn run_tournament_series_simulation(config: &holdem_core::game::config::GameConfig, num_tournaments: u32) {
|
||||
let game_type_name = match config.game_type {
|
||||
GameType::SitAndGo => "SNG",
|
||||
GameType::MultiTableTournament => "MTT",
|
||||
GameType::Cash => "Cash",
|
||||
};
|
||||
println!("=== {} Tournament Series ===", game_type_name);
|
||||
println!("Tournaments: {}", num_tournaments);
|
||||
println!("Buy-in: {}", config.buy_in);
|
||||
println!("Starting chips: {}", config.starting_chips);
|
||||
println!("Blinds: {}/{}", config.small_blind, config.big_blind);
|
||||
println!("Players: {}", config.bots.len());
|
||||
if config.game_type == GameType::MultiTableTournament {
|
||||
println!("Tables: {}", config.num_tables);
|
||||
}
|
||||
if let Some(seed) = config.seed {
|
||||
println!("Seed: {}", seed);
|
||||
}
|
||||
println!();
|
||||
|
||||
let result = run_sng_series(config, num_tournaments).unwrap_or_else(|e| {
|
||||
eprintln!("Tournament series error: {}", e);
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
println!("{}", result.summary());
|
||||
}
|
||||
|
|
|
|||
193
plans/meerkat-alignment-fixes.md
Normal file
193
plans/meerkat-alignment-fixes.md
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue