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:
parent
05db24f670
commit
edf8d7ae3f
13 changed files with 60 additions and 5 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -12,3 +12,6 @@ Thumbs.db
|
||||||
# Proptest regressions (auto-generated)
|
# Proptest regressions (auto-generated)
|
||||||
proptest-regressions/
|
proptest-regressions/
|
||||||
|
|
||||||
|
|
||||||
|
# Agent notes
|
||||||
|
AGENTS.md
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue