diff --git a/holdem_core/src/game/action.rs b/holdem_core/src/game/action.rs index 111a69f..90e8ef7 100644 --- a/holdem_core/src/game/action.rs +++ b/holdem_core/src/game/action.rs @@ -296,7 +296,7 @@ mod tests { #[test] fn fold_is_always_valid() { - let bet_limit = BetLimit::NoLimit { min_raise: 10 }; + let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None }; assert!(validate_action( &Action::Fold, pid(1), @@ -313,7 +313,7 @@ mod tests { #[test] fn check_allowed_when_no_bet() { - let bet_limit = BetLimit::NoLimit { min_raise: 10 }; + let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None }; assert!(validate_action( &Action::Check, pid(1), @@ -330,7 +330,7 @@ mod tests { #[test] fn check_not_allowed_when_bet_exists() { - let bet_limit = BetLimit::NoLimit { min_raise: 10 }; + let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None }; let result = validate_action( &Action::Check, pid(1), @@ -347,7 +347,7 @@ mod tests { #[test] fn call_always_valid_with_chips() { - let bet_limit = BetLimit::NoLimit { min_raise: 10 }; + let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None }; assert!(validate_action( &Action::Call, pid(1), @@ -364,7 +364,7 @@ mod tests { #[test] fn raise_below_minimum_rejected() { - let bet_limit = BetLimit::NoLimit { min_raise: 20 }; + let bet_limit = BetLimit::NoLimit { min_raise: 20, max_raises: None }; let result = validate_action( &Action::Raise { total: 30 }, pid(1), @@ -384,7 +384,7 @@ mod tests { #[test] fn raise_with_insufficient_chips_rejected() { - let bet_limit = BetLimit::NoLimit { min_raise: 10 }; + let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None }; let result = validate_action( &Action::Raise { total: 200 }, pid(1), @@ -404,7 +404,7 @@ mod tests { #[test] fn all_in_with_zero_chips_rejected() { - let bet_limit = BetLimit::NoLimit { min_raise: 10 }; + let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None }; let result = validate_action( &Action::AllIn, pid(1), diff --git a/holdem_core/src/game/betting.rs b/holdem_core/src/game/betting.rs index 5008794..a6fc0ec 100644 --- a/holdem_core/src/game/betting.rs +++ b/holdem_core/src/game/betting.rs @@ -22,6 +22,9 @@ pub enum BetLimit { NoLimit { /// Minimum raise increment. min_raise: ChipCount, + /// Optional cap on voluntary raises per street (None = unlimited). + /// When set, the Nth raise is converted to a call automatically. + max_raises: Option, }, /// Pot-Limit — players may raise up to the current pot size. @@ -80,7 +83,7 @@ pub trait BettingRules { impl BettingRules for BetLimit { fn min_raise_amount(&self, _current_bet: ChipCount, previous_raise: ChipCount) -> ChipCount { match self { - BetLimit::NoLimit { min_raise } => (*min_raise).max(previous_raise), + BetLimit::NoLimit { min_raise, .. } => (*min_raise).max(previous_raise), BetLimit::PotLimit { min_raise } => (*min_raise).max(previous_raise), BetLimit::FixedLimit { small_bet, @@ -147,6 +150,10 @@ impl BettingRules for BetLimit { /// 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 { + max_raises: Some(cap), + .. + } => raises_this_round < *cap, BetLimit::NoLimit { .. } | BetLimit::PotLimit { .. } => true, BetLimit::FixedLimit { .. } => raises_this_round < 3, } @@ -267,7 +274,7 @@ mod tests { /// Helper — No-Limit with a minimum raise of 10 (big-blind sized). fn nl() -> BetLimit { - BetLimit::NoLimit { min_raise: 10 } + BetLimit::NoLimit { min_raise: 10, max_raises: None } } /// Helper — Pot-Limit with a minimum raise of 10. diff --git a/holdem_core/src/game/bots/call_bot.rs b/holdem_core/src/game/bots/call_bot.rs index 303e7a8..732e215 100644 --- a/holdem_core/src/game/bots/call_bot.rs +++ b/holdem_core/src/game/bots/call_bot.rs @@ -101,7 +101,7 @@ mod tests { fn make_game_info(current_bet: ChipCount, wagered_this_round: ChipCount) -> GameInfo { GameInfo { num_seats: 2, - bet_limit: BetLimit::NoLimit { min_raise: 10 }, + bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None }, small_blind: 5, big_blind: 10, ante: 0, diff --git a/holdem_core/src/game/bots/chump_bot.rs b/holdem_core/src/game/bots/chump_bot.rs index ffbc8dc..cae62ae 100644 --- a/holdem_core/src/game/bots/chump_bot.rs +++ b/holdem_core/src/game/bots/chump_bot.rs @@ -321,7 +321,7 @@ mod tests { ) -> GameInfo { GameInfo { num_seats: 2, - bet_limit: BetLimit::NoLimit { min_raise: 20 }, + bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None }, small_blind: 10, big_blind: 20, ante: 0, diff --git a/holdem_core/src/game/bots/flock_bot.rs b/holdem_core/src/game/bots/flock_bot.rs index 89984b5..bff436d 100644 --- a/holdem_core/src/game/bots/flock_bot.rs +++ b/holdem_core/src/game/bots/flock_bot.rs @@ -601,7 +601,7 @@ mod tests { ) -> GameInfo { GameInfo { num_seats: 2, - bet_limit: BetLimit::NoLimit { min_raise: 20 }, + bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None }, small_blind: 10, big_blind: 20, ante: 0, diff --git a/holdem_core/src/game/bots/fold_bot.rs b/holdem_core/src/game/bots/fold_bot.rs index df69adc..257ed77 100644 --- a/holdem_core/src/game/bots/fold_bot.rs +++ b/holdem_core/src/game/bots/fold_bot.rs @@ -102,7 +102,7 @@ mod tests { fn make_game_info(current_bet: ChipCount, wagered_this_round: ChipCount) -> GameInfo { GameInfo { num_seats: 2, - bet_limit: BetLimit::NoLimit { min_raise: 10 }, + bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None }, small_blind: 5, big_blind: 10, ante: 0, diff --git a/holdem_core/src/game/bots/raise_bot.rs b/holdem_core/src/game/bots/raise_bot.rs index 9bd2d1a..37ac870 100644 --- a/holdem_core/src/game/bots/raise_bot.rs +++ b/holdem_core/src/game/bots/raise_bot.rs @@ -213,7 +213,7 @@ mod tests { ) -> GameInfo { GameInfo { num_seats: 2, - bet_limit: BetLimit::NoLimit { min_raise: 10 }, + bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None }, small_blind: 5, big_blind: 10, ante: 0, diff --git a/holdem_core/src/game/bots/random_bot.rs b/holdem_core/src/game/bots/random_bot.rs index 701e1a3..f704eb5 100644 --- a/holdem_core/src/game/bots/random_bot.rs +++ b/holdem_core/src/game/bots/random_bot.rs @@ -232,7 +232,7 @@ mod tests { ) -> GameInfo { GameInfo { num_seats: 2, - bet_limit: BetLimit::NoLimit { min_raise: 10 }, + bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None }, small_blind: 5, big_blind: 10, ante: 0, diff --git a/holdem_core/src/game/config.rs b/holdem_core/src/game/config.rs index 7a21b7a..a48483e 100644 --- a/holdem_core/src/game/config.rs +++ b/holdem_core/src/game/config.rs @@ -186,6 +186,9 @@ pub enum BetLimitConfig { NoLimit { /// Minimum raise increment. min_raise: ChipCount, + /// Optional cap on voluntary raises per street (None = unlimited). + #[serde(default)] + max_raises: Option, }, /// Pot-Limit — players may raise up to the current pot size. #[serde(rename = "pot_limit")] @@ -206,7 +209,7 @@ pub enum BetLimitConfig { impl From for BetLimit { fn from(config: BetLimitConfig) -> Self { match config { - BetLimitConfig::NoLimit { min_raise } => BetLimit::NoLimit { min_raise }, + BetLimitConfig::NoLimit { min_raise, max_raises } => BetLimit::NoLimit { min_raise, max_raises }, BetLimitConfig::PotLimit { min_raise } => BetLimit::PotLimit { min_raise }, BetLimitConfig::FixedLimit { small_bet, big_bet } => { BetLimit::FixedLimit { small_bet, big_bet } @@ -218,7 +221,7 @@ impl From for BetLimit { impl From for BetLimitConfig { fn from(limit: BetLimit) -> Self { match limit { - BetLimit::NoLimit { min_raise } => BetLimitConfig::NoLimit { min_raise }, + BetLimit::NoLimit { min_raise, max_raises } => BetLimitConfig::NoLimit { min_raise, max_raises }, BetLimit::PotLimit { min_raise } => BetLimitConfig::PotLimit { min_raise }, BetLimit::FixedLimit { small_bet, big_bet } => { BetLimitConfig::FixedLimit { small_bet, big_bet } @@ -411,6 +414,7 @@ impl Default for GameConfig { num_seats: default_num_seats(), bet_limit: BetLimitConfig::NoLimit { min_raise: 20, + max_raises: None, }, small_blind: default_small_blind(), big_blind: default_big_blind(), @@ -874,7 +878,7 @@ big_bet = 20 fn bet_limit_config_to_bet_limit() { let config = BetLimitConfig::NoLimit { min_raise: 20 }; let limit: BetLimit = config.into(); - assert_eq!(limit, BetLimit::NoLimit { min_raise: 20 }); + assert_eq!(limit, BetLimit::NoLimit { min_raise: 20, max_raises: None }); let config = BetLimitConfig::FixedLimit { small_bet: 10, @@ -1170,7 +1174,7 @@ min_raise = 50 assert_eq!(table_config.small_blind, 10); assert_eq!(table_config.big_blind, 20); assert_eq!(table_config.ante, 5); - assert_eq!(table_config.bet_limit, BetLimit::NoLimit { min_raise: 20 }); + assert_eq!(table_config.bet_limit, BetLimit::NoLimit { min_raise: 20, max_raises: None }); } #[test] diff --git a/holdem_core/src/game/hand_runner.rs b/holdem_core/src/game/hand_runner.rs index 04ede7c..305314d 100644 --- a/holdem_core/src/game/hand_runner.rs +++ b/holdem_core/src/game/hand_runner.rs @@ -559,6 +559,17 @@ impl HandRunner { .unwrap_or(0); let street = self.current_street(); + // Enforce raise cap for No-Limit with max_raises: convert Raise to Call. + let action = if let Action::Raise { .. } = &action { + if !self.bet_limit.can_raise(street, raises_this_round) { + Action::Call + } else { + action + } + } else { + action + }; + validate_action( &action, next_id, @@ -1128,6 +1139,20 @@ impl HandRunner { } } + // 4b. Reveal all hole cards for RL training (no-op for production bots). + { + let all_cards: Vec<(PlayerId, HoleCards)> = self + .seats + .iter() + .filter_map(|s| s.hole_cards.map(|hc| (s.player_id, hc))) + .collect(); + let game_info = self.build_game_info(); + for seat in &mut self.seats { + seat.player + .cards_revealed_event(&all_cards, &game_info); + } + } + // 5. Set state to Complete. self.state = HandState::Complete; @@ -1643,7 +1668,7 @@ mod tests { let seats = vec![seat0, seat1]; let deck = Deck::new(); - let bet_limit = BetLimit::NoLimit { min_raise: 10 }; + let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None }; let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1); assert_eq!(runner.state(), HandState::Idle); @@ -1678,7 +1703,7 @@ mod tests { let seats = vec![seat0, seat1]; let deck = Deck::new(); - let bet_limit = BetLimit::NoLimit { min_raise: 10 }; + let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None }; let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1); runner.start(10, 20, 0); @@ -1714,7 +1739,7 @@ mod tests { let seats = vec![seat0, seat1, seat2]; let deck = Deck::new(); - let bet_limit = BetLimit::NoLimit { min_raise: 10 }; + let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None }; let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1); runner.start(10, 20, 0); @@ -1740,7 +1765,7 @@ mod tests { let seats = vec![seat0, seat1]; let deck = Deck::new(); - let bet_limit = BetLimit::NoLimit { min_raise: 10 }; + let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None }; let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1); runner.start(10, 20, 0); @@ -1762,7 +1787,7 @@ mod tests { let seats = vec![seat0, seat1]; let deck = Deck::new(); - let bet_limit = BetLimit::NoLimit { min_raise: 10 }; + let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None }; let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1); assert_eq!(runner.state(), HandState::Idle); @@ -1785,7 +1810,7 @@ mod tests { let seats = vec![seat0, seat1]; let deck = Deck::new(); - let bet_limit = BetLimit::NoLimit { min_raise: 10 }; + let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None }; let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1); let result = runner.step(); @@ -1805,7 +1830,7 @@ mod tests { let seats = vec![seat0, seat1]; let deck = Deck::new(); - let bet_limit = BetLimit::NoLimit { min_raise: 10 }; + let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None }; let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1); runner.start(10, 20, 0); @@ -1841,7 +1866,7 @@ mod tests { let seats = vec![seat0, seat1, seat2]; let deck = Deck::new(); - let bet_limit = BetLimit::NoLimit { min_raise: 10 }; + let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None }; let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1); runner.start(10, 20, 0); @@ -1957,7 +1982,7 @@ mod tests { let seats = vec![seat0, seat1, seat2]; let deck = Deck::new(); - let bet_limit = BetLimit::NoLimit { min_raise: 10 }; + let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None }; let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1); runner.start(10, 20, 0); @@ -1983,7 +2008,7 @@ mod tests { let seats = vec![seat0, seat1]; let deck = Deck::new(); - let bet_limit = BetLimit::NoLimit { min_raise: 10 }; + let bet_limit = BetLimit::NoLimit { min_raise: 10, max_raises: None }; let mut runner = HandRunner::new(seats, 0, bet_limit, deck, 1); runner.start(10, 20, 0); diff --git a/holdem_core/src/game/player.rs b/holdem_core/src/game/player.rs index d489ba8..6ae0fe2 100644 --- a/holdem_core/src/game/player.rs +++ b/holdem_core/src/game/player.rs @@ -230,6 +230,24 @@ pub trait HoldemPlayer: Send + Sync { /// Corresponds to `com.biotools.meerkat.Player#gameOverEvent`. fn game_over_event(&mut self, game_info: &GameInfo); + /// Called after the hand ends with ALL players' hole cards revealed. + /// + /// **Training only** — allows RL agents to compute realized equity for + /// every decision point. The default implementation is a no-op; production + /// bots should never override this (they don't see opponents' cards). + /// + /// # Arguments + /// + /// * `all_hole_cards` — Slice of `(PlayerId, HoleCards)` for every player + /// who was dealt into this hand, including those who folded. + /// * `game_info` — Final game state snapshot. + fn cards_revealed_event( + &mut self, + _all_hole_cards: &[(PlayerId, HoleCards)], + _game_info: &GameInfo, + ) { + } + /// Signal an early-fold intent. /// /// Called by the framework layer when a player wants to fold before it is diff --git a/holdem_core/src/game/simulation.rs b/holdem_core/src/game/simulation.rs index dcf7940..4be7da4 100644 --- a/holdem_core/src/game/simulation.rs +++ b/holdem_core/src/game/simulation.rs @@ -129,8 +129,15 @@ pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), Ga } } } - Err(GameError::NotEnoughPlayers(_)) => { - // Not enough active players to continue. + Err(GameError::NotEnoughPlayers(count)) => { + // Not enough active players to continue. This should only + // happen if reset_bankroll_each_hand / auto_rebuy are both + // disabled, or if seats were lost due to a hand error. + log::warn!( + "Simulation stopped after {} hands: only {count} active players. \ + Set reset_bankroll_each_hand=true or auto_rebuy=true to prevent this.", + stats.total_hands + ); break; } Err(GameError::RaiseBelowMinimum(raise, min)) => { diff --git a/holdem_core/src/game/stats.rs b/holdem_core/src/game/stats.rs index 64cf4bf..c7d2adf 100644 --- a/holdem_core/src/game/stats.rs +++ b/holdem_core/src/game/stats.rs @@ -887,7 +887,7 @@ mod tests { fn record_tournament_result_processes_completed_tournament() { let config = TableConfig { num_seats: 9, - bet_limit: BetLimit::NoLimit { min_raise: 20 }, + bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None }, small_blind: 10, big_blind: 20, ante: 0, diff --git a/holdem_core/src/game/table.rs b/holdem_core/src/game/table.rs index e68536a..ace6e5e 100644 --- a/holdem_core/src/game/table.rs +++ b/holdem_core/src/game/table.rs @@ -56,7 +56,7 @@ impl Default for TableConfig { fn default() -> Self { Self { num_seats: 9, - bet_limit: BetLimit::NoLimit { min_raise: 10 }, + bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None }, small_blind: 10, big_blind: 20, ante: 0, @@ -411,7 +411,36 @@ impl Table { .ok_or(GameError::NoHandInProgress)?; // Run the hand to completion. - let result = runner.run_to_completion()?; + let result = match runner.run_to_completion() { + Ok(result) => result, + Err(e) => { + // CRITICAL: Restore seats even on error. Without this, the + // seats are trapped inside the dropped runner, leaving + // self.seats empty. The next start_hand() would see 0 active + // players, return NotEnoughPlayers, and silently terminate + // the entire simulation. + log::warn!("Hand execution failed ({e:?}) — restoring seats to table"); + let seats = runner.into_seats(); + for seat in seats { + let idx = seat.index as usize; + if idx < self.seats.len() { + let mut seat = seat; + // Refund wagered chips before resetting — the pot_manager + // is dropped with the runner, so wagered chips would + // vanish without this refund. + seat.stack += seat.wagered_this_hand; + if seat.stack == 0 { + seat.state = PlayerState::SittingOut; + } else { + seat.reset_for_new_hand(); + } + self.seats[idx] = Some(seat); + } + } + self.current_hand = None; + return Err(e); + } + }; // Extract seats from the runner and restore them to the table. // HandRunner::into_seats() consumes the runner and returns the seats @@ -829,7 +858,7 @@ mod tests { fn default_config() -> TableConfig { TableConfig { num_seats: 6, - bet_limit: BetLimit::NoLimit { min_raise: 10 }, + bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None }, small_blind: 10, big_blind: 20, ante: 0, @@ -1284,7 +1313,7 @@ mod tests { fn test_table_full() { let config = TableConfig { num_seats: 2, - bet_limit: BetLimit::NoLimit { min_raise: 10 }, + bet_limit: BetLimit::NoLimit { min_raise: 10, max_raises: None }, small_blind: 10, big_blind: 20, ante: 0, diff --git a/holdem_core/src/game/tournament.rs b/holdem_core/src/game/tournament.rs index ccbb96c..33d0eb5 100644 --- a/holdem_core/src/game/tournament.rs +++ b/holdem_core/src/game/tournament.rs @@ -1006,7 +1006,7 @@ mod tests { fn default_table_config() -> TableConfig { TableConfig { num_seats: 9, - bet_limit: BetLimit::NoLimit { min_raise: 20 }, + bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None }, small_blind: 10, big_blind: 20, ante: 0, @@ -1188,7 +1188,7 @@ mod tests { fn test_sng_registration_fills_table() { let config = TableConfig { num_seats: 4, - bet_limit: BetLimit::NoLimit { min_raise: 20 }, + bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None }, small_blind: 10, big_blind: 20, ante: 0, @@ -1217,7 +1217,7 @@ mod tests { fn test_sng_run_single_hand() { let config = TableConfig { num_seats: 4, - bet_limit: BetLimit::NoLimit { min_raise: 20 }, + bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None }, small_blind: 10, big_blind: 20, ante: 0, @@ -1283,7 +1283,7 @@ mod tests { fn test_mtt_table_balancing() { let config = TableConfig { num_seats: 9, - bet_limit: BetLimit::NoLimit { min_raise: 20 }, + bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None }, small_blind: 10, big_blind: 20, ante: 0, @@ -1328,7 +1328,7 @@ mod tests { fn test_blind_advancement() { let config = TableConfig { num_seats: 4, - bet_limit: BetLimit::NoLimit { min_raise: 20 }, + bet_limit: BetLimit::NoLimit { min_raise: 20, max_raises: None }, small_blind: 10, big_blind: 20, ante: 0,