rusty_marvin/docs/ARCHITECTURE_HAND_LOGGING_TOURNAMENT_IMPROVEMENTS.md
Jan-Peter v. Hunnius c041cde3fe 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
2026-05-20 21:05:22 +02:00

29 KiB

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
  2. Feature 1: Hand History Logging in iPoker Format
  3. Feature 2: Tournament Improvements
  4. Implementation Plan
  5. Data Structures
  6. 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/
├── 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 currently does NOT include action history or player names — only pot winners, hand values, board, chip movements, and rake.

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 rather than exposing it separately from HandRunner.

Rationale:

  • HandRunner::actions_this_hand is a private field that already collects Vec<Vec<(PlayerId, Action)>> (4 elements: preflop, flop, turn, river).
  • HandRunner::into_seats() 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.

/// 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:

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 to include action history:

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(), 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:

/// 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 — 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:

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(), after each table.run_hand():

// 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(), after each hand result:

// 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:

/// 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:

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 for timestamp support:

[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:

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:

[[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() to check for explicit levels first:

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:

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:

[game]
prizes = [5, 3, 2]

Updated to_prize_structure()

Modify GameConfig::to_prize_structure():

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

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.
/// 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 that shuffles seats on all tables:

/// 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 (or GameConfig):

pub struct OutputConfig {
    // ... existing fields ...
    
    /// Number of tournaments to run in a series. Default: 1.
    #[serde(default = "default_one")]
    pub num_tournaments: u32,
}

TOML:

[output]
log_hands = true
num_tournaments = 100

New: SngSeriesResult

Aggregate results across multiple tournaments:

/// 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() 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

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 to handle multi-tournament:

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

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.