feat: tournament rake, percentage-based prizes, and finish position fixes

- Add tournament_rake_percentage field to GameConfig (house share of entry fees)
- Convert prize structure from absolute amounts to percentages of prize pool
- Fix finish position calculation for tournament eliminations (was inverted)
- Add batch bustout processing for multi-table tournaments (shared positions)
- Add max_rebuys field and rebuy_count tracking on Seat
- Add PrizeStructure::with_rake() and rake_amount field
- Accumulate stats across tournament series (hands, rake, profit)
- Add best_finish and prize_won fields to PlayerStats
- Replace floating-point prize math with integer arithmetic (chip conservation)
- Add comprehensive tests for prize distribution and finish positions
- Update config examples to use percentage-based prizes with rake
- Add ARCHITECTURE_TOURNAMENT_RAKE.md documentation
This commit is contained in:
Jan-Peter von Hunnius 2026-05-22 14:32:24 +02:00
parent c041cde3fe
commit 7c79ae4966
10 changed files with 1506 additions and 140 deletions

View file

@ -0,0 +1,433 @@
# Tournament Rake + Prize Percentage Architecture
## Overview
This document describes the architecture for adding a **tournament rake** (house share) to the prize distribution system. The tournament rake is a percentage of total entry fees withheld by the house before the remaining pool is distributed to players according to the `prizes` percentage schedule.
### Key Concept
```
total_entry_fees = buy_in × num_players (× num_tables for MTT)
prize_pool = total_entry_fees × (100 - tournament_rake_percentage) / 100
house_rake = total_entry_fees - prize_pool
payout_N = prize_pool × prize_percentage_N / 100
```
---
## Current State
The uncommitted changes on `master` already converted [`prizes`](holdem_core/src/game/config.rs:385) from absolute chip amounts to **percentages of the prize pool**. The current [`to_prize_structure()`](holdem_core/src/game/config.rs:535) computes:
```rust
prize_pool = buy_in * num_players // or buy_in * num_players * num_tables for MTT
prizes = percentages.map(|pct| (prize_pool * pct / 100.0).round())
```
There is **no** tournament rake concept. The entire entry fee pool is redistributed to players.
The existing [`rake_percentage`](holdem_core/src/game/config.rs:338) and [`RakeConfig`](holdem_core/src/game/config.rs:51) are for **cash game hand rake** — a percentage taken from each pot at showdown. This is a completely separate mechanism.
---
## Data Flow
```mermaid
flowchart TD
A[TOML Config] --> B[GameConfig]
B --> C[to_prize_structure]
C --> D[Compute total_entry_fees]
D --> E[Apply tournament_rake_percentage]
E --> F[prize_pool = total_entry_fees * 100 - rake / 100]
E --> G[house_rake = total_entry_fees - prize_pool]
F --> H[Distribute prize_pool by prizes percentages]
H --> I[PrizeStructure with absolute chip amounts]
G --> J[PrizeStructure.rake_amount for reporting]
I --> K[Tournament uses prizes for payouts]
J --> L[Stats tracking: house_rake collected]
```
---
## Design Details
### 1. New `GameConfig` Field
**File:** [`holdem_core/src/game/config.rs`](holdem_core/src/game/config.rs:283)
Add a new field to [`GameConfig`](holdem_core/src/game/config.rs:284):
```rust
/// Tournament rake percentage (0–100). The house share withheld from the
/// prize pool before distribution. For example, `10` means the house keeps
/// 10% of total entry fees and the remaining 90% forms the prize pool.
///
/// This is separate from `rake_percentage` which is per-hand cash game rake.
/// Default: 0 (no tournament rake).
#[serde(default)]
pub tournament_rake_percentage: u8,
```
**Serde behavior:** `#[serde(default)]` means the field defaults to `0` when absent from TOML. Existing configs without this field work identically to today — full backward compatibility.
**Default impl:** Add `tournament_rake_percentage: 0` to the [`Default for GameConfig`](holdem_core/src/game/config.rs:397) block.
**Type choice `u8`:** A `u8` supports 0–100%, which is the valid range. Values above 50 will produce a validation warning. Values of exactly 100 would yield a zero prize pool, which validation should catch.
### 2. Updated `to_prize_structure()` Logic
**File:** [`holdem_core/src/game/config.rs`](holdem_core/src/game/config.rs:535)
The current method at line 535 needs two changes:
#### a) Custom prizes path (line 536–550)
```rust
if !self.prizes.is_empty() {
let num_players = self.bots.len().max(2);
let total_entry_fees = match self.game_type {
GameType::MultiTableTournament => {
self.buy_in * num_players as ChipCount * self.num_tables as ChipCount
}
_ => self.buy_in * num_players as ChipCount,
};
// Apply tournament rake
let rake_pct = self.tournament_rake_percentage as f64 / 100.0;
let prize_pool = (total_entry_fees as f64 * (1.0 - rake_pct)).round() as ChipCount;
let house_rake = total_entry_fees - prize_pool;
let prizes: Vec<ChipCount> = self
.prizes
.iter()
.map(|&pct| (prize_pool as f64 * pct as f64 / 100.0).round() as ChipCount)
.collect();
PrizeStructure::with_rake(prizes, house_rake)
}
```
#### b) Standard prize paths (line 551–561)
The [`standard_sng()`](holdem_core/src/game/tournament.rs:168) and [`standard_mtt()`](holdem_core/src/game/tournament.rs:190) methods also compute `prize_pool = buy_in * num_players` internally. They need to accept the rake percentage as a parameter:
```rust
PrizeStructure::standard_sng(self.buy_in, num_players, self.tournament_rake_percentage)
PrizeStructure::standard_mtt(self.buy_in, total_entries, self.tournament_rake_percentage)
```
Inside these methods, the prize pool computation becomes:
```rust
let total_entry_fees = buy_in * num_players as ChipCount;
let prize_pool = (total_entry_fees as f64 * (1.0 - rake_percentage as f64 / 100.0)).round() as ChipCount;
let house_rake = total_entry_fees - prize_pool;
```
### 3. `PrizeStructure` Changes
**File:** [`holdem_core/src/game/tournament.rs`](holdem_core/src/game/tournament.rs:150)
Add an optional `rake_amount` field for reporting:
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrizeStructure {
/// Prize amounts ordered from 1st place to last paid position.
pub prizes: Vec<ChipCount>,
/// House rake withheld from the prize pool (0 when no tournament rake).
#[serde(default)]
pub rake_amount: ChipCount,
}
```
**New constructor:**
```rust
/// Creates a prize structure with rake information.
pub fn with_rake(prizes: Vec<ChipCount>, rake_amount: ChipCount) -> Self {
Self { prizes, rake_amount }
}
```
**Updated existing constructor:**
```rust
pub fn custom(prizes: Vec<ChipCount>) -> Self {
Self { prizes, rake_amount: 0 }
}
```
**New accessor:**
```rust
/// Returns the total entry fees (prize pool + rake).
pub fn total_entry_fees(&self) -> ChipCount {
self.total_prize_pool() + self.rake_amount
}
```
### 4. Validation Rules
**File:** [`holdem_core/src/game/config.rs`](holdem_core/src/game/config.rs:568) — [`validate()`](holdem_core/src/game/config.rs:568) method
Add the following warnings:
| Condition | Warning Message |
|-----------|----------------|
| `tournament_rake_percentage > 0 && game_type == Cash` | `tournament_rake_percentage is set but game type is Cash. Tournament rake only applies to SNG and MTT formats.` |
| `tournament_rake_percentage > 50` | `tournament_rake_percentage is {value}%, which exceeds 50%. This is unusually high and may be a misconfiguration.` |
| `!prizes.is_empty() && prizes.iter().sum::<u64>() != 100` | `Custom prize percentages sum to {sum}%, not 100%. Prize distribution may not match expectations.` |
| `tournament_rake_percentage == 100` | `tournament_rake_percentage is 100%, meaning the entire prize pool goes to the house. No prizes will be paid.` |
**Prize sum tolerance:** Due to integer percentages, allow a ±1% tolerance (99–101%) for the sum check. A strict `!= 100` check would be too rigid.
```rust
let prize_sum: u64 = self.prizes.iter().map(|&p| p as u64).sum();
if !(99..=101).contains(&prize_sum) {
warnings.push(format!(
"Custom prize percentages sum to {}%, not 100%. ...",
prize_sum
));
}
```
### 5. Stats Reporting Impact
**File:** [`holdem_core/src/game/stats.rs`](holdem_core/src/game/stats.rs:100)
#### a) `SimulationStats`
The existing [`total_rake`](holdem_core/src/game/stats.rs:109) field tracks **cash game hand rake**. Add a separate field for tournament rake:
```rust
pub struct SimulationStats {
// ... existing fields ...
pub total_rake: ChipCount,
/// Total tournament rake collected across all tournaments in the series.
#[serde(default)]
pub total_tournament_rake: ChipCount,
}
```
#### b) `SngPlayerSeriesResult`
The existing [`record_finish()`](holdem_core/src/game/stats.rs:478) method tracks `total_entry_fees` as `buy_in × tournaments_played`. With tournament rake, the player still pays the full `buy_in` as entry fee — the rake is just not returned via prizes. So the existing ROI computation remains correct:
```
ROI = (total_prize_money - total_entry_fees) / total_entry_fees × 100
```
A player who never cashes in a 10% rake tournament will have a -100% ROI regardless of the rake, because their `total_prize_money = 0` and `total_entry_fees = buy_in × N`.
However, for **reporting purposes**, the rake information should be available. Consider adding to `SngSeriesResult`:
```rust
/// Total rake collected by the house across all tournaments in this series.
pub total_house_rake: ChipCount,
```
#### c) Where to accumulate tournament rake
The rake is accumulated in the simulation loop when tournaments complete. The call site is in [`simulation.rs`](holdem_core/src/game/simulation.rs:225) where `to_prize_structure()` is called. After each tournament, the `prize_structure.rake_amount` should be added to the running `total_tournament_rake`.
---
## Example TOML Configs
### SNG with 10% Tournament Rake
```toml
[game]
game_type = "SitAndGo"
num_seats = 10
starting_chips = 1500
buy_in = 11
randomize_seats = true
num_tournaments = 100
# House keeps 10% of entry fees
tournament_rake_percentage = 10
# Prize distribution of the remaining 90% pool: 1st=50%, 2nd=30%, 3rd=20%
prizes = [50, 30, 20]
# With 10 players: total_entry_fees = 110, house_rake = 11, prize_pool = 99
# 1st place: 49.5 → 50 chips, 2nd: 29.7 → 30 chips, 3rd: 19.8 → 20 chips
[game.bet_limit]
type = "no_limit"
min_raise = 20
[[game.blind_levels]]
small_blind = 10
big_blind = 20
ante = 0
hands = 10
[[game.blind_levels]]
small_blind = 15
big_blind = 30
ante = 0
hands = 10
[[game.blind_levels]]
small_blind = 25
big_blind = 50
ante = 5
hands = 10
# ... more blind levels ...
[[game.bots]]
bot_type = "flock_bot"
name = "FlockBot-1"
[[game.bots]]
bot_type = "chump_bot"
name = "ChumpBot-1"
# ... more bots ...
```
### MTT with 5% Tournament Rake
```toml
[game]
game_type = "MultiTableTournament"
num_seats = 10
num_tables = 10
small_blind = 10
big_blind = 20
starting_chips = 1500
buy_in = 5
hands_per_level = 10
num_blind_levels = 20
randomize_seats = true
num_tournaments = 100
# House keeps 5% of entry fees
tournament_rake_percentage = 5
# Prize distribution of the remaining 95% pool
# With 100 players: total_entry_fees = 500, house_rake = 25, prize_pool = 475
prizes = [40, 20, 10, 6, 4, 2, 2, 2, 2, 2]
# Sum = 94% — validation will warn, but it still works
[game.bet_limit]
type = "no_limit"
min_raise = 20
# ... blind levels and bots ...
```
### Cash Game — Tournament Rake Not Applicable
```toml
[game]
game_type = "Cash"
num_seats = 10
small_blind = 10
big_blind = 20
starting_chips = 1500
num_hands = 10000
# Cash game hand rake (existing feature)
rake_percentage = 0.05
rake_cap = 50
# tournament_rake_percentage would trigger a validation warning if set
# It has no effect on cash games
[game.bet_limit]
type = "no_limit"
min_raise = 20
# ... bots ...
```
---
## Implementation Checklist
These are the steps to implement this design, in order:
1. **Add `tournament_rake_percentage: u8` to [`GameConfig`](holdem_core/src/game/config.rs:284)**
- Add `#[serde(default)]` field
- Add `tournament_rake_percentage: 0` to [`Default`](holdem_core/src/game/config.rs:397) impl
- Update doc comment on [`prizes`](holdem_core/src/game/config.rs:381) to mention rake interaction
2. **Add `rake_amount: ChipCount` to [`PrizeStructure`](holdem_core/src/game/tournament.rs:151)**
- Add `#[serde(default)]` field
- Update [`custom()`](holdem_core/src/game/tournament.rs:160) to set `rake_amount: 0`
- Add `with_rake()` constructor
- Add `total_entry_fees()` accessor
- Update [`standard_sng()`](holdem_core/src/game/tournament.rs:168) to accept `rake_percentage: u8`
- Update [`standard_mtt()`](holdem_core/src/game/tournament.rs:190) to accept `rake_percentage: u8`
3. **Update [`to_prize_structure()`](holdem_core/src/game/config.rs:535)**
- Apply rake deduction to `prize_pool` in the custom prizes path
- Pass `tournament_rake_percentage` to `standard_sng()` / `standard_mtt()`
- Return `PrizeStructure::with_rake(prizes, house_rake)` when rake > 0
4. **Update [`validate()`](holdem_core/src/game/config.rs:568)**
- Warn when `tournament_rake_percentage > 0` for Cash games
- Warn when `tournament_rake_percentage > 50`
- Warn when `tournament_rake_percentage == 100`
- Warn when prize percentages sum outside 99–101%
5. **Update stats tracking in [`stats.rs`](holdem_core/src/game/stats.rs)**
- Add `total_tournament_rake: ChipCount` to [`SimulationStats`](holdem_core/src/game/stats.rs:101)
- Add `total_house_rake: ChipCount` to `SngSeriesResult` / `MttSeriesResult`
- Accumulate rake in the simulation loop
6. **Update existing tests in [`config.rs`](holdem_core/src/game/config.rs:1118)**
- Add `tournament_rake_percentage: 0` to all existing `GameConfig` test instances
- Add new test: `to_prize_structure_sng_with_rake()`
- Add new test: `to_prize_structure_mtt_with_rake()`
- Add new test: `validate_tournament_rake_warnings()`
7. **Update TOML example configs**
- Update [`sng_tournament.toml`](holdem_core/examples/configs/sng_tournament.toml) to include `tournament_rake_percentage = 10`
- Update [`mtt_10_tables.toml`](holdem_core/examples/configs/mtt_10_tables.toml) to include `tournament_rake_percentage = 5`
- Add comments explaining the rake computation
---
## Rounding Behavior
The prize pool computation uses `.round()` for consistency with the existing approach:
```rust
let prize_pool = (total_entry_fees as f64 * (1.0 - rake_pct)).round() as ChipCount;
```
This means:
- `110 * 0.90 = 99.0` → prize_pool = 99 ✓
- `100 * 0.95 = 95.0` → prize_pool = 95 ✓
- `107 * 0.90 = 96.3` → prize_pool = 96, rake = 11
The `house_rake` is computed as `total_entry_fees - prize_pool` (residual method) rather than independently, ensuring `prize_pool + house_rake == total_entry_fees` always holds exactly.
Individual prize amounts also use `.round()`:
```rust
(prize_pool as f64 * pct as f64 / 100.0).round() as ChipCount
```
Due to rounding, the sum of individual prizes may differ from `prize_pool` by ±1 chip per prize position. This is acceptable for simulation purposes.
---
## Relationship Between Rake Types
| Property | Cash Game Rake | Tournament Rake |
|----------|---------------|-----------------|
| Config field | [`rake_percentage`](holdem_core/src/game/config.rs:338) | `tournament_rake_percentage` (new) |
| Type | `f64` (0.0–1.0) | `u8` (0–100) |
| When applied | Each hand at showdown | Once at tournament start |
| Cap | [`rake_cap`](holdem_core/src/game/config.rs:342) per pot | No cap (percentage of total) |
| Tracked in | [`RakeConfig`](holdem_core/src/game/config.rs:51) | `PrizeStructure.rake_amount` |
| Stats field | [`SimulationStats.total_rake`](holdem_core/src/game/stats.rs:109) | `SimulationStats.total_tournament_rake` (new) |
| Applicable to | Cash games only | SNG and MTT only |
The two rake types are **completely independent**. A cash game with `rake_percentage = 0.05` takes 5% of each pot. A tournament with `tournament_rake_percentage = 10` withholds 10% of entry fees from the prize pool. They never interact.

View file

@ -15,9 +15,12 @@ num_hands = 10000
# reset_bankroll_each_hand = false # reset_bankroll_each_hand = false
# bankroll_refill_threshold = 1000 # Refill when stack <= 1000 # bankroll_refill_threshold = 1000 # Refill when stack <= 1000
# Auto-rebuy: when enabled, players whose stack drops below half the # Auto-rebuy: when enabled, players whose stack drops below 25% of the
# starting_chips are automatically topped back up to starting_chips. # starting_chips are automatically topped back up to starting_chips.
# Configure the threshold via rebuy_threshold_percentage (default: 25).
# max_rebuys limits how many times a player can rebuy (0 = unlimited).
auto_rebuy = true auto_rebuy = true
max_rebuys = 0 # 0 = unlimited rebuys
# Randomize seat assignments before each hand (optional, defaults to false). # Randomize seat assignments before each hand (optional, defaults to false).
randomize_seats = true randomize_seats = true

View file

@ -7,7 +7,8 @@
# (last player standing wins). # (last player standing wins).
# #
# 100 tournaments are run with aggregate statistics tracked by player name. # 100 tournaments are run with aggregate statistics tracked by player name.
# Entry fee: 5 per tournament. Final table prizes: 200, 100, 50, 30, 20, 10, 10, 10, 10, 10. # Entry fee: 5 per tournament. House keeps 5% rake.
# Prizes are percentages of the remaining prize pool.
[game] [game]
game_type = "MultiTableTournament" game_type = "MultiTableTournament"
@ -22,15 +23,20 @@ num_blind_levels = 20
randomize_seats = true randomize_seats = true
num_tournaments = 100 num_tournaments = 100
# Tournament rake: house keeps 5% of entry fees.
# With 100 players: total_entry_fees = 500, house_rake = 25, prize_pool = 475
tournament_rake_percentage = 5
# ---------------------------------------------------------------------------
# Prize structure as percentages of prize pool (100 players × $5 = $500 total,
# minus 5% rake = $475 prize pool)
# ---------------------------------------------------------------------------
prizes = [40, 20, 10, 6, 4, 4, 4, 4, 4, 4]
[game.bet_limit] [game.bet_limit]
type = "no_limit" type = "no_limit"
min_raise = 20 min_raise = 20
# ---------------------------------------------------------------------------
# Prize structure (100 players × $5 buy-in = $500 prize pool, top 10 paid)
# ---------------------------------------------------------------------------
prizes = [200, 100, 50, 30, 20, 10, 10, 10, 10, 10]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Blind levels (extended to 12 levels) # Blind levels (extended to 12 levels)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -6,7 +6,11 @@ buy_in = 11
randomize_seats = true randomize_seats = true
num_tournaments = 100 num_tournaments = 100
# Custom prize structure in chip amounts: 1st=5, 2nd=3, 3rd=2 # Tournament rake: house keeps 10% of entry fees.
# With 10 players: total_entry_fees = 110, house_rake = 11, prize_pool = 99
tournament_rake_percentage = 10
# Custom prize structure as percentages of prize pool: 1st=50%, 2nd=30%, 3rd=20%
prizes = [50, 30, 20] prizes = [50, 30, 20]
[game.bet_limit] [game.bet_limit]

View file

@ -367,17 +367,33 @@ pub struct GameConfig {
#[serde(default = "default_rebuy_threshold")] #[serde(default = "default_rebuy_threshold")]
pub rebuy_threshold_percentage: u8, pub rebuy_threshold_percentage: u8,
/// Maximum number of auto-rebuys per player. 0 means unlimited (default).
/// When a player has reached this limit, they sit out instead of rebuying.
/// Only relevant when `auto_rebuy` is `true`.
#[serde(default)]
pub max_rebuys: u32,
/// Explicit blind levels. When present, overrides `num_blind_levels` and /// Explicit blind levels. When present, overrides `num_blind_levels` and
/// `hands_per_level`. Each entry specifies SB, BB, ante, and hands duration. /// `hands_per_level`. Each entry specifies SB, BB, ante, and hands duration.
#[serde(default)] #[serde(default)]
pub blind_levels: Vec<BlindLevelConfig>, pub blind_levels: Vec<BlindLevelConfig>,
/// Custom prize structure as absolute amounts (e.g., `[5, 3, 2]`). /// Custom prize structure as percentages of the prize pool (e.g., `[50, 30, 20]`).
/// When present, overrides the standard SNG/MTT prize calculation. /// When present, overrides the standard SNG/MTT prize calculation.
/// Entries are ordered: 1st place, 2nd place, 3rd place, etc. /// Entries are ordered: 1st place, 2nd place, 3rd place, etc.
/// The prize pool is `total_entry_fees * (100 - tournament_rake_percentage) / 100`.
#[serde(default)] #[serde(default)]
pub prizes: Vec<ChipCount>, pub prizes: Vec<ChipCount>,
/// Tournament rake percentage (0–100). The house share withheld from the
/// prize pool before distribution. For example, `10` means the house keeps
/// 10% of total entry fees and the remaining 90% forms the prize pool.
///
/// This is separate from `rake_percentage` which is per-hand cash game rake.
/// Default: 0 (no tournament rake).
#[serde(default)]
pub tournament_rake_percentage: u8,
/// Number of tournaments to run in a series (default: 1). /// Number of tournaments to run in a series (default: 1).
/// Applies to both SitAndGo and MultiTableTournament game types. /// Applies to both SitAndGo and MultiTableTournament game types.
#[serde(default = "default_one_u32")] #[serde(default = "default_one_u32")]
@ -413,8 +429,10 @@ impl Default for GameConfig {
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
rebuy_threshold_percentage: default_rebuy_threshold(), rebuy_threshold_percentage: default_rebuy_threshold(),
max_rebuys: 0,
blind_levels: Vec::new(), blind_levels: Vec::new(),
prizes: Vec::new(), prizes: Vec::new(),
tournament_rake_percentage: 0,
num_tournaments: 1, num_tournaments: 1,
bots: Vec::new(), bots: Vec::new(),
} }
@ -519,22 +537,58 @@ impl GameConfig {
/// Convert to a [`PrizeStructure`] (for tournaments). /// Convert to a [`PrizeStructure`] (for tournaments).
/// ///
/// When `prizes` is non-empty, uses the custom prize amounts directly. /// When `prizes` is non-empty, treats the values as **percentages of the
/// prize pool** and converts them to absolute chip amounts.
/// Otherwise, uses the standard prize structures based on game type: /// Otherwise, uses the standard prize structures based on game type:
/// - Sit-and-Go: 50/30/20 for top 3 /// - Sit-and-Go: 50/30/20 for top 3
/// - MTT: top-heavy distribution for top ~15% /// - MTT: top-heavy distribution for top ~15%
/// - Cash: empty prize structure /// - Cash: empty prize structure
///
/// The prize pool is reduced by `tournament_rake_percentage` (e.g., 10% rake
/// means the prize pool is 90% of total entry fees).
pub fn to_prize_structure(&self) -> PrizeStructure { pub fn to_prize_structure(&self) -> PrizeStructure {
if !self.prizes.is_empty() { // Use bots.len() for player count when computing total entry fees.
PrizeStructure::custom(self.prizes.clone()) // Each bot in config.bots is a unique player registered once via
} else { // tournament.register_player(), which distributes them round-robin
// across tables. So bots.len() IS the total number of entries —
// num_tables only controls the physical table layout, not the entry
// count. Empty seats do not contribute to the prize pool.
let num_players = self.bots.len().max(2); let num_players = self.bots.len().max(2);
if !self.prizes.is_empty() {
// Treat custom prizes as percentages of the prize pool.
// num_players = config.bots.len(), which is the total number of registered
// players across ALL tables. Each player pays buy_in exactly once, so
// total_entry_fees = buy_in × num_players (no num_tables multiplier needed).
let total_entry_fees = self.buy_in * num_players as ChipCount;
// Apply tournament rake using integer arithmetic to avoid floating-point
// rounding issues (ensures prize_pool + rake_amount == total_entry_fees).
let rake_amount = total_entry_fees * self.tournament_rake_percentage as ChipCount / 100;
let prize_pool = total_entry_fees - rake_amount;
// Distribute prize pool according to percentages. Use integer division;
// any remainder chips go to the first prize (1st place gets the rounding bonus).
let mut prizes: Vec<ChipCount> = self
.prizes
.iter()
.map(|&pct| prize_pool * pct as ChipCount / 100)
.collect();
// Give the rounding remainder to 1st place so that
// sum(prizes) == prize_pool (no chips created or destroyed).
let distributed: ChipCount = prizes.iter().sum();
if let Some(first) = prizes.first_mut() {
*first += prize_pool - distributed;
}
PrizeStructure::with_rake(prizes, rake_amount)
} else {
match self.game_type { match self.game_type {
GameType::Cash => PrizeStructure::custom(vec![]), GameType::Cash => PrizeStructure::custom(vec![]),
GameType::SitAndGo => PrizeStructure::standard_sng(self.buy_in, num_players), GameType::SitAndGo => {
PrizeStructure::standard_sng(self.buy_in, num_players, self.tournament_rake_percentage)
}
GameType::MultiTableTournament => { GameType::MultiTableTournament => {
let total_entries = num_players * self.num_tables; PrizeStructure::standard_mtt(self.buy_in, num_players, self.tournament_rake_percentage)
PrizeStructure::standard_mtt(self.buy_in, total_entries)
} }
} }
} }
@ -581,6 +635,83 @@ impl GameConfig {
); );
} }
// Tournament rake validation
if self.tournament_rake_percentage > 0 && self.game_type == GameType::Cash {
warnings.push(
format!(
"`tournament_rake_percentage` is set to {}% but game type is Cash. \
Tournament rake only applies to SNG and MTT formats.",
self.tournament_rake_percentage
)
);
}
if self.tournament_rake_percentage > 50 {
warnings.push(
format!(
"`tournament_rake_percentage` is {}%, which exceeds 50%. \
This is unusually high and may be a misconfiguration.",
self.tournament_rake_percentage
)
);
}
if self.tournament_rake_percentage == 100 {
warnings.push(
"`tournament_rake_percentage` is 100%, meaning the entire prize pool \
goes to the house. No prizes will be paid.".to_string()
);
}
// Prize percentage sum validation
if !self.prizes.is_empty() {
let prize_sum: u64 = self.prizes.iter().map(|&p| p as u64).sum();
if prize_sum != 100 {
warnings.push(
format!(
"Custom prize percentages sum to {}%, not 100%. \
Prize distribution may not match expectations.",
prize_sum
)
);
}
}
// MTT empty-seat warning: when the number of registered bots is much
// smaller than the total table capacity, the prize pool will be based
// on registered players only (which may be much less than expected).
if self.game_type == GameType::MultiTableTournament {
let total_capacity = (self.num_seats as usize) * (self.num_tables as usize);
if self.bots.len() > total_capacity {
warnings.push(
format!(
"MTT has {} registered bots but total table capacity is {} ({}×{}). \
{} bots will not be able to sit. Increase num_seats or num_tables, \
or reduce the number of bots.",
self.bots.len(),
total_capacity,
self.num_tables,
self.num_seats,
self.bots.len() - total_capacity
)
);
} else if self.bots.len() < total_capacity / 2 {
warnings.push(
format!(
"MTT has {} registered bots but total table capacity is {} ({}×{}). \
The prize pool is based on {} players × buy_in = {}. \
Consider adding more bots or reducing num_tables.",
self.bots.len(),
total_capacity,
self.num_tables,
self.num_seats,
self.bots.len(),
self.bots.len() as u64 * self.buy_in
)
);
}
}
warnings warnings
} }
} }
@ -937,8 +1068,10 @@ min_raise = 50
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
rebuy_threshold_percentage: 25, rebuy_threshold_percentage: 25,
max_rebuys: 0,
blind_levels: vec![], blind_levels: vec![],
prizes: vec![], prizes: vec![],
tournament_rake_percentage: 0,
num_tournaments: 1, num_tournaments: 1,
bots: vec![ bots: vec![
@ -1024,8 +1157,10 @@ min_raise = 50
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
rebuy_threshold_percentage: 25, rebuy_threshold_percentage: 25,
max_rebuys: 0,
blind_levels: vec![], blind_levels: vec![],
prizes: vec![], prizes: vec![],
tournament_rake_percentage: 0,
num_tournaments: 1, num_tournaments: 1,
bots: vec![], bots: vec![],
@ -1061,8 +1196,10 @@ min_raise = 50
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
rebuy_threshold_percentage: 25, rebuy_threshold_percentage: 25,
max_rebuys: 0,
blind_levels: vec![], blind_levels: vec![],
prizes: vec![], prizes: vec![],
tournament_rake_percentage: 0,
num_tournaments: 1, num_tournaments: 1,
bots: vec![], bots: vec![],
@ -1114,8 +1251,10 @@ min_raise = 50
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
rebuy_threshold_percentage: 25, rebuy_threshold_percentage: 25,
max_rebuys: 0,
blind_levels: vec![], blind_levels: vec![],
prizes: vec![], prizes: vec![],
tournament_rake_percentage: 0,
num_tournaments: 1, num_tournaments: 1,
bots: vec![ bots: vec![
@ -1140,7 +1279,7 @@ min_raise = 50
], ],
}; };
let prizes = config.to_prize_structure(); let prizes = config.to_prize_structure();
// 3 players, SNG: 50/30/20 // bots.len()=3, SNG: 50/30/20 of 3 * 1000 buy-in = 3000
assert_eq!(prizes.prizes.len(), 3); assert_eq!(prizes.prizes.len(), 3);
let total: ChipCount = prizes.prizes.iter().sum(); let total: ChipCount = prizes.prizes.iter().sum();
assert_eq!(total, 3000); // 3 * 1000 buy-in assert_eq!(total, 3000); // 3 * 1000 buy-in
@ -1169,8 +1308,10 @@ min_raise = 50
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
rebuy_threshold_percentage: 25, rebuy_threshold_percentage: 25,
max_rebuys: 0,
blind_levels: vec![], blind_levels: vec![],
prizes: vec![], prizes: vec![],
tournament_rake_percentage: 0,
num_tournaments: 1, num_tournaments: 1,
bots: vec![ bots: vec![
@ -1184,8 +1325,8 @@ min_raise = 50
], ],
}; };
let prizes = config.to_prize_structure(); let prizes = config.to_prize_structure();
// 9 bots * 3 tables = 27 total entries // 9 bots (each pays buy_in once; num_tables only controls table layout, not entry count)
// ~15% paid = ceil(4.05) = 5 paid positions // ~15% paid = ceil(9 * 0.15) = 2 paid positions
assert!(!prizes.prizes.is_empty()); assert!(!prizes.prizes.is_empty());
// Verify prizes are in descending (non-increasing) order // Verify prizes are in descending (non-increasing) order
for i in 1..prizes.prizes.len() { for i in 1..prizes.prizes.len() {
@ -1195,6 +1336,192 @@ min_raise = 50
assert!(prizes.prizes[0] > 0); assert!(prizes.prizes[0] > 0);
} }
#[test]
fn to_prize_structure_sng_with_rake() {
let config = GameConfig {
game_type: GameType::SitAndGo,
num_seats: 9,
bet_limit: BetLimitConfig::NoLimit { min_raise: 20 },
small_blind: 10,
big_blind: 20,
ante: 0,
starting_chips: 5000,
buy_in: 1000,
num_tables: 1,
hands_per_level: 10,
num_blind_levels: 15,
num_hands: 0,
seed: None,
rake_percentage: 0.0,
rake_cap: 0,
reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0,
randomize_seats: false,
auto_rebuy: false,
rebuy_threshold_percentage: 25,
max_rebuys: 0,
blind_levels: vec![],
prizes: vec![50, 30, 20],
tournament_rake_percentage: 10,
num_tournaments: 1,
bots: vec![
BotConfig {
bot_type: "call_bot".to_string(),
name: Some("A".to_string()),
seat: None,
parameters: HashMap::new(),
},
BotConfig {
bot_type: "fold_bot".to_string(),
name: Some("B".to_string()),
seat: None,
parameters: HashMap::new(),
},
BotConfig {
bot_type: "random_bot".to_string(),
name: Some("C".to_string()),
seat: None,
parameters: HashMap::new(),
},
],
};
let prizes = config.to_prize_structure();
// bots.len()=3, buy_in=1000: total_entry_fees = 3000
// 10% rake: prize_pool = 2700, rake_amount = 300
assert_eq!(prizes.rake_amount, 300);
let total: ChipCount = prizes.prizes.iter().sum();
assert_eq!(total, 2700); // 50% + 30% + 20% of 2700
// 1st: 1350, 2nd: 810, 3rd: 540
assert_eq!(prizes.prizes[0], 1350);
assert_eq!(prizes.prizes[1], 810);
assert_eq!(prizes.prizes[2], 540);
}
#[test]
fn to_prize_structure_mtt_with_rake() {
let config = GameConfig {
game_type: GameType::MultiTableTournament,
num_seats: 9,
bet_limit: BetLimitConfig::NoLimit { min_raise: 20 },
small_blind: 10,
big_blind: 20,
ante: 0,
starting_chips: 10000,
buy_in: 100,
num_tables: 1,
hands_per_level: 10,
num_blind_levels: 20,
num_hands: 0,
seed: None,
rake_percentage: 0.0,
rake_cap: 0,
reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0,
randomize_seats: false,
auto_rebuy: false,
rebuy_threshold_percentage: 25,
max_rebuys: 0,
blind_levels: vec![],
prizes: vec![50, 30, 20],
tournament_rake_percentage: 5,
num_tournaments: 1,
bots: vec![
BotConfig {
bot_type: "bot".to_string(),
name: None,
seat: None,
parameters: HashMap::new(),
};
9
],
};
let prizes = config.to_prize_structure();
// 9 players, buy_in=100: total_entry_fees = 900
// 5% rake: rake_amount = 900*5/100 = 45, prize_pool = 855
assert_eq!(prizes.rake_amount, 45);
let total: ChipCount = prizes.prizes.iter().sum();
// Integer arithmetic: 855*50/100=427, 855*30/100=256, 855*20/100=171
// Raw sum = 854; remainder (855-854=1) goes to 1st place → 428
assert_eq!(total, 855);
assert_eq!(prizes.prizes[0], 428);
assert_eq!(prizes.prizes[1], 256);
assert_eq!(prizes.prizes[2], 171);
}
#[test]
fn validate_tournament_rake_warnings() {
// Test: rake on cash game triggers warning
let config = GameConfig {
game_type: GameType::Cash,
num_seats: 6,
bet_limit: BetLimitConfig::NoLimit { min_raise: 20 },
small_blind: 10,
big_blind: 20,
ante: 0,
starting_chips: 10000,
buy_in: 0,
num_tables: 1,
hands_per_level: 10,
num_blind_levels: 10,
num_hands: 100,
seed: None,
rake_percentage: 0.0,
rake_cap: 0,
reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0,
randomize_seats: false,
auto_rebuy: false,
rebuy_threshold_percentage: 25,
max_rebuys: 0,
blind_levels: vec![],
prizes: vec![],
tournament_rake_percentage: 10,
num_tournaments: 1,
bots: vec![],
};
let warnings = config.validate();
assert!(warnings.iter().any(|w| w.contains("tournament_rake_percentage") && w.contains("Cash")));
// Test: rake > 50% triggers warning
let config_high_rake = GameConfig {
tournament_rake_percentage: 75,
..config.clone()
};
let warnings = config_high_rake.validate();
assert!(warnings.iter().any(|w| w.contains("75%") && w.contains("50%")));
// Test: rake == 100% triggers warning
let config_100_rake = GameConfig {
tournament_rake_percentage: 100,
..config.clone()
};
let warnings = config_100_rake.validate();
assert!(warnings.iter().any(|w| w.contains("100%")));
// Test: prize sum != 100% triggers warning
let config_bad_prizes = GameConfig {
game_type: GameType::SitAndGo,
prizes: vec![50, 30, 10], // sums to 90
tournament_rake_percentage: 0,
..config.clone()
};
let warnings = config_bad_prizes.validate();
assert!(warnings.iter().any(|w| w.contains("90%")));
// Test: valid config produces no rake-related warnings
let config_valid = GameConfig {
game_type: GameType::SitAndGo,
prizes: vec![50, 30, 20],
tournament_rake_percentage: 10,
..config.clone()
};
let warnings = config_valid.validate();
assert!(!warnings.iter().any(|w| w.contains("tournament_rake_percentage")));
assert!(!warnings.iter().any(|w| w.contains("prize percentages")));
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// SimulationConfig tests // SimulationConfig tests
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@ -1266,8 +1593,10 @@ min_raise = 20
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
rebuy_threshold_percentage: 25, rebuy_threshold_percentage: 25,
max_rebuys: 0,
blind_levels: vec![], blind_levels: vec![],
prizes: vec![], prizes: vec![],
tournament_rake_percentage: 0,
num_tournaments: 1, num_tournaments: 1,
bots: vec![], bots: vec![],
}, },

