- 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
786 lines
27 KiB
Markdown
786 lines
27 KiB
Markdown
# 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` |
|