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.
This commit is contained in:
Jan-Peter von Hunnius 2026-06-28 07:11:56 +02:00
parent 05db24f670
commit edf8d7ae3f
13 changed files with 60 additions and 5 deletions

3
.gitignore vendored
View file

@ -12,3 +12,6 @@ Thumbs.db
# Proptest regressions (auto-generated) # Proptest regressions (auto-generated)
proptest-regressions/ proptest-regressions/
# Agent notes
AGENTS.md

View file

@ -126,12 +126,14 @@ mod tests {
is_dealer: true, is_dealer: true,
is_small_blind: false, is_small_blind: false,
is_big_blind: false, is_big_blind: false,
external_id: None,
bankroll: 0, bankroll: 0,
})], })],
actions_this_hand: vec![], actions_this_hand: vec![],
num_raises: 0, num_raises: 0,
hand_number: 1, hand_number: 1,
active_players: 2, active_players: 2,
table_id: String::new(),
} }
} }

View file

@ -346,12 +346,14 @@ mod tests {
is_dealer: true, is_dealer: true,
is_small_blind: false, is_small_blind: false,
is_big_blind: false, is_big_blind: false,
external_id: None,
bankroll: 0, bankroll: 0,
})], })],
actions_this_hand: vec![], actions_this_hand: vec![],
num_raises: 0, num_raises: 0,
hand_number: 1, hand_number: 1,
active_players: 2, active_players: 2,
table_id: String::new(),
} }
} }

View file

@ -474,6 +474,17 @@ impl FlockBot {
// Always raise/re-raise: 3x BB or 3x current bet, whichever is higher // 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); let raise_target = (3 * big_blind).max(3 * game_info.current_bet);
self.compute_raise_total(game_info, raise_target) 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) .unwrap_or(Action::AllIn)
} }
} }
@ -615,12 +626,14 @@ mod tests {
is_dealer: true, is_dealer: true,
is_small_blind: false, is_small_blind: false,
is_big_blind: false, is_big_blind: false,
external_id: None,
bankroll: 0, bankroll: 0,
})], })],
actions_this_hand: vec![], actions_this_hand: vec![],
num_raises: 0, num_raises: 0,
hand_number: 1, hand_number: 1,
active_players: 2, active_players: 2,
table_id: String::new(),
} }
} }

View file

@ -127,12 +127,14 @@ mod tests {
is_dealer: true, is_dealer: true,
is_small_blind: false, is_small_blind: false,
is_big_blind: false, is_big_blind: false,
external_id: None,
bankroll: 0, bankroll: 0,
})], })],
actions_this_hand: vec![], actions_this_hand: vec![],
num_raises: 0, num_raises: 0,
hand_number: 1, hand_number: 1,
active_players: 2, active_players: 2,
table_id: String::new(),
} }
} }

View file

@ -238,12 +238,14 @@ mod tests {
is_dealer: true, is_dealer: true,
is_small_blind: false, is_small_blind: false,
is_big_blind: false, is_big_blind: false,
external_id: None,
bankroll: 0, bankroll: 0,
})], })],
actions_this_hand: vec![], actions_this_hand: vec![],
num_raises: 0, num_raises: 0,
hand_number: 1, hand_number: 1,
active_players: 2, active_players: 2,
table_id: String::new(),
} }
} }
@ -285,12 +287,14 @@ mod tests {
is_dealer: true, is_dealer: true,
is_small_blind: false, is_small_blind: false,
is_big_blind: false, is_big_blind: false,
external_id: None,
bankroll: 0, bankroll: 0,
})], })],
actions_this_hand: vec![], actions_this_hand: vec![],
num_raises: 0, num_raises: 0,
hand_number: 1, hand_number: 1,
active_players: 2, active_players: 2,
table_id: String::new(),
} }
} }
@ -328,12 +332,14 @@ mod tests {
is_dealer: true, is_dealer: true,
is_small_blind: false, is_small_blind: false,
is_big_blind: false, is_big_blind: false,
external_id: None,
bankroll: 0, bankroll: 0,
})], })],
actions_this_hand: vec![], actions_this_hand: vec![],
num_raises: 0, num_raises: 0,
hand_number: 1, hand_number: 1,
active_players: 2, active_players: 2,
table_id: String::new(),
} }
} }

View file

@ -257,12 +257,14 @@ mod tests {
is_dealer: true, is_dealer: true,
is_small_blind: false, is_small_blind: false,
is_big_blind: false, is_big_blind: false,
external_id: None,
bankroll: 0, bankroll: 0,
})], })],
actions_this_hand: vec![], actions_this_hand: vec![],
num_raises: 0, num_raises: 0,
hand_number: 1, hand_number: 1,
active_players: 2, active_players: 2,
table_id: String::new(),
} }
} }

View file

@ -94,6 +94,10 @@ pub struct GameInfo {
/// Number of active (non-folded, non-all-in) players. /// Number of active (non-folded, non-all-in) players.
pub active_players: usize, 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 { impl GameInfo {

View file

@ -1430,6 +1430,7 @@ impl HandRunner {
num_raises, num_raises,
hand_number: self.hand_number, hand_number: self.hand_number,
active_players: self.count_active_players(), active_players: self.count_active_players(),
table_id: String::new(),
} }
} }
} }

View file

@ -251,6 +251,12 @@ pub trait HoldemPlayer: Send + Sync {
/// * `placement` — The player's finishing position (1-based). A placement /// * `placement` — The player's finishing position (1-based). A placement
/// of `1` means the player won the tournament. /// of `1` means the player won the tournament.
fn on_eliminated(&self, placement: u32); 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_dealer,
is_small_blind, is_small_blind,
is_big_blind, is_big_blind,
external_id: None,
bankroll: self.bankroll, bankroll: self.bankroll,
} }
} }

View file

@ -66,6 +66,11 @@ pub struct PlayerInfo {
/// Whether this player is the big blind. /// Whether this player is the big blind.
pub is_big_blind: bool, 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<String>,
/// The player's overall bankroll balance across all hands. /// The player's overall bankroll balance across all hands.
/// Positive = profit, negative = loss. /// Positive = profit, negative = loss.
pub bankroll: i64, pub bankroll: i64,

View file

@ -133,6 +133,18 @@ pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), Ga
// Not enough active players to continue. // Not enough active players to continue.
break; 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) => { Err(e) => {
return Err(e); return Err(e);
} }

View file

@ -330,11 +330,6 @@ impl Table {
return Err(GameError::HandAlreadyInProgress); 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. // 2.5. Bankroll management.
if self.reset_bankroll_each_hand { if self.reset_bankroll_each_hand {
@ -563,6 +558,7 @@ impl Table {
num_raises: 0, num_raises: 0,
hand_number: self.hand_number, hand_number: self.hand_number,
active_players: self.active_player_count(), active_players: self.active_player_count(),
table_id: String::new(),
} }
} }