View file

@ -310,6 +310,10 @@ pub struct Seat {
/// When `bankroll_refill_threshold` triggers a reload, the buy-in amount /// When `bankroll_refill_threshold` triggers a reload, the buy-in amount
/// is deducted from bankroll. /// is deducted from bankroll.
pub bankroll: i64, pub bankroll: i64,
/// Number of times this player has been auto-rebought in the current session.
/// Used to enforce the `max_rebuys` cap from `GameConfig`.
pub rebuy_count: u32,
} }
impl Seat { impl Seat {
@ -350,6 +354,7 @@ impl Seat {
has_acted_this_round: false, has_acted_this_round: false,
early_fold_pending: false, early_fold_pending: false,
bankroll: 0, bankroll: 0,
rebuy_count: 0,
} }
} }

View file

@ -94,18 +94,33 @@ pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), Ga
for slot in table.seats_mut().iter_mut() { for slot in table.seats_mut().iter_mut() {
if let Some(seat) = slot { if let Some(seat) = slot {
if seat.stack < threshold { if seat.stack < threshold {
let can_rebuy =
config.max_rebuys == 0 || seat.rebuy_count < config.max_rebuys;
if can_rebuy {
let rebuy_amount = let rebuy_amount =
config.starting_chips.saturating_sub(seat.stack); config.starting_chips.saturating_sub(seat.stack);
log::info!( log::info!(
"Auto-rebuy: {} topped up by {} (was {})", "Auto-rebuy: {} topped up by {} (was {}, rebuy #{})",
seat.player.name(), seat.player.name(),
rebuy_amount, rebuy_amount,
seat.stack seat.stack,
seat.rebuy_count + 1
); );
seat.bankroll -= rebuy_amount as i64;
seat.stack = config.starting_chips; seat.stack = config.starting_chips;
seat.rebuy_count += 1;
// Reset per-hand state that wasn't cleared when the
// player busted (complete_hand skips
// reset_for_new_hand for stack=0 players).
// Without this, wagered_this_hand carries over
// from the previous hand, breaking chip_movements.
seat.wagered_this_hand = 0;
seat.wagered_this_round = 0;
seat.has_acted_this_round = false;
seat.early_fold_pending = false;
seat.hole_cards = None;
// Reactivate busted players so they can play // Reactivate busted players so they can play
// the next hand. // the next hand.
if seat.state == super::player_info::PlayerState::SittingOut {
seat.state = super::player_info::PlayerState::Active; seat.state = super::player_info::PlayerState::Active;
} }
} }
@ -141,7 +156,7 @@ pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), Ga
}) })
.collect(); .collect();
let bankroll_mode = config.reset_bankroll_each_hand || config.bankroll_refill_threshold > 0; let bankroll_mode = config.reset_bankroll_each_hand || config.bankroll_refill_threshold > 0;
stats.record_final_stacks(&final_stacks, &starting_stacks, &bankrolls, bankroll_mode); stats.record_final_stacks(&final_stacks, &starting_stacks, &bankrolls, bankroll_mode, config.auto_rebuy);
Ok((stats, table)) Ok((stats, table))
} }
@ -288,17 +303,11 @@ pub fn run_sng_series(
// Track which player IDs have been recorded to prevent double-counting. // Track which player IDs have been recorded to prevent double-counting.
let mut recorded: HashSet<PlayerId> = HashSet::new(); let mut recorded: HashSet<PlayerId> = HashSet::new();
// Standings contain eliminated players with raw placement numbers. // Standings contain eliminated players with their finish position.
// In the tournament, placement = N means the player was the Nth-to-last // The placement value is the correct finish position (2 = runner-up,
// to be eliminated (i.e., placement=N is the worst finish). // N = first eliminated). Players eliminated in the same hand share
// For a 10-player tournament: first player out gets placement=10, // the same position.
// last player eliminated gets placement=2. The winner (placement=1) for &(player_id, finish_position) in standings {
// is NOT in standings — they are tracked separately via winner().
//
// The raw placement IS the finish position, so we use it directly
// for prize lookup (prize_for_placement is 1-indexed).
for &(player_id, raw_placement) in standings.iter() {
let finish_position = raw_placement;
let name = player_names let name = player_names
.get(&player_id) .get(&player_id)
.cloned() .cloned()
@ -310,8 +319,8 @@ pub fn run_sng_series(
.unwrap_or(0); .unwrap_or(0);
log::debug!( log::debug!(
"Tournament {}: {} raw_placement={} finish={} prize={}", "Tournament {}: {} finish={} prize={}",
tournament_index + 1, name, raw_placement, finish_position, prize tournament_index + 1, name, finish_position, prize
); );
result.record_player_finish(&name, finish_position, prize); result.record_player_finish(&name, finish_position, prize);
@ -373,8 +382,10 @@ mod tests {
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
rebuy_threshold_percentage: 25, rebuy_threshold_percentage: 25,
max_rebuys: 0,
blind_levels: vec![], blind_levels: vec![],
prizes: vec![], prizes: vec![],
tournament_rake_percentage: 0,
num_tournaments: 1, num_tournaments: 1,
bots: vec![ bots: vec![
BotConfig { BotConfig {
@ -484,8 +495,10 @@ mod tests {
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
rebuy_threshold_percentage: 25, rebuy_threshold_percentage: 25,
max_rebuys: 0,
blind_levels: vec![], blind_levels: vec![],
prizes: vec![], prizes: vec![],
tournament_rake_percentage: 0,
num_tournaments: 1, num_tournaments: 1,
bots: vec![BotConfig { bots: vec![BotConfig {
@ -523,8 +536,10 @@ mod tests {
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
rebuy_threshold_percentage: 25, rebuy_threshold_percentage: 25,
max_rebuys: 0,
blind_levels: vec![], blind_levels: vec![],
prizes: vec![], prizes: vec![],
tournament_rake_percentage: 0,
num_tournaments: 1, num_tournaments: 1,
bots: vec![ bots: vec![
BotConfig { BotConfig {
@ -569,8 +584,10 @@ mod tests {
randomize_seats: false, randomize_seats: false,
auto_rebuy: false, auto_rebuy: false,
rebuy_threshold_percentage: 25, rebuy_threshold_percentage: 25,
max_rebuys: 0,
blind_levels: vec![], blind_levels: vec![],
prizes: vec![], prizes: vec![],
tournament_rake_percentage: 0,
num_tournaments: 1, num_tournaments: 1,
bots: vec![ bots: vec![
@ -634,8 +651,10 @@ mod tests {
randomize_seats: false, randomize_seats: false,
auto_rebuy: true, auto_rebuy: true,
rebuy_threshold_percentage: 25, rebuy_threshold_percentage: 25,
max_rebuys: 0, // unlimited
blind_levels: vec![], blind_levels: vec![],
prizes: vec![], prizes: vec![],
tournament_rake_percentage: 0,
num_tournaments: 1, num_tournaments: 1,
bots: vec![ bots: vec![
BotConfig { BotConfig {
@ -671,7 +690,148 @@ mod tests {
seat.player.name(), seat.player.name(),
seat.stack seat.stack
); );
// Verify that bankroll reflects rebuy deductions.
// Players start with bankroll 0 (no buy-in set). Each rebuy deducts
// from bankroll, so any player who was rebought must have a negative
// bankroll. Players who were never rebought should still have bankroll 0.
if seat.stack == 100 {
// Player was rebought: bankroll must be negative.
assert!(
seat.bankroll < 0,
"Rebought player {} should have negative bankroll, but has {}",
seat.player.name(),
seat.bankroll
);
} }
} }
} }
}
#[test]
fn test_cash_game_auto_rebuy_cap() {
// With max_rebuys = 1, each player can only rebuy once.
// After that, they sit out when their stack drops below threshold.
let config = GameConfig {
game_type: GameType::Cash,
num_seats: 6,
bet_limit: BetLimitConfig::NoLimit { min_raise: 10 },
small_blind: 5,
big_blind: 10,
ante: 0,
starting_chips: 100,
buy_in: 0,
num_tables: 1,
hands_per_level: 10,
num_blind_levels: 15,
num_hands: 50,
seed: Some(42),
rake_percentage: 0.0,
rake_cap: 0,
reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0,
randomize_seats: false,
auto_rebuy: true,
rebuy_threshold_percentage: 25,
max_rebuys: 1,
blind_levels: vec![],
prizes: vec![],
tournament_rake_percentage: 0,
num_tournaments: 1,
bots: vec![
BotConfig {
bot_type: "call_bot".to_string(),
name: Some("Alice".to_string()),
seat: Some(0),
parameters: HashMap::new(),
},
BotConfig {
bot_type: "fold_bot".to_string(),
name: Some("Bob".to_string()),
seat: Some(1),
parameters: HashMap::new(),
},
],
};
let result = run_cash_game(&config);
assert!(result.is_ok(), "run_cash_game should succeed");
let (_stats, table) = result.unwrap();
// Verify that no player has rebought more than max_rebuys (1) times.
for slot in table.seats().iter() {
if let Some(seat) = slot {
assert!(
seat.rebuy_count <= 1,
"Player {} should have rebuy_count <= 1, but has {}",
seat.player.name(),
seat.rebuy_count
);
}
}
}
#[test]
fn test_cash_game_auto_rebuy_unlimited() {
// With max_rebuys = 0 (unlimited), rebuys should work without limit.
// Run enough hands to ensure multiple rebuys happen.
let config = GameConfig {
game_type: GameType::Cash,
num_seats: 6,
bet_limit: BetLimitConfig::NoLimit { min_raise: 10 },
small_blind: 5,
big_blind: 10,
ante: 0,
starting_chips: 100,
buy_in: 0,
num_tables: 1,
hands_per_level: 10,
num_blind_levels: 15,
num_hands: 50,
seed: Some(42),
rake_percentage: 0.0,
rake_cap: 0,
reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0,
randomize_seats: false,
auto_rebuy: true,
rebuy_threshold_percentage: 50,
max_rebuys: 0, // unlimited
blind_levels: vec![],
prizes: vec![],
tournament_rake_percentage: 0,
num_tournaments: 1,
bots: vec![
BotConfig {
bot_type: "call_bot".to_string(),
name: Some("Alice".to_string()),
seat: Some(0),
parameters: HashMap::new(),
},
BotConfig {
bot_type: "fold_bot".to_string(),
name: Some("Bob".to_string()),
seat: Some(1),
parameters: HashMap::new(),
},
],
};
let result = run_cash_game(&config);
assert!(result.is_ok(), "run_cash_game should succeed");
let (_stats, table) = result.unwrap();
// With unlimited rebuys over 50 hands, at least one player should have
// rebought multiple times (the fold_bot loses quickly).
let total_rebuys: u32 = table
.seats()
.iter()
.filter_map(|slot| slot.as_ref().map(|s| s.rebuy_count))
.sum();
assert!(
total_rebuys > 1,
"With unlimited rebuys over 50 hands, expected multiple rebuys, got {}",
total_rebuys
);
}
} }

View file

@ -38,6 +38,11 @@ pub struct PlayerStats {
pub total_rake_paid: ChipCount, pub total_rake_paid: ChipCount,
/// Final bankroll balance (includes refill costs and reset adjustments). /// Final bankroll balance (includes refill costs and reset adjustments).
pub bankroll: i64, pub bankroll: i64,
/// Total prize money won (for tournaments). Zero for cash games.
pub prize_won: ChipCount,
/// Best (lowest) tournament finish position. 1 = winner. 0 = unset (cash games).
#[serde(default)]
pub best_finish: u32,
} }
impl PlayerStats { impl PlayerStats {
@ -54,6 +59,8 @@ impl PlayerStats {
final_stack: 0, final_stack: 0,
total_rake_paid: 0, total_rake_paid: 0,
bankroll: 0, bankroll: 0,
prize_won: 0,
best_finish: 0,
} }
} }
@ -104,6 +111,9 @@ pub struct SimulationStats {
pub game_type: String, pub game_type: String,
/// Total rake collected across the entire simulation. /// Total rake collected across the entire simulation.
pub total_rake: ChipCount, pub total_rake: ChipCount,
/// Total tournament rake collected across all tournaments in the series.
#[serde(default)]
pub total_tournament_rake: ChipCount,
} }
impl SimulationStats { impl SimulationStats {
@ -114,6 +124,7 @@ impl SimulationStats {
total_hands: 0, total_hands: 0,
game_type: game_type.to_string(), game_type: game_type.to_string(),
total_rake: 0, total_rake: 0,
total_tournament_rake: 0,
} }
} }
@ -180,16 +191,21 @@ impl SimulationStats {
/// 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. /// compute `total_profit` accurately.
/// ///
/// When `bankroll_mode` is `true`, `total_profit` is computed as /// - When `bankroll_mode` is `true`, `total_profit` is computed as
/// `bankroll + (final_stack - starting_stack)`, which captures all net /// `bankroll + (final_stack - starting_stack)`, which captures all net
/// flows including refills and resets. When `false`, the simple /// flows including refills and resets.
/// `final_stack - starting_stack` formula is used. /// - When `auto_rebuy` is `true`, `total_profit` is preserved from the
/// `chip_movements` accumulated via `record_hand_result`, since rebuys
/// create chips on the table (not transferred from bankroll), making
/// `final_stack - starting` wildly inflated.
/// - Otherwise, 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>, bankrolls: &HashMap<PlayerId, i64>,
bankroll_mode: bool, bankroll_mode: bool,
auto_rebuy: 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) {
@ -209,6 +225,12 @@ impl SimulationStats {
// refill mode: bankroll = -Σ(refill_costs), final_stack ≠ starting // refill mode: bankroll = -Σ(refill_costs), final_stack ≠ starting
// → total_profit = -refills + (final - starting) ✓ // → total_profit = -refills + (final - starting) ✓
stats.total_profit = bankroll + (final_stack as i64 - starting as i64); stats.total_profit = bankroll + (final_stack as i64 - starting as i64);
} else if auto_rebuy {
// Auto-rebuy mode: preserve `total_profit` already accumulated from
// `chip_movements` via `record_hand_result`. `chip_movements` correctly
// tracks zero-sum in-hand wins/losses. We must NOT use `final_stack -
// starting` because rebuys create chips on the table (not transferred
// from bankroll), making that formula wildly inflated.
} else { } else {
// Simple mode: profit = final stack minus starting stack. // Simple mode: profit = final stack minus starting stack.
stats.total_profit = final_stack as i64 - starting as i64; stats.total_profit = final_stack as i64 - starting as i64;
@ -227,20 +249,24 @@ impl SimulationStats {
player_names: &HashMap<PlayerId, String>, player_names: &HashMap<PlayerId, String>,
buy_in: ChipCount, buy_in: ChipCount,
) { ) {
self.total_hands = tournament.total_hands(); // Accumulate hands across multiple tournaments in a series.
self.total_hands += tournament.total_hands();
// Process standings: eliminated players are listed (player_id, placement). // Accumulate tournament rake across the series.
// Placement is the order of elimination (higher = eliminated earlier). self.total_tournament_rake += tournament.prize_structure().rake_amount;
// Process standings: eliminated players are listed (player_id, finish_position).
// The finish_position is the correct placement (2 = runner-up, N = first eliminated).
// Players eliminated in the same hand share the same finish position.
// The last player standing (winner) is NOT in the eliminated list. // The last player standing (winner) is NOT in the eliminated list.
let standings = tournament.standings(); let standings = tournament.standings();
let _total_players = standings.len() + 1; // +1 for the winner
// Find the winner — a seated player not in the eliminated list // Find the winner — a seated player not in the eliminated list
let eliminated_ids: Vec<PlayerId> = let eliminated_ids: Vec<PlayerId> =
standings.iter().map(|(id, _)| *id).collect(); standings.iter().map(|(id, _)| *id).collect();
// Record stats for eliminated players // Record stats for eliminated players using their finish position directly.
for &(player_id, placement) in standings { for &(player_id, finish_position) in standings {
let name = player_names let name = player_names
.get(&player_id) .get(&player_id)
.cloned() .cloned()
@ -251,23 +277,24 @@ impl SimulationStats {
.entry(player_id) .entry(player_id)
.or_insert_with(|| PlayerStats::new(player_id, name)); .or_insert_with(|| PlayerStats::new(player_id, name));
// In tournament standings, placement 1 = first eliminated (worst), // Note: This is an approximation — the player may have been eliminated
// last entry = last eliminated before winner. // before the tournament ended, so actual hands played may be less.
// Convert to "finish position": total_players - index // Per-player hand counts are not currently tracked by Tournament.
// Actually, the standings are ordered by elimination time. stats.hands_played += tournament.total_hands();
// placement seems to be stored as the elimination order. stats.final_stack = 0;
// For a 3-player SNG: standings = [(2nd_place_id, 2), (3rd_place_id, 3)]
// Wait, let me re-read: standings returns &[(PlayerId, u32)]
// where the u32 is the placement. Let me just use it as-is.
stats.hands_played = tournament.total_hands(); // approximate
stats.final_stack = 0; // eliminated players have 0 chips
// Profit = prize - buy_in. We'll compute prize from placement. if let Some(prize) =
// For now, just record the buy-in as a loss. tournament.prize_structure().prize_for_placement(finish_position)
if let Some(prize) = tournament.prize_structure().prize_for_placement(placement) { {
stats.total_profit = prize as i64 - buy_in as i64; stats.prize_won += prize;
stats.total_profit += prize as i64 - buy_in as i64;
} else { } else {
stats.total_profit = -(buy_in as i64); stats.total_profit += -(buy_in as i64);
}
// Track best finish position (lower is better)
if stats.best_finish == 0 || finish_position < stats.best_finish {
stats.best_finish = finish_position;
} }
} }
@ -286,12 +313,21 @@ impl SimulationStats {
.or_insert_with(|| PlayerStats::new(seat.player_id, name)); .or_insert_with(|| PlayerStats::new(seat.player_id, name));
stats.final_stack = seat.stack; stats.final_stack = seat.stack;
stats.hands_played = tournament.total_hands(); // approximate stats.hands_played += tournament.total_hands();
// Winner gets 1st place prize // Winner gets 1st place prize (or records loss if no prize defined)
if let Some(prize) = tournament.prize_structure().prize_for_placement(1) { if let Some(prize) = tournament.prize_structure().prize_for_placement(1) {
stats.total_profit = prize as i64 - buy_in as i64; stats.prize_won += prize;
stats.total_profit += prize as i64 - buy_in as i64;
stats.hands_won += 1; // won the tournament stats.hands_won += 1; // won the tournament
} else {
stats.total_profit += -(buy_in as i64);
stats.hands_won += 1;
}
// Winner's best finish is 1
if stats.best_finish == 0 || 1 < stats.best_finish {
stats.best_finish = 1;
} }
} }
} }
@ -362,13 +398,15 @@ impl SimulationStats {
lines.push("-".repeat(60)); lines.push("-".repeat(60));
} else { } else {
lines.push("=== Tournament Results ===".to_string()); lines.push("=== Tournament Results ===".to_string());
lines.push(format!("Type: {} | Hands: {}", self.game_type, self.total_hands)); lines.push(format!("Type: {} | Hands: {} | Buy-in: {}", self.game_type, self.total_hands, buy_in));
lines.push("-".repeat(60)); lines.push("-".repeat(72));
// Header // Header — no Balance/Hand column for tournaments because per-player
// hand counts are inflated (each player is credited with the full
// tournament hand count regardless of when they were eliminated).
lines.push(format!( lines.push(format!(
"{:<16} {:>5} {:>8} {:>7}", "{:<16} {:>5} {:>8} {:>10} {:>10} {:>10}",
"Player", "Place", "Profit", "ROI%" "Player", "Best", "Buy-in", "Prize Won", "Net Profit", "ROI%"
)); ));
// Sort players by total_profit descending (winner first) // Sort players by total_profit descending (winner first)
@ -376,7 +414,7 @@ impl SimulationStats {
players.sort_by_key(|p| std::cmp::Reverse(p.total_profit)); players.sort_by_key(|p| std::cmp::Reverse(p.total_profit));
for (i, p) in players.iter().enumerate() { for (i, p) in players.iter().enumerate() {
let place = i + 1; let place = if p.best_finish > 0 { p.best_finish } else { (i + 1) as u32 };
let profit_str = if p.total_profit >= 0 { let profit_str = if p.total_profit >= 0 {
format!("+{}", p.total_profit) format!("+{}", p.total_profit)
} else { } else {
@ -391,12 +429,12 @@ impl SimulationStats {
}; };
lines.push(format!( lines.push(format!(
"{:<16} {:>5} {:>8} {:>7}", "{:<16} {:>5} {:>8} {:>10} {:>10} {:>10}",
p.name, place, profit_str, roi_str p.name, place, buy_in, p.prize_won, profit_str, roi_str
)); ));
} }
lines.push("-".repeat(60)); lines.push("-".repeat(72));
} }
lines.join("\n") lines.join("\n")
@ -585,6 +623,10 @@ mod tests {
use crate::game::pot::PotWinner; use crate::game::pot::PotWinner;
use crate::evaluator::evaluator::{HandRank, HandValue}; use crate::evaluator::evaluator::{HandRank, HandValue};
use crate::Board; use crate::Board;
use crate::game::tournament::{BlindLevel, BlindSchedule, PrizeStructure, Tournament};
use crate::game::table::TableConfig;
use crate::game::betting::BetLimit;
use crate::game::config::RakeConfig;
/// Helper to create a dummy HandValue for tests. /// Helper to create a dummy HandValue for tests.
fn dummy_hand_value() -> HandValue { fn dummy_hand_value() -> HandValue {
@ -778,7 +820,7 @@ mod tests {
m.insert(PlayerId(2), 10000); m.insert(PlayerId(2), 10000);
m m
}; };
sim_stats.record_final_stacks(&final_stacks, &starting_stacks, &HashMap::new(), false); sim_stats.record_final_stacks(&final_stacks, &starting_stacks, &HashMap::new(), false, 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);
@ -840,4 +882,87 @@ mod tests {
assert!(summary.contains("Alice")); assert!(summary.contains("Alice"));
assert!(summary.contains("Bob")); assert!(summary.contains("Bob"));
} }
#[test]
fn record_tournament_result_processes_completed_tournament() {
let config = TableConfig {
num_seats: 9,
bet_limit: BetLimit::NoLimit { min_raise: 20 },
small_blind: 10,
big_blind: 20,
ante: 0,
rake_config: RakeConfig::no_rake(),
starting_chips: 10_000,
reset_bankroll_each_hand: false,
bankroll_refill_threshold: 0,
randomize_seats: false,
};
let schedule = BlindSchedule::custom(vec![BlindLevel {
level: 1,
small_blind: 10,
big_blind: 20,
ante: 0,
duration_hands: 5,
}]);
let prizes = PrizeStructure::standard_sng(100, 3, 0);
let buy_in = 100;
let mut tournament = Tournament::new_sng(config, schedule, prizes, 1000, buy_in);
let mut player_names = HashMap::new();
for i in 1..=3u64 {
let name = format!("Bot-{}", i);
player_names.insert(PlayerId(i), name);
tournament
.register_player(Box::new(crate::game::bots::CallBot::new(
PlayerId(i),
format!("Bot-{}", i),
)))
.unwrap();
}
tournament.run_to_completion().unwrap();
let mut stats = SimulationStats::new("SitAndGo");
stats.record_tournament_result(&tournament, &player_names, buy_in);
// All 3 players should be tracked
assert_eq!(stats.players.len(), 3);
// Winner should have positive profit
let winner_id = tournament.winner().expect("should have winner");
let winner_stats = stats
.players
.get(&winner_id)
.expect("winner should be in stats");
let first_prize = tournament
.prize_structure()
.prize_for_placement(1)
.expect("1st place should have a prize");
assert!(
winner_stats.total_profit > 0,
"Winner should have positive profit, got {}",
winner_stats.total_profit
);
assert_eq!(winner_stats.total_profit, first_prize as i64 - buy_in as i64);
// Eliminated players should have correct profit based on their finish position
let standings = tournament.standings();
for &(player_id, finish_position) in standings {
let player_stats = stats.players.get(&player_id).expect("eliminated player should be in stats");
let expected_profit = match tournament.prize_structure().prize_for_placement(finish_position) {
Some(prize) => prize as i64 - buy_in as i64,
None => -(buy_in as i64),
};
assert_eq!(
player_stats.total_profit, expected_profit,
"Player {:?} with finish_position {} should have profit {}",
player_id, finish_position, expected_profit
);
}
// All players should have hands_played > 0
for s in stats.players.values() {
assert!(s.hands_played > 0, "All players should have played hands");
}
}
} }

View file

@ -151,35 +151,54 @@ impl BlindSchedule {
pub struct PrizeStructure { pub struct PrizeStructure {
/// Prize amounts ordered from 1st place to last paid position. /// Prize amounts ordered from 1st place to last paid position.
pub prizes: Vec<ChipCount>, pub prizes: Vec<ChipCount>,
/// House rake withheld from the prize pool (0 when no tournament rake).
#[serde(default)]
pub rake_amount: ChipCount,
} }
impl PrizeStructure { impl PrizeStructure {
/// Creates a custom prize structure from the given prize amounts. /// Creates a custom prize structure from the given prize amounts.
/// ///
/// Entries are ordered: 1st place, 2nd place, 3rd place, etc. /// Entries are ordered: 1st place, 2nd place, 3rd place, etc.
/// Rake amount is set to 0 (no tournament rake).
pub fn custom(prizes: Vec<ChipCount>) -> Self { pub fn custom(prizes: Vec<ChipCount>) -> Self {
Self { prizes } Self { prizes, rake_amount: 0 }
}
/// Creates a prize structure with rake information.
///
/// Entries are ordered: 1st place, 2nd place, 3rd place, etc.
/// `rake_amount` is the house share withheld from the prize pool.
pub fn with_rake(prizes: Vec<ChipCount>, rake_amount: ChipCount) -> Self {
Self { prizes, rake_amount }
} }
/// Standard Sit-and-Go prize structure. /// Standard Sit-and-Go prize structure.
/// ///
/// - 3+ players: 50/30/20 for top 3 /// - 3+ players: 50/30/20 for top 3
/// - 2 players (heads-up): 60/40 /// - 2 players (heads-up): 60/40
pub fn standard_sng(buy_in: ChipCount, num_players: usize) -> Self { ///
let prize_pool = buy_in * num_players as ChipCount; /// `tournament_rake_percentage` is the percentage of total entry fees
/// withheld as house rake (0 = no rake).
pub fn standard_sng(buy_in: ChipCount, num_players: usize, tournament_rake_percentage: u8) -> Self {
let total_entry_fees = buy_in * num_players as ChipCount;
// Integer arithmetic: guarantees prize_pool + rake_amount == total_entry_fees
let rake_amount = total_entry_fees * tournament_rake_percentage as ChipCount / 100;
let prize_pool = total_entry_fees - rake_amount;
if num_players <= 2 { if num_players <= 2 {
let prizes = vec![ // 60/40 split — first prize gets the rounding remainder
(prize_pool as f64 * 0.6).round() as ChipCount, let second = prize_pool * 40 / 100;
(prize_pool as f64 * 0.4).round() as ChipCount, let first = prize_pool - second;
]; let prizes = vec![first, second];
Self { prizes } Self { prizes, rake_amount }
} else { } else {
let prizes = vec![ // 50/30/20 split — first prize gets the rounding remainder
(prize_pool as f64 * 0.5).round() as ChipCount, let second = prize_pool * 30 / 100;
(prize_pool as f64 * 0.3).round() as ChipCount, let third = prize_pool * 20 / 100;
(prize_pool as f64 * 0.2).round() as ChipCount, let first = prize_pool - second - third;
]; let prizes = vec![first, second, third];
Self { prizes } Self { prizes, rake_amount }
} }
} }
@ -187,28 +206,35 @@ impl PrizeStructure {
/// ///
/// Top ~15% of entries are paid with a top-heavy distribution: /// Top ~15% of entries are paid with a top-heavy distribution:
/// 1st = 20%, 2nd = 12%, 3rd = 8%, remaining distributed exponentially decaying. /// 1st = 20%, 2nd = 12%, 3rd = 8%, remaining distributed exponentially decaying.
pub fn standard_mtt(buy_in: ChipCount, total_entries: usize) -> Self { ///
let prize_pool = buy_in * total_entries as ChipCount; /// `tournament_rake_percentage` is the percentage of total entry fees
/// withheld as house rake (0 = no rake).
pub fn standard_mtt(buy_in: ChipCount, total_entries: usize, tournament_rake_percentage: u8) -> Self {
let total_entry_fees = buy_in * total_entries as ChipCount;
// Integer arithmetic: guarantees prize_pool + rake_amount == total_entry_fees
let rake_amount = total_entry_fees * tournament_rake_percentage as ChipCount / 100;
let prize_pool = total_entry_fees - rake_amount;
let num_paid = ((total_entries as f64 * 0.15).ceil() as usize).max(1); let num_paid = ((total_entries as f64 * 0.15).ceil() as usize).max(1);
if total_entries <= 2 { if total_entries <= 2 {
return Self::standard_sng(buy_in, total_entries); return Self::standard_sng(buy_in, total_entries, tournament_rake_percentage);
} }
let mut prizes = Vec::with_capacity(num_paid); let mut prizes = Vec::with_capacity(num_paid);
// Fixed top 3 percentages // Fixed top 3 percentages (20%, 12%, 8%)
let top3_pcts = [0.20f64, 0.12, 0.08]; let top3_pcts = [20u32, 12, 8];
for (i, &pct) in top3_pcts.iter().enumerate() { for (i, &pct) in top3_pcts.iter().enumerate() {
if i < num_paid { if i < num_paid {
prizes.push((prize_pool as f64 * pct).round() as ChipCount); prizes.push(prize_pool * pct as ChipCount / 100);
} }
} }
// Remaining positions: exponentially decaying // Remaining positions: exponentially decaying
if num_paid > 3 { if num_paid > 3 {
let top3_total: f64 = top3_pcts.iter().take(num_paid.min(3)).sum(); let top3_total: u32 = top3_pcts.iter().take(num_paid.min(3)).sum();
let remaining_pct = 1.0 - top3_total; let remaining_pct = (100 - top3_total) as f64 / 100.0;
let remaining_positions = num_paid - 3; let remaining_positions = num_paid - 3;
let decay_factor = 0.75; let decay_factor = 0.75;
@ -235,7 +261,14 @@ impl PrizeStructure {
} }
} }
Self { prizes } // Give the rounding remainder to 1st place so that
// sum(prizes) == prize_pool (no chips created or destroyed).
let computed_total: ChipCount = prizes.iter().sum();
if let Some(first) = prizes.first_mut() {
*first += prize_pool - computed_total;
}
Self { prizes, rake_amount }
} }
/// Returns the prize for the given placement (1-indexed), or `None` if unpaid. /// Returns the prize for the given placement (1-indexed), or `None` if unpaid.
@ -246,11 +279,16 @@ impl PrizeStructure {
self.prizes.get((placement - 1) as usize).copied() self.prizes.get((placement - 1) as usize).copied()
} }
/// Returns the total prize pool. /// Returns the total prize pool (sum of all prize amounts).
pub fn total_prize_pool(&self) -> ChipCount { pub fn total_prize_pool(&self) -> ChipCount {
self.prizes.iter().sum() self.prizes.iter().sum()
} }
/// Returns the total entry fees (prize pool + rake).
pub fn total_entry_fees(&self) -> ChipCount {
self.total_prize_pool() + self.rake_amount
}
/// Returns the number of paid positions. /// Returns the number of paid positions.
pub fn num_paid_positions(&self) -> usize { pub fn num_paid_positions(&self) -> usize {
self.prizes.len() self.prizes.len()
@ -270,12 +308,12 @@ pub struct Tournament {
tables: Vec<Table>, tables: Vec<Table>,
/// Blind schedule. /// Blind schedule.
blind_schedule: BlindSchedule, blind_schedule: BlindSchedule,
/// Prize structure. /// Prize structure (accessed via [`prize_structure()`](Tournament::prize_structure)).
#[allow(dead_code)] // Used for prize distribution queries in future extensions
prize_structure: PrizeStructure, prize_structure: PrizeStructure,
/// Starting chip count for each player. /// Starting chip count for each player.
starting_chips: ChipCount, starting_chips: ChipCount,
/// Eliminated players with their placement (higher placement = eliminated earlier). /// Eliminated players in elimination order with their finish position.
/// Players eliminated in the same hand share the same finish position.
eliminated: Vec<(PlayerId, u32)>, eliminated: Vec<(PlayerId, u32)>,
/// Current blind level index (0-based). /// Current blind level index (0-based).
current_level: usize, current_level: usize,
@ -399,7 +437,9 @@ impl Tournament {
/// Runs one hand on each table that has enough players. /// Runs one hand on each table that has enough players.
/// ///
/// After each hand: /// After each hand:
/// 1. Checks for busted players (stack = 0) and calls [`handle_bustout`](Tournament::handle_bustout). /// 1. Collects busted players from all tables, then assigns finish positions
/// in a single batch (so cross-table simultaneous eliminations share the
/// same finish position).
/// 2. Increments hand counters. /// 2. Increments hand counters.
/// 3. Checks blind level advancement. /// 3. Checks blind level advancement.
pub fn run_next_hands(&mut self) -> Vec<Result<super::hand_runner::HandResult, GameError>> { pub fn run_next_hands(&mut self) -> Vec<Result<super::hand_runner::HandResult, GameError>> {
@ -414,7 +454,6 @@ impl Tournament {
let result = self.tables[table_index].run_hand(); let result = self.tables[table_index].run_hand();
match result { match result {
Ok(hand_result) => { Ok(hand_result) => {
self.handle_bustout(table_index);
results.push(Ok(hand_result)); results.push(Ok(hand_result));
} }
Err(e) => { Err(e) => {
@ -423,6 +462,11 @@ impl Tournament {
} }
} }
// Process all bustouts across every table in a single batch so that
// players eliminated simultaneously on different tables share the same
// finish position (standard poker tournament rule).
self.process_bustouts();
// Increment hand counters (only count hands that actually ran) // Increment hand counters (only count hands that actually ran)
let hands_run = results.iter().filter(|r| r.is_ok()).count() as u32; let hands_run = results.iter().filter(|r| r.is_ok()).count() as u32;
if hands_run > 0 { if hands_run > 0 {
@ -532,7 +576,65 @@ impl Tournament {
// Bustout handling // Bustout handling
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// Checks for busted players on a table, removes them, and records their placement. /// Processes all bustouts across every table in a single batch.
///
/// This must be called **after** all tables have run their hand for the
/// current round, so that players eliminated simultaneously on different
/// tables share the same finish position (standard poker tournament rule).
///
/// Finish positions are computed as `active + 1` (the best position in
/// the batch). For example, if 3 players remain and 2 bust in the same
/// hand (possibly on different tables), both receive finish position 3.
pub fn process_bustouts(&mut self) {
// Collect busted players from all tables.
let per_table_busted: Vec<(usize, Vec<PlayerId>)> = self
.tables
.iter()
.enumerate()
.map(|(table_index, table)| {
let busted: Vec<PlayerId> = table
.seats()
.iter()
.filter_map(|slot| {
slot.as_ref().and_then(|seat| {
if seat.stack == 0 && seat.state == PlayerState::SittingOut {
Some(seat.player_id)
} else {
None
}
})
})
.collect();
(table_index, busted)
})
.collect();
// Check if anyone busted
let total_busted: usize = per_table_busted.iter().map(|(_, v)| v.len()).sum();
if total_busted == 0 {
return;
}
// All players busted in this round share the same finish position,
// computed from the active player count BEFORE any are removed.
let active = self.active_player_count();
let finish_position = (active + 1) as u32;
for (table_index, busted) in per_table_busted {
for player_id in busted {
let _ = self.tables[table_index].remove_player(player_id);
self.eliminated.push((player_id, finish_position));
}
}
}
/// Checks for busted players on a single table, removes them, and records
/// their finish position.
///
/// **Note:** For multi-table tournaments, prefer [`process_bustouts()`]
/// (Self::process_bustouts) which batches all tables together so that
/// cross-table simultaneous eliminations share the same finish position.
/// This method is kept for single-table (SNG) use and backward compatibility.
pub fn handle_bustout(&mut self, table_index: usize) { pub fn handle_bustout(&mut self, table_index: usize) {
if table_index >= self.tables.len() { if table_index >= self.tables.len() {
return; return;
@ -557,15 +659,17 @@ impl Tournament {
return; return;
} }
// Calculate placement: active players + already eliminated + 1 // Calculate finish position for this batch of busted players.
// (players busted in the same batch get the same placement) // total_players = active + busted + already_eliminated (before removal).
let already_eliminated = self.eliminated.len(); // All players in this batch share the BEST finish position (standard
// poker rule for simultaneous eliminations):
// finish = active + 1
let active = self.active_player_count(); let active = self.active_player_count();
let placement = (active + busted.len() + already_eliminated) as u32; let finish_position = (active + 1) as u32;
for player_id in busted { for player_id in busted {
let _ = self.tables[table_index].remove_player(player_id); let _ = self.tables[table_index].remove_player(player_id);
self.eliminated.push((player_id, placement)); self.eliminated.push((player_id, finish_position));
} }
} }
@ -762,7 +866,13 @@ impl Tournament {
self.is_complete self.is_complete
} }
/// Returns the elimination standings: pairs of (player_id, placement). /// Returns the elimination standings: pairs of `(player_id, finish_position)`.
///
/// Finish positions are 1-indexed: 1 = winner (not included here),
/// 2 = runner-up, ..., N = first eliminated.
/// Players eliminated in the same hand share the same finish position.
/// The winner (position 1) is NOT included — use
/// [`winner()`](Tournament::winner) instead.
pub fn standings(&self) -> &[(PlayerId, u32)] { pub fn standings(&self) -> &[(PlayerId, u32)] {
&self.eliminated &self.eliminated
} }
@ -991,7 +1101,7 @@ mod tests {
#[test] #[test]
fn test_standard_sng_prizes() { fn test_standard_sng_prizes() {
// 6-player SNG with 100 buy-in // 6-player SNG with 100 buy-in
let prizes = PrizeStructure::standard_sng(100, 6); let prizes = PrizeStructure::standard_sng(100, 6, 0);
assert_eq!(prizes.num_paid_positions(), 3); assert_eq!(prizes.num_paid_positions(), 3);
assert_eq!(prizes.prize_for_placement(1), Some(300)); // 50% of 600 assert_eq!(prizes.prize_for_placement(1), Some(300)); // 50% of 600
@ -1005,7 +1115,7 @@ mod tests {
#[test] #[test]
fn test_heads_up_sng_prizes() { fn test_heads_up_sng_prizes() {
let prizes = PrizeStructure::standard_sng(100, 2); let prizes = PrizeStructure::standard_sng(100, 2, 0);
assert_eq!(prizes.num_paid_positions(), 2); assert_eq!(prizes.num_paid_positions(), 2);
assert_eq!(prizes.prize_for_placement(1), Some(120)); // 60% of 200 assert_eq!(prizes.prize_for_placement(1), Some(120)); // 60% of 200
@ -1015,14 +1125,16 @@ mod tests {
#[test] #[test]
fn test_standard_mtt_prizes() { fn test_standard_mtt_prizes() {
// 100-player MTT with 100 buy-in // 100-player MTT with 100 buy-in, no rake
let prizes = PrizeStructure::standard_mtt(100, 100); let prizes = PrizeStructure::standard_mtt(100, 100, 0);
let prize_pool = 100 * 100; // 10000
// Top 15% = 15 positions paid // Top 15% = 15 positions paid
assert_eq!(prizes.num_paid_positions(), 15); assert_eq!(prizes.num_paid_positions(), 15);
// 1st place = 20% of 10000 = 2000 // Total prizes should equal the prize pool (no chips lost to rounding)
assert_eq!(prizes.prize_for_placement(1), Some(2000)); assert_eq!(prizes.total_prize_pool(), prize_pool);
// 2nd place = 12% of 10000 = 1200 // 2nd place = 12% of 10000 = 1200
assert_eq!(prizes.prize_for_placement(2), Some(1200)); assert_eq!(prizes.prize_for_placement(2), Some(1200));
// 3rd place = 8% of 10000 = 800 // 3rd place = 8% of 10000 = 800
@ -1042,7 +1154,7 @@ mod tests {
#[test] #[test]
fn test_prize_for_placement_edge_cases() { fn test_prize_for_placement_edge_cases() {
let prizes = PrizeStructure::standard_sng(100, 6); let prizes = PrizeStructure::standard_sng(100, 6, 0);
// 0 placement is invalid // 0 placement is invalid
assert_eq!(prizes.prize_for_placement(0), None); assert_eq!(prizes.prize_for_placement(0), None);
@ -1058,7 +1170,7 @@ mod tests {
fn test_sng_registration() { fn test_sng_registration() {
let config = default_table_config(); let config = default_table_config();
let schedule = BlindSchedule::standard(1000, 10); let schedule = BlindSchedule::standard(1000, 10);
let prizes = PrizeStructure::standard_sng(100, 6); let prizes = PrizeStructure::standard_sng(100, 6, 0);
let mut tournament = Tournament::new_sng(config, schedule, prizes, 1000, 100); let mut tournament = Tournament::new_sng(config, schedule, prizes, 1000, 100);
// Register 6 players // Register 6 players
@ -1087,7 +1199,7 @@ mod tests {
randomize_seats: false, 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, 0);
let mut tournament = Tournament::new_sng(config, schedule, prizes, 1000, 100); let mut tournament = Tournament::new_sng(config, schedule, prizes, 1000, 100);
for i in 0..4u64 { for i in 0..4u64 {
@ -1116,7 +1228,7 @@ mod tests {
randomize_seats: false, 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, 0);
let mut tournament = Tournament::new_sng(config, schedule, prizes, 1000, 100); let mut tournament = Tournament::new_sng(config, schedule, prizes, 1000, 100);
for i in 0..4u64 { for i in 0..4u64 {
@ -1139,7 +1251,7 @@ mod tests {
fn test_mtt_creation() { fn test_mtt_creation() {
let config = default_table_config(); let config = default_table_config();
let schedule = BlindSchedule::standard(1000, 10); let schedule = BlindSchedule::standard(1000, 10);
let prizes = PrizeStructure::standard_mtt(100, 18); let prizes = PrizeStructure::standard_mtt(100, 18, 0);
let tournament = Tournament::new_mtt(2, config, schedule, prizes, 1000, 100); let tournament = Tournament::new_mtt(2, config, schedule, prizes, 1000, 100);
assert_eq!(tournament.game_type, GameType::MultiTableTournament); assert_eq!(tournament.game_type, GameType::MultiTableTournament);
@ -1151,7 +1263,7 @@ mod tests {
fn test_mtt_player_distribution() { fn test_mtt_player_distribution() {
let config = default_table_config(); let config = default_table_config();
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, 0);
let mut tournament = Tournament::new_mtt(2, config, schedule, prizes, 1000, 100); let mut tournament = Tournament::new_mtt(2, config, schedule, prizes, 1000, 100);
// Register 6 players — should be distributed 3/3 across 2 tables // Register 6 players — should be distributed 3/3 across 2 tables
@ -1182,7 +1294,7 @@ mod tests {
randomize_seats: false, 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, 0);
let mut tournament = Tournament::new_mtt(2, config, schedule, prizes, 1000, 100); let mut tournament = Tournament::new_mtt(2, config, schedule, prizes, 1000, 100);
// Manually register 5 players on table 0 and 1 on table 1 // Manually register 5 players on table 0 and 1 on table 1
@ -1227,7 +1339,7 @@ mod tests {
randomize_seats: false, 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, 0);
let mut tournament = Tournament::new_sng(config, schedule, prizes, 1000, 100); let mut tournament = Tournament::new_sng(config, schedule, prizes, 1000, 100);
for i in 0..4u64 { for i in 0..4u64 {
@ -1288,7 +1400,7 @@ mod tests {
ante: 0, ante: 0,
duration_hands: 10, duration_hands: 10,
}]); }]);
let prizes = PrizeStructure::standard_sng(100, 3); let prizes = PrizeStructure::standard_sng(100, 3, 0);
let mut tournament = Tournament::new_sng(config, schedule, prizes, 1000, 100); let mut tournament = Tournament::new_sng(config, schedule, prizes, 1000, 100);
tournament tournament
@ -1312,7 +1424,7 @@ mod tests {
ante: 0, ante: 0,
duration_hands: 5, duration_hands: 5,
}]); }]);
let prizes = PrizeStructure::standard_sng(100, 3); let prizes = PrizeStructure::standard_sng(100, 3, 0);
let mut tournament = Tournament::new_sng(config, schedule, prizes, 1000, 100); let mut tournament = Tournament::new_sng(config, schedule, prizes, 1000, 100);
tournament tournament
@ -1357,7 +1469,7 @@ mod tests {
ante: 0, ante: 0,
duration_hands: 5, duration_hands: 5,
}]); }]);
let prizes = PrizeStructure::standard_sng(100, 4); let prizes = PrizeStructure::standard_sng(100, 4, 0);
let mut tournament = let mut tournament =
Tournament::new_mtt(2, config, schedule, prizes, 1000, 100); Tournament::new_mtt(2, config, schedule, prizes, 1000, 100);
@ -1414,4 +1526,192 @@ mod tests {
"Should have 2 active players" "Should have 2 active players"
); );
} }
#[test]
fn test_sng_prize_distribution_after_completion() {
let config = default_table_config();
let schedule = BlindSchedule::custom(vec![BlindLevel {
level: 1,
small_blind: 10,
big_blind: 20,
ante: 0,
duration_hands: 5,
}]);
let prizes = PrizeStructure::standard_sng(100, 3, 0);
let mut tournament = Tournament::new_sng(config, schedule, prizes, 1000, 100);
tournament
.register_player(Box::new(CallBot::new(1, "Alice")))
.unwrap();
tournament
.register_player(Box::new(CallBot::new(2, "Bob")))
.unwrap();
tournament
.register_player(Box::new(CallBot::new(3, "Charlie")))
.unwrap();
tournament.run_to_completion().unwrap();
assert!(tournament.is_complete());
let standings = tournament.standings();
let winner = tournament.winner();
// All 3 players accounted for
assert_eq!(standings.len(), 2, "Should have 2 eliminated players");
assert!(winner.is_some(), "Should have a winner");
// Winner gets 1st place prize (50% of 300 = 150)
let first_prize = tournament.prize_structure().prize_for_placement(1);
assert_eq!(first_prize, Some(150));
// Verify eliminated players' finish positions are correct.
// The finish position is stored directly in the standings.
for &(_player_id, finish_position) in standings.iter() {
assert!(
finish_position >= 2,
"Eliminated player finish_position should be >= 2"
);
assert!(
finish_position <= 3,
"Eliminated player finish_position should be <= 3 for 3-player SNG"
);
// Positions 2 and 3 both pay in a 3-player SNG (top 3 positions paid)
if finish_position == 2 {
assert_eq!(
tournament.prize_structure().prize_for_placement(finish_position),
Some(90),
"2nd place should win 90"
);
}
if finish_position == 3 {
assert_eq!(
tournament.prize_structure().prize_for_placement(finish_position),
Some(60),
"3rd place should win 60"
);
}
}
// Total prize pool should equal buy_in * players
assert_eq!(tournament.prize_structure().total_prize_pool(), 300);
}
/// Verifies that prize distribution is correct for ALL places in a tournament.
///
/// This test runs a 6-player SNG with a standard 50/30/20 prize structure
/// and verifies that:
/// - The winner gets 1st place prize (50%)
/// - Eliminated players get the correct prize for their finish position
/// - Players who bust simultaneously share the best finish position
/// (standard poker rule), which may award the same prize to multiple players
///
/// This catches the placement inversion bug where raw placement values
/// (which are always N due to the active+already_elim cancellation) are
/// used directly as finish positions for prize lookup.
#[test]
fn test_sng_prize_distribution_all_places() {
let config = default_table_config();
let schedule = BlindSchedule::custom(vec![BlindLevel {
level: 1,
small_blind: 10,
big_blind: 20,
ante: 0,
duration_hands: 5,
}]);
let buy_in = 100;
let prizes = PrizeStructure::standard_sng(buy_in, 6, 0);
let mut tournament = Tournament::new_sng(config, schedule, prizes, 1000, buy_in);
// Register 6 players
tournament
.register_player(Box::new(CallBot::new(1, "Alice")))
.unwrap();
tournament
.register_player(Box::new(CallBot::new(2, "Bob")))
.unwrap();
tournament
.register_player(Box::new(CallBot::new(3, "Charlie")))
.unwrap();
tournament
.register_player(Box::new(CallBot::new(4, "Diana")))
.unwrap();
tournament
.register_player(Box::new(CallBot::new(5, "Eve")))
.unwrap();
tournament
.register_player(Box::new(CallBot::new(6, "Frank")))
.unwrap();
tournament.run_to_completion().unwrap();
assert!(tournament.is_complete());
let standings = tournament.standings();
let _winner_id = tournament.winner().expect("should have winner");
// 6 players total: 5 eliminated + 1 winner
assert_eq!(standings.len(), 5, "Should have 5 eliminated players");
// Prize structure: 50/30/20 of 600 = 300/180/120
let prize_pool = buy_in * 6;
assert_eq!(tournament.prize_structure().total_prize_pool(), prize_pool);
let first_prize = tournament.prize_structure().prize_for_placement(1);
let second_prize = tournament.prize_structure().prize_for_placement(2);
let third_prize = tournament.prize_structure().prize_for_placement(3);
assert_eq!(first_prize, Some(300), "1st place prize should be 300");
assert_eq!(second_prize, Some(180), "2nd place prize should be 180");
assert_eq!(third_prize, Some(120), "3rd place prize should be 120");
// Verify that finish positions stored in standings give correct prizes
// for ALL eliminated players. With best-position semantics for
// simultaneous eliminations, multiple players may share the same
// finish position and thus the same prize.
let mut total_prizes_awarded: ChipCount = 0;
let mut players_paid_count = 0;
for &(_player_id, finish_position) in standings.iter() {
let prize = tournament
.prize_structure()
.prize_for_placement(finish_position)
.unwrap_or(0);
total_prizes_awarded += prize;
if prize > 0 {
players_paid_count += 1;
}
// Finish positions range from 2 (last eliminated before winner)
// to 6 (first eliminated). Positions 2 and 3 are paid.
// With simultaneous eliminations, multiple players may share
// the same position (e.g., two players both at position 3).
match finish_position {
2 => assert_eq!(prize, 180, "2nd place should get 180"),
3 => assert_eq!(prize, 120, "3rd place should get 120"),
4..=6 => assert_eq!(prize, 0, "Position {} should get no prize", finish_position),
_ => panic!("Unexpected finish position {}", finish_position),
}
}
// At least 1 and at most 3 eliminated players should receive prizes.
// With single eliminations: exactly 2 (positions 2 and 3).
// With simultaneous eliminations at position 3: 3 players paid.
// With simultaneous eliminations at position 2: 2+ players paid.
assert!(
(1..=3).contains(&players_paid_count),
"Expected 1-3 eliminated players to receive prizes, got {}",
players_paid_count
);
// Add winner's prize
total_prizes_awarded += first_prize.unwrap_or(0);
// Total prizes awarded should be at least the prize pool.
// With simultaneous eliminations, the same prize may be awarded
// multiple times, so total can exceed the prize pool.
assert!(
total_prizes_awarded >= prize_pool,
"Total prizes ({}) should be at least prize pool ({})",
total_prizes_awarded, prize_pool
);
}
} }

View file

@ -161,11 +161,12 @@ fn run_tournament_simulation(config: &holdem_core::game::config::GameConfig) {
_ => unreachable!(), _ => unreachable!(),
}; };
// Warn if seed was requested but cannot be applied to tournaments yet // Warn if seed was requested but cannot be applied to single-tournament runs
if config.seed.is_some() { if config.seed.is_some() {
eprintln!( eprintln!(
"Warning: seed is not yet supported for tournament simulations; \ "Warning: seed is not supported for single-tournament runs; \
results will not be reproducible." results will not be reproducible. Use num_tournaments > 1 for \
reproducible series."
); );
} }