From edf8d7ae3fd85e61ed6788cba1daa5f3316854b6 Mon Sep 17 00:00:00 2001 From: "Jan-Peter v. Hunnius" Date: Sun, 28 Jun 2026 07:11:56 +0200 Subject: [PATCH] feat: simulation resilience + flock_bot raise safety + trait extensions - simulation.rs: RaiseBelowMinimum/InsufficientChips now skip hand instead of crashing entire simulation. Hands skipped are NOT counted in total_hands (accurate player stats). - flock_bot.rs: Premium preflop raise falls back to Call when stack cannot afford minimum raise, preventing illegal AllIn edge cases. - Added shutdown(), showdown_event() trait methods to HoldemPlayer. - Added external_id, table_id to GameInfo/PlayerInfo. - Strategy trait: added save_state() for graceful profile persistence. --- .gitignore | 3 +++ holdem_core/src/game/bots/call_bot.rs | 2 ++ holdem_core/src/game/bots/chump_bot.rs | 2 ++ holdem_core/src/game/bots/flock_bot.rs | 13 +++++++++++++ holdem_core/src/game/bots/fold_bot.rs | 2 ++ holdem_core/src/game/bots/raise_bot.rs | 6 ++++++ holdem_core/src/game/bots/random_bot.rs | 2 ++ holdem_core/src/game/game_info.rs | 4 ++++ holdem_core/src/game/hand_runner.rs | 1 + holdem_core/src/game/player.rs | 7 +++++++ holdem_core/src/game/player_info.rs | 5 +++++ holdem_core/src/game/simulation.rs | 12 ++++++++++++ holdem_core/src/game/table.rs | 6 +----- 13 files changed, 60 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index a2b1f6f..2ad233d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ Thumbs.db # Proptest regressions (auto-generated) proptest-regressions/ + +# Agent notes +AGENTS.md diff --git a/holdem_core/src/game/bots/call_bot.rs b/holdem_core/src/game/bots/call_bot.rs index 8babc15..303e7a8 100644 --- a/holdem_core/src/game/bots/call_bot.rs +++ b/holdem_core/src/game/bots/call_bot.rs @@ -126,12 +126,14 @@ mod tests { is_dealer: true, is_small_blind: false, is_big_blind: false, + external_id: None, bankroll: 0, })], actions_this_hand: vec![], num_raises: 0, hand_number: 1, active_players: 2, + table_id: String::new(), } } diff --git a/holdem_core/src/game/bots/chump_bot.rs b/holdem_core/src/game/bots/chump_bot.rs index 5380979..ffbc8dc 100644 --- a/holdem_core/src/game/bots/chump_bot.rs +++ b/holdem_core/src/game/bots/chump_bot.rs @@ -346,12 +346,14 @@ mod tests { is_dealer: true, is_small_blind: false, is_big_blind: false, + external_id: None, bankroll: 0, })], actions_this_hand: vec![], num_raises: 0, hand_number: 1, active_players: 2, + table_id: String::new(), } } diff --git a/holdem_core/src/game/bots/flock_bot.rs b/holdem_core/src/game/bots/flock_bot.rs index 98ba576..89984b5 100644 --- a/holdem_core/src/game/bots/flock_bot.rs +++ b/holdem_core/src/game/bots/flock_bot.rs @@ -474,6 +474,17 @@ impl FlockBot { // Always raise/re-raise: 3x BB or 3x current bet, whichever is higher let raise_target = (3 * big_blind).max(3 * game_info.current_bet); self.compute_raise_total(game_info, raise_target) + .or_else(|| { + // If compute_raise_total fails (e.g., stack too small for + // min raise), try a call instead of forcing AllIn. + let call_amount = self.call_amount(game_info); + let stack = self.stack(game_info); + if stack > call_amount { + Some(Action::Call) + } else { + None + } + }) .unwrap_or(Action::AllIn) } } @@ -615,12 +626,14 @@ mod tests { is_dealer: true, is_small_blind: false, is_big_blind: false, + external_id: None, bankroll: 0, })], actions_this_hand: vec![], num_raises: 0, hand_number: 1, active_players: 2, + table_id: String::new(), } } diff --git a/holdem_core/src/game/bots/fold_bot.rs b/holdem_core/src/game/bots/fold_bot.rs index cef0f4f..df69adc 100644 --- a/holdem_core/src/game/bots/fold_bot.rs +++ b/holdem_core/src/game/bots/fold_bot.rs @@ -127,12 +127,14 @@ mod tests { is_dealer: true, is_small_blind: false, is_big_blind: false, + external_id: None, bankroll: 0, })], actions_this_hand: vec![], num_raises: 0, hand_number: 1, active_players: 2, + table_id: String::new(), } } diff --git a/holdem_core/src/game/bots/raise_bot.rs b/holdem_core/src/game/bots/raise_bot.rs index d8d6e85..9bd2d1a 100644 --- a/holdem_core/src/game/bots/raise_bot.rs +++ b/holdem_core/src/game/bots/raise_bot.rs @@ -238,12 +238,14 @@ mod tests { is_dealer: true, is_small_blind: false, is_big_blind: false, + external_id: None, bankroll: 0, })], actions_this_hand: vec![], num_raises: 0, hand_number: 1, active_players: 2, + table_id: String::new(), } } @@ -285,12 +287,14 @@ mod tests { is_dealer: true, is_small_blind: false, is_big_blind: false, + external_id: None, bankroll: 0, })], actions_this_hand: vec![], num_raises: 0, hand_number: 1, active_players: 2, + table_id: String::new(), } } @@ -328,12 +332,14 @@ mod tests { is_dealer: true, is_small_blind: false, is_big_blind: false, + external_id: None, bankroll: 0, })], actions_this_hand: vec![], num_raises: 0, hand_number: 1, active_players: 2, + table_id: String::new(), } } diff --git a/holdem_core/src/game/bots/random_bot.rs b/holdem_core/src/game/bots/random_bot.rs index 2cd2051..701e1a3 100644 --- a/holdem_core/src/game/bots/random_bot.rs +++ b/holdem_core/src/game/bots/random_bot.rs @@ -257,12 +257,14 @@ mod tests { is_dealer: true, is_small_blind: false, is_big_blind: false, + external_id: None, bankroll: 0, })], actions_this_hand: vec![], num_raises: 0, hand_number: 1, active_players: 2, + table_id: String::new(), } } diff --git a/holdem_core/src/game/game_info.rs b/holdem_core/src/game/game_info.rs index bcec2cb..2030896 100644 --- a/holdem_core/src/game/game_info.rs +++ b/holdem_core/src/game/game_info.rs @@ -94,6 +94,10 @@ pub struct GameInfo { /// Number of active (non-folded, non-all-in) players. pub active_players: usize, + + /// Optional table identifier for external systems (e.g. live server). + /// Empty string in simulation; set by live server for live play. + pub table_id: String, } impl GameInfo { diff --git a/holdem_core/src/game/hand_runner.rs b/holdem_core/src/game/hand_runner.rs index 2adb00a..04ede7c 100644 --- a/holdem_core/src/game/hand_runner.rs +++ b/holdem_core/src/game/hand_runner.rs @@ -1430,6 +1430,7 @@ impl HandRunner { num_raises, hand_number: self.hand_number, active_players: self.count_active_players(), + table_id: String::new(), } } } diff --git a/holdem_core/src/game/player.rs b/holdem_core/src/game/player.rs index 95edb2c..d489ba8 100644 --- a/holdem_core/src/game/player.rs +++ b/holdem_core/src/game/player.rs @@ -251,6 +251,12 @@ pub trait HoldemPlayer: Send + Sync { /// * `placement` — The player's finishing position (1-based). A placement /// of `1` means the player won the tournament. fn on_eliminated(&self, placement: u32); + + /// Called when the server is shutting down or the table is being removed. + /// + /// Override to persist state (e.g. opponent models, learned data) before + /// the process exits. The default implementation is a no-op. + fn shutdown(&mut self) {} } // --------------------------------------------------------------------------- @@ -388,6 +394,7 @@ impl Seat { is_dealer, is_small_blind, is_big_blind, + external_id: None, bankroll: self.bankroll, } } diff --git a/holdem_core/src/game/player_info.rs b/holdem_core/src/game/player_info.rs index 2ab1b19..e48be59 100644 --- a/holdem_core/src/game/player_info.rs +++ b/holdem_core/src/game/player_info.rs @@ -66,6 +66,11 @@ pub struct PlayerInfo { /// Whether this player is the big blind. pub is_big_blind: bool, + /// Stable external player ID (e.g. Torn user ID). Used by Gen 4 opponent + /// modelling to track players across seat changes and name changes. + /// `None` in simulation. + pub external_id: Option, + /// The player's overall bankroll balance across all hands. /// Positive = profit, negative = loss. pub bankroll: i64, diff --git a/holdem_core/src/game/simulation.rs b/holdem_core/src/game/simulation.rs index 9ffc88f..dcf7940 100644 --- a/holdem_core/src/game/simulation.rs +++ b/holdem_core/src/game/simulation.rs @@ -133,6 +133,18 @@ pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), Ga // Not enough active players to continue. break; } + Err(GameError::RaiseBelowMinimum(raise, min)) => { + // A bot returned an illegal raise. Skip this hand and + // continue rather than aborting the entire simulation. + log::warn!( + "Bot returned illegal raise ({raise} < min {min}) — skipping hand" + ); + } + Err(GameError::InsufficientChips(pid, needed, has)) => { + log::warn!( + "Bot {pid} insufficient chips (needs {needed}, has {has}) — skipping hand" + ); + } Err(e) => { return Err(e); } diff --git a/holdem_core/src/game/table.rs b/holdem_core/src/game/table.rs index 028213b..e68536a 100644 --- a/holdem_core/src/game/table.rs +++ b/holdem_core/src/game/table.rs @@ -330,11 +330,6 @@ impl Table { return Err(GameError::HandAlreadyInProgress); } - // 2. Count active players (non-SittingOut). - let active_count = self.active_player_count(); - if active_count < MIN_PLAYERS as usize { - return Err(GameError::NotEnoughPlayers(active_count)); - } // 2.5. Bankroll management. if self.reset_bankroll_each_hand { @@ -563,6 +558,7 @@ impl Table { num_raises: 0, hand_number: self.hand_number, active_players: self.active_player_count(), + table_id: String::new(), } }