feat: add rake, bankroll management, seat randomization, FlockBot & ChumpBot
- Add RakeConfig with percentage-based rake and per-pot cap - Add bankroll tracking (reset per hand or auto-refill threshold) - Add seat randomization with seeded RNG for reproducibility - Add FlockBot (tight-aggressive) and ChumpBot (loose-passive) bots - Refactor HandRunner: extract finalize_result, compute_chip_movements, credit_winnings - Add num_raises to GameInfo for authoritative raise counting - Record SB/BB as Raise actions (Meerkat convention) - Update all existing bot tests for new GameInfo fields - Add ARCHITECTURE_RAKE_BANKROLL_SEATS.md documentation
This commit is contained in:
parent
17d0440c86
commit
9dfd9c3ee3
20 changed files with 2880 additions and 173 deletions
786
docs/ARCHITECTURE_RAKE_BANKROLL_SEATS.md
Normal file
786
docs/ARCHITECTURE_RAKE_BANKROLL_SEATS.md
Normal file
|
|
@ -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<Vec<PotWinner>>,
|
||||||
|
pub hand_values: HashMap<PlayerId, HandValue>,
|
||||||
|
pub board: Board,
|
||||||
|
pub chip_movements: HashMap<PlayerId, i64>,
|
||||||
|
/// 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<ChipCount>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<PotWinner>>,
|
||||||
|
) -> (Vec<Vec<PotWinner>>, Vec<ChipCount>, 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<Vec<PotWinner>> = 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<HashMap<PlayerId, ChipCount>> {
|
||||||
|
// 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<PlayerId, ChipCount>,
|
||||||
|
starting_stacks: &HashMap<PlayerId, ChipCount>,
|
||||||
|
bankrolls: &HashMap<PlayerId, i64>, // 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<PlayerId, i64> = 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<Seat> = 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<ChipCount>` |
|
||||||
|
| `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` |
|
||||||
|
|
@ -1,24 +1,63 @@
|
||||||
[game]
|
[game]
|
||||||
game_type = "Cash"
|
game_type = "Cash"
|
||||||
num_seats = 6
|
num_seats = 10
|
||||||
small_blind = 10
|
small_blind = 10
|
||||||
big_blind = 20
|
big_blind = 20
|
||||||
ante = 0
|
ante = 0
|
||||||
starting_chips = 10000
|
starting_chips = 5000
|
||||||
num_hands = 1000
|
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]
|
[game.bet_limit]
|
||||||
type = "no_limit"
|
type = "no_limit"
|
||||||
min_raise = 20
|
min_raise = 20
|
||||||
|
|
||||||
|
[[game.bots]]
|
||||||
|
bot_type = "flock_bot"
|
||||||
|
name = "FlockBot-1"
|
||||||
|
|
||||||
|
[[game.bots]]
|
||||||
|
bot_type = "chump_bot"
|
||||||
|
name = "ChumpBot-1"
|
||||||
|
|
||||||
[[game.bots]]
|
[[game.bots]]
|
||||||
bot_type = "call_bot"
|
bot_type = "call_bot"
|
||||||
name = "Alice"
|
name = "CallBot-1"
|
||||||
|
|
||||||
[[game.bots]]
|
[[game.bots]]
|
||||||
bot_type = "fold_bot"
|
bot_type = "fold_bot"
|
||||||
name = "Bob"
|
name = "FoldBot-1"
|
||||||
|
|
||||||
|
[[game.bots]]
|
||||||
|
bot_type = "raise_bot"
|
||||||
|
name = "RaiseBot-1"
|
||||||
|
|
||||||
[[game.bots]]
|
[[game.bots]]
|
||||||
bot_type = "random_bot"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,13 @@
|
||||||
//! | `bot_type` string | Bot implementation |
|
//! | `bot_type` string | Bot implementation |
|
||||||
//! |-------------------|--------------------|
|
//! |-------------------|--------------------|
|
||||||
//! | `"call_bot"` | [`CallBot`](super::bots::CallBot) |
|
//! | `"call_bot"` | [`CallBot`](super::bots::CallBot) |
|
||||||
|
//! | `"chump_bot"` | [`ChumpBot`](super::bots::ChumpBot) |
|
||||||
|
//! | `"flock_bot"` | [`FlockBot`](super::bots::FlockBot) |
|
||||||
//! | `"fold_bot"` | [`FoldBot`](super::bots::FoldBot) |
|
//! | `"fold_bot"` | [`FoldBot`](super::bots::FoldBot) |
|
||||||
//! | `"raise_bot"` | [`RaiseBot`](super::bots::RaiseBot) |
|
//! | `"raise_bot"` | [`RaiseBot`](super::bots::RaiseBot) |
|
||||||
//! | `"random_bot"` | [`RandomBot`](super::bots::RandomBot) |
|
//! | `"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::config::BotConfig;
|
||||||
use super::player::HoldemPlayer;
|
use super::player::HoldemPlayer;
|
||||||
use super::types::PlayerId;
|
use super::types::PlayerId;
|
||||||
|
|
@ -54,6 +56,8 @@ pub fn create_bot(config: &BotConfig, id: PlayerId) -> Result<Box<dyn HoldemPlay
|
||||||
.unwrap_or_else(|| config.bot_type.clone());
|
.unwrap_or_else(|| config.bot_type.clone());
|
||||||
match config.bot_type.as_str() {
|
match config.bot_type.as_str() {
|
||||||
"call_bot" => Ok(Box::new(CallBot::new(id, name))),
|
"call_bot" => 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))),
|
"fold_bot" => Ok(Box::new(FoldBot::new(id, name))),
|
||||||
"raise_bot" => Ok(Box::new(RaiseBot::new(id, name))),
|
"raise_bot" => Ok(Box::new(RaiseBot::new(id, name))),
|
||||||
"random_bot" => Ok(Box::new(RandomBot::new(id, name))),
|
"random_bot" => Ok(Box::new(RandomBot::new(id, name))),
|
||||||
|
|
|
||||||
|
|
@ -126,8 +126,10 @@ mod tests {
|
||||||
is_dealer: true,
|
is_dealer: true,
|
||||||
is_small_blind: false,
|
is_small_blind: false,
|
||||||
is_big_blind: false,
|
is_big_blind: false,
|
||||||
|
bankroll: 0,
|
||||||
})],
|
})],
|
||||||
actions_this_hand: vec![],
|
actions_this_hand: vec![],
|
||||||
|
num_raises: 0,
|
||||||
hand_number: 1,
|
hand_number: 1,
|
||||||
active_players: 2,
|
active_players: 2,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
523
holdem_core/src/game/bots/chump_bot.rs
Normal file
523
holdem_core/src/game/bots/chump_bot.rs
Normal file
|
|
@ -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<HoleCards>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<u8> = 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<u8> = 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<Card>,
|
||||||
|
) -> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
868
holdem_core/src/game/bots/flock_bot.rs
Normal file
868
holdem_core/src/game/bots/flock_bot.rs
Normal file
|
|
@ -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<HoleCards>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Action> {
|
||||||
|
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<Action> {
|
||||||
|
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<u8> = 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<u8> = 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<Card>,
|
||||||
|
) -> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -127,8 +127,10 @@ mod tests {
|
||||||
is_dealer: true,
|
is_dealer: true,
|
||||||
is_small_blind: false,
|
is_small_blind: false,
|
||||||
is_big_blind: false,
|
is_big_blind: false,
|
||||||
|
bankroll: 0,
|
||||||
})],
|
})],
|
||||||
actions_this_hand: vec![],
|
actions_this_hand: vec![],
|
||||||
|
num_raises: 0,
|
||||||
hand_number: 1,
|
hand_number: 1,
|
||||||
active_players: 2,
|
active_players: 2,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,13 @@
|
||||||
//! | [`FoldBot`] | Ultra-passive | Folds to any bet, checks otherwise |
|
//! | [`FoldBot`] | Ultra-passive | Folds to any bet, checks otherwise |
|
||||||
//! | [`RaiseBot`] | Ultra-aggressive | Always raises the maximum allowed amount |
|
//! | [`RaiseBot`] | Ultra-aggressive | Always raises the maximum allowed amount |
|
||||||
//! | [`RandomBot`] | Random | Picks a random legal action at each decision point |
|
//! | [`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
|
//! # Usage
|
||||||
//!
|
//!
|
||||||
//! ```rust
|
//! ```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::player::HoldemPlayer;
|
||||||
//! use holdem_core::game::types::PlayerId;
|
//! use holdem_core::game::types::PlayerId;
|
||||||
//!
|
//!
|
||||||
|
|
@ -24,15 +26,21 @@
|
||||||
//! let fold_bot = FoldBot::new(PlayerId(2), "Folder".to_string());
|
//! let fold_bot = FoldBot::new(PlayerId(2), "Folder".to_string());
|
||||||
//! let raise_bot = RaiseBot::new(PlayerId(3), "Raiser".to_string());
|
//! let raise_bot = RaiseBot::new(PlayerId(3), "Raiser".to_string());
|
||||||
//! let random_bot = RandomBot::new(PlayerId(4), "Random".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 call_bot;
|
||||||
|
pub mod chump_bot;
|
||||||
|
pub mod flock_bot;
|
||||||
pub mod fold_bot;
|
pub mod fold_bot;
|
||||||
pub mod random_bot;
|
pub mod random_bot;
|
||||||
pub mod raise_bot;
|
pub mod raise_bot;
|
||||||
|
|
||||||
// Re-export all bots at the module level for convenience.
|
// Re-export all bots at the module level for convenience.
|
||||||
pub use call_bot::CallBot;
|
pub use call_bot::CallBot;
|
||||||
|
pub use chump_bot::ChumpBot;
|
||||||
|
pub use flock_bot::FlockBot;
|
||||||
pub use fold_bot::FoldBot;
|
pub use fold_bot::FoldBot;
|
||||||
pub use random_bot::RandomBot;
|
pub use random_bot::RandomBot;
|
||||||
pub use raise_bot::RaiseBot;
|
pub use raise_bot::RaiseBot;
|
||||||
|
|
|
||||||
|
|
@ -149,8 +149,8 @@ impl HoldemPlayer for RaiseBot {
|
||||||
.unwrap_or(small_bet);
|
.unwrap_or(small_bet);
|
||||||
|
|
||||||
// Check if raises are still allowed (typically max 3 raises per round)
|
// Check if raises are still allowed (typically max 3 raises per round)
|
||||||
// We approximate raises_this_round from actions
|
// Use the authoritative raise counter from GameInfo.
|
||||||
let raises_this_round = count_raises(game_info);
|
let raises_this_round = game_info.num_raises;
|
||||||
|
|
||||||
if !game_info.bet_limit.can_raise(game_info.street, raises_this_round) {
|
if !game_info.bet_limit.can_raise(game_info.street, raises_this_round) {
|
||||||
// Can't raise anymore, just call
|
// Can't raise anymore, just call
|
||||||
|
|
@ -197,30 +197,6 @@ impl HoldemPlayer for RaiseBot {
|
||||||
fn on_eliminated(&self, _placement: u32) {}
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -261,8 +237,10 @@ mod tests {
|
||||||
is_dealer: true,
|
is_dealer: true,
|
||||||
is_small_blind: false,
|
is_small_blind: false,
|
||||||
is_big_blind: false,
|
is_big_blind: false,
|
||||||
|
bankroll: 0,
|
||||||
})],
|
})],
|
||||||
actions_this_hand: vec![],
|
actions_this_hand: vec![],
|
||||||
|
num_raises: 0,
|
||||||
hand_number: 1,
|
hand_number: 1,
|
||||||
active_players: 2,
|
active_players: 2,
|
||||||
}
|
}
|
||||||
|
|
@ -306,8 +284,10 @@ mod tests {
|
||||||
is_dealer: true,
|
is_dealer: true,
|
||||||
is_small_blind: false,
|
is_small_blind: false,
|
||||||
is_big_blind: false,
|
is_big_blind: false,
|
||||||
|
bankroll: 0,
|
||||||
})],
|
})],
|
||||||
actions_this_hand: vec![],
|
actions_this_hand: vec![],
|
||||||
|
num_raises: 0,
|
||||||
hand_number: 1,
|
hand_number: 1,
|
||||||
active_players: 2,
|
active_players: 2,
|
||||||
}
|
}
|
||||||
|
|
@ -347,8 +327,10 @@ mod tests {
|
||||||
is_dealer: true,
|
is_dealer: true,
|
||||||
is_small_blind: false,
|
is_small_blind: false,
|
||||||
is_big_blind: false,
|
is_big_blind: false,
|
||||||
|
bankroll: 0,
|
||||||
})],
|
})],
|
||||||
actions_this_hand: vec![],
|
actions_this_hand: vec![],
|
||||||
|
num_raises: 0,
|
||||||
hand_number: 1,
|
hand_number: 1,
|
||||||
active_players: 2,
|
active_players: 2,
|
||||||
}
|
}
|
||||||
|
|
@ -449,15 +431,7 @@ mod tests {
|
||||||
let mut bot = RaiseBot::new(PlayerId(3), "RaiseBot".to_string());
|
let mut bot = RaiseBot::new(PlayerId(3), "RaiseBot".to_string());
|
||||||
// Create game info with 3 raises already made on the flop (Fixed-Limit max)
|
// 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);
|
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.num_raises = 3; // 3 voluntary raises already on the flop
|
||||||
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 }),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
let action = bot.act(&game_info);
|
let action = bot.act(&game_info);
|
||||||
// Can't raise anymore, should call
|
// Can't raise anymore, should call
|
||||||
assert_eq!(action, Action::Call);
|
assert_eq!(action, Action::Call);
|
||||||
|
|
|
||||||
|
|
@ -59,26 +59,6 @@ impl RandomBot {
|
||||||
game_info.current_bet.saturating_sub(wagered)
|
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.
|
/// Builds a list of legal actions and picks one at random.
|
||||||
///
|
///
|
||||||
|
|
@ -123,7 +103,7 @@ impl RandomBot {
|
||||||
// Check if raising is possible
|
// Check if raising is possible
|
||||||
let can_raise = game_info
|
let can_raise = game_info
|
||||||
.bet_limit
|
.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 {
|
if can_raise && stack > call_amount {
|
||||||
// Player has chips remaining after calling
|
// Player has chips remaining after calling
|
||||||
|
|
@ -274,8 +254,10 @@ mod tests {
|
||||||
is_dealer: true,
|
is_dealer: true,
|
||||||
is_small_blind: false,
|
is_small_blind: false,
|
||||||
is_big_blind: false,
|
is_big_blind: false,
|
||||||
|
bankroll: 0,
|
||||||
})],
|
})],
|
||||||
actions_this_hand: vec![],
|
actions_this_hand: vec![],
|
||||||
|
num_raises: 0,
|
||||||
hand_number: 1,
|
hand_number: 1,
|
||||||
active_players: 2,
|
active_players: 2,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,63 @@ use super::table::TableConfig;
|
||||||
use super::tournament::{BlindSchedule, GameType, PrizeStructure};
|
use super::tournament::{BlindSchedule, GameType, PrizeStructure};
|
||||||
use super::types::{ChipCount, GameError, SeatIndex};
|
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
|
// Default helper functions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -223,6 +280,30 @@ pub struct GameConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub seed: Option<u64>,
|
pub seed: Option<u64>,
|
||||||
|
|
||||||
|
/// 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.
|
/// Bot configurations.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub bots: Vec<BotConfig>,
|
pub bots: Vec<BotConfig>,
|
||||||
|
|
@ -246,6 +327,11 @@ impl Default for GameConfig {
|
||||||
num_blind_levels: default_num_levels(),
|
num_blind_levels: default_num_levels(),
|
||||||
num_hands: 0,
|
num_hands: 0,
|
||||||
seed: None,
|
seed: None,
|
||||||
|
rake_percentage: 0.0,
|
||||||
|
rake_cap: 0,
|
||||||
|
reset_bankroll_each_hand: false,
|
||||||
|
bankroll_refill_threshold: 0,
|
||||||
|
randomize_seats: false,
|
||||||
bots: Vec::new(),
|
bots: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -287,6 +373,14 @@ impl GameConfig {
|
||||||
small_blind: self.small_blind,
|
small_blind: self.small_blind,
|
||||||
big_blind: self.big_blind,
|
big_blind: self.big_blind,
|
||||||
ante: self.ante,
|
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_blind_levels: 15,
|
||||||
num_hands: 500,
|
num_hands: 500,
|
||||||
seed: Some(12345),
|
seed: Some(12345),
|
||||||
|
rake_percentage: 0.0,
|
||||||
|
rake_cap: 0,
|
||||||
|
reset_bankroll_each_hand: false,
|
||||||
|
bankroll_refill_threshold: 0,
|
||||||
|
randomize_seats: false,
|
||||||
|
|
||||||
bots: vec![
|
bots: vec![
|
||||||
BotConfig {
|
BotConfig {
|
||||||
bot_type: "call_bot".to_string(),
|
bot_type: "call_bot".to_string(),
|
||||||
|
|
@ -760,6 +860,12 @@ min_raise = 50
|
||||||
num_blind_levels: 15,
|
num_blind_levels: 15,
|
||||||
num_hands: 0,
|
num_hands: 0,
|
||||||
seed: None,
|
seed: None,
|
||||||
|
rake_percentage: 0.0,
|
||||||
|
rake_cap: 0,
|
||||||
|
reset_bankroll_each_hand: false,
|
||||||
|
bankroll_refill_threshold: 0,
|
||||||
|
randomize_seats: false,
|
||||||
|
|
||||||
bots: vec![],
|
bots: vec![],
|
||||||
};
|
};
|
||||||
let table_config = config.to_table_config();
|
let table_config = config.to_table_config();
|
||||||
|
|
@ -786,6 +892,12 @@ min_raise = 50
|
||||||
num_blind_levels: 5,
|
num_blind_levels: 5,
|
||||||
num_hands: 0,
|
num_hands: 0,
|
||||||
seed: None,
|
seed: None,
|
||||||
|
rake_percentage: 0.0,
|
||||||
|
rake_cap: 0,
|
||||||
|
reset_bankroll_each_hand: false,
|
||||||
|
bankroll_refill_threshold: 0,
|
||||||
|
randomize_seats: false,
|
||||||
|
|
||||||
bots: vec![],
|
bots: vec![],
|
||||||
};
|
};
|
||||||
let schedule = config.to_blind_schedule();
|
let schedule = config.to_blind_schedule();
|
||||||
|
|
@ -828,6 +940,12 @@ min_raise = 50
|
||||||
num_blind_levels: 15,
|
num_blind_levels: 15,
|
||||||
num_hands: 0,
|
num_hands: 0,
|
||||||
seed: None,
|
seed: None,
|
||||||
|
rake_percentage: 0.0,
|
||||||
|
rake_cap: 0,
|
||||||
|
reset_bankroll_each_hand: false,
|
||||||
|
bankroll_refill_threshold: 0,
|
||||||
|
randomize_seats: false,
|
||||||
|
|
||||||
bots: vec![
|
bots: vec![
|
||||||
BotConfig {
|
BotConfig {
|
||||||
bot_type: "call_bot".to_string(),
|
bot_type: "call_bot".to_string(),
|
||||||
|
|
@ -872,6 +990,12 @@ min_raise = 50
|
||||||
num_blind_levels: 20,
|
num_blind_levels: 20,
|
||||||
num_hands: 0,
|
num_hands: 0,
|
||||||
seed: None,
|
seed: None,
|
||||||
|
rake_percentage: 0.0,
|
||||||
|
rake_cap: 0,
|
||||||
|
reset_bankroll_each_hand: false,
|
||||||
|
bankroll_refill_threshold: 0,
|
||||||
|
randomize_seats: false,
|
||||||
|
|
||||||
bots: vec![
|
bots: vec![
|
||||||
BotConfig {
|
BotConfig {
|
||||||
bot_type: "bot".to_string(),
|
bot_type: "bot".to_string(),
|
||||||
|
|
@ -958,6 +1082,11 @@ min_raise = 20
|
||||||
num_blind_levels: 20,
|
num_blind_levels: 20,
|
||||||
num_hands: 0,
|
num_hands: 0,
|
||||||
seed: Some(42),
|
seed: Some(42),
|
||||||
|
rake_percentage: 0.0,
|
||||||
|
rake_cap: 0,
|
||||||
|
reset_bankroll_each_hand: false,
|
||||||
|
bankroll_refill_threshold: 0,
|
||||||
|
randomize_seats: false,
|
||||||
bots: vec![],
|
bots: vec![],
|
||||||
},
|
},
|
||||||
output: OutputConfig {
|
output: OutputConfig {
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,19 @@ pub struct GameInfo {
|
||||||
// --- Hand history for current hand ---
|
// --- Hand history for current hand ---
|
||||||
/// Actions taken so far in the current hand, grouped by street.
|
/// Actions taken so far in the current hand, grouped by street.
|
||||||
/// Index 0 = preflop, 1 = flop, 2 = turn, 3 = river.
|
/// 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<Vec<(PlayerId, Action)>>,
|
pub actions_this_hand: Vec<Vec<(PlayerId, Action)>>,
|
||||||
|
|
||||||
|
// --- 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 ---
|
// --- Game metadata ---
|
||||||
/// Hand number within the session.
|
/// Hand number within the session.
|
||||||
pub hand_number: u64,
|
pub hand_number: u64,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ use crate::{Board, Deck, Hand, HoleCards};
|
||||||
|
|
||||||
use super::action::{validate_action, Action};
|
use super::action::{validate_action, Action};
|
||||||
use super::betting::{BetLimit, BettingRound};
|
use super::betting::{BetLimit, BettingRound};
|
||||||
|
use super::config::RakeConfig;
|
||||||
use super::game_info::GameInfo;
|
use super::game_info::GameInfo;
|
||||||
use super::player::Seat;
|
use super::player::Seat;
|
||||||
use super::player_info::PlayerState;
|
use super::player_info::PlayerState;
|
||||||
|
|
@ -92,6 +93,10 @@ pub struct HandResult {
|
||||||
pub board: Board,
|
pub board: Board,
|
||||||
/// Net chip movement for each player in units of chips (positive = won, negative = lost).
|
/// Net chip movement for each player in units of chips (positive = won, negative = lost).
|
||||||
pub chip_movements: HashMap<PlayerId, i64>,
|
pub chip_movements: HashMap<PlayerId, i64>,
|
||||||
|
/// 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<ChipCount>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -141,6 +146,8 @@ pub struct HandRunner {
|
||||||
actions_this_hand: Vec<Vec<(PlayerId, Action)>>,
|
actions_this_hand: Vec<Vec<(PlayerId, Action)>>,
|
||||||
/// Optional RNG seed for reproducible shuffles.
|
/// Optional RNG seed for reproducible shuffles.
|
||||||
seed: Option<u64>,
|
seed: Option<u64>,
|
||||||
|
/// Rake configuration for this hand.
|
||||||
|
rake_config: RakeConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HandRunner {
|
impl HandRunner {
|
||||||
|
|
@ -188,6 +195,7 @@ impl HandRunner {
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
],
|
],
|
||||||
seed: None,
|
seed: None,
|
||||||
|
rake_config: RakeConfig::no_rake(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,6 +208,12 @@ impl HandRunner {
|
||||||
self
|
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
|
// Start the hand
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
@ -279,6 +293,11 @@ impl HandRunner {
|
||||||
if actual_sb > 0 {
|
if actual_sb > 0 {
|
||||||
self.pot_manager
|
self.pot_manager
|
||||||
.place_bet(self.seats[self.sb_seat_idx].player_id, actual_sb);
|
.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 {
|
if actual_bb > 0 {
|
||||||
self.pot_manager
|
self.pot_manager
|
||||||
.place_bet(self.seats[self.bb_seat_idx].player_id, actual_bb);
|
.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();
|
.collect();
|
||||||
self.pot_manager.finalize_pots(&active_ids);
|
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);
|
let pot_winners = self.pot_manager.distribute(&hand_values);
|
||||||
|
self.finalize_result(pot_winners, hand_values)
|
||||||
// 6. Calculate chip movements (net: winnings - wagered).
|
|
||||||
// Use i64 internally to handle negative values.
|
|
||||||
let mut chip_movements_i64: HashMap<PlayerId, i64> = 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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
@ -665,46 +645,7 @@ impl HandRunner {
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let pot_winners = self.pot_manager.distribute(&hand_values);
|
let pot_winners = self.pot_manager.distribute(&hand_values);
|
||||||
|
Ok(self.finalize_result(pot_winners, hand_values))
|
||||||
// Calculate chip movements (net: winnings - wagered).
|
|
||||||
let mut chip_movements_i64: HashMap<PlayerId, i64> = 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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
@ -940,8 +881,8 @@ impl HandRunner {
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
HandState::Complete => {
|
HandState::Complete => {
|
||||||
// Hand was completed (e.g., by resolve_last_player_wins via step).
|
// Hand was already resolved (e.g., by resolve_last_player_wins via step).
|
||||||
// Reconstruct the result from the pot manager.
|
// Stacks were already credited there — only reconstruct the HandResult.
|
||||||
let hand_values: HashMap<PlayerId, HandValue> = self
|
let hand_values: HashMap<PlayerId, HandValue> = self
|
||||||
.seats
|
.seats
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -950,24 +891,18 @@ impl HandRunner {
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let pot_winners = self.pot_manager.distribute(&hand_values);
|
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<PlayerId, i64> = HashMap::new();
|
// Compute chip movements for the result (no stack crediting — already done).
|
||||||
for seat in &self.seats {
|
let chip_movements = self.compute_chip_movements(&raked_winners);
|
||||||
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;
|
|
||||||
|
|
||||||
return Ok(HandResult {
|
return Ok(HandResult {
|
||||||
pot_winners,
|
pot_winners: raked_winners,
|
||||||
hand_values,
|
hand_values,
|
||||||
board: self.board.clone(),
|
board: self.board.clone(),
|
||||||
chip_movements,
|
chip_movements,
|
||||||
|
total_rake,
|
||||||
|
rake_per_pot,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
HandState::Idle => {
|
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<PotWinner>],
|
||||||
|
) -> HashMap<PlayerId, i64> {
|
||||||
|
let mut chip_movements: HashMap<PlayerId, i64> = 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<PotWinner>]) {
|
||||||
|
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<Vec<PotWinner>>,
|
||||||
|
hand_values: HashMap<PlayerId, HandValue>,
|
||||||
|
) -> 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<PotWinner>>,
|
||||||
|
) -> (Vec<Vec<PotWinner>>, Vec<ChipCount>, 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<Vec<PotWinner>> = 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
|
// Private helpers
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
@ -1101,6 +1164,27 @@ impl HandRunner {
|
||||||
.and_then(|id| self.find_seat_index_by_player_id(id))
|
.and_then(|id| self.find_seat_index_by_player_id(id))
|
||||||
.map(|idx| self.seats[idx].index);
|
.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 {
|
GameInfo {
|
||||||
num_seats: self.seats.len() as SeatIndex,
|
num_seats: self.seats.len() as SeatIndex,
|
||||||
bet_limit: self.bet_limit,
|
bet_limit: self.bet_limit,
|
||||||
|
|
@ -1118,6 +1202,7 @@ impl HandRunner {
|
||||||
next_to_act: next_to_act_seat,
|
next_to_act: next_to_act_seat,
|
||||||
players,
|
players,
|
||||||
actions_this_hand: self.actions_this_hand.clone(),
|
actions_this_hand: self.actions_this_hand.clone(),
|
||||||
|
num_raises,
|
||||||
hand_number: self.hand_number,
|
hand_number: self.hand_number,
|
||||||
active_players: self.count_active_players(),
|
active_players: self.count_active_players(),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ pub use action::{Action, PlayerAction};
|
||||||
pub use betting::BetLimit;
|
pub use betting::BetLimit;
|
||||||
pub use bot_factory::create_bot;
|
pub use bot_factory::create_bot;
|
||||||
pub use bots::{CallBot, FoldBot, RandomBot, RaiseBot};
|
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 game_info::GameInfo;
|
||||||
pub use hand_runner::{HandResult, HandRunner, HandState};
|
pub use hand_runner::{HandResult, HandRunner, HandState};
|
||||||
pub use player::{HoldemPlayer, Seat};
|
pub use player::{HoldemPlayer, Seat};
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,14 @@ pub struct Seat {
|
||||||
/// early fold intent; applied as [`Action::Fold`] when it becomes this
|
/// early fold intent; applied as [`Action::Fold`] when it becomes this
|
||||||
/// player's turn.
|
/// player's turn.
|
||||||
pub early_fold_pending: bool,
|
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 {
|
impl Seat {
|
||||||
|
|
@ -341,6 +349,7 @@ impl Seat {
|
||||||
hole_cards: None,
|
hole_cards: None,
|
||||||
has_acted_this_round: false,
|
has_acted_this_round: false,
|
||||||
early_fold_pending: false,
|
early_fold_pending: false,
|
||||||
|
bankroll: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -374,6 +383,7 @@ impl Seat {
|
||||||
is_dealer,
|
is_dealer,
|
||||||
is_small_blind,
|
is_small_blind,
|
||||||
is_big_blind,
|
is_big_blind,
|
||||||
|
bankroll: self.bankroll,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,10 @@ pub struct PlayerInfo {
|
||||||
|
|
||||||
/// Whether this player is the big blind.
|
/// Whether this player is the big blind.
|
||||||
pub is_big_blind: bool,
|
pub is_big_blind: bool,
|
||||||
|
|
||||||
|
/// The player's overall bankroll balance across all hands.
|
||||||
|
/// Positive = profit, negative = loss.
|
||||||
|
pub bankroll: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlayerInfo {
|
impl PlayerInfo {
|
||||||
|
|
|
||||||
|
|
@ -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<PlayerId, ChipCount> = table
|
let final_stacks: HashMap<PlayerId, ChipCount> = table
|
||||||
.seats()
|
.seats()
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -93,7 +93,16 @@ pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), Ga
|
||||||
.map(|seat| (seat.player_id, seat.stack))
|
.map(|seat| (seat.player_id, seat.stack))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
stats.record_final_stacks(&final_stacks, &starting_stacks);
|
let bankrolls: HashMap<PlayerId, i64> = 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))
|
Ok((stats, table))
|
||||||
}
|
}
|
||||||
|
|
@ -124,6 +133,11 @@ mod tests {
|
||||||
num_blind_levels: 15,
|
num_blind_levels: 15,
|
||||||
num_hands,
|
num_hands,
|
||||||
seed,
|
seed,
|
||||||
|
rake_percentage: 0.0,
|
||||||
|
rake_cap: 0,
|
||||||
|
reset_bankroll_each_hand: false,
|
||||||
|
bankroll_refill_threshold: 0,
|
||||||
|
randomize_seats: false,
|
||||||
bots: vec![
|
bots: vec![
|
||||||
BotConfig {
|
BotConfig {
|
||||||
bot_type: "call_bot".to_string(),
|
bot_type: "call_bot".to_string(),
|
||||||
|
|
@ -225,6 +239,12 @@ mod tests {
|
||||||
num_blind_levels: 15,
|
num_blind_levels: 15,
|
||||||
num_hands: 10,
|
num_hands: 10,
|
||||||
seed: None,
|
seed: None,
|
||||||
|
rake_percentage: 0.0,
|
||||||
|
rake_cap: 0,
|
||||||
|
reset_bankroll_each_hand: false,
|
||||||
|
bankroll_refill_threshold: 0,
|
||||||
|
randomize_seats: false,
|
||||||
|
|
||||||
bots: vec![BotConfig {
|
bots: vec![BotConfig {
|
||||||
bot_type: "call_bot".to_string(),
|
bot_type: "call_bot".to_string(),
|
||||||
name: Some("Lonely".to_string()),
|
name: Some("Lonely".to_string()),
|
||||||
|
|
@ -253,6 +273,12 @@ mod tests {
|
||||||
num_blind_levels: 15,
|
num_blind_levels: 15,
|
||||||
num_hands: 10,
|
num_hands: 10,
|
||||||
seed: None,
|
seed: None,
|
||||||
|
rake_percentage: 0.0,
|
||||||
|
rake_cap: 0,
|
||||||
|
reset_bankroll_each_hand: false,
|
||||||
|
bankroll_refill_threshold: 0,
|
||||||
|
randomize_seats: false,
|
||||||
|
|
||||||
bots: vec![
|
bots: vec![
|
||||||
BotConfig {
|
BotConfig {
|
||||||
bot_type: "call_bot".to_string(),
|
bot_type: "call_bot".to_string(),
|
||||||
|
|
@ -289,6 +315,12 @@ mod tests {
|
||||||
num_blind_levels: 15,
|
num_blind_levels: 15,
|
||||||
num_hands: 5,
|
num_hands: 5,
|
||||||
seed: Some(99),
|
seed: Some(99),
|
||||||
|
rake_percentage: 0.0,
|
||||||
|
rake_cap: 0,
|
||||||
|
reset_bankroll_each_hand: false,
|
||||||
|
bankroll_refill_threshold: 0,
|
||||||
|
randomize_seats: false,
|
||||||
|
|
||||||
bots: vec![
|
bots: vec![
|
||||||
BotConfig {
|
BotConfig {
|
||||||
bot_type: "call_bot".to_string(),
|
bot_type: "call_bot".to_string(),
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@ pub struct PlayerStats {
|
||||||
pub biggest_pot_won: ChipCount,
|
pub biggest_pot_won: ChipCount,
|
||||||
/// Stack at the end of the simulation.
|
/// Stack at the end of the simulation.
|
||||||
pub final_stack: ChipCount,
|
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 {
|
impl PlayerStats {
|
||||||
|
|
@ -48,6 +52,8 @@ impl PlayerStats {
|
||||||
total_wagered: 0,
|
total_wagered: 0,
|
||||||
biggest_pot_won: 0,
|
biggest_pot_won: 0,
|
||||||
final_stack: 0,
|
final_stack: 0,
|
||||||
|
total_rake_paid: 0,
|
||||||
|
bankroll: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,6 +102,8 @@ pub struct SimulationStats {
|
||||||
pub total_hands: u64,
|
pub total_hands: u64,
|
||||||
/// Game type label (e.g. `"Cash"`, `"SitAndGo"`).
|
/// Game type label (e.g. `"Cash"`, `"SitAndGo"`).
|
||||||
pub game_type: String,
|
pub game_type: String,
|
||||||
|
/// Total rake collected across the entire simulation.
|
||||||
|
pub total_rake: ChipCount,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SimulationStats {
|
impl SimulationStats {
|
||||||
|
|
@ -105,6 +113,7 @@ impl SimulationStats {
|
||||||
players: HashMap::new(),
|
players: HashMap::new(),
|
||||||
total_hands: 0,
|
total_hands: 0,
|
||||||
game_type: game_type.to_string(),
|
game_type: game_type.to_string(),
|
||||||
|
total_rake: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,6 +132,7 @@ impl SimulationStats {
|
||||||
player_names: &HashMap<PlayerId, String>,
|
player_names: &HashMap<PlayerId, String>,
|
||||||
) {
|
) {
|
||||||
self.total_hands += 1;
|
self.total_hands += 1;
|
||||||
|
self.total_rake += result.total_rake;
|
||||||
|
|
||||||
for (&player_id, &movement) in &result.chip_movements {
|
for (&player_id, &movement) in &result.chip_movements {
|
||||||
let stats = self
|
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
|
/// 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(
|
pub fn record_final_stacks(
|
||||||
&mut self,
|
&mut self,
|
||||||
stacks: &HashMap<PlayerId, ChipCount>,
|
stacks: &HashMap<PlayerId, ChipCount>,
|
||||||
starting_stacks: &HashMap<PlayerId, ChipCount>,
|
starting_stacks: &HashMap<PlayerId, ChipCount>,
|
||||||
|
bankrolls: &HashMap<PlayerId, i64>,
|
||||||
|
bankroll_mode: bool,
|
||||||
) {
|
) {
|
||||||
for (&player_id, &final_stack) in stacks {
|
for (&player_id, &final_stack) in stacks {
|
||||||
if let Some(stats) = self.players.get_mut(&player_id) {
|
if let Some(stats) = self.players.get_mut(&player_id) {
|
||||||
stats.final_stack = final_stack;
|
stats.final_stack = final_stack;
|
||||||
let starting = starting_stacks.get(&player_id).copied().unwrap_or(0);
|
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(),
|
hand_values: HashMap::new(),
|
||||||
board: Board::new(),
|
board: Board::new(),
|
||||||
chip_movements,
|
chip_movements,
|
||||||
|
total_rake: 0,
|
||||||
|
rake_per_pot: vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
sim_stats.record_hand_result(&result, &player_names);
|
sim_stats.record_hand_result(&result, &player_names);
|
||||||
|
|
@ -481,6 +532,8 @@ mod tests {
|
||||||
m.insert(PlayerId(2), 0);
|
m.insert(PlayerId(2), 0);
|
||||||
m
|
m
|
||||||
},
|
},
|
||||||
|
total_rake: 0,
|
||||||
|
rake_per_pot: vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hand 2: Bob wins 200
|
// Hand 2: Bob wins 200
|
||||||
|
|
@ -494,6 +547,8 @@ mod tests {
|
||||||
m.insert(PlayerId(2), 200);
|
m.insert(PlayerId(2), 200);
|
||||||
m
|
m
|
||||||
},
|
},
|
||||||
|
total_rake: 0,
|
||||||
|
rake_per_pot: vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
sim_stats.record_hand_result(&result1, &player_names);
|
sim_stats.record_hand_result(&result1, &player_names);
|
||||||
|
|
@ -530,6 +585,8 @@ mod tests {
|
||||||
m.insert(PlayerId(2), 0);
|
m.insert(PlayerId(2), 0);
|
||||||
m
|
m
|
||||||
},
|
},
|
||||||
|
total_rake: 0,
|
||||||
|
rake_per_pot: vec![],
|
||||||
};
|
};
|
||||||
sim_stats.record_hand_result(&result, &player_names);
|
sim_stats.record_hand_result(&result, &player_names);
|
||||||
|
|
||||||
|
|
@ -546,7 +603,7 @@ mod tests {
|
||||||
m.insert(PlayerId(2), 10000);
|
m.insert(PlayerId(2), 10000);
|
||||||
m
|
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();
|
let alice = sim_stats.players.get(&PlayerId(1)).unwrap();
|
||||||
assert_eq!(alice.final_stack, 10500);
|
assert_eq!(alice.final_stack, 10500);
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,10 @@
|
||||||
//! `Box<dyn HoldemPlayer>`.
|
//! `Box<dyn HoldemPlayer>`.
|
||||||
|
|
||||||
use crate::Deck;
|
use crate::Deck;
|
||||||
|
use rand::SeedableRng;
|
||||||
|
|
||||||
use super::betting::BetLimit;
|
use super::betting::BetLimit;
|
||||||
|
use super::config::RakeConfig;
|
||||||
use super::game_info::GameInfo;
|
use super::game_info::GameInfo;
|
||||||
use super::hand_runner::{HandResult, HandRunner, HandState};
|
use super::hand_runner::{HandResult, HandRunner, HandState};
|
||||||
use super::player::{HoldemPlayer, Seat};
|
use super::player::{HoldemPlayer, Seat};
|
||||||
|
|
@ -37,6 +39,16 @@ pub struct TableConfig {
|
||||||
pub big_blind: ChipCount,
|
pub big_blind: ChipCount,
|
||||||
/// Ante amount per hand (0 if no ante).
|
/// Ante amount per hand (0 if no ante).
|
||||||
pub ante: ChipCount,
|
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 {
|
impl Default for TableConfig {
|
||||||
|
|
@ -48,6 +60,11 @@ impl Default for TableConfig {
|
||||||
small_blind: 10,
|
small_blind: 10,
|
||||||
big_blind: 20,
|
big_blind: 20,
|
||||||
ante: 0,
|
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,
|
hand_number: u64,
|
||||||
/// Optional RNG seed for reproducible shuffles.
|
/// Optional RNG seed for reproducible shuffles.
|
||||||
seed: Option<u64>,
|
seed: Option<u64>,
|
||||||
|
/// 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 {
|
impl Table {
|
||||||
|
|
@ -85,6 +112,11 @@ impl Table {
|
||||||
///
|
///
|
||||||
/// The dealer button starts at position 0. No players are seated.
|
/// The dealer button starts at position 0. No players are seated.
|
||||||
pub fn new(config: TableConfig) -> Self {
|
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();
|
let seats = (0..config.num_seats).map(|_| None).collect();
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
|
|
@ -93,6 +125,11 @@ impl Table {
|
||||||
current_hand: None,
|
current_hand: None,
|
||||||
hand_number: 0,
|
hand_number: 0,
|
||||||
seed: None,
|
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.
|
/// Starts a new hand.
|
||||||
///
|
///
|
||||||
/// 1. Validates enough active players (≥ 2).
|
/// 1. Validates no hand is already in progress.
|
||||||
/// 2. 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.
|
/// 3. Advances the dealer button.
|
||||||
/// 4. Takes active seats from the table and passes them to [`HandRunner`].
|
/// 4. Takes active seats from the table and passes them to [`HandRunner`].
|
||||||
/// 5. Creates a new deck and starts the hand.
|
/// 5. Creates a new deck and starts the hand.
|
||||||
|
|
@ -297,6 +336,24 @@ impl Table {
|
||||||
return Err(GameError::NotEnoughPlayers(active_count));
|
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.
|
// 3. Advance the button.
|
||||||
self.advance_button();
|
self.advance_button();
|
||||||
|
|
||||||
|
|
@ -325,6 +382,9 @@ impl Table {
|
||||||
if let Some(seed) = self.seed {
|
if let Some(seed) = self.seed {
|
||||||
runner = runner.with_seed(seed.wrapping_add(self.hand_number));
|
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);
|
runner.start(self.config.small_blind, self.config.big_blind, self.config.ante);
|
||||||
|
|
||||||
|
|
@ -500,10 +560,106 @@ impl Table {
|
||||||
next_to_act: None,
|
next_to_act: None,
|
||||||
players,
|
players,
|
||||||
actions_this_hand: vec![Vec::new(), Vec::new(), Vec::new(), Vec::new()],
|
actions_this_hand: vec![Vec::new(), Vec::new(), Vec::new(), Vec::new()],
|
||||||
|
num_raises: 0,
|
||||||
hand_number: self.hand_number,
|
hand_number: self.hand_number,
|
||||||
active_players: self.active_player_count(),
|
active_players: self.active_player_count(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 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<Seat> = 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,
|
small_blind: 10,
|
||||||
big_blind: 20,
|
big_blind: 20,
|
||||||
ante: 0,
|
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,
|
small_blind: 10,
|
||||||
big_blind: 20,
|
big_blind: 20,
|
||||||
ante: 0,
|
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);
|
let mut table = Table::new(config);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -694,6 +694,11 @@ mod tests {
|
||||||
small_blind: 10,
|
small_blind: 10,
|
||||||
big_blind: 20,
|
big_blind: 20,
|
||||||
ante: 0,
|
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,
|
small_blind: 10,
|
||||||
big_blind: 20,
|
big_blind: 20,
|
||||||
ante: 0,
|
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 schedule = BlindSchedule::standard(1000, 10);
|
||||||
let prizes = PrizeStructure::standard_sng(100, 4);
|
let prizes = PrizeStructure::standard_sng(100, 4);
|
||||||
|
|
@ -893,6 +903,11 @@ mod tests {
|
||||||
small_blind: 10,
|
small_blind: 10,
|
||||||
big_blind: 20,
|
big_blind: 20,
|
||||||
ante: 0,
|
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 schedule = BlindSchedule::standard(1000, 10);
|
||||||
let prizes = PrizeStructure::standard_sng(100, 4);
|
let prizes = PrizeStructure::standard_sng(100, 4);
|
||||||
|
|
@ -954,6 +969,11 @@ mod tests {
|
||||||
small_blind: 10,
|
small_blind: 10,
|
||||||
big_blind: 20,
|
big_blind: 20,
|
||||||
ante: 0,
|
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 schedule = BlindSchedule::standard(1000, 10);
|
||||||
let prizes = PrizeStructure::standard_mtt(100, 6);
|
let prizes = PrizeStructure::standard_mtt(100, 6);
|
||||||
|
|
@ -999,6 +1019,11 @@ mod tests {
|
||||||
small_blind: 10,
|
small_blind: 10,
|
||||||
big_blind: 20,
|
big_blind: 20,
|
||||||
ante: 0,
|
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 schedule = BlindSchedule::standard(1000, 3);
|
||||||
let prizes = PrizeStructure::standard_sng(100, 4);
|
let prizes = PrizeStructure::standard_sng(100, 4);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue