feat(sim): per-bot starting_chips override

BotConfig.starting_chips (None = game default). Table tracks
per-seat starting stacks so reset_bankroll_each_hand restores each
bot to its own depth. Enables asymmetric stack experiments
(hero deep vs opponents short and vice versa).
This commit is contained in:
Jan-Peter von Hunnius 2026-09-08 16:44:06 +02:00
parent b9963bfbb1
commit b4c501ec8c
4 changed files with 51 additions and 6 deletions

View file

@ -96,6 +96,7 @@ where
/// bot_type: "call_bot".to_string(), /// bot_type: "call_bot".to_string(),
/// name: Some("Alice".to_string()), /// name: Some("Alice".to_string()),
/// seat: None, /// seat: None,
/// starting_chips: None,
/// parameters: HashMap::new(), /// parameters: HashMap::new(),
/// }; /// };
/// ///
@ -141,6 +142,7 @@ mod tests {
bot_type: bot_type.to_string(), bot_type: bot_type.to_string(),
name: name.map(|s| s.to_string()), name: name.map(|s| s.to_string()),
seat: None, seat: None,
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
} }
} }

View file

@ -258,6 +258,11 @@ pub struct BotConfig {
#[serde(default)] #[serde(default)]
pub seat: Option<SeatIndex>, pub seat: Option<SeatIndex>,
/// Per-bot starting stack override (`None` = game starting_chips).
/// Enables asymmetric stack-depth experiments.
#[serde(default)]
pub starting_chips: Option<ChipCount>,
/// Custom parameters for the bot. /// Custom parameters for the bot.
#[serde(default)] #[serde(default)]
pub parameters: HashMap<String, toml::Value>, pub parameters: HashMap<String, toml::Value>,
@ -1091,6 +1096,7 @@ min_raise = 50
bot_type: "call_bot".to_string(), bot_type: "call_bot".to_string(),
name: Some("Alice".to_string()), name: Some("Alice".to_string()),
seat: None, seat: None,
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}, },
], ],
@ -1277,18 +1283,21 @@ min_raise = 50
bot_type: "call_bot".to_string(), bot_type: "call_bot".to_string(),
name: Some("A".to_string()), name: Some("A".to_string()),
seat: None, seat: None,
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}, },
BotConfig { BotConfig {
bot_type: "fold_bot".to_string(), bot_type: "fold_bot".to_string(),
name: Some("B".to_string()), name: Some("B".to_string()),
seat: None, seat: None,
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}, },
BotConfig { BotConfig {
bot_type: "random_bot".to_string(), bot_type: "random_bot".to_string(),
name: Some("C".to_string()), name: Some("C".to_string()),
seat: None, seat: None,
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}, },
], ],
@ -1335,6 +1344,7 @@ min_raise = 50
bot_type: "bot".to_string(), bot_type: "bot".to_string(),
name: None, name: None,
seat: None, seat: None,
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}; };
9 9
@ -1387,18 +1397,21 @@ min_raise = 50
bot_type: "call_bot".to_string(), bot_type: "call_bot".to_string(),
name: Some("A".to_string()), name: Some("A".to_string()),
seat: None, seat: None,
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}, },
BotConfig { BotConfig {
bot_type: "fold_bot".to_string(), bot_type: "fold_bot".to_string(),
name: Some("B".to_string()), name: Some("B".to_string()),
seat: None, seat: None,
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}, },
BotConfig { BotConfig {
bot_type: "random_bot".to_string(), bot_type: "random_bot".to_string(),
name: Some("C".to_string()), name: Some("C".to_string()),
seat: None, seat: None,
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}, },
], ],
@ -1450,6 +1463,7 @@ min_raise = 50
bot_type: "bot".to_string(), bot_type: "bot".to_string(),
name: None, name: None,
seat: None, seat: None,
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}; };
9 9

View file

