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`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue