fix: seat restoration + chip refund on hand error, NoLimit max_raises

Critical fix: complete_hand() now restores seats when run_to_completion()
fails, preventing silent NotEnoughPlayers termination that killed
simulations at random seeds. Wagered chips are refunded before reset to
prevent chip destruction on error path.

Also adds max_raises: Option<usize> to BetLimit::NoLimit, propagated
across all game/bot/tournament construction sites.

NotEnoughPlayers now logs a warning with hand count instead of silently
breaking the simulation loop.
This commit is contained in:
Jan-Peter von Hunnius 2026-07-06 08:07:38 +02:00
parent edf8d7ae3f
commit d00df65a98
15 changed files with 131 additions and 41 deletions

View file

@ -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),

View file

@ -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<usize>,
},
/// 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.

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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<usize>,
},
/// Pot-Limit — players may raise up to the current pot size.
#[serde(rename = "pot_limit")]
@ -206,7 +209,7 @@ pub enum BetLimitConfig {
impl From<BetLimitConfig> 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<BetLimitConfig> for BetLimit {
impl From<BetLimit> 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]

View file

@ -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);

View file

@ -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

View file

@ -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)) => {

View file

@ -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,

View file

@ -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,

View file

@ -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,