@ -61,14 +61,15 @@ pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), Ga
// Store the name before seating (seating consumes the bot). // Store the name before seating (seating consumes the bot).
let name = bot.name().to_string(); let name = bot.name().to_string();
player_names.insert(player_id, name); player_names.insert(player_id, name);
starting_stacks.insert(player_id, config.starting_chips); let bot_stack = bot_config.starting_chips.unwrap_or(config.starting_chips);
starting_stacks.insert(player_id, bot_stack);
// 3. Seat the bot. // 3. Seat the bot.
let seat_index = bot_config let seat_index = bot_config
.seat .seat
.unwrap_or_else(|| table.find_empty_seat().unwrap_or(i as u8)); .unwrap_or_else(|| table.find_empty_seat().unwrap_or(i as u8));
table.seat_player(seat_index, bot, config.starting_chips)?; table.seat_player(seat_index, bot, bot_stack)?;
} }
// 4. Run hands and collect stats. // 4. Run hands and collect stats.
@ -423,6 +424,7 @@ mod tests {
rake_percentage: 0.0, rake_percentage: 0.0,
rake_cap: 0, rake_cap: 0,
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
cash_out_threshold: 0,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
@ -437,18 +439,21 @@ mod tests {
bot_type: "call_bot".to_string(), bot_type: "call_bot".to_string(),
name: Some("Alice".to_string()), name: Some("Alice".to_string()),
seat: Some(0), seat: Some(0),
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}, },
BotConfig { BotConfig {
bot_type: "call_bot".to_string(), bot_type: "call_bot".to_string(),
name: Some("Bob".to_string()), name: Some("Bob".to_string()),
seat: Some(1), seat: Some(1),
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}, },
BotConfig { BotConfig {
bot_type: "fold_bot".to_string(), bot_type: "fold_bot".to_string(),
name: Some("Charlie".to_string()), name: Some("Charlie".to_string()),
seat: Some(2), seat: Some(2),
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}, },
], ],
@ -536,6 +541,7 @@ mod tests {
rake_percentage: 0.0, rake_percentage: 0.0,
rake_cap: 0, rake_cap: 0,
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
cash_out_threshold: 0,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
@ -550,6 +556,7 @@ mod tests {
bot_type: "call_bot".to_string(), bot_type: "call_bot".to_string(),
name: Some("Lonely".to_string()), name: Some("Lonely".to_string()),
seat: None, seat: None,
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}], }],
}; };
@ -577,6 +584,7 @@ mod tests {
rake_percentage: 0.0, rake_percentage: 0.0,
rake_cap: 0, rake_cap: 0,
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
cash_out_threshold: 0,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
@ -591,12 +599,14 @@ mod tests {
bot_type: "call_bot".to_string(), bot_type: "call_bot".to_string(),
name: Some("Alice".to_string()), name: Some("Alice".to_string()),
seat: Some(0), seat: Some(0),
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}, },
BotConfig { BotConfig {
bot_type: "super_ai".to_string(), bot_type: "super_ai".to_string(),
name: Some("Bob".to_string()), name: Some("Bob".to_string()),
seat: Some(1), seat: Some(1),
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}, },
], ],
@ -625,6 +635,7 @@ mod tests {
rake_percentage: 0.0, rake_percentage: 0.0,
rake_cap: 0, rake_cap: 0,
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
cash_out_threshold: 0,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
@ -640,12 +651,14 @@ mod tests {
bot_type: "call_bot".to_string(), bot_type: "call_bot".to_string(),
name: Some("A".to_string()), name: Some("A".to_string()),
seat: None, // auto-assign seat: None, // auto-assign
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}, },
BotConfig { BotConfig {
bot_type: "fold_bot".to_string(), bot_type: "fold_bot".to_string(),
name: Some("B".to_string()), name: Some("B".to_string()),
seat: None, // auto-assign seat: None, // auto-assign
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}, },
], ],
@ -692,6 +705,7 @@ mod tests {
rake_percentage: 0.0, rake_percentage: 0.0,
rake_cap: 0, rake_cap: 0,
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
cash_out_threshold: 0,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: true, auto_rebuy: true,
@ -706,12 +720,14 @@ mod tests {
bot_type: "call_bot".to_string(), bot_type: "call_bot".to_string(),
name: Some("Alice".to_string()), name: Some("Alice".to_string()),
seat: Some(0), seat: Some(0),
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}, },
BotConfig { BotConfig {
bot_type: "call_bot".to_string(), bot_type: "call_bot".to_string(),
name: Some("Bob".to_string()), name: Some("Bob".to_string()),
seat: Some(1), seat: Some(1),
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}, },
], ],
@ -773,6 +789,7 @@ mod tests {
rake_percentage: 0.0, rake_percentage: 0.0,
rake_cap: 0, rake_cap: 0,
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
cash_out_threshold: 0,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: true, auto_rebuy: true,
@ -787,12 +804,14 @@ mod tests {
bot_type: "call_bot".to_string(), bot_type: "call_bot".to_string(),
name: Some("Alice".to_string()), name: Some("Alice".to_string()),
seat: Some(0), seat: Some(0),
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}, },
BotConfig { BotConfig {
bot_type: "fold_bot".to_string(), bot_type: "fold_bot".to_string(),
name: Some("Bob".to_string()), name: Some("Bob".to_string()),
seat: Some(1), seat: Some(1),
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}, },
], ],
@ -836,6 +855,7 @@ mod tests {
rake_percentage: 0.0, rake_percentage: 0.0,
rake_cap: 0, rake_cap: 0,
reset_bankroll_each_hand: false, reset_bankroll_each_hand: false,
cash_out_threshold: 0,
bankroll_refill_threshold: 0, bankroll_refill_threshold: 0,
randomize_seats: false, randomize_seats: false,
auto_rebuy: true, auto_rebuy: true,
@ -850,12 +870,14 @@ mod tests {
bot_type: "call_bot".to_string(), bot_type: "call_bot".to_string(),
name: Some("Alice".to_string()), name: Some("Alice".to_string()),
seat: Some(0), seat: Some(0),
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}, },
BotConfig { BotConfig {
bot_type: "fold_bot".to_string(), bot_type: "fold_bot".to_string(),
name: Some("Bob".to_string()), name: Some("Bob".to_string()),
seat: Some(1), seat: Some(1),
starting_chips: None,
parameters: HashMap::new(), parameters: HashMap::new(),
}, },
], ],

View file

@ -83,6 +83,11 @@ pub struct Table {
config: TableConfig, config: TableConfig,
/// All seats at the table; `None` for empty seats. /// All seats at the table; `None` for empty seats.
seats: Vec<Option<Seat>>, seats: Vec<Option<Seat>>,
/// Per-seat starting stack for resets (defaults to config.starting_chips;
/// records the buy-in of each seated player so per-bot overrides reset
/// to their own depth).
starting_by_seat: Vec<ChipCount>,
/// Current dealer button position (seat index). /// Current dealer button position (seat index).
button: SeatIndex, button: SeatIndex,
/// The currently running hand, if any. /// The currently running hand, if any.
@ -118,9 +123,11 @@ impl Table {
let bankroll_refill_threshold = config.bankroll_refill_threshold; let bankroll_refill_threshold = config.bankroll_refill_threshold;
let randomize_seats = config.randomize_seats; let randomize_seats = config.randomize_seats;
let seats = (0..config.num_seats).map(|_| None).collect(); let seats = (0..config.num_seats).map(|_| None).collect();
let starting_by_seat = (0..config.num_seats).map(|_| starting_chips).collect();
Self { Self {
config, config,
seats, seats,
starting_by_seat,
button: 0, button: 0,
current_hand: None, current_hand: None,
hand_number: 0, hand_number: 0,
@ -168,6 +175,7 @@ impl Table {
} }
let mut seat_obj = Seat::new(seat, player, buy_in); let mut seat_obj = Seat::new(seat, player, buy_in);
self.starting_by_seat[seat as usize] = buy_in;
// Build a GameInfo snapshot so the player can initialise. // Build a GameInfo snapshot so the player can initialise.
let game_info = self.build_game_info(); let game_info = self.build_game_info();
@ -598,15 +606,14 @@ impl Table {
/// Reset all seated players' stacks to starting_chips. /// Reset all seated players' stacks to starting_chips.
/// Track the change in their bankroll. /// Track the change in their bankroll.
fn reset_stacks_to_starting(&mut self) { fn reset_stacks_to_starting(&mut self) {
let starting = self.starting_chips; for (slot, starting) in &mut self.seats.iter_mut().zip(self.starting_by_seat.iter()) {
for slot in &mut self.seats {
if let Some(seat) = slot.as_mut() { if let Some(seat) = slot.as_mut() {
// The "cost" of resetting is the difference between // The "cost" of resetting is the difference between
// starting and current stack. // starting and current stack.
// If they have more than starting, they profit. // If they have more than starting, they profit.
// If they have less, they lost. // If they have less, they lost.
seat.bankroll += seat.stack as i64 - starting as i64; seat.bankroll += seat.stack as i64 - *starting as i64;
seat.stack = starting; seat.stack = *starting;
// Re-activate sitting-out players (they have chips again). // Re-activate sitting-out players (they have chips again).
if seat.state == PlayerState::SittingOut { if seat.state == PlayerState::SittingOut {
seat.state = PlayerState::Active; seat.state = PlayerState::Active;