diff --git a/docs/ARCHITECTURE_RAKE_BANKROLL_SEATS.md b/docs/ARCHITECTURE_RAKE_BANKROLL_SEATS.md new file mode 100644 index 0000000..302086e --- /dev/null +++ b/docs/ARCHITECTURE_RAKE_BANKROLL_SEATS.md @@ -0,0 +1,786 @@ +# Architecture: Rake, Bankroll Management & Randomized Seats + +## Overview + +This document describes the architecture for three new features in the `holdem_core` poker engine: + +1. **Rake** — Configurable percentage deducted from pots at showdown +2. **Bankroll Management** — Per-hand stack reset and automatic short-stack refill +3. **Randomized Seats** — Shuffle player seat assignments before each hand or tournament + +All three features are backward-compatible: existing TOML configs without these fields continue to work with sensible defaults (no rake, no reset, no refill, no randomization). + +--- + +## Feature 1: Rake + +### 1.1 Config Changes + +Add two fields to [`GameConfig`](holdem_core/src/game/config.rs:178): + +```rust +// In GameConfig struct: + +/// Rake percentage taken from each pot at showdown (e.g., 0.05 = 5%). +/// Rake is deducted proportionally from each pot (main + side pots). +/// Set to 0.0 (default) to disable rake. +#[serde(default)] +pub rake_percentage: f64, + +/// Maximum rake in chips per pot. 0 means no cap. +#[serde(default)] +pub rake_cap: ChipCount, +``` + +**Defaults**: `rake_percentage: 0.0`, `rake_cap: 0` (no rake, no cap). Since both use `#[serde(default)]`, existing TOML files without these fields deserialize correctly. + +**Validation**: Add a validation method or `#[serde(deserialize_with = ...)]` that ensures `rake_percentage` is in `[0.0, 1.0]`. + +### 1.2 Data Model Changes + +#### New struct: `RakeConfig` + +```rust +/// Runtime rake configuration, derived from GameConfig. +#[derive(Debug, Clone, Copy)] +pub struct RakeConfig { + /// Fraction of each pot taken as rake (e.g., 0.05). + pub percentage: f64, + /// Maximum rake per pot in chips; 0 = uncapped. + pub cap: ChipCount, +} + +impl RakeConfig { + pub fn no_rake() -> Self { + Self { percentage: 0.0, cap: 0 } + } + + /// Returns true if rake is effectively disabled. + pub fn is_zero(&self) -> bool { + self.percentage == 0.0 + } + + /// Calculate rake for a pot of the given size. + /// Returns the rake amount (floored to whole chips). + pub fn calculate_rake(&self, pot_amount: ChipCount) -> ChipCount { + if self.percentage == 0.0 || pot_amount == 0 { + return 0; + } + let rake = (pot_amount as f64 * self.percentage).floor() as ChipCount; + if self.cap > 0 { + rake.min(self.cap) + } else { + rake + } + } +} +``` + +#### Changes to `HandResult` + +Add a field to [`HandResult`](holdem_core/src/game/hand_runner.rs:86): + +```rust +pub struct HandResult { + pub pot_winners: Vec>, + pub hand_values: HashMap, + pub board: Board, + pub chip_movements: HashMap, + /// Total rake deducted from all pots this hand. + pub total_rake: ChipCount, + /// Rake deducted per pot, parallel to pot_winners. + pub rake_per_pot: Vec, +} +``` + +### 1.3 Behavior Flow + +The rake is applied **after** [`PotManager::distribute()`](holdem_core/src/game/pot.rs:180) returns its results, but **before** chip movements are calculated and stacks are credited. This keeps the pot manager's core logic clean and testable. + +```mermaid +flowchart TD + A[pot_manager.distribute - hand_values] --> B[For each pot: calculate rake] + B --> C[For each winner in pot: reduce award by proportional rake] + C --> D[Accumulate total_rake and rake_per_pot] + D --> E[Calculate chip_movements with raked amounts] + E --> F[Credit raked winnings to stacks] + F --> G[Build HandResult with rake info] +``` + +**Hook point**: Both [`HandRunner::showdown()`](holdem_core/src/game/hand_runner.rs:543) and [`HandRunner::resolve_last_player_wins()`](holdem_core/src/game/hand_runner.rs:664) need rake logic. The cleanest approach is: + +1. Add `rake_config: RakeConfig` field to [`HandRunner`](holdem_core/src/game/hand_runner.rs:100) +2. Create a private helper method: + +```rust +impl HandRunner { + /// Apply rake to pot distribution results. + /// Returns (raked_pot_winners, rake_per_pot, total_rake). + fn apply_rake( + &self, + pot_winners: Vec>, + ) -> (Vec>, Vec, ChipCount) { + if self.rake_config.is_zero() { + let total: ChipCount = 0; + let per_pot = vec![0; pot_winners.len()]; + return (pot_winners, per_pot, total); + } + + let mut total_rake: ChipCount = 0; + let mut rake_per_pot = Vec::with_capacity(pot_winners.len()); + + let raked_winners: Vec> = pot_winners + .into_iter() + .map(|mut pot_result| { + // Sum the pot from winner amounts (they haven't been raked yet). + let pot_total: ChipCount = pot_result.iter().map(|w| w.amount).sum(); + let rake = self.rake_config.calculate_rake(pot_total); + rake_per_pot.push(rake); + total_rake += rake; + + // Deduct rake proportionally from each winner. + if rake == 0 || pot_result.is_empty() { + return pot_result; + } + + let num_winners = pot_result.len() as ChipCount; + let rake_per_winner = rake / num_winners; + let rake_remainder = rake % num_winners; + + for (i, winner) in pot_result.iter_mut().enumerate() { + let extra = if (i as ChipCount) < rake_remainder { 1 } else { 0 }; + winner.amount = winner.amount.saturating_sub(rake_per_winner + extra); + } + pot_result + }) + .collect(); + + (raked_winners, rake_per_pot, total_rake) + } +} +``` + +3. In [`showdown()`](holdem_core/src/game/hand_runner.rs:611), after line 611 (`let pot_winners = self.pot_manager.distribute(&hand_values);`), call `apply_rake()` before computing chip movements. + +4. In [`resolve_last_player_wins()`](holdem_core/src/game/hand_runner.rs:677), same pattern after line 677. + +5. Pass `RakeConfig` through [`HandRunner::new()`](holdem_core/src/game/hand_runner.rs:155) — add it as a parameter. + +6. Thread `RakeConfig` from [`GameConfig`](holdem_core/src/game/config.rs:178) → [`Table`](holdem_core/src/game/table.rs:64) → [`HandRunner`](holdem_core/src/game/hand_runner.rs:100): + - Add `rake_config: RakeConfig` to [`TableConfig`](holdem_core/src/game/table.rs:29) + - Store it in [`Table`](holdem_core/src/game/table.rs:64) + - Pass it when constructing `HandRunner` in [`Table::start_hand()`](holdem_core/src/game/table.rs:322) + +### 1.4 Stats Impact + +Add to [`PlayerStats`](holdem_core/src/game/stats.rs:20): + +```rust +/// Total rake paid by this player across all hands. +/// Rake is attributed proportionally based on the player's contribution to each pot. +pub total_rake_paid: ChipCount, +``` + +Add to [`SimulationStats`](holdem_core/src/game/stats.rs:92): + +```rust +/// Total rake collected across the entire simulation. +pub total_rake: ChipCount, +``` + +Update [`SimulationStats::record_hand_result()`](holdem_core/src/game/stats.rs:120) to accumulate `total_rake` from `result.total_rake`. + +**Rake attribution**: To attribute rake to individual players, we need to know each player's contribution to each pot. The `PotManager` already tracks this via `wagers`. We can add a method to compute per-player rake: + +```rust +impl PotManager { + /// Returns each player's contribution to each finalized pot. + /// Useful for proportional rake attribution. + pub fn player_pot_contributions(&self) -> Vec> { + // For each pot, compute what each player contributed + // based on the side pot algorithm thresholds + } +} +``` + +Alternatively, a simpler approach: track rake only at the simulation level (total_rake) and don't attribute per-player. This is simpler and sufficient for most use cases. Per-player rake attribution can be added later. + +### 1.5 Edge Cases + +| Scenario | Behavior | +|----------|----------| +| Pot of 0 chips | Rake = 0 (guard in `calculate_rake`) | +| Rake > winner's share | Use `saturating_sub` — winner gets 0, rake is capped at pot total | +| Split pot with uneven shares | Remainder rake chips distributed round-robin to winners in order | +| No showdown (all fold) | **Rake still applies** — the winner's pot is raked | +| Rake cap reached | `rake.min(self.cap)` applied per pot | +| Multiple side pots | Rake calculated independently per pot | + +### 1.6 TOML Config Example + +```toml +[game] +game_type = "Cash" +num_seats = 6 +small_blind = 10 +big_blind = 20 +starting_chips = 10000 +num_hands = 1000 +rake_percentage = 0.05 # 5% rake +rake_cap = 50 # Max 50 chips rake per pot + +[game.bet_limit] +type = "no_limit" +min_raise = 20 +``` + +--- + +## Feature 2: Bankroll Management + +### 2.1 Config Changes + +Add two fields to [`GameConfig`](holdem_core/src/game/config.rs:178): + +```rust +/// If true, all players' stacks are reset to `starting_chips` before every hand. +/// Their overall profit/loss balance is tracked separately via `bankroll`. +#[serde(default)] +pub reset_bankroll_each_hand: bool, + +/// When a player's stack drops to or below this threshold (and reset is NOT enabled), +/// they automatically buy back up to `starting_chips`. Their overall balance +/// decreases by the buy-in amount. +#[serde(default)] +pub bankroll_refill_threshold: ChipCount, +``` + +**Defaults**: `reset_bankroll_each_hand: false`, `bankroll_refill_threshold: 0` (disabled). Both use `#[serde(default)]` for backward compatibility. + +### 2.2 Data Model Changes + +#### New field on `Seat` + +Add a `bankroll` field to [`Seat`](holdem_core/src/game/player.rs:268): + +```rust +pub struct Seat { + // ... existing fields ... + + /// The player's overall balance (profit/loss) across all hands. + /// Starts at 0. Increases when the player wins, decreases when they lose. + /// When reset_bankroll_each_hand is enabled, this tracks the cumulative + /// profit/loss while the stack is reset each hand. + /// When bankroll_refill_threshold triggers a reload, the buy-in amount + /// is deducted from bankroll. + pub bankroll: i64, +} +``` + +Update [`Seat::new()`](holdem_core/src/game/player.rs:331) to initialize `bankroll: 0`. + +#### Changes to `Table` + +Add bankroll config fields to [`Table`](holdem_core/src/game/table.rs:64): + +```rust +pub struct Table { + // ... existing fields ... + + /// If true, reset all stacks to starting_chips before each hand. + reset_bankroll_each_hand: bool, + + /// Auto-refill threshold. 0 = disabled. + bankroll_refill_threshold: ChipCount, + + /// Starting chips (for resets and refills). + starting_chips: ChipCount, +} +``` + +Add these to [`TableConfig`](holdem_core/src/game/table.rs:29) as well so they flow from config: + +```rust +pub struct TableConfig { + // ... existing fields ... + pub reset_bankroll_each_hand: bool, + pub bankroll_refill_threshold: ChipCount, + pub starting_chips: ChipCount, +} +``` + +#### Changes to `PlayerInfo` + +Add to [`PlayerInfo`](holdem_core/src/game/player_info.rs:35): + +```rust +/// The player's overall bankroll balance across all hands. +/// Positive = profit, negative = loss. +pub bankroll: i64, +``` + +#### Changes to `GameInfo` + +Add to [`GameInfo`](holdem_core/src/game/game_info.rs:21): + +```rust +/// Whether bankroll reset mode is enabled. +pub reset_bankroll_each_hand: bool, +``` + +### 2.3 Behavior Flow + +#### Reset Mode (`reset_bankroll_each_hand = true`) + +```mermaid +flowchart TD + A[Before start_hand] --> B{reset_bankroll_each_hand?} + B -- Yes --> C[For each seated player] + C --> D[bankroll += stack as i64 - starting_chips as i64] + D --> E[stack = starting_chips] + E --> F[state = Active] + B -- No --> G{refill_threshold > 0?} + G -- Yes --> H[For each seated player] + H --> I{stack <= threshold AND state != SittingOut?} + I -- Yes --> J[refill_amount = starting_chips - stack] + J --> K[bankroll -= refill_amount as i64] + K --> L[stack = starting_chips] + I -- No --> M[Skip] + G -- No --> N[Proceed normally] + F --> N + L --> N + M --> N +``` + +**Hook point**: In [`Table::start_hand()`](holdem_core/src/game/table.rs:288), insert the bankroll logic **before** step 3 (advance button). The new step 2.5: + +```rust +// In Table::start_hand(), after the active_count check: + +// 2.5. Bankroll management. +if self.reset_bankroll_each_hand { + self.reset_stacks_to_starting(); +} else if self.bankroll_refill_threshold > 0 { + self.refill_short_stacks(); +} +``` + +New private methods on `Table`: + +```rust +impl Table { + /// Reset all seated players' stacks to starting_chips. + /// Track the change in their bankroll. + fn reset_stacks_to_starting(&mut self) { + let starting = self.starting_chips; + for slot in &mut self.seats { + if let Some(seat) = slot.as_mut() { + // The "cost" of resetting is the difference between + // starting and current stack. + // If they have more than starting, they profit. + // If they have less, they lost. + seat.bankroll += seat.stack as i64 - starting as i64; + seat.stack = starting; + // Re-activate sitting-out players (they have chips again). + if seat.state == PlayerState::SittingOut { + seat.state = PlayerState::Active; + } + } + } + } + + /// Refill short-stacked players up to starting_chips. + fn refill_short_stacks(&mut self) { + let threshold = self.bankroll_refill_threshold; + let starting = self.starting_chips; + for slot in &mut self.seats { + if let Some(seat) = slot.as_mut() { + if seat.state == PlayerState::SittingOut { + continue; + } + if seat.stack <= threshold { + let refill = starting.saturating_sub(seat.stack); + if refill > 0 { + seat.bankroll -= refill as i64; + seat.stack = starting; + } + } + } + } + } +} +``` + +**Important**: When `reset_bankroll_each_hand = true`, busted players (stack = 0, state = SittingOut) are re-activated with a full stack. This means all players play every hand — the simulation tracks their cumulative profit/loss via `bankroll`. + +When `bankroll_refill_threshold > 0` (without reset), only players below the threshold get refilled. Busted players (SittingOut) are **not** refilled — they stay out. This matches real poker behavior where a bust-out is different from being short-stacked. If you want busted players to also reload, set `reset_bankroll_each_hand = true` instead. + +#### After Hand Completion + +In [`Table::complete_hand()`](holdem_core/src/game/table.rs:351), after restoring seats, the bankroll needs to be updated with the hand results. The chip movements from `HandResult` already reflect the net change per hand. However, with `reset_bankroll_each_hand`, the bankroll is updated at the **start** of the next hand (via `reset_stacks_to_starting`), not at the end of the current one. This is simpler and avoids double-counting. + +For `bankroll_refill_threshold` mode (without reset), the bankroll is only affected by refill purchases. The stack changes from winning/losing are already reflected in `seat.stack`. The `bankroll` field tracks the additional money spent on refills. + +### 2.4 Stats Impact + +The existing [`SimulationStats::record_final_stacks()`](holdem_core/src/game/stats.rs:153) computes `total_profit = final_stack - starting_stack`. With bankroll management, this is no longer accurate. + +**Solution**: Add `bankroll` tracking to stats: + +```rust +// In PlayerStats: +/// Total bankroll balance (includes refill costs and reset adjustments). +pub bankroll: i64, +``` + +Update [`SimulationStats::record_final_stacks()`](holdem_core/src/game/stats.rs:153) to also record bankroll: + +```rust +pub fn record_final_stacks( + &mut self, + stacks: &HashMap, + starting_stacks: &HashMap, + bankrolls: &HashMap, // NEW parameter +) { + for (&player_id, &final_stack) in stacks { + if let Some(stats) = self.players.get_mut(&player_id) { + stats.final_stack = final_stack; + let starting = starting_stacks.get(&player_id).copied().unwrap_or(0); + stats.bankroll = *bankrolls.get(&player_id).unwrap_or(&0); + // With bankroll tracking, total_profit = bankroll + // (bankroll already captures all net flows) + if stats.bankroll != 0 { + stats.total_profit = stats.bankroll; + } else { + // Fallback for non-bankroll mode + stats.total_profit = final_stack as i64 - starting as i64; + } + } + } +} +``` + +In [`run_cash_game()`](holdem_core/src/game/simulation.rs:87), collect bankrolls alongside final stacks: + +```rust +let bankrolls: HashMap = table + .seats() + .iter() + .filter_map(|slot| { + slot.as_ref().map(|seat| (seat.player_id, seat.bankroll)) + }) + .collect(); +stats.record_final_stacks(&final_stacks, &starting_stacks, &bankrolls); +``` + +### 2.5 Edge Cases + +| Scenario | Behavior | +|----------|----------| +| Player wins big, stack > starting_chips, reset mode | bankroll increases by the surplus, stack reset to starting_chips | +| Player busts, reset mode | Re-activated with starting_chips, bankroll decreases by starting_chips | +| Refill threshold = 0 | No refills (disabled) | +| Refill threshold >= starting_chips | Refill every hand (unusual but valid) | +| Both reset and refill enabled | Reset takes precedence (refill is skipped) | +| Tournament mode | Bankroll management is **cash-game only** — ignored for tournaments | +| Bankroll goes very negative | `i64` provides ~9 quintillion range — no practical overflow concern | + +### 2.6 TOML Config Example + +```toml +# Example: Cash game with bankroll reset (tournament-style tracking) +[game] +game_type = "Cash" +num_seats = 6 +small_blind = 10 +big_blind = 20 +starting_chips = 10000 +num_hands = 1000 +reset_bankroll_each_hand = true + +[game.bet_limit] +type = "no_limit" +min_raise = 20 +``` + +```toml +# Example: Cash game with auto-refill +[game] +game_type = "Cash" +num_seats = 6 +small_blind = 10 +big_blind = 20 +starting_chips = 10000 +num_hands = 1000 +bankroll_refill_threshold = 1000 # Refill when stack <= 1000 + +[game.bet_limit] +type = "no_limit" +min_raise = 20 +``` + +--- + +## Feature 3: Randomized Seats + +### 3.1 Config Changes + +Add one field to [`GameConfig`](holdem_core/src/game/config.rs:178): + +```rust +/// If true, player seat assignments are randomized before each hand +/// (cash game) or before tournament start. +#[serde(default)] +pub randomize_seats: bool, +``` + +**Default**: `false`. Uses `#[serde(default)]` for backward compatibility. + +### 3.2 Data Model Changes + +Add to [`Table`](holdem_core/src/game/table.rs:64): + +```rust +pub struct Table { + // ... existing fields ... + + /// If true, shuffle seat assignments before each hand. + randomize_seats: bool, +} +``` + +Add to [`TableConfig`](holdem_core/src/game/table.rs:29): + +```rust +pub struct TableConfig { + // ... existing fields ... + pub randomize_seats: bool, +} +``` + +### 3.3 Behavior Flow + +```mermaid +flowchart TD + A[Table::start_hand] --> B{randomize_seats?} + B -- Yes --> C[Collect all seated players into a Vec] + C --> D[Shuffle the Vec using seeded RNG] + D --> E[Clear all seats] + E --> F[Re-seat players in shuffled order to positions 0..N] + B -- No --> G[Proceed with existing seat assignments] + F --> G + G --> H[Advance button and continue hand start] +``` + +**Hook point**: In [`Table::start_hand()`](holdem_core/src/game/table.rs:288), insert randomization **before** step 3 (advance button) and **after** bankroll management. The new step 2.7: + +```rust +// In Table::start_hand(): + +// 2.7. Randomize seats if configured. +if self.randomize_seats { + self.shuffle_seats()?; +} +``` + +New method on `Table`: + +```rust +impl Table { + /// Randomize seat assignments by collecting all seated players, + /// shuffling them, and re-seating at positions 0..N. + fn shuffle_seats(&mut self) -> Result<(), GameError> { + // 1. Collect all occupied seats. + let mut occupied: Vec = self + .seats + .iter_mut() + .filter_map(|slot| slot.take()) + .collect(); + + if occupied.len() < 2 { + // Put them back. + for (i, seat) in occupied.into_iter().enumerate() { + self.seats[i] = Some(seat); + } + return Ok(()); + } + + // 2. Shuffle using seeded RNG for reproducibility. + // Use a seed derived from the table seed + hand number. + let rng_seed = self.seed + .map(|s| s.wrapping_add(self.hand_number)) + .unwrap_or_else(|| { + // Use a simple hash of hand_number as fallback. + self.hand_number + }); + let mut rng = rand::rngs::StdRng::seed_from_u64(rng_seed); + use rand::seq::SliceRandom; + occupied.shuffle(&mut rng); + + // 3. Clear all seats (already empty from take). + // 4. Re-seat at positions 0, 1, 2, ... + for (i, mut seat) in occupied.into_iter().enumerate() { + seat.index = i as SeatIndex; + self.seats[i] = Some(seat); + } + + // 5. Reset button to 0 since seat assignments changed. + self.button = 0; + + Ok(()) + } +} +``` + +**For tournaments**: Randomization happens once at tournament start, not per hand. The tournament code in [`tournament.rs`](holdem_core/src/game/tournament.rs) would call a `Table::shuffle_seats()` method during initialization. This is a separate call site but uses the same method. + +### 3.4 Stats Impact + +No new stats fields needed. Seat randomization is transparent to stats tracking, which uses `PlayerId` (not seat index) as the key. + +### 3.5 Edge Cases + +| Scenario | Behavior | +|----------|----------| +| Only 1 player seated | No shuffle (not enough players) | +| No seed configured | Use hand_number as a fallback seed | +| Seed configured | Use `seed + hand_number` for per-hand variation | +| During a hand | No-op (randomization only happens between hands) | +| Tournament | Shuffle once at start, not per hand | +| Button position | Reset to 0 after shuffle to avoid invalid button references | + +### 3.6 TOML Config Example + +```toml +[game] +game_type = "Cash" +num_seats = 6 +small_blind = 10 +big_blind = 20 +starting_chips = 10000 +num_hands = 1000 +randomize_seats = true + +[game.bet_limit] +type = "no_limit" +min_raise = 20 +``` + +--- + +## Cross-Feature Interactions + +### Rake + Bankroll Reset + +When both are enabled: +1. Rake is deducted from pot winnings during the hand (inside `HandRunner`) +2. Bankroll reset happens between hands (inside `Table`) +3. The `chip_movements` in `HandResult` already reflect rake deductions +4. Bankroll tracking picks up the post-rake stack values + +The interaction is clean because rake operates **within** a hand and bankroll operates **between** hands. + +### Rake + Stats + +With rake enabled, `total_profit` across all players will sum to `-(total_rake as i64)` instead of 0. This is correct — the rake is money leaving the system. + +### Bankroll Reset + Randomized Seats + +Both operate between hands in `Table::start_hand()`. The execution order is: + +1. Bankroll management (reset/refill) — step 2.5 +2. Seat randomization — step 2.7 +3. Advance button — step 3 + +This order ensures bankroll adjustments happen before seats are shuffled. + +### All Three Together + +```mermaid +flowchart TD + A[Table::start_hand] --> B[Validate: enough players?] + B -- No --> ERR[Return NotEnoughPlayers] + B -- Yes --> C{reset_bankroll?} + C -- Yes --> D[Reset all stacks to starting_chips] + C -- No --> E{refill_threshold > 0?} + E -- Yes --> F[Refill short stacks] + E -- No --> G[Skip] + D --> H{randomize_seats?} + F --> H + G --> H + H -- Yes --> I[Shuffle seat assignments] + H -- No --> J[Skip] + I --> K[Advance button] + J --> K + K --> L[Collect active seats] + L --> M[Create HandRunner with RakeConfig] + M --> N[Start hand: post blinds, deal] +``` + +--- + +## Implementation Todo List + +The following is the recommended implementation order, broken into independent units: + +### Phase 1: Rake +1. Add `RakeConfig` struct to `pot.rs` (or a new `rake.rs` module) +2. Add `rake_percentage` and `rake_cap` fields to `GameConfig` with `#[serde(default)]` +3. Add `RakeConfig::from_game_config()` conversion +4. Add `rake_config: RakeConfig` to `TableConfig` and `Table` +5. Add `rake_config: RakeConfig` to `HandRunner` +6. Implement `HandRunner::apply_rake()` helper method +7. Update `HandRunner::showdown()` to call `apply_rake()` +8. Update `HandRunner::resolve_last_player_wins()` to call `apply_rake()` +9. Add `total_rake` and `rake_per_pot` to `HandResult` +10. Add `total_rake` to `SimulationStats` and update `record_hand_result()` +11. Update `GameConfig::to_table_config()` to pass rake config +12. Add unit tests for `RakeConfig::calculate_rake()` +13. Add integration tests for raked hands + +### Phase 2: Bankroll Management +1. Add `bankroll: i64` field to `Seat` (default 0) +2. Add `reset_bankroll_each_hand` and `bankroll_refill_threshold` to `GameConfig` +3. Add these fields to `TableConfig` and `Table` +4. Implement `Table::reset_stacks_to_starting()` +5. Implement `Table::refill_short_stacks()` +6. Update `Table::start_hand()` to call bankroll management +7. Add `bankroll: i64` to `PlayerInfo` and update `Seat::to_player_info()` +8. Add `reset_bankroll_each_hand: bool` to `GameInfo` +9. Update `SimulationStats::record_final_stacks()` to accept bankrolls +10. Update `run_cash_game()` to collect bankrolls +11. Add `bankroll: i64` to `PlayerStats` +12. Add tests for reset mode, refill mode, and edge cases + +### Phase 3: Randomized Seats +1. Add `randomize_seats: bool` to `GameConfig`, `TableConfig`, and `Table` +2. Implement `Table::shuffle_seats()` using seeded RNG +3. Update `Table::start_hand()` to call shuffle when enabled +4. Ensure `rand` crate is available as a dependency +5. Add tests for seat randomization with and without seed +6. Add tournament integration (shuffle at start) + +### Phase 4: Cross-Cutting +1. Update existing TOML config examples with commented-out new fields +2. Update `GameConfig::default()` to include new fields +3. Update `SimulationStats::summary()` to display rake and bankroll info +4. Verify backward compatibility: run existing tests unchanged +5. Update `docs/` README with new config options + +--- + +## Summary of All Struct Changes + +| Struct | File | New Fields | +|--------|------|------------| +| `GameConfig` | `config.rs` | `rake_percentage: f64`, `rake_cap: ChipCount`, `reset_bankroll_each_hand: bool`, `bankroll_refill_threshold: ChipCount`, `randomize_seats: bool` | +| `TableConfig` | `table.rs` | `rake_config: RakeConfig`, `reset_bankroll_each_hand: bool`, `bankroll_refill_threshold: ChipCount`, `starting_chips: ChipCount`, `randomize_seats: bool` | +| `Table` | `table.rs` | `rake_config: RakeConfig`, `reset_bankroll_each_hand: bool`, `bankroll_refill_threshold: ChipCount`, `starting_chips: ChipCount`, `randomize_seats: bool` | +| `HandRunner` | `hand_runner.rs` | `rake_config: RakeConfig` | +| `HandResult` | `hand_runner.rs` | `total_rake: ChipCount`, `rake_per_pot: Vec` | +| `Seat` | `player.rs` | `bankroll: i64` | +| `PlayerInfo` | `player_info.rs` | `bankroll: i64` | +| `GameInfo` | `game_info.rs` | `reset_bankroll_each_hand: bool` | +| `PlayerStats` | `stats.rs` | `total_rake_paid: ChipCount`, `bankroll: i64` | +| `SimulationStats` | `stats.rs` | `total_rake: ChipCount` | +| `RakeConfig` | NEW | `percentage: f64`, `cap: ChipCount` | diff --git a/holdem_core/examples/configs/cash_game.toml b/holdem_core/examples/configs/cash_game.toml index 04c0011..da9fc29 100644 --- a/holdem_core/examples/configs/cash_game.toml +++ b/holdem_core/examples/configs/cash_game.toml @@ -1,24 +1,63 @@ [game] game_type = "Cash" -num_seats = 6 +num_seats = 10 small_blind = 10 big_blind = 20 ante = 0 -starting_chips = 10000 -num_hands = 1000 +starting_chips = 5000 +num_hands = 10000 + +# Rake configuration (optional, defaults to no rake). +# rake_percentage = 0.05 # 5% rake +# rake_cap = 50 # Max 50 chips rake per pot + +# Bankroll management (optional, defaults to disabled). +# reset_bankroll_each_hand = false +# bankroll_refill_threshold = 1000 # Refill when stack <= 1000 + +# Randomize seat assignments before each hand (optional, defaults to false). +# randomize_seats = true [game.bet_limit] type = "no_limit" min_raise = 20 +[[game.bots]] +bot_type = "flock_bot" +name = "FlockBot-1" + +[[game.bots]] +bot_type = "chump_bot" +name = "ChumpBot-1" + [[game.bots]] bot_type = "call_bot" -name = "Alice" +name = "CallBot-1" [[game.bots]] bot_type = "fold_bot" -name = "Bob" +name = "FoldBot-1" + +[[game.bots]] +bot_type = "raise_bot" +name = "RaiseBot-1" [[game.bots]] bot_type = "random_bot" -name = "Charlie" +name = "RandomBot-1" + +[[game.bots]] +bot_type = "flock_bot" +name = "FlockBot-2" + +[[game.bots]] +bot_type = "chump_bot" +name = "ChumpBot-2" + +[[game.bots]] +bot_type = "call_bot" +name = "CallBot-2" + +[[game.bots]] +bot_type = "random_bot" +name = "RandomBot-2" diff --git a/holdem_core/src/game/bot_factory.rs b/holdem_core/src/game/bot_factory.rs index b2383dd..9df59f9 100644 --- a/holdem_core/src/game/bot_factory.rs +++ b/holdem_core/src/game/bot_factory.rs @@ -9,11 +9,13 @@ //! | `bot_type` string | Bot implementation | //! |-------------------|--------------------| //! | `"call_bot"` | [`CallBot`](super::bots::CallBot) | +//! | `"chump_bot"` | [`ChumpBot`](super::bots::ChumpBot) | +//! | `"flock_bot"` | [`FlockBot`](super::bots::FlockBot) | //! | `"fold_bot"` | [`FoldBot`](super::bots::FoldBot) | //! | `"raise_bot"` | [`RaiseBot`](super::bots::RaiseBot) | //! | `"random_bot"` | [`RandomBot`](super::bots::RandomBot) | -use super::bots::{CallBot, FoldBot, RandomBot, RaiseBot}; +use super::bots::{CallBot, ChumpBot, FlockBot, FoldBot, RandomBot, RaiseBot}; use super::config::BotConfig; use super::player::HoldemPlayer; use super::types::PlayerId; @@ -54,6 +56,8 @@ pub fn create_bot(config: &BotConfig, id: PlayerId) -> Result Ok(Box::new(CallBot::new(id, name))), + "chump_bot" => Ok(Box::new(ChumpBot::new(id, name))), + "flock_bot" => Ok(Box::new(FlockBot::new(id, name))), "fold_bot" => Ok(Box::new(FoldBot::new(id, name))), "raise_bot" => Ok(Box::new(RaiseBot::new(id, name))), "random_bot" => Ok(Box::new(RandomBot::new(id, name))), diff --git a/holdem_core/src/game/bots/call_bot.rs b/holdem_core/src/game/bots/call_bot.rs index 9d8377c..8babc15 100644 --- a/holdem_core/src/game/bots/call_bot.rs +++ b/holdem_core/src/game/bots/call_bot.rs @@ -126,8 +126,10 @@ mod tests { is_dealer: true, is_small_blind: false, is_big_blind: false, + bankroll: 0, })], actions_this_hand: vec![], + num_raises: 0, hand_number: 1, active_players: 2, } diff --git a/holdem_core/src/game/bots/chump_bot.rs b/holdem_core/src/game/bots/chump_bot.rs new file mode 100644 index 0000000..5380979 --- /dev/null +++ b/holdem_core/src/game/bots/chump_bot.rs @@ -0,0 +1,523 @@ +//! A loose-passive calling station bot. +//! +//! [`ChumpBot`] plays a loose-passive strategy: it calls with any two cards +//! preflop, rarely raises, and calls down with any made hand or draw. It is +//! the classic "calling station" archetype. + +use crate::game::action::Action; +use crate::game::game_info::GameInfo; +use crate::game::player::HoldemPlayer; +use crate::game::types::{ChipCount, PlayerId}; +use crate::{HandValue, HoleCards, Street}; + +/// A **loose-passive calling station** bot. +/// +/// ChumpBot almost never folds preflop, rarely raises, and calls down with +/// any made hand or draw. It only folds postflop when facing a large bet +/// with absolutely nothing. +/// +/// # Examples +/// +/// ``` +/// use holdem_core::game::bots::ChumpBot; +/// use holdem_core::game::player::HoldemPlayer; +/// use holdem_core::game::types::PlayerId; +/// +/// let bot = ChumpBot::new(PlayerId(0), "ChumpBot-1".to_string()); +/// assert_eq!(bot.id(), PlayerId(0)); +/// assert_eq!(bot.name(), "ChumpBot-1"); +/// ``` +#[derive(Debug)] +pub struct ChumpBot { + id: PlayerId, + name: String, + hole_cards: Option, +} + +impl ChumpBot { + /// Creates a new [`ChumpBot`] with the given player ID and display name. + pub fn new(id: PlayerId, name: String) -> Self { + Self { + id, + name, + hole_cards: None, + } + } + + /// Returns this player's current stack. + fn stack(&self, game_info: &GameInfo) -> ChipCount { + game_info + .player_by_id(self.id) + .map(|p| p.stack) + .unwrap_or(0) + } + + /// Computes the call amount for this player. + fn call_amount(&self, game_info: &GameInfo) -> ChipCount { + let wagered = game_info + .player_by_id(self.id) + .map(|p| p.wagered_this_round) + .unwrap_or(0); + game_info.current_bet.saturating_sub(wagered) + } + + // ----------------------------------------------------------------------- + // Postflop heuristic evaluation + // ----------------------------------------------------------------------- + + /// Returns `true` if the bot has any pair or better with the board. + fn has_pair_or_better(&self, game_info: &GameInfo) -> bool { + let hc = match self.hole_cards { + Some(hc) => hc, + None => return false, + }; + + let c1 = hc.first_card(); + let c2 = hc.second_card(); + let board = &game_info.board; + + if board.is_empty() { + return false; + } + + // Pocket pair always counts + if hc.is_pair() { + return true; + } + + // Check if either hole card pairs with the board + for card in board { + if card.rank() == c1.rank() || card.rank() == c2.rank() { + return true; + } + } + + // Check for two pair using board cards + let mut board_ranks: Vec = board.iter().map(|c| c.rank()).collect(); + board_ranks.sort_unstable(); + for window in board_ranks.windows(2) { + if window[0] == window[1] { + return true; + } + } + + false + } + + /// Returns `true` if the bot has a flush draw (4 cards of the same suit). + fn has_flush_draw(&self, game_info: &GameInfo) -> bool { + let hc = match self.hole_cards { + Some(hc) => hc, + None => return false, + }; + + let c1 = hc.first_card(); + let c2 = hc.second_card(); + let board = &game_info.board; + + let all_cards = std::iter::once(c1) + .chain(std::iter::once(c2)) + .chain(board.iter().copied()); + + for suit in 0u8..4 { + if all_cards + .clone() + .filter(|c| c.suit() == suit) + .count() + >= 4 + { + return true; + } + } + false + } + + /// Returns `true` if the bot has a straight draw (4 consecutive ranks). + fn has_straight_draw(&self, game_info: &GameInfo) -> bool { + let hc = match self.hole_cards { + Some(hc) => hc, + None => return false, + }; + + let c1 = hc.first_card(); + let c2 = hc.second_card(); + let board = &game_info.board; + + let mut ranks: Vec = std::iter::once(c1.rank()) + .chain(std::iter::once(c2.rank())) + .chain(board.iter().map(|c| c.rank())) + .collect(); + ranks.sort_unstable(); + ranks.dedup(); + + if ranks.len() >= 4 { + // Open-ended: 4 consecutive ranks + for window in ranks.windows(4) { + if window[3] - window[0] == 3 + && window[1] - window[0] == 1 + && window[2] - window[1] == 1 + && window[3] - window[2] == 1 + { + return true; + } + } + + // Gutshot: 4 ranks spanning 5 with exactly one gap + for window in ranks.windows(4) { + let total_span = window[3] - window[0]; + if total_span == 4 { + let gaps = (window[1] - window[0] - 1) + + (window[2] - window[1] - 1) + + (window[3] - window[2] - 1); + if gaps == 1 { + return true; + } + } + } + } + + // Ace-low straight draw: A-2-3-4 + let has_ace = ranks.contains(&12); + if has_ace && ranks.contains(&0) && ranks.contains(&1) && ranks.contains(&2) { + return true; + } + + false + } + + // ----------------------------------------------------------------------- + // Preflop action logic + // ----------------------------------------------------------------------- + + fn act_preflop(&self, game_info: &GameInfo) -> Action { + let call_amount = self.call_amount(game_info); + let stack = self.stack(game_info); + + if call_amount == 0 { + // No bet: check (never raise preflop) + Action::Check + } else { + // Call any bet, unless facing an all-in that costs more than half + // the stack + if call_amount > stack / 2 && stack > call_amount { + // Facing a bet that costs more than half our stack — fold + Action::Fold + } else if stack <= call_amount { + // Can't afford full call — go all-in + Action::AllIn + } else { + Action::Call + } + } + } + + // ----------------------------------------------------------------------- + // Postflop action logic + // ----------------------------------------------------------------------- + + fn act_postflop(&self, game_info: &GameInfo) -> Action { + let call_amount = self.call_amount(game_info); + let stack = self.stack(game_info); + + // Has any pair or better: call (never raise) + if self.has_pair_or_better(game_info) { + if call_amount == 0 { + Action::Check + } else if stack <= call_amount { + Action::AllIn + } else { + Action::Call + } + } else if self.has_flush_draw(game_info) || self.has_straight_draw(game_info) { + // Has a draw: call + if call_amount == 0 { + Action::Check + } else if stack <= call_amount { + Action::AllIn + } else { + Action::Call + } + } else { + // Nothing: check if free, call small bets (≤ pot/2), fold to large bets + if call_amount == 0 { + Action::Check + } else if call_amount <= game_info.pot / 2 { + if stack <= call_amount { + Action::AllIn + } else { + Action::Call + } + } else { + Action::Fold + } + } + } +} + +impl HoldemPlayer for ChumpBot { + fn id(&self) -> PlayerId { + self.id + } + + fn name(&self) -> &str { + &self.name + } + + fn init(&mut self, _game_info: &GameInfo) {} + + fn game_start_event(&mut self, _game_info: &GameInfo) {} + + fn hole_cards_dealt(&mut self, hole_cards: &HoleCards, _game_info: &GameInfo) { + self.hole_cards = Some(*hole_cards); + } + + fn stage_event(&mut self, _game_info: &GameInfo) {} + + fn act(&mut self, game_info: &GameInfo) -> Action { + match game_info.street { + Street::Preflop => self.act_preflop(game_info), + _ => self.act_postflop(game_info), + } + } + + fn action_event( + &mut self, + _player_id: PlayerId, + _action: &Action, + _game_info: &GameInfo, + ) { + } + + fn showdown_event( + &mut self, + _player_id: PlayerId, + _hole_cards: Option<&HoleCards>, + _hand_value: Option<&HandValue>, + _game_info: &GameInfo, + ) { + } + + fn game_over_event(&mut self, _game_info: &GameInfo) {} + + fn on_eliminated(&self, _placement: u32) {} +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::betting::BetLimit; + use crate::game::player_info::{PlayerInfo, PlayerState}; + use crate::Card; + use std::str::FromStr; + + /// Helper to build a minimal [`GameInfo`] for testing. + fn make_game_info( + current_bet: ChipCount, + pot: ChipCount, + stack: ChipCount, + wagered_this_round: ChipCount, + street: Street, + board: Vec, + ) -> GameInfo { + GameInfo { + num_seats: 2, + bet_limit: BetLimit::NoLimit { min_raise: 20 }, + small_blind: 10, + big_blind: 20, + ante: 0, + street, + board, + pot, + current_bet, + min_raise: 20, + button_seat: 0, + small_blind_seat: 0, + big_blind_seat: 1, + next_to_act: Some(0), + players: vec![Some(PlayerInfo { + id: PlayerId(0), + name: "ChumpBot".to_string(), + seat: 0, + stack, + wagered_this_round, + wagered_this_hand: wagered_this_round, + state: PlayerState::Active, + has_acted_this_round: false, + is_dealer: true, + is_small_blind: false, + is_big_blind: false, + bankroll: 0, + })], + actions_this_hand: vec![], + num_raises: 0, + hand_number: 1, + active_players: 2, + } + } + + #[test] + fn chump_bot_calls_any_bet_preflop() { + let mut bot = ChumpBot::new(PlayerId(0), "ChumpBot".to_string()); + bot.hole_cards = Some( + HoleCards::new( + Card::from_str("7c").unwrap(), + Card::from_str("2d").unwrap(), + ) + .unwrap(), + ); + // Even 72 offsuit — call + let game_info = make_game_info(40, 60, 1000, 0, Street::Preflop, vec![]); + assert_eq!(bot.act(&game_info), Action::Call); + } + + #[test] + fn chump_bot_checks_when_free_preflop() { + let mut bot = ChumpBot::new(PlayerId(0), "ChumpBot".to_string()); + bot.hole_cards = Some( + HoleCards::new( + Card::from_str("7c").unwrap(), + Card::from_str("2d").unwrap(), + ) + .unwrap(), + ); + let game_info = make_game_info(0, 30, 1000, 0, Street::Preflop, vec![]); + assert_eq!(bot.act(&game_info), Action::Check); + } + + #[test] + fn chump_bot_folds_preflop_to_massive_bet() { + let mut bot = ChumpBot::new(PlayerId(0), "ChumpBot".to_string()); + bot.hole_cards = Some( + HoleCards::new( + Card::from_str("7c").unwrap(), + Card::from_str("2d").unwrap(), + ) + .unwrap(), + ); + // Facing a bet of 600 (> half of 1000 stack) + let game_info = make_game_info(600, 620, 1000, 0, Street::Preflop, vec![]); + assert_eq!(bot.act(&game_info), Action::Fold); + } + + #[test] + fn chump_bot_calls_with_pair_postflop() { + let mut bot = ChumpBot::new(PlayerId(0), "ChumpBot".to_string()); + // K7 with K on board — pair of Kings + bot.hole_cards = Some( + HoleCards::new( + Card::from_str("Ks").unwrap(), + Card::from_str("7c").unwrap(), + ) + .unwrap(), + ); + let board = vec![ + Card::from_str("Kd").unwrap(), + Card::from_str("4h").unwrap(), + Card::from_str("2c").unwrap(), + ]; + let game_info = make_game_info(80, 120, 1000, 0, Street::Flop, board); + assert_eq!(bot.act(&game_info), Action::Call); + } + + #[test] + fn chump_bot_calls_small_bet_with_nothing_postflop() { + let mut bot = ChumpBot::new(PlayerId(0), "ChumpBot".to_string()); + bot.hole_cards = Some( + HoleCards::new( + Card::from_str("3s").unwrap(), + Card::from_str("2c").unwrap(), + ) + .unwrap(), + ); + let board = vec![ + Card::from_str("Kd").unwrap(), + Card::from_str("Qh").unwrap(), + Card::from_str("Jc").unwrap(), + ]; + // Pot is 120, bet is 40 (≤ pot/2 = 60) — call + let game_info = make_game_info(40, 120, 1000, 0, Street::Flop, board); + assert_eq!(bot.act(&game_info), Action::Call); + } + + #[test] + fn chump_bot_folds_to_large_bet_with_nothing_postflop() { + let mut bot = ChumpBot::new(PlayerId(0), "ChumpBot".to_string()); + bot.hole_cards = Some( + HoleCards::new( + Card::from_str("3s").unwrap(), + Card::from_str("2c").unwrap(), + ) + .unwrap(), + ); + let board = vec![ + Card::from_str("Kd").unwrap(), + Card::from_str("Qh").unwrap(), + Card::from_str("Jc").unwrap(), + ]; + // Pot is 120, bet is 100 (> pot/2 = 60) — fold + let game_info = make_game_info(100, 120, 1000, 0, Street::Flop, board); + assert_eq!(bot.act(&game_info), Action::Fold); + } + + #[test] + fn chump_bot_checks_with_nothing_when_free_postflop() { + let mut bot = ChumpBot::new(PlayerId(0), "ChumpBot".to_string()); + bot.hole_cards = Some( + HoleCards::new( + Card::from_str("3s").unwrap(), + Card::from_str("2c").unwrap(), + ) + .unwrap(), + ); + let board = vec![ + Card::from_str("Kd").unwrap(), + Card::from_str("Qh").unwrap(), + Card::from_str("Jc").unwrap(), + ]; + let game_info = make_game_info(0, 60, 1000, 0, Street::Flop, board); + assert_eq!(bot.act(&game_info), Action::Check); + } + + #[test] + fn chump_bot_calls_with_flush_draw() { + let mut bot = ChumpBot::new(PlayerId(0), "ChumpBot".to_string()); + // Two spades + bot.hole_cards = Some( + HoleCards::new( + Card::from_str("As").unwrap(), + Card::from_str("9s").unwrap(), + ) + .unwrap(), + ); + // Board with two more spades — flush draw + let board = vec![ + Card::from_str("Ks").unwrap(), + Card::from_str("4s").unwrap(), + Card::from_str("2c").unwrap(), + ]; + let game_info = make_game_info(80, 120, 1000, 0, Street::Flop, board); + assert_eq!(bot.act(&game_info), Action::Call); + } + + #[test] + fn chump_bot_trait_methods_are_no_ops() { + let mut bot = ChumpBot::new(PlayerId(0), "ChumpBot".to_string()); + let game_info = make_game_info(0, 30, 1000, 0, Street::Preflop, vec![]); + + bot.init(&game_info); + bot.game_start_event(&game_info); + bot.hole_cards_dealt( + &HoleCards::new( + Card::from_str("As").unwrap(), + Card::from_str("Kh").unwrap(), + ) + .unwrap(), + &game_info, + ); + bot.stage_event(&game_info); + bot.action_event(PlayerId(1), &Action::Call, &game_info); + bot.showdown_event(PlayerId(1), None, None, &game_info); + bot.game_over_event(&game_info); + bot.on_eliminated(3); + } +} diff --git a/holdem_core/src/game/bots/flock_bot.rs b/holdem_core/src/game/bots/flock_bot.rs new file mode 100644 index 0000000..0bc8319 --- /dev/null +++ b/holdem_core/src/game/bots/flock_bot.rs @@ -0,0 +1,868 @@ +//! A tight-aggressive bot that only enters pots with strong hands. +//! +//! [`FlockBot`] plays a selective, aggressive strategy: it folds most hands +//! preflop, raises with premium holdings, and plays postflop based on +//! heuristic hand strength evaluation. + +use crate::game::action::Action; +use crate::game::game_info::GameInfo; +use crate::game::player::HoldemPlayer; +use crate::game::types::{ChipCount, PlayerId}; +use crate::{Card, HandValue, HoleCards, Street}; + +// Rank constants (0=Two, 12=Ace) +const RANK_JACK: u8 = 9; +const RANK_TEN: u8 = 8; +const RANK_NINE: u8 = 7; +const RANK_SEVEN: u8 = 5; +const RANK_ACE: u8 = 12; +const RANK_KING: u8 = 11; +const RANK_QUEEN: u8 = 10; + +/// Preflop hand strength classification. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum PreflopStrength { + /// Not playable — fold if there's any bet. + None, + /// Top ~15%: 88+, ATs+, KJs+, QJs, AQo. + Playable, + /// Top ~10%: TT+, 99, AQs+, AJs+, KQs, AKo. + Strong, + /// Top ~3%: AA, KK, QQ, JJ, AKs. + Premium, +} + +/// Postflop hand strength classification. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum PostflopStrength { + /// Nothing — no pair, no draw. + Nothing, + /// Decent hand — middle pair or better, any pair with hole cards. + Decent, + /// Strong made hand — pair of Jacks or better using board + hole cards, + /// two pair, trips, straight, flush, full house, etc. + Strong, +} + +/// A **tight-aggressive** poker bot. +/// +/// FlockBot only enters pots with strong hands (top 15% preflop), raises or +/// re-raises with premium holdings, and folds marginal hands under pressure. +/// Postflop it uses heuristic hand evaluation to decide whether to check, call, +/// or raise. +/// +/// # Examples +/// +/// ``` +/// use holdem_core::game::bots::FlockBot; +/// use holdem_core::game::player::HoldemPlayer; +/// use holdem_core::game::types::PlayerId; +/// +/// let bot = FlockBot::new(PlayerId(0), "FlockBot-1".to_string()); +/// assert_eq!(bot.id(), PlayerId(0)); +/// assert_eq!(bot.name(), "FlockBot-1"); +/// ``` +#[derive(Debug)] +pub struct FlockBot { + id: PlayerId, + name: String, + hole_cards: Option, +} + +impl FlockBot { + /// Creates a new [`FlockBot`] with the given player ID and display name. + pub fn new(id: PlayerId, name: String) -> Self { + Self { + id, + name, + hole_cards: None, + } + } + + /// Returns this player's current stack. + fn stack(&self, game_info: &GameInfo) -> ChipCount { + game_info + .player_by_id(self.id) + .map(|p| p.stack) + .unwrap_or(0) + } + + /// Computes the call amount for this player. + fn call_amount(&self, game_info: &GameInfo) -> ChipCount { + let wagered = game_info + .player_by_id(self.id) + .map(|p| p.wagered_this_round) + .unwrap_or(0); + game_info.current_bet.saturating_sub(wagered) + } + + /// Returns the wagered amount this round. + fn wagered_this_round(&self, game_info: &GameInfo) -> ChipCount { + game_info + .player_by_id(self.id) + .map(|p| p.wagered_this_round) + .unwrap_or(0) + } + + /// Classifies hole cards into a preflop strength category. + fn classify_preflop(&self) -> PreflopStrength { + let hc = match self.hole_cards { + Some(hc) => hc, + None => return PreflopStrength::None, + }; + + let r1 = hc.first_card().rank(); + let r2 = hc.second_card().rank(); + let suited = hc.is_suited(); + let pair = hc.is_pair(); + + // Premium: AA, KK, QQ, JJ, AKs + if pair && r1 >= RANK_JACK { + return PreflopStrength::Premium; + } + if r1 == RANK_ACE && r2 == RANK_KING && suited { + return PreflopStrength::Premium; + } + + // Strong: TT, 99, AQs, AJs, KQs, AKo + if pair && r1 >= RANK_NINE { + return PreflopStrength::Strong; + } + if r1 == RANK_ACE && r2 == RANK_QUEEN && suited { + return PreflopStrength::Strong; + } + if r1 == RANK_ACE && r2 == RANK_JACK && suited { + return PreflopStrength::Strong; + } + if r1 == RANK_KING && r2 == RANK_QUEEN && suited { + return PreflopStrength::Strong; + } + if r1 == RANK_ACE && r2 == RANK_KING { + // AKo (offsuit) is strong + return PreflopStrength::Strong; + } + + // Playable: 88, 77, ATs, KJs, QJs, AQo + if pair && r1 >= RANK_SEVEN { + return PreflopStrength::Playable; + } + if r1 == RANK_ACE && r2 == RANK_TEN && suited { + return PreflopStrength::Playable; + } + if r1 == RANK_KING && r2 == RANK_JACK && suited { + return PreflopStrength::Playable; + } + if r1 == RANK_QUEEN && r2 == RANK_JACK && suited { + return PreflopStrength::Playable; + } + if r1 == RANK_ACE && r2 == RANK_QUEEN { + // AQo (offsuit) + return PreflopStrength::Playable; + } + + PreflopStrength::None + } + + + /// Computes a legal raise total. + /// + /// Returns `None` if raising is not possible (stack too small, raise + /// limit reached, etc.). + fn compute_raise_total( + &self, + game_info: &GameInfo, + target_total: ChipCount, + ) -> Option { + let stack = self.stack(game_info); + let call_amount = self.call_amount(game_info); + let wagered = self.wagered_this_round(game_info); + let max_total = wagered + stack; + + // Can't raise if we can't even call + if stack <= call_amount { + return None; + } + + let min_raise_total = game_info.current_bet + game_info.min_raise; + + // Ensure target is at least the minimum raise + let effective_total = target_total.max(min_raise_total); + + if effective_total > max_total { + // Can't afford the raise; go all-in instead + Some(Action::AllIn) + } else { + Some(Action::Raise { + total: effective_total, + }) + } + } + + /// Returns a pot-sized raise action, or all-in if that exceeds the stack. + fn pot_sized_raise(&self, game_info: &GameInfo) -> Option { + let pot_raise_total = game_info.current_bet + game_info.pot + game_info.min_raise; + self.compute_raise_total(game_info, pot_raise_total) + } + + // ----------------------------------------------------------------------- + // Postflop heuristic evaluation + // ----------------------------------------------------------------------- + + /// Evaluates postflop hand strength using simple heuristics. + fn evaluate_postflop(&self, game_info: &GameInfo) -> PostflopStrength { + let hc = match self.hole_cards { + Some(hc) => hc, + None => return PostflopStrength::Nothing, + }; + + let c1 = hc.first_card(); + let c2 = hc.second_card(); + let board = &game_info.board; + + if board.is_empty() { + return PostflopStrength::Nothing; + } + + // Count pairs with the board + let mut pair_count = 0u8; + let mut high_pair_rank = 0u8; + for card in board { + if card.rank() == c1.rank() { + pair_count += 1; + high_pair_rank = high_pair_rank.max(c1.rank()); + } + if card.rank() == c2.rank() { + pair_count += 1; + high_pair_rank = high_pair_rank.max(c2.rank()); + } + } + + // Pocket pair higher than any board card counts as "strong" + if hc.is_pair() { + let pocket_rank = c1.rank(); + let max_board_rank = board.iter().map(|c| c.rank()).max().unwrap_or(0); + if pocket_rank > max_board_rank { + return PostflopStrength::Strong; + } + } + + // Check for trips or better + if pair_count >= 2 { + return PostflopStrength::Strong; + } + + // Check for flush (5 cards of same suit between hole cards and board) + if Self::has_flush(c1, c2, board) { + return PostflopStrength::Strong; + } + + // Check for straight + if Self::has_straight(c1, c2, board) { + return PostflopStrength::Strong; + } + + // One pair + if pair_count == 1 { + // Strong if the pair rank is Jack or higher + if high_pair_rank >= RANK_JACK { + return PostflopStrength::Strong; + } + // Otherwise decent (middle pair or low pair) + return PostflopStrength::Decent; + } + + // Check for flush draw (4 cards of same suit) + if Self::has_flush_draw(c1, c2, board) { + return PostflopStrength::Decent; + } + + // Check for straight draw (open-ended or gutshot with 4 consecutive) + if Self::has_straight_draw(c1, c2, board) { + return PostflopStrength::Decent; + } + + // Two overcards (both hole cards higher than all board cards) + let max_board_rank = board.iter().map(|c| c.rank()).max().unwrap_or(255); + if c1.rank() > max_board_rank && c2.rank() > max_board_rank { + return PostflopStrength::Decent; + } + + PostflopStrength::Nothing + } + + /// Checks if the combined cards form a flush. + fn has_flush(c1: Card, c2: Card, board: &[Card]) -> bool { + let all_cards = std::iter::once(c1) + .chain(std::iter::once(c2)) + .chain(board.iter().copied()); + + for suit in 0u8..4 { + if all_cards + .clone() + .filter(|c| c.suit() == suit) + .count() + >= 5 + { + return true; + } + } + false + } + + /// Checks if the combined cards form a straight. + fn has_straight(c1: Card, c2: Card, board: &[Card]) -> bool { + let mut ranks: Vec = std::iter::once(c1.rank()) + .chain(std::iter::once(c2.rank())) + .chain(board.iter().map(|c| c.rank())) + .collect(); + ranks.sort_unstable(); + ranks.dedup(); + + // Check for 5 consecutive ranks + if ranks.len() >= 5 { + for window in ranks.windows(5) { + if window[4] - window[0] == 4 + && window[1] - window[0] == 1 + && window[2] - window[1] == 1 + && window[3] - window[2] == 1 + && window[4] - window[3] == 1 + { + return true; + } + } + } + + // Check for wheel (A-2-3-4-5) + let has_ace = ranks.contains(&RANK_ACE); + if has_ace && ranks.contains(&0) && ranks.contains(&1) && ranks.contains(&2) && ranks.contains(&3) + { + return true; + } + + false + } + + /// Checks for a flush draw (4 cards of the same suit). + fn has_flush_draw(c1: Card, c2: Card, board: &[Card]) -> bool { + let all_cards = std::iter::once(c1) + .chain(std::iter::once(c2)) + .chain(board.iter().copied()); + + for suit in 0u8..4 { + if all_cards + .clone() + .filter(|c| c.suit() == suit) + .count() + >= 4 + { + return true; + } + } + false + } + + /// Checks for a straight draw (4 consecutive ranks). + fn has_straight_draw(c1: Card, c2: Card, board: &[Card]) -> bool { + let mut ranks: Vec = std::iter::once(c1.rank()) + .chain(std::iter::once(c2.rank())) + .chain(board.iter().map(|c| c.rank())) + .collect(); + ranks.sort_unstable(); + ranks.dedup(); + + if ranks.len() >= 4 { + // Open-ended straight draw: 4 consecutive ranks + for window in ranks.windows(4) { + if window[3] - window[0] == 3 + && window[1] - window[0] == 1 + && window[2] - window[1] == 1 + && window[3] - window[2] == 1 + { + return true; + } + } + + // Gutshot: 4 ranks with exactly one gap of size 2 + for window in ranks.windows(4) { + let total_span = window[3] - window[0]; + if total_span == 4 { + let gaps = (window[1] - window[0] - 1) + + (window[2] - window[1] - 1) + + (window[3] - window[2] - 1); + if gaps == 1 { + return true; + } + } + } + } + + // Ace-low straight draw: A-2-3-4 + let has_ace = ranks.contains(&RANK_ACE); + if has_ace && ranks.contains(&0) && ranks.contains(&1) && ranks.contains(&2) { + return true; + } + if has_ace && ranks.contains(&0) && ranks.contains(&1) && ranks.contains(&3) { + return true; + } + if has_ace && ranks.contains(&0) && ranks.contains(&2) && ranks.contains(&3) { + return true; + } + + false + } + + // ----------------------------------------------------------------------- + // Preflop action logic + // ----------------------------------------------------------------------- + + fn act_preflop(&self, game_info: &GameInfo) -> Action { + let strength = self.classify_preflop(); + let call_amount = self.call_amount(game_info); + let stack = self.stack(game_info); + let big_blind = game_info.big_blind; + let num_raises = game_info.num_raises; + + match strength { + PreflopStrength::None => { + // Fold if there's any bet, check if free + if call_amount == 0 { + Action::Check + } else { + Action::Fold + } + } + PreflopStrength::Playable => { + // Call if cheap (bet ≤ 3x BB), fold if expensive + if call_amount == 0 { + Action::Check + } else if call_amount <= 3 * big_blind { + if stack <= call_amount { + Action::AllIn + } else { + Action::Call + } + } else { + Action::Fold + } + } + PreflopStrength::Strong => { + // num_raises is the count of voluntary raises (excludes blinds). + if num_raises == 0 { + // No voluntary raise: raise to 3x BB + let raise_target = 3 * big_blind; + self.compute_raise_total(game_info, raise_target) + .unwrap_or_else(|| { + if stack <= call_amount { + Action::AllIn + } else { + Action::Call + } + }) + } else if num_raises == 1 { + // Facing one voluntary raise: call + if stack <= call_amount { + Action::AllIn + } else { + Action::Call + } + } else { + // 2+ voluntary raises: fold (not premium) + Action::Fold + } + } + PreflopStrength::Premium => { + // 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); + self.compute_raise_total(game_info, raise_target) + .unwrap_or(Action::AllIn) + } + } + } + + // ----------------------------------------------------------------------- + // Postflop action logic + // ----------------------------------------------------------------------- + + fn act_postflop(&self, game_info: &GameInfo) -> Action { + let strength = self.evaluate_postflop(game_info); + let call_amount = self.call_amount(game_info); + let stack = self.stack(game_info); + + match strength { + PostflopStrength::Nothing => { + // Check if free, fold if there's a bet + if call_amount == 0 { + Action::Check + } else { + Action::Fold + } + } + PostflopStrength::Decent => { + // Call + if call_amount == 0 { + Action::Check + } else if stack <= call_amount { + Action::AllIn + } else { + Action::Call + } + } + PostflopStrength::Strong => { + // Raise pot-sized or max legal raise + self.pot_sized_raise(game_info).unwrap_or_else(|| { + // Can't raise (e.g., stack is 0); just call or check + if call_amount == 0 { + Action::Check + } else if stack <= call_amount { + Action::AllIn + } else { + Action::Call + } + }) + } + } + } +} + +impl HoldemPlayer for FlockBot { + fn id(&self) -> PlayerId { + self.id + } + + fn name(&self) -> &str { + &self.name + } + + fn init(&mut self, _game_info: &GameInfo) {} + + fn game_start_event(&mut self, _game_info: &GameInfo) {} + + fn hole_cards_dealt(&mut self, hole_cards: &HoleCards, _game_info: &GameInfo) { + self.hole_cards = Some(*hole_cards); + } + + fn stage_event(&mut self, _game_info: &GameInfo) {} + + fn act(&mut self, game_info: &GameInfo) -> Action { + match game_info.street { + Street::Preflop => self.act_preflop(game_info), + _ => self.act_postflop(game_info), + } + } + + fn action_event( + &mut self, + _player_id: PlayerId, + _action: &Action, + _game_info: &GameInfo, + ) { + } + + fn showdown_event( + &mut self, + _player_id: PlayerId, + _hole_cards: Option<&HoleCards>, + _hand_value: Option<&HandValue>, + _game_info: &GameInfo, + ) { + } + + fn game_over_event(&mut self, _game_info: &GameInfo) {} + + fn on_eliminated(&self, _placement: u32) {} +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::betting::BetLimit; + use crate::game::player_info::{PlayerInfo, PlayerState}; + use std::str::FromStr; + + /// Helper to build a minimal [`GameInfo`] for testing. + fn make_game_info( + current_bet: ChipCount, + pot: ChipCount, + stack: ChipCount, + wagered_this_round: ChipCount, + street: Street, + board: Vec, + ) -> GameInfo { + GameInfo { + num_seats: 2, + bet_limit: BetLimit::NoLimit { min_raise: 20 }, + small_blind: 10, + big_blind: 20, + ante: 0, + street, + board, + pot, + current_bet, + min_raise: 20, + button_seat: 0, + small_blind_seat: 0, + big_blind_seat: 1, + next_to_act: Some(0), + players: vec![Some(PlayerInfo { + id: PlayerId(0), + name: "FlockBot".to_string(), + seat: 0, + stack, + wagered_this_round, + wagered_this_hand: wagered_this_round, + state: PlayerState::Active, + has_acted_this_round: false, + is_dealer: true, + is_small_blind: false, + is_big_blind: false, + bankroll: 0, + })], + actions_this_hand: vec![], + num_raises: 0, + hand_number: 1, + active_players: 2, + } + } + + #[test] + fn flock_bot_folds_weak_hand_preflop_facing_bet() { + let mut bot = FlockBot::new(PlayerId(0), "FlockBot".to_string()); + // 72 offsuit — weak hand + bot.hole_cards = Some( + HoleCards::new( + Card::from_str("7c").unwrap(), + Card::from_str("2d").unwrap(), + ) + .unwrap(), + ); + let game_info = make_game_info(40, 60, 1000, 0, Street::Preflop, vec![]); + assert_eq!(bot.act(&game_info), Action::Fold); + } + + #[test] + fn flock_bot_checks_weak_hand_preflop_when_free() { + let mut bot = FlockBot::new(PlayerId(0), "FlockBot".to_string()); + // 72 offsuit — weak hand + bot.hole_cards = Some( + HoleCards::new( + Card::from_str("7c").unwrap(), + Card::from_str("2d").unwrap(), + ) + .unwrap(), + ); + let game_info = make_game_info(0, 30, 1000, 0, Street::Preflop, vec![]); + assert_eq!(bot.act(&game_info), Action::Check); + } + + #[test] + fn flock_bot_raises_premium_hand_preflop() { + let mut bot = FlockBot::new(PlayerId(0), "FlockBot".to_string()); + // AA — premium + bot.hole_cards = Some( + HoleCards::new( + Card::from_str("As").unwrap(), + Card::from_str("Ac").unwrap(), + ) + .unwrap(), + ); + let game_info = make_game_info(0, 30, 1000, 0, Street::Preflop, vec![]); + let action = bot.act(&game_info); + // Should raise to 3x BB = 60 + assert!(matches!(action, Action::Raise { total: 60 })); + } + + #[test] + fn flock_bot_calls_playable_hand_preflop() { + let mut bot = FlockBot::new(PlayerId(0), "FlockBot".to_string()); + // 88 — playable + bot.hole_cards = Some( + HoleCards::new( + Card::from_str("8s").unwrap(), + Card::from_str("8c").unwrap(), + ) + .unwrap(), + ); + // Facing a bet of 40 (2x BB = within 3x BB) + let game_info = make_game_info(40, 60, 1000, 0, Street::Preflop, vec![]); + assert_eq!(bot.act(&game_info), Action::Call); + } + + #[test] + fn flock_bot_folds_playable_hand_to_big_raise() { + let mut bot = FlockBot::new(PlayerId(0), "FlockBot".to_string()); + // 88 — playable but not worth a huge bet + bot.hole_cards = Some( + HoleCards::new( + Card::from_str("8s").unwrap(), + Card::from_str("8c").unwrap(), + ) + .unwrap(), + ); + // Facing a raise of 100 (> 3x BB = 60) + let game_info = make_game_info(100, 120, 1000, 0, Street::Preflop, vec![]); + assert_eq!(bot.act(&game_info), Action::Fold); + } + + #[test] + fn flock_bot_folds_nothing_postflop_facing_bet() { + let mut bot = FlockBot::new(PlayerId(0), "FlockBot".to_string()); + bot.hole_cards = Some( + HoleCards::new( + Card::from_str("3s").unwrap(), + Card::from_str("2c").unwrap(), + ) + .unwrap(), + ); + let board = vec![ + Card::from_str("Kd").unwrap(), + Card::from_str("Qh").unwrap(), + Card::from_str("Jc").unwrap(), + ]; + let game_info = make_game_info(40, 80, 1000, 0, Street::Flop, board); + assert_eq!(bot.act(&game_info), Action::Fold); + } + + #[test] + fn flock_bot_checks_nothing_postflop_when_free() { + let mut bot = FlockBot::new(PlayerId(0), "FlockBot".to_string()); + bot.hole_cards = Some( + HoleCards::new( + Card::from_str("3s").unwrap(), + Card::from_str("2c").unwrap(), + ) + .unwrap(), + ); + let board = vec![ + Card::from_str("Kd").unwrap(), + Card::from_str("Qh").unwrap(), + Card::from_str("Jc").unwrap(), + ]; + let game_info = make_game_info(0, 60, 1000, 0, Street::Flop, board); + assert_eq!(bot.act(&game_info), Action::Check); + } + + #[test] + fn flock_bot_raises_strong_postflop() { + let mut bot = FlockBot::new(PlayerId(0), "FlockBot".to_string()); + // KQ with K-Q-J on board — pair of Kings (strong) + bot.hole_cards = Some( + HoleCards::new( + Card::from_str("Ks").unwrap(), + Card::from_str("Qc").unwrap(), + ) + .unwrap(), + ); + let board = vec![ + Card::from_str("Kd").unwrap(), + Card::from_str("Jh").unwrap(), + Card::from_str("4c").unwrap(), + ]; + let game_info = make_game_info(0, 60, 1000, 0, Street::Flop, board); + let action = bot.act(&game_info); + // Should raise (pot-sized) + assert!( + matches!(action, Action::Raise { .. } | Action::AllIn), + "Expected raise or all-in for strong hand, got {:?}", + action + ); + } + + #[test] + fn flock_bot_calls_decent_postflop() { + let mut bot = FlockBot::new(PlayerId(0), "FlockBot".to_string()); + // 97 with 9-x-x on board — middle pair (decent) + bot.hole_cards = Some( + HoleCards::new( + Card::from_str("9s").unwrap(), + Card::from_str("7c").unwrap(), + ) + .unwrap(), + ); + let board = vec![ + Card::from_str("9d").unwrap(), + Card::from_str("4h").unwrap(), + Card::from_str("2c").unwrap(), + ]; + let game_info = make_game_info(40, 80, 1000, 0, Street::Flop, board); + assert_eq!(bot.act(&game_info), Action::Call); + } + + #[test] + fn flock_bot_raises_strong_hand_when_facing_limper() { + let mut bot = FlockBot::new(PlayerId(0), "FlockBot".to_string()); + // AKo — strong hand + bot.hole_cards = Some( + HoleCards::new( + Card::from_str("As").unwrap(), + Card::from_str("Kc").unwrap(), + ) + .unwrap(), + ); + // Someone limped (call_amount = BB = 20, no voluntary raises) + let mut game_info = make_game_info(20, 40, 1000, 0, Street::Preflop, vec![]); + game_info.num_raises = 0; // No voluntary raises yet + let action = bot.act(&game_info); + // Should raise to 3x BB = 60, not just call + assert!( + matches!(action, Action::Raise { total: 60 }), + "Expected raise to 60 for strong hand facing limp, got {:?}", + action + ); + } + + #[test] + fn flock_bot_calls_strong_hand_facing_one_raise() { + let mut bot = FlockBot::new(PlayerId(0), "FlockBot".to_string()); + // AKo — strong hand + bot.hole_cards = Some( + HoleCards::new( + Card::from_str("As").unwrap(), + Card::from_str("Kc").unwrap(), + ) + .unwrap(), + ); + // Facing one voluntary raise to 60 (3x BB) + let mut game_info = make_game_info(60, 80, 1000, 0, Street::Preflop, vec![]); + game_info.num_raises = 1; // One voluntary raise + assert_eq!(bot.act(&game_info), Action::Call); + } + + #[test] + fn flock_bot_folds_strong_hand_facing_3bet() { + let mut bot = FlockBot::new(PlayerId(0), "FlockBot".to_string()); + // AKo — strong hand but not premium + bot.hole_cards = Some( + HoleCards::new( + Card::from_str("As").unwrap(), + Card::from_str("Kc").unwrap(), + ) + .unwrap(), + ); + // Facing a 3-bet to 180 (2 voluntary raises) + let mut game_info = make_game_info(180, 240, 1000, 0, Street::Preflop, vec![]); + game_info.num_raises = 2; // Two voluntary raises + assert_eq!(bot.act(&game_info), Action::Fold); + } + + #[test] + fn flock_bot_trait_methods_are_no_ops() { + let mut bot = FlockBot::new(PlayerId(0), "FlockBot".to_string()); + let game_info = make_game_info(0, 30, 1000, 0, Street::Preflop, vec![]); + + bot.init(&game_info); + bot.game_start_event(&game_info); + bot.hole_cards_dealt( + &HoleCards::new( + Card::from_str("As").unwrap(), + Card::from_str("Kh").unwrap(), + ) + .unwrap(), + &game_info, + ); + bot.stage_event(&game_info); + bot.action_event(PlayerId(1), &Action::Call, &game_info); + bot.showdown_event(PlayerId(1), None, None, &game_info); + bot.game_over_event(&game_info); + bot.on_eliminated(3); + } +} diff --git a/holdem_core/src/game/bots/fold_bot.rs b/holdem_core/src/game/bots/fold_bot.rs index 17e1384..cef0f4f 100644 --- a/holdem_core/src/game/bots/fold_bot.rs +++ b/holdem_core/src/game/bots/fold_bot.rs @@ -127,8 +127,10 @@ mod tests { is_dealer: true, is_small_blind: false, is_big_blind: false, + bankroll: 0, })], actions_this_hand: vec![], + num_raises: 0, hand_number: 1, active_players: 2, } diff --git a/holdem_core/src/game/bots/mod.rs b/holdem_core/src/game/bots/mod.rs index 7651506..049fc73 100644 --- a/holdem_core/src/game/bots/mod.rs +++ b/holdem_core/src/game/bots/mod.rs @@ -12,11 +12,13 @@ //! | [`FoldBot`] | Ultra-passive | Folds to any bet, checks otherwise | //! | [`RaiseBot`] | Ultra-aggressive | Always raises the maximum allowed amount | //! | [`RandomBot`] | Random | Picks a random legal action at each decision point | +//! | [`FlockBot`] | Tight-aggressive | Plays few hands aggressively with strong holdings | +//! | [`ChumpBot`] | Loose-passive | Calling station that rarely folds or raises | //! //! # Usage //! //! ```rust -//! use holdem_core::game::bots::{CallBot, FoldBot, RaiseBot, RandomBot}; +//! use holdem_core::game::bots::{CallBot, FoldBot, RaiseBot, RandomBot, FlockBot, ChumpBot}; //! use holdem_core::game::player::HoldemPlayer; //! use holdem_core::game::types::PlayerId; //! @@ -24,15 +26,21 @@ //! let fold_bot = FoldBot::new(PlayerId(2), "Folder".to_string()); //! let raise_bot = RaiseBot::new(PlayerId(3), "Raiser".to_string()); //! let random_bot = RandomBot::new(PlayerId(4), "Random".to_string()); +//! let flock_bot = FlockBot::new(PlayerId(5), "Flock".to_string()); +//! let chump_bot = ChumpBot::new(PlayerId(6), "Chump".to_string()); //! ``` pub mod call_bot; +pub mod chump_bot; +pub mod flock_bot; pub mod fold_bot; pub mod random_bot; pub mod raise_bot; // Re-export all bots at the module level for convenience. pub use call_bot::CallBot; +pub use chump_bot::ChumpBot; +pub use flock_bot::FlockBot; pub use fold_bot::FoldBot; pub use random_bot::RandomBot; pub use raise_bot::RaiseBot; diff --git a/holdem_core/src/game/bots/raise_bot.rs b/holdem_core/src/game/bots/raise_bot.rs index a2150fd..9a6ffd9 100644 --- a/holdem_core/src/game/bots/raise_bot.rs +++ b/holdem_core/src/game/bots/raise_bot.rs @@ -149,8 +149,8 @@ impl HoldemPlayer for RaiseBot { .unwrap_or(small_bet); // Check if raises are still allowed (typically max 3 raises per round) - // We approximate raises_this_round from actions - let raises_this_round = count_raises(game_info); + // Use the authoritative raise counter from GameInfo. + let raises_this_round = game_info.num_raises; if !game_info.bet_limit.can_raise(game_info.street, raises_this_round) { // Can't raise anymore, just call @@ -197,30 +197,6 @@ impl HoldemPlayer for RaiseBot { fn on_eliminated(&self, _placement: u32) {} } -/// Count the number of raises in the current betting round from the action history. -/// -/// Both [`Action::Raise`] and [`Action::AllIn`] are counted as raises for the -/// purpose of the Fixed-Limit cap check. -fn count_raises(game_info: &GameInfo) -> usize { - let street_index = match game_info.street { - crate::Street::Preflop => 0, - crate::Street::Flop => 1, - crate::Street::Turn => 2, - crate::Street::River => 3, - }; - - game_info - .actions_this_hand - .get(street_index) - .map(|actions| { - actions - .iter() - .filter(|(_, a)| matches!(a, Action::Raise { .. } | Action::AllIn)) - .count() - }) - .unwrap_or(0) -} - #[cfg(test)] mod tests { use super::*; @@ -261,8 +237,10 @@ mod tests { is_dealer: true, is_small_blind: false, is_big_blind: false, + bankroll: 0, })], actions_this_hand: vec![], + num_raises: 0, hand_number: 1, active_players: 2, } @@ -306,8 +284,10 @@ mod tests { is_dealer: true, is_small_blind: false, is_big_blind: false, + bankroll: 0, })], actions_this_hand: vec![], + num_raises: 0, hand_number: 1, active_players: 2, } @@ -347,8 +327,10 @@ mod tests { is_dealer: true, is_small_blind: false, is_big_blind: false, + bankroll: 0, })], actions_this_hand: vec![], + num_raises: 0, hand_number: 1, active_players: 2, } @@ -449,15 +431,7 @@ mod tests { let mut bot = RaiseBot::new(PlayerId(3), "RaiseBot".to_string()); // Create game info with 3 raises already made on the flop (Fixed-Limit max) let mut game_info = make_fl_game_info(40, 100, 1000, 0, Street::Flop); - // Index 0 = preflop (empty), Index 1 = flop (3 raises) - game_info.actions_this_hand = vec![ - vec![], - vec![ - (PlayerId(1), Action::Raise { total: 20 }), - (PlayerId(2), Action::Raise { total: 30 }), - (PlayerId(1), Action::Raise { total: 40 }), - ], - ]; + game_info.num_raises = 3; // 3 voluntary raises already on the flop let action = bot.act(&game_info); // Can't raise anymore, should call assert_eq!(action, Action::Call); diff --git a/holdem_core/src/game/bots/random_bot.rs b/holdem_core/src/game/bots/random_bot.rs index 17ee360..de26d88 100644 --- a/holdem_core/src/game/bots/random_bot.rs +++ b/holdem_core/src/game/bots/random_bot.rs @@ -59,26 +59,6 @@ impl RandomBot { game_info.current_bet.saturating_sub(wagered) } - /// Counts the number of raises in the current betting round. - fn count_raises_current_street(&self, game_info: &GameInfo) -> usize { - let street_index = match game_info.street { - crate::Street::Preflop => 0, - crate::Street::Flop => 1, - crate::Street::Turn => 2, - crate::Street::River => 3, - }; - - game_info - .actions_this_hand - .get(street_index) - .map(|actions| { - actions - .iter() - .filter(|(_, a)| matches!(a, Action::Raise { .. })) - .count() - }) - .unwrap_or(0) - } /// Builds a list of legal actions and picks one at random. /// @@ -123,7 +103,7 @@ impl RandomBot { // Check if raising is possible let can_raise = game_info .bet_limit - .can_raise(game_info.street, self.count_raises_current_street(game_info)); + .can_raise(game_info.street, game_info.num_raises); if can_raise && stack > call_amount { // Player has chips remaining after calling @@ -274,8 +254,10 @@ mod tests { is_dealer: true, is_small_blind: false, is_big_blind: false, + bankroll: 0, })], actions_this_hand: vec![], + num_raises: 0, hand_number: 1, active_players: 2, } diff --git a/holdem_core/src/game/config.rs b/holdem_core/src/game/config.rs index 4c39118..61b36ca 100644 --- a/holdem_core/src/game/config.rs +++ b/holdem_core/src/game/config.rs @@ -35,6 +35,63 @@ use super::table::TableConfig; use super::tournament::{BlindSchedule, GameType, PrizeStructure}; use super::types::{ChipCount, GameError, SeatIndex}; +// --------------------------------------------------------------------------- +// RakeConfig +// --------------------------------------------------------------------------- + +/// Rake configuration for a poker game. +/// +/// Rake is a percentage of each pot taken by the house. It can be capped +/// at a maximum amount per pot. +/// +/// # Defaults +/// +/// `percentage: 0.0`, `cap: 0` — no rake. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct RakeConfig { + /// Rake percentage (0.0 to 1.0). E.g., 0.05 = 5%. + #[serde(default)] + pub percentage: f64, + /// Maximum rake per hand (0 = no cap). + #[serde(default)] + pub cap: ChipCount, +} + +impl Default for RakeConfig { + fn default() -> Self { + Self { percentage: 0.0, cap: 0 } + } +} + +impl RakeConfig { + /// Creates a no-rake configuration. + pub fn no_rake() -> Self { + Self { percentage: 0.0, cap: 0 } + } + + /// Returns `true` if rake is effectively disabled. + pub fn is_zero(&self) -> bool { + self.percentage == 0.0 + } + + /// Calculate rake for a pot of the given size. + /// + /// Returns the rake amount (floored to whole chips), capped at `self.cap`. + /// Clamps `percentage` to `[0.0, 1.0]` to guard against misconfiguration. + pub fn calculate_rake(&self, pot_amount: ChipCount) -> ChipCount { + if self.percentage <= 0.0 || pot_amount == 0 { + return 0; + } + let pct = self.percentage.min(1.0); + let rake = (pot_amount as f64 * pct).floor() as ChipCount; + if self.cap > 0 { + rake.min(self.cap) + } else { + rake + } + } +} + // --------------------------------------------------------------------------- // Default helper functions // --------------------------------------------------------------------------- @@ -223,6 +280,30 @@ pub struct GameConfig { #[serde(default)] pub seed: Option, + /// Rake percentage taken from each pot at showdown (e.g., 0.05 = 5%). + /// Set to 0.0 (default) to disable rake. + #[serde(default)] + pub rake_percentage: f64, + + /// Maximum rake in chips per pot. 0 means no cap. + #[serde(default)] + pub rake_cap: ChipCount, + + /// If true, all players' stacks are reset to `starting_chips` before every hand. + /// Their overall profit/loss balance is tracked via `bankroll`. + #[serde(default)] + pub reset_bankroll_each_hand: bool, + + /// When a player's stack drops to or below this threshold (and reset is NOT enabled), + /// they automatically buy back up to `starting_chips`. 0 = disabled. + #[serde(default)] + pub bankroll_refill_threshold: ChipCount, + + /// If true, player seat assignments are randomized before each hand + /// (cash game) or before tournament start. + #[serde(default)] + pub randomize_seats: bool, + /// Bot configurations. #[serde(default)] pub bots: Vec, @@ -246,6 +327,11 @@ impl Default for GameConfig { num_blind_levels: default_num_levels(), num_hands: 0, seed: None, + rake_percentage: 0.0, + rake_cap: 0, + reset_bankroll_each_hand: false, + bankroll_refill_threshold: 0, + randomize_seats: false, bots: Vec::new(), } } @@ -287,6 +373,14 @@ impl GameConfig { small_blind: self.small_blind, big_blind: self.big_blind, ante: self.ante, + rake_config: RakeConfig { + percentage: self.rake_percentage, + cap: self.rake_cap, + }, + starting_chips: self.starting_chips, + reset_bankroll_each_hand: self.reset_bankroll_each_hand, + bankroll_refill_threshold: self.bankroll_refill_threshold, + randomize_seats: self.randomize_seats, } } @@ -684,6 +778,12 @@ min_raise = 50 num_blind_levels: 15, num_hands: 500, seed: Some(12345), + rake_percentage: 0.0, + rake_cap: 0, + reset_bankroll_each_hand: false, + bankroll_refill_threshold: 0, + randomize_seats: false, + bots: vec![ BotConfig { bot_type: "call_bot".to_string(), @@ -760,6 +860,12 @@ min_raise = 50 num_blind_levels: 15, num_hands: 0, seed: None, + rake_percentage: 0.0, + rake_cap: 0, + reset_bankroll_each_hand: false, + bankroll_refill_threshold: 0, + randomize_seats: false, + bots: vec![], }; let table_config = config.to_table_config(); @@ -786,6 +892,12 @@ min_raise = 50 num_blind_levels: 5, num_hands: 0, seed: None, + rake_percentage: 0.0, + rake_cap: 0, + reset_bankroll_each_hand: false, + bankroll_refill_threshold: 0, + randomize_seats: false, + bots: vec![], }; let schedule = config.to_blind_schedule(); @@ -828,6 +940,12 @@ min_raise = 50 num_blind_levels: 15, num_hands: 0, seed: None, + rake_percentage: 0.0, + rake_cap: 0, + reset_bankroll_each_hand: false, + bankroll_refill_threshold: 0, + randomize_seats: false, + bots: vec![ BotConfig { bot_type: "call_bot".to_string(), @@ -872,6 +990,12 @@ min_raise = 50 num_blind_levels: 20, num_hands: 0, seed: None, + rake_percentage: 0.0, + rake_cap: 0, + reset_bankroll_each_hand: false, + bankroll_refill_threshold: 0, + randomize_seats: false, + bots: vec![ BotConfig { bot_type: "bot".to_string(), @@ -958,6 +1082,11 @@ min_raise = 20 num_blind_levels: 20, num_hands: 0, seed: Some(42), + rake_percentage: 0.0, + rake_cap: 0, + reset_bankroll_each_hand: false, + bankroll_refill_threshold: 0, + randomize_seats: false, bots: vec![], }, output: OutputConfig { diff --git a/holdem_core/src/game/game_info.rs b/holdem_core/src/game/game_info.rs index df036e7..ee4aff3 100644 --- a/holdem_core/src/game/game_info.rs +++ b/holdem_core/src/game/game_info.rs @@ -72,8 +72,19 @@ pub struct GameInfo { // --- Hand history for current hand --- /// Actions taken so far in the current hand, grouped by street. /// Index 0 = preflop, 1 = flop, 2 = turn, 3 = river. + /// + /// **Meerkat convention:** On the preflop street (index 0), the small blind + /// and big blind postings are recorded as [`Action::Raise`] entries at the + /// beginning of the list. These are *not* voluntary player raises. Use + /// [`num_raises`](Self::num_raises) for the authoritative count of voluntary + /// raises on the current street, which excludes blind postings. pub actions_this_hand: Vec>, + // --- Action counters (authoritative, maintained by the engine) --- + /// Number of voluntary raises on the current street (excludes blinds). + /// Reset to 0 when advancing to a new street. + pub num_raises: usize, + // --- Game metadata --- /// Hand number within the session. pub hand_number: u64, diff --git a/holdem_core/src/game/hand_runner.rs b/holdem_core/src/game/hand_runner.rs index ff51552..d783749 100644 --- a/holdem_core/src/game/hand_runner.rs +++ b/holdem_core/src/game/hand_runner.rs @@ -17,6 +17,7 @@ use crate::{Board, Deck, Hand, HoleCards}; use super::action::{validate_action, Action}; use super::betting::{BetLimit, BettingRound}; +use super::config::RakeConfig; use super::game_info::GameInfo; use super::player::Seat; use super::player_info::PlayerState; @@ -92,6 +93,10 @@ pub struct HandResult { pub board: Board, /// Net chip movement for each player in units of chips (positive = won, negative = lost). pub chip_movements: HashMap, + /// Total rake deducted from all pots this hand. + pub total_rake: ChipCount, + /// Rake deducted per pot, parallel to `pot_winners`. + pub rake_per_pot: Vec, } // --------------------------------------------------------------------------- @@ -141,6 +146,8 @@ pub struct HandRunner { actions_this_hand: Vec>, /// Optional RNG seed for reproducible shuffles. seed: Option, + /// Rake configuration for this hand. + rake_config: RakeConfig, } impl HandRunner { @@ -188,6 +195,7 @@ impl HandRunner { Vec::new(), ], seed: None, + rake_config: RakeConfig::no_rake(), } } @@ -200,6 +208,12 @@ impl HandRunner { self } + /// Sets the rake configuration for this hand. + pub fn with_rake_config(mut self, config: RakeConfig) -> Self { + self.rake_config = config; + self + } + // ----------------------------------------------------------------------- // Start the hand // ----------------------------------------------------------------------- @@ -279,6 +293,11 @@ impl HandRunner { if actual_sb > 0 { self.pot_manager .place_bet(self.seats[self.sb_seat_idx].player_id, actual_sb); + // Record SB as a raise (Meerkat convention: blinds count as raises). + self.actions_this_hand[0].push(( + self.seats[self.sb_seat_idx].player_id, + Action::Raise { total: actual_sb }, + )); } } @@ -288,6 +307,11 @@ impl HandRunner { if actual_bb > 0 { self.pot_manager .place_bet(self.seats[self.bb_seat_idx].player_id, actual_bb); + // Record BB as a raise (Meerkat convention: blinds count as raises). + self.actions_this_hand[0].push(( + self.seats[self.bb_seat_idx].player_id, + Action::Raise { total: actual_bb }, + )); } } @@ -597,53 +621,9 @@ impl HandRunner { .collect(); self.pot_manager.finalize_pots(&active_ids); - // 5. Distribute. + // 5. Distribute and finalize (rake, chip movements, stack credit, game_over). let pot_winners = self.pot_manager.distribute(&hand_values); - - // 6. Calculate chip movements (net: winnings - wagered). - // Use i64 internally to handle negative values. - let mut chip_movements_i64: HashMap = HashMap::new(); - for seat in &self.seats { - chip_movements_i64.insert(seat.player_id, -(seat.wagered_this_hand as i64)); - } - for pot_result in &pot_winners { - for winner in pot_result { - *chip_movements_i64.get_mut(&winner.player_id).unwrap() += - winner.amount as i64; - } - } - let chip_movements = chip_movements_i64; - - // Credit winnings to stacks. - for pot_result in &pot_winners { - for winner in pot_result { - if let Some(seat) = self - .seats - .iter_mut() - .find(|s| s.player_id == winner.player_id) - { - seat.stack += winner.amount; - } - } - } - - // 7. Call game_over_event on all players. - { - let game_info = self.build_game_info(); - for seat in &mut self.seats { - seat.player.game_over_event(&game_info); - } - } - - // 8. Set state to Complete. - self.state = HandState::Complete; - - HandResult { - pot_winners, - hand_values, - board: self.board.clone(), - chip_movements, - } + self.finalize_result(pot_winners, hand_values) } // ----------------------------------------------------------------------- @@ -665,46 +645,7 @@ impl HandRunner { .collect(); let pot_winners = self.pot_manager.distribute(&hand_values); - - // Calculate chip movements (net: winnings - wagered). - let mut chip_movements_i64: HashMap = HashMap::new(); - for seat in &self.seats { - chip_movements_i64.insert(seat.player_id, -(seat.wagered_this_hand as i64)); - } - for pot_result in &pot_winners { - for winner in pot_result { - *chip_movements_i64.get_mut(&winner.player_id).unwrap() += - winner.amount as i64; - } - } - let chip_movements = chip_movements_i64; - // Credit winnings to stack. - for pot_result in &pot_winners { - for winner in pot_result { - if let Some(seat) = self - .seats - .iter_mut() - .find(|s| s.player_id == winner.player_id) - { - seat.stack += winner.amount; - } - } - } - - // Call game_over_event on all players. - let game_info = self.build_game_info(); - for seat in &mut self.seats { - seat.player.game_over_event(&game_info); - } - - self.state = HandState::Complete; - - Ok(HandResult { - pot_winners, - hand_values, - board: self.board.clone(), - chip_movements, - }) + Ok(self.finalize_result(pot_winners, hand_values)) } // ----------------------------------------------------------------------- @@ -940,8 +881,8 @@ impl HandRunner { return Ok(result); } HandState::Complete => { - // Hand was completed (e.g., by resolve_last_player_wins via step). - // Reconstruct the result from the pot manager. + // Hand was already resolved (e.g., by resolve_last_player_wins via step). + // Stacks were already credited there — only reconstruct the HandResult. let hand_values: HashMap = self .seats .iter() @@ -950,24 +891,18 @@ impl HandRunner { .collect(); let pot_winners = self.pot_manager.distribute(&hand_values); + let (raked_winners, rake_per_pot, total_rake) = self.apply_rake(pot_winners); - let mut chip_movements_i64: HashMap = HashMap::new(); - for seat in &self.seats { - chip_movements_i64.insert(seat.player_id, -(seat.wagered_this_hand as i64)); - } - for pot_result in &pot_winners { - for winner in pot_result { - *chip_movements_i64.get_mut(&winner.player_id).unwrap() += - winner.amount as i64; - } - } - let chip_movements = chip_movements_i64; + // Compute chip movements for the result (no stack crediting — already done). + let chip_movements = self.compute_chip_movements(&raked_winners); return Ok(HandResult { - pot_winners, + pot_winners: raked_winners, hand_values, board: self.board.clone(), chip_movements, + total_rake, + rake_per_pot, }); } HandState::Idle => { @@ -980,6 +915,134 @@ impl HandRunner { } } + // ----------------------------------------------------------------------- + // Result finalization + // ----------------------------------------------------------------------- + + /// Computes chip movements (net: raked winnings - wagered) for each player. + fn compute_chip_movements( + &self, + raked_winners: &[Vec], + ) -> HashMap { + let mut chip_movements: HashMap = HashMap::new(); + for seat in &self.seats { + chip_movements.insert(seat.player_id, -(seat.wagered_this_hand as i64)); + } + for pot_result in raked_winners { + for winner in pot_result { + *chip_movements.get_mut(&winner.player_id).unwrap() += + winner.amount as i64; + } + } + chip_movements + } + + /// Credits raked winnings to player stacks. + fn credit_winnings(&mut self, raked_winners: &[Vec]) { + for pot_result in raked_winners { + for winner in pot_result { + if let Some(seat) = self + .seats + .iter_mut() + .find(|s| s.player_id == winner.player_id) + { + seat.stack += winner.amount; + } + } + } + } + + /// Finalizes a hand result: applies rake, computes chip movements, credits + /// stacks, fires `game_over_event`, and sets state to `Complete`. + /// + /// This is the single canonical path for hand finalization, used by both + /// [`showdown()`](Self::showdown) and + /// [`resolve_last_player_wins()`](Self::resolve_last_player_wins). + fn finalize_result( + &mut self, + pot_winners: Vec>, + hand_values: HashMap, + ) -> HandResult { + // 1. Apply rake. + let (raked_winners, rake_per_pot, total_rake) = self.apply_rake(pot_winners); + + // 2. Compute chip movements (net: winnings - wagered). + let chip_movements = self.compute_chip_movements(&raked_winners); + + // 3. Credit winnings to stacks. + self.credit_winnings(&raked_winners); + + // 4. Call game_over_event on all players. + { + let game_info = self.build_game_info(); + for seat in &mut self.seats { + seat.player.game_over_event(&game_info); + } + } + + // 5. Set state to Complete. + self.state = HandState::Complete; + + HandResult { + pot_winners: raked_winners, + hand_values, + board: self.board.clone(), + chip_movements, + total_rake, + rake_per_pot, + } + } + + // ----------------------------------------------------------------------- + // Rake + // ----------------------------------------------------------------------- + + /// Apply rake to pot distribution results. + /// + /// Returns `(raked_pot_winners, rake_per_pot, total_rake)`. + /// When rake is disabled, returns the input unchanged with zero rake. + fn apply_rake( + &self, + pot_winners: Vec>, + ) -> (Vec>, Vec, ChipCount) { + if self.rake_config.is_zero() { + let total: ChipCount = 0; + let per_pot = vec![0; pot_winners.len()]; + return (pot_winners, per_pot, total); + } + + let mut total_rake: ChipCount = 0; + let mut rake_per_pot = Vec::with_capacity(pot_winners.len()); + + let raked_winners: Vec> = pot_winners + .into_iter() + .map(|mut pot_result| { + // Sum the pot from winner amounts (they haven't been raked yet). + let pot_total: ChipCount = pot_result.iter().map(|w| w.amount).sum(); + let rake = self.rake_config.calculate_rake(pot_total); + rake_per_pot.push(rake); + total_rake += rake; + + // Deduct rake proportionally from each winner. + if rake == 0 || pot_result.is_empty() { + return pot_result; + } + + let num_winners = pot_result.len() as ChipCount; + let rake_per_winner = rake / num_winners; + let rake_remainder = rake % num_winners; + + for (i, winner) in pot_result.iter_mut().enumerate() { + let extra = if (i as ChipCount) < rake_remainder { 1 } else { 0 }; + winner.amount = winner.amount.saturating_sub(rake_per_winner + extra); + } + pot_result + }) + .collect(); + + (raked_winners, rake_per_pot, total_rake) + } + // ----------------------------------------------------------------------- // Private helpers // ----------------------------------------------------------------------- @@ -1101,6 +1164,27 @@ impl HandRunner { .and_then(|id| self.find_seat_index_by_player_id(id)) .map(|idx| self.seats[idx].index); + // Count voluntary raises on the current street (exclude blind postings). + // On preflop, the first two Raise entries are the SB and BB postings. + let street_idx = self.street_to_action_index(); + let num_raises = self + .actions_this_hand + .get(street_idx) + .map(|actions| { + let total_raises = actions + .iter() + .filter(|(_, a)| matches!(a, Action::Raise { .. })) + .count(); + // On preflop, the SB and BB are recorded as raises (Meerkat convention). + // Subtract them to get the count of voluntary player raises. + if self.current_street() == Street::Preflop { + total_raises.saturating_sub(2) + } else { + total_raises + } + }) + .unwrap_or(0); + GameInfo { num_seats: self.seats.len() as SeatIndex, bet_limit: self.bet_limit, @@ -1118,6 +1202,7 @@ impl HandRunner { next_to_act: next_to_act_seat, players, actions_this_hand: self.actions_this_hand.clone(), + num_raises, hand_number: self.hand_number, active_players: self.count_active_players(), } diff --git a/holdem_core/src/game/mod.rs b/holdem_core/src/game/mod.rs index a02044b..43bf762 100644 --- a/holdem_core/src/game/mod.rs +++ b/holdem_core/src/game/mod.rs @@ -39,7 +39,7 @@ pub use action::{Action, PlayerAction}; pub use betting::BetLimit; pub use bot_factory::create_bot; pub use bots::{CallBot, FoldBot, RandomBot, RaiseBot}; -pub use config::{BetLimitConfig, BotConfig, GameConfig, OutputConfig, SimulationConfig}; +pub use config::{BetLimitConfig, BotConfig, GameConfig, OutputConfig, RakeConfig, SimulationConfig}; pub use game_info::GameInfo; pub use hand_runner::{HandResult, HandRunner, HandState}; pub use player::{HoldemPlayer, Seat}; diff --git a/holdem_core/src/game/player.rs b/holdem_core/src/game/player.rs index 2b6eaa3..b1153f5 100644 --- a/holdem_core/src/game/player.rs +++ b/holdem_core/src/game/player.rs @@ -302,6 +302,14 @@ pub struct Seat { /// early fold intent; applied as [`Action::Fold`] when it becomes this /// player's turn. pub early_fold_pending: bool, + + /// The player's overall balance (profit/loss) across all hands. + /// Starts at 0. Increases when the player wins, decreases when they lose. + /// When `reset_bankroll_each_hand` is enabled, this tracks the cumulative + /// profit/loss while the stack is reset each hand. + /// When `bankroll_refill_threshold` triggers a reload, the buy-in amount + /// is deducted from bankroll. + pub bankroll: i64, } impl Seat { @@ -341,6 +349,7 @@ impl Seat { hole_cards: None, has_acted_this_round: false, early_fold_pending: false, + bankroll: 0, } } @@ -374,6 +383,7 @@ impl Seat { is_dealer, is_small_blind, is_big_blind, + bankroll: self.bankroll, } } diff --git a/holdem_core/src/game/player_info.rs b/holdem_core/src/game/player_info.rs index 470e6f4..2ab1b19 100644 --- a/holdem_core/src/game/player_info.rs +++ b/holdem_core/src/game/player_info.rs @@ -65,6 +65,10 @@ pub struct PlayerInfo { /// Whether this player is the big blind. pub is_big_blind: bool, + + /// The player's overall bankroll balance across all hands. + /// Positive = profit, negative = loss. + pub bankroll: i64, } impl PlayerInfo { diff --git a/holdem_core/src/game/simulation.rs b/holdem_core/src/game/simulation.rs index 478efd3..d1246ed 100644 --- a/holdem_core/src/game/simulation.rs +++ b/holdem_core/src/game/simulation.rs @@ -84,7 +84,7 @@ pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), Ga } } - // 5. Record final stacks for accurate profit calculation. + // 5. Record final stacks and bankrolls for accurate profit calculation. let final_stacks: HashMap = table .seats() .iter() @@ -93,7 +93,16 @@ pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), Ga .map(|seat| (seat.player_id, seat.stack)) }) .collect(); - stats.record_final_stacks(&final_stacks, &starting_stacks); + let bankrolls: HashMap = table + .seats() + .iter() + .filter_map(|slot| { + slot.as_ref() + .map(|seat| (seat.player_id, seat.bankroll)) + }) + .collect(); + let bankroll_mode = config.reset_bankroll_each_hand || config.bankroll_refill_threshold > 0; + stats.record_final_stacks(&final_stacks, &starting_stacks, &bankrolls, bankroll_mode); Ok((stats, table)) } @@ -124,6 +133,11 @@ mod tests { num_blind_levels: 15, num_hands, seed, + rake_percentage: 0.0, + rake_cap: 0, + reset_bankroll_each_hand: false, + bankroll_refill_threshold: 0, + randomize_seats: false, bots: vec![ BotConfig { bot_type: "call_bot".to_string(), @@ -225,6 +239,12 @@ mod tests { num_blind_levels: 15, num_hands: 10, seed: None, + rake_percentage: 0.0, + rake_cap: 0, + reset_bankroll_each_hand: false, + bankroll_refill_threshold: 0, + randomize_seats: false, + bots: vec![BotConfig { bot_type: "call_bot".to_string(), name: Some("Lonely".to_string()), @@ -253,6 +273,12 @@ mod tests { num_blind_levels: 15, num_hands: 10, seed: None, + rake_percentage: 0.0, + rake_cap: 0, + reset_bankroll_each_hand: false, + bankroll_refill_threshold: 0, + randomize_seats: false, + bots: vec![ BotConfig { bot_type: "call_bot".to_string(), @@ -289,6 +315,12 @@ mod tests { num_blind_levels: 15, num_hands: 5, seed: Some(99), + rake_percentage: 0.0, + rake_cap: 0, + reset_bankroll_each_hand: false, + bankroll_refill_threshold: 0, + randomize_seats: false, + bots: vec![ BotConfig { bot_type: "call_bot".to_string(), diff --git a/holdem_core/src/game/stats.rs b/holdem_core/src/game/stats.rs index d41c623..5b265bc 100644 --- a/holdem_core/src/game/stats.rs +++ b/holdem_core/src/game/stats.rs @@ -34,6 +34,10 @@ pub struct PlayerStats { pub biggest_pot_won: ChipCount, /// Stack at the end of the simulation. pub final_stack: ChipCount, + /// Total rake paid by this player across all hands. + pub total_rake_paid: ChipCount, + /// Final bankroll balance (includes refill costs and reset adjustments). + pub bankroll: i64, } impl PlayerStats { @@ -48,6 +52,8 @@ impl PlayerStats { total_wagered: 0, biggest_pot_won: 0, final_stack: 0, + total_rake_paid: 0, + bankroll: 0, } } @@ -96,6 +102,8 @@ pub struct SimulationStats { pub total_hands: u64, /// Game type label (e.g. `"Cash"`, `"SitAndGo"`). pub game_type: String, + /// Total rake collected across the entire simulation. + pub total_rake: ChipCount, } impl SimulationStats { @@ -105,6 +113,7 @@ impl SimulationStats { players: HashMap::new(), total_hands: 0, game_type: game_type.to_string(), + total_rake: 0, } } @@ -123,6 +132,7 @@ impl SimulationStats { player_names: &HashMap, ) { self.total_hands += 1; + self.total_rake += result.total_rake; for (&player_id, &movement) in &result.chip_movements { let stats = self @@ -148,22 +158,61 @@ impl SimulationStats { } } } + + // Attribute rake to pot winners proportionally. + for (pot_idx, pot_result) in result.pot_winners.iter().enumerate() { + let rake = *result.rake_per_pot.get(pot_idx).unwrap_or(&0); + if rake > 0 && !pot_result.is_empty() { + let rake_per_winner = rake / pot_result.len() as ChipCount; + let remainder = rake % pot_result.len() as ChipCount; + for (i, winner) in pot_result.iter().enumerate() { + let extra = if (i as ChipCount) < remainder { 1 } else { 0 }; + if let Some(stats) = self.players.get_mut(&winner.player_id) { + stats.total_rake_paid += rake_per_winner + extra; + } + } + } + } } - /// Records the final stacks from a table into the stats. + /// Records the final stacks and bankrolls from a table into the stats. /// /// Call this after the simulation loop ends to set `final_stack` and - /// compute `total_profit` accurately (final_stack - starting_stack). + /// compute `total_profit` accurately. + /// + /// When `bankroll_mode` is `true`, `total_profit` is computed as + /// `bankroll + (final_stack - starting_stack)`, which captures all net + /// flows including refills and resets. When `false`, the simple + /// `final_stack - starting_stack` formula is used. pub fn record_final_stacks( &mut self, stacks: &HashMap, starting_stacks: &HashMap, + bankrolls: &HashMap, + bankroll_mode: bool, ) { for (&player_id, &final_stack) in stacks { if let Some(stats) = self.players.get_mut(&player_id) { stats.final_stack = final_stack; let starting = starting_stacks.get(&player_id).copied().unwrap_or(0); - stats.total_profit = final_stack as i64 - starting as i64; + let bankroll = *bankrolls.get(&player_id).unwrap_or(&0); + stats.bankroll = bankroll; + if bankroll_mode { + // With bankroll tracking (reset or refill mode), `bankroll` captures + // all out-of-band costs: reset deltas (stack - starting each hand) and + // refill buy-in amounts. `chip_movements` from `record_hand_result` + // only captures in-hand wins/losses. The two are complementary: + // + // reset mode: bankroll = Σ(stack_i - starting), final_stack == starting + // → total_profit = bankroll + 0 = bankroll ✓ + // + // refill mode: bankroll = -Σ(refill_costs), final_stack ≠ starting + // → total_profit = -refills + (final - starting) ✓ + stats.total_profit = bankroll + (final_stack as i64 - starting as i64); + } else { + // Simple mode: profit = final stack minus starting stack. + stats.total_profit = final_stack as i64 - starting as i64; + } } } } @@ -446,6 +495,8 @@ mod tests { hand_values: HashMap::new(), board: Board::new(), chip_movements, + total_rake: 0, + rake_per_pot: vec![], }; sim_stats.record_hand_result(&result, &player_names); @@ -481,6 +532,8 @@ mod tests { m.insert(PlayerId(2), 0); m }, + total_rake: 0, + rake_per_pot: vec![], }; // Hand 2: Bob wins 200 @@ -494,6 +547,8 @@ mod tests { m.insert(PlayerId(2), 200); m }, + total_rake: 0, + rake_per_pot: vec![], }; sim_stats.record_hand_result(&result1, &player_names); @@ -530,6 +585,8 @@ mod tests { m.insert(PlayerId(2), 0); m }, + total_rake: 0, + rake_per_pot: vec![], }; sim_stats.record_hand_result(&result, &player_names); @@ -546,7 +603,7 @@ mod tests { m.insert(PlayerId(2), 10000); m }; - sim_stats.record_final_stacks(&final_stacks, &starting_stacks); + sim_stats.record_final_stacks(&final_stacks, &starting_stacks, &HashMap::new(), false); let alice = sim_stats.players.get(&PlayerId(1)).unwrap(); assert_eq!(alice.final_stack, 10500); diff --git a/holdem_core/src/game/table.rs b/holdem_core/src/game/table.rs index a7214b6..028213b 100644 --- a/holdem_core/src/game/table.rs +++ b/holdem_core/src/game/table.rs @@ -12,8 +12,10 @@ //! `Box`. use crate::Deck; +use rand::SeedableRng; use super::betting::BetLimit; +use super::config::RakeConfig; use super::game_info::GameInfo; use super::hand_runner::{HandResult, HandRunner, HandState}; use super::player::{HoldemPlayer, Seat}; @@ -37,6 +39,16 @@ pub struct TableConfig { pub big_blind: ChipCount, /// Ante amount per hand (0 if no ante). pub ante: ChipCount, + /// Rake configuration. + pub rake_config: RakeConfig, + /// Starting chip count for each player (for resets and refills). + pub starting_chips: ChipCount, + /// If true, reset all stacks to starting_chips before each hand. + pub reset_bankroll_each_hand: bool, + /// Auto-refill threshold. 0 = disabled. + pub bankroll_refill_threshold: ChipCount, + /// If true, shuffle seat assignments before each hand. + pub randomize_seats: bool, } impl Default for TableConfig { @@ -48,6 +60,11 @@ impl Default for TableConfig { small_blind: 10, big_blind: 20, ante: 0, + rake_config: RakeConfig::no_rake(), + starting_chips: 10_000, + reset_bankroll_each_hand: false, + bankroll_refill_threshold: 0, + randomize_seats: false, } } } @@ -74,6 +91,16 @@ pub struct Table { hand_number: u64, /// Optional RNG seed for reproducible shuffles. seed: Option, + /// Rake configuration. + rake_config: RakeConfig, + /// Starting chip count (for resets and refills). + starting_chips: ChipCount, + /// If true, reset all stacks to starting_chips before each hand. + reset_bankroll_each_hand: bool, + /// Auto-refill threshold. 0 = disabled. + bankroll_refill_threshold: ChipCount, + /// If true, shuffle seat assignments before each hand. + randomize_seats: bool, } impl Table { @@ -85,6 +112,11 @@ impl Table { /// /// The dealer button starts at position 0. No players are seated. pub fn new(config: TableConfig) -> Self { + let rake_config = config.rake_config; + let starting_chips = config.starting_chips; + let reset_bankroll_each_hand = config.reset_bankroll_each_hand; + let bankroll_refill_threshold = config.bankroll_refill_threshold; + let randomize_seats = config.randomize_seats; let seats = (0..config.num_seats).map(|_| None).collect(); Self { config, @@ -93,6 +125,11 @@ impl Table { current_hand: None, hand_number: 0, seed: None, + rake_config, + starting_chips, + reset_bankroll_each_hand, + bankroll_refill_threshold, + randomize_seats, } } @@ -280,8 +317,10 @@ impl Table { /// Starts a new hand. /// - /// 1. Validates enough active players (≥ 2). - /// 2. Validates no hand is already in progress. + /// 1. Validates no hand is already in progress. + /// 2. Validates enough active players (≥ 2). + /// 2.5. Bankroll management (reset/refill). + /// 2.7. Randomize seats if configured. /// 3. Advances the dealer button. /// 4. Takes active seats from the table and passes them to [`HandRunner`]. /// 5. Creates a new deck and starts the hand. @@ -297,6 +336,24 @@ impl Table { return Err(GameError::NotEnoughPlayers(active_count)); } + // 2.5. Bankroll management. + if self.reset_bankroll_each_hand { + self.reset_stacks_to_starting(); + } else if self.bankroll_refill_threshold > 0 { + self.refill_short_stacks(); + } + + // 2.7. Randomize seats if configured. + if self.randomize_seats { + self.shuffle_seats(); + } + + // Re-count active players after bankroll management (may have re-activated players). + let active_count = self.active_player_count(); + if active_count < MIN_PLAYERS as usize { + return Err(GameError::NotEnoughPlayers(active_count)); + } + // 3. Advance the button. self.advance_button(); @@ -325,6 +382,9 @@ impl Table { if let Some(seed) = self.seed { runner = runner.with_seed(seed.wrapping_add(self.hand_number)); } + + // Pass rake config to runner. + runner = runner.with_rake_config(self.rake_config); runner.start(self.config.small_blind, self.config.big_blind, self.config.ante); @@ -500,10 +560,106 @@ impl Table { next_to_act: None, players, actions_this_hand: vec![Vec::new(), Vec::new(), Vec::new(), Vec::new()], + num_raises: 0, hand_number: self.hand_number, active_players: self.active_player_count(), } } + + // ----------------------------------------------------------------------- + // Bankroll management + // ----------------------------------------------------------------------- + + /// Reset all seated players' stacks to starting_chips. + /// Track the change in their bankroll. + fn reset_stacks_to_starting(&mut self) { + let starting = self.starting_chips; + for slot in &mut self.seats { + if let Some(seat) = slot.as_mut() { + // The "cost" of resetting is the difference between + // starting and current stack. + // If they have more than starting, they profit. + // If they have less, they lost. + seat.bankroll += seat.stack as i64 - starting as i64; + seat.stack = starting; + // Re-activate sitting-out players (they have chips again). + if seat.state == PlayerState::SittingOut { + seat.state = PlayerState::Active; + } + } + } + } + + /// Refill short-stacked players up to starting_chips. + /// + /// Only refills players who are NOT SittingOut. Busted players stay out. + fn refill_short_stacks(&mut self) { + let threshold = self.bankroll_refill_threshold; + let starting = self.starting_chips; + for slot in &mut self.seats { + if let Some(seat) = slot.as_mut() { + if seat.state == PlayerState::SittingOut { + continue; + } + if seat.stack <= threshold { + let refill = starting.saturating_sub(seat.stack); + if refill > 0 { + seat.bankroll -= refill as i64; + seat.stack = starting; + } + } + } + } + } + + // ----------------------------------------------------------------------- + // Seat randomization + // ----------------------------------------------------------------------- + + /// Randomize seat assignments by collecting all seated players, + /// shuffling them, and re-seating at positions 0..N. + /// + /// Resets the button to position 0 after shuffling. + pub fn shuffle_seats(&mut self) { + // 1. Collect all occupied seats. + let mut occupied: Vec = self + .seats + .iter_mut() + .filter_map(|slot| slot.take()) + .collect(); + + if occupied.len() < 2 { + // Put them back. + for (i, seat) in occupied.into_iter().enumerate() { + self.seats[i] = Some(seat); + } + return; + } + + // 2. Shuffle using seeded RNG for reproducibility. + // Use a seed derived from the table seed + hand number. + let rng_seed = self.seed + .map(|s| s.wrapping_add(self.hand_number)) + .unwrap_or(self.hand_number); + let mut rng = rand::rngs::StdRng::seed_from_u64(rng_seed); + use rand::seq::SliceRandom; + occupied.shuffle(&mut rng); + + // 3. Re-seat at positions 0, 1, 2, ... + for (i, seat) in occupied.into_iter().enumerate() { + if i >= self.seats.len() { + // Safety: should never happen (occupied ≤ total seats), but + // guard against a panic if num_seats were ever reduced. + break; + } + let mut seat = seat; + seat.index = i as SeatIndex; + self.seats[i] = Some(seat); + } + + // 4. Reset button to 0 since seat assignments changed. + self.button = 0; + } } // --------------------------------------------------------------------------- @@ -681,6 +837,11 @@ mod tests { small_blind: 10, big_blind: 20, ante: 0, + rake_config: crate::game::config::RakeConfig::no_rake(), + starting_chips: 10_000, + reset_bankroll_each_hand: false, + bankroll_refill_threshold: 0, + randomize_seats: false, } } @@ -1131,6 +1292,11 @@ mod tests { small_blind: 10, big_blind: 20, ante: 0, + rake_config: crate::game::config::RakeConfig::no_rake(), + starting_chips: 10_000, + reset_bankroll_each_hand: false, + bankroll_refill_threshold: 0, + randomize_seats: false, }; let mut table = Table::new(config); diff --git a/holdem_core/src/game/tournament.rs b/holdem_core/src/game/tournament.rs index 3bfa7f4..0cddd9a 100644 --- a/holdem_core/src/game/tournament.rs +++ b/holdem_core/src/game/tournament.rs @@ -694,6 +694,11 @@ mod tests { small_blind: 10, big_blind: 20, ante: 0, + rake_config: crate::game::config::RakeConfig::no_rake(), + starting_chips: 10_000, + reset_bankroll_each_hand: false, + bankroll_refill_threshold: 0, + randomize_seats: false, } } @@ -869,6 +874,11 @@ mod tests { small_blind: 10, big_blind: 20, ante: 0, + rake_config: crate::game::config::RakeConfig::no_rake(), + starting_chips: 10_000, + reset_bankroll_each_hand: false, + bankroll_refill_threshold: 0, + randomize_seats: false, }; let schedule = BlindSchedule::standard(1000, 10); let prizes = PrizeStructure::standard_sng(100, 4); @@ -893,6 +903,11 @@ mod tests { small_blind: 10, big_blind: 20, ante: 0, + rake_config: crate::game::config::RakeConfig::no_rake(), + starting_chips: 10_000, + reset_bankroll_each_hand: false, + bankroll_refill_threshold: 0, + randomize_seats: false, }; let schedule = BlindSchedule::standard(1000, 10); let prizes = PrizeStructure::standard_sng(100, 4); @@ -954,6 +969,11 @@ mod tests { small_blind: 10, big_blind: 20, ante: 0, + rake_config: crate::game::config::RakeConfig::no_rake(), + starting_chips: 10_000, + reset_bankroll_each_hand: false, + bankroll_refill_threshold: 0, + randomize_seats: false, }; let schedule = BlindSchedule::standard(1000, 10); let prizes = PrizeStructure::standard_mtt(100, 6); @@ -999,6 +1019,11 @@ mod tests { small_blind: 10, big_blind: 20, ante: 0, + rake_config: crate::game::config::RakeConfig::no_rake(), + starting_chips: 10_000, + reset_bankroll_each_hand: false, + bankroll_refill_threshold: 0, + randomize_seats: false, }; let schedule = BlindSchedule::standard(1000, 3); let prizes = PrizeStructure::standard_sng(100, 4);