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:
parent
c041cde3fe
commit
7c79ae4966
10 changed files with 1506 additions and 140 deletions
433
docs/ARCHITECTURE_TOURNAMENT_RAKE.md
Normal file
433
docs/ARCHITECTURE_TOURNAMENT_RAKE.md
Normal 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.
|
||||
|
|
@ -15,9 +15,12 @@ num_hands = 10000
|
|||
# reset_bankroll_each_hand = false
|
||||
# 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.
|
||||
# Configure the threshold via rebuy_threshold_percentage (default: 25).
|
||||
# max_rebuys limits how many times a player can rebuy (0 = unlimited).
|
||||
auto_rebuy = true
|
||||
max_rebuys = 0 # 0 = unlimited rebuys
|
||||
|
||||
# Randomize seat assignments before each hand (optional, defaults to false).
|
||||
randomize_seats = true
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
# (last player standing wins).
|
||||
#
|
||||
# 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_type = "MultiTableTournament"
|
||||
|
|
@ -22,15 +23,20 @@ num_blind_levels = 20
|
|||
randomize_seats = true
|
||||
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]
|
||||
type = "no_limit"
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ buy_in = 11
|
|||
randomize_seats = true
|
||||
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]
|
||||
|
||||
[game.bet_limit]
|
||||
|
|
|
|||
|
|
@ -367,17 +367,33 @@ pub struct GameConfig {
|
|||
#[serde(default = "default_rebuy_threshold")]
|
||||
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
|
||||
/// `hands_per_level`. Each entry specifies SB, BB, ante, and hands duration.
|
||||
#[serde(default)]
|
||||
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.
|
||||
/// Entries are ordered: 1st place, 2nd place, 3rd place, etc.
|
||||
/// The prize pool is `total_entry_fees * (100 - tournament_rake_percentage) / 100`.
|
||||
#[serde(default)]
|
||||
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).
|
||||
/// Applies to both SitAndGo and MultiTableTournament game types.
|
||||
#[serde(default = "default_one_u32")]
|
||||
|
|
@ -413,8 +429,10 @@ impl Default for GameConfig {
|
|||
randomize_seats: false,
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: default_rebuy_threshold(),
|
||||
max_rebuys: 0,
|
||||
blind_levels: Vec::new(),
|
||||
prizes: Vec::new(),
|
||||
tournament_rake_percentage: 0,
|
||||
num_tournaments: 1,
|
||||
bots: Vec::new(),
|
||||
}
|
||||
|
|
@ -519,22 +537,58 @@ impl GameConfig {
|
|||
|
||||
/// 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:
|
||||
/// - Sit-and-Go: 50/30/20 for top 3
|
||||
/// - MTT: top-heavy distribution for top ~15%
|
||||
/// - 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 {
|
||||
// Use bots.len() for player count when computing total entry fees.
|
||||
// Each bot in config.bots is a unique player registered once via
|
||||
// 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);
|
||||
|
||||
if !self.prizes.is_empty() {
|
||||
PrizeStructure::custom(self.prizes.clone())
|
||||
// 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 {
|
||||
let num_players = self.bots.len().max(2);
|
||||
match self.game_type {
|
||||
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 => {
|
||||
let total_entries = num_players * self.num_tables;
|
||||
PrizeStructure::standard_mtt(self.buy_in, total_entries)
|
||||
PrizeStructure::standard_mtt(self.buy_in, num_players, self.tournament_rake_percentage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -937,8 +1068,10 @@ min_raise = 50
|
|||
randomize_seats: false,
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: 25,
|
||||
max_rebuys: 0,
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
tournament_rake_percentage: 0,
|
||||
num_tournaments: 1,
|
||||
|
||||
bots: vec![
|
||||
|
|
@ -1024,8 +1157,10 @@ min_raise = 50
|
|||
randomize_seats: false,
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: 25,
|
||||
max_rebuys: 0,
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
tournament_rake_percentage: 0,
|
||||
num_tournaments: 1,
|
||||
|
||||
bots: vec![],
|
||||
|
|
@ -1061,8 +1196,10 @@ min_raise = 50
|
|||
randomize_seats: false,
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: 25,
|
||||
max_rebuys: 0,
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
tournament_rake_percentage: 0,
|
||||
num_tournaments: 1,
|
||||
|
||||
bots: vec![],
|
||||
|
|
@ -1114,8 +1251,10 @@ min_raise = 50
|
|||
randomize_seats: false,
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: 25,
|
||||
max_rebuys: 0,
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
tournament_rake_percentage: 0,
|
||||
num_tournaments: 1,
|
||||
|
||||
bots: vec![
|
||||
|
|
@ -1140,7 +1279,7 @@ min_raise = 50
|
|||
],
|
||||
};
|
||||
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);
|
||||
let total: ChipCount = prizes.prizes.iter().sum();
|
||||
assert_eq!(total, 3000); // 3 * 1000 buy-in
|
||||
|
|
@ -1169,8 +1308,10 @@ min_raise = 50
|
|||
randomize_seats: false,
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: 25,
|
||||
max_rebuys: 0,
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
tournament_rake_percentage: 0,
|
||||
num_tournaments: 1,
|
||||
|
||||
bots: vec![
|
||||
|
|
@ -1184,8 +1325,8 @@ min_raise = 50
|
|||
],
|
||||
};
|
||||
let prizes = config.to_prize_structure();
|
||||
// 9 bots * 3 tables = 27 total entries
|
||||
// ~15% paid = ceil(4.05) = 5 paid positions
|
||||
// 9 bots (each pays buy_in once; num_tables only controls table layout, not entry count)
|
||||
// ~15% paid = ceil(9 * 0.15) = 2 paid positions
|
||||
assert!(!prizes.prizes.is_empty());
|
||||
// Verify prizes are in descending (non-increasing) order
|
||||
for i in 1..prizes.prizes.len() {
|
||||
|
|
@ -1195,6 +1336,192 @@ min_raise = 50
|
|||
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
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -1266,9 +1593,11 @@ min_raise = 20
|
|||
randomize_seats: false,
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: 25,
|
||||
max_rebuys: 0,
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
num_tournaments: 1,
|
||||
prizes: vec![],
|
||||
tournament_rake_percentage: 0,
|
||||
num_tournaments: 1,
|
||||
bots: vec![],
|
||||
},
|
||||
output: OutputConfig {
|
||||
|
|
|
|||
|
|
@ -310,6 +310,10 @@ pub struct Seat {
|
|||
/// When `bankroll_refill_threshold` triggers a reload, the buy-in amount
|
||||
/// is deducted from bankroll.
|
||||
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 {
|
||||
|
|
@ -350,6 +354,7 @@ impl Seat {
|
|||
has_acted_this_round: false,
|
||||
early_fold_pending: false,
|
||||
bankroll: 0,
|
||||
rebuy_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -94,18 +94,33 @@ pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), Ga
|
|||
for slot in table.seats_mut().iter_mut() {
|
||||
if let Some(seat) = slot {
|
||||
if seat.stack < threshold {
|
||||
let rebuy_amount =
|
||||
config.starting_chips.saturating_sub(seat.stack);
|
||||
log::info!(
|
||||
"Auto-rebuy: {} topped up by {} (was {})",
|
||||
seat.player.name(),
|
||||
rebuy_amount,
|
||||
seat.stack
|
||||
);
|
||||
seat.stack = config.starting_chips;
|
||||
// Reactivate busted players so they can play
|
||||
// the next hand.
|
||||
if seat.state == super::player_info::PlayerState::SittingOut {
|
||||
let can_rebuy =
|
||||
config.max_rebuys == 0 || seat.rebuy_count < config.max_rebuys;
|
||||
if can_rebuy {
|
||||
let rebuy_amount =
|
||||
config.starting_chips.saturating_sub(seat.stack);
|
||||
log::info!(
|
||||
"Auto-rebuy: {} topped up by {} (was {}, rebuy #{})",
|
||||
seat.player.name(),
|
||||
rebuy_amount,
|
||||
seat.stack,
|
||||
seat.rebuy_count + 1
|
||||
);
|
||||
seat.bankroll -= rebuy_amount as i64;
|
||||
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
|
||||
// the next hand.
|
||||
seat.state = super::player_info::PlayerState::Active;
|
||||
}
|
||||
}
|
||||
|
|
@ -141,7 +156,7 @@ pub fn run_cash_game(config: &GameConfig) -> Result<(SimulationStats, Table), Ga
|
|||
})
|
||||
.collect();
|
||||
let bankroll_mode = config.reset_bankroll_each_hand || config.bankroll_refill_threshold > 0;
|
||||
stats.record_final_stacks(&final_stacks, &starting_stacks, &bankrolls, bankroll_mode);
|
||||
stats.record_final_stacks(&final_stacks, &starting_stacks, &bankrolls, bankroll_mode, config.auto_rebuy);
|
||||
|
||||
Ok((stats, table))
|
||||
}
|
||||
|
|
@ -288,17 +303,11 @@ pub fn run_sng_series(
|
|||
// Track which player IDs have been recorded to prevent double-counting.
|
||||
let mut recorded: HashSet<PlayerId> = HashSet::new();
|
||||
|
||||
// Standings contain eliminated players with raw placement numbers.
|
||||
// In the tournament, placement = N means the player was the Nth-to-last
|
||||
// to be eliminated (i.e., placement=N is the worst finish).
|
||||
// For a 10-player tournament: first player out gets placement=10,
|
||||
// last player eliminated gets placement=2. The winner (placement=1)
|
||||
// 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;
|
||||
// Standings contain eliminated players with their finish position.
|
||||
// The placement value is the correct finish position (2 = runner-up,
|
||||
// N = first eliminated). Players eliminated in the same hand share
|
||||
// the same position.
|
||||
for &(player_id, finish_position) in standings {
|
||||
let name = player_names
|
||||
.get(&player_id)
|
||||
.cloned()
|
||||
|
|
@ -310,8 +319,8 @@ pub fn run_sng_series(
|
|||
.unwrap_or(0);
|
||||
|
||||
log::debug!(
|
||||
"Tournament {}: {} raw_placement={} finish={} prize={}",
|
||||
tournament_index + 1, name, raw_placement, finish_position, prize
|
||||
"Tournament {}: {} finish={} prize={}",
|
||||
tournament_index + 1, name, finish_position, prize
|
||||
);
|
||||
|
||||
result.record_player_finish(&name, finish_position, prize);
|
||||
|
|
@ -373,8 +382,10 @@ mod tests {
|
|||
randomize_seats: false,
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: 25,
|
||||
max_rebuys: 0,
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
tournament_rake_percentage: 0,
|
||||
num_tournaments: 1,
|
||||
bots: vec![
|
||||
BotConfig {
|
||||
|
|
@ -484,8 +495,10 @@ mod tests {
|
|||
randomize_seats: false,
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: 25,
|
||||
max_rebuys: 0,
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
tournament_rake_percentage: 0,
|
||||
num_tournaments: 1,
|
||||
|
||||
bots: vec![BotConfig {
|
||||
|
|
@ -523,8 +536,10 @@ mod tests {
|
|||
randomize_seats: false,
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: 25,
|
||||
max_rebuys: 0,
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
tournament_rake_percentage: 0,
|
||||
num_tournaments: 1,
|
||||
bots: vec![
|
||||
BotConfig {
|
||||
|
|
@ -569,8 +584,10 @@ mod tests {
|
|||
randomize_seats: false,
|
||||
auto_rebuy: false,
|
||||
rebuy_threshold_percentage: 25,
|
||||
max_rebuys: 0,
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
tournament_rake_percentage: 0,
|
||||
num_tournaments: 1,
|
||||
|
||||
bots: vec![
|
||||
|
|
@ -634,8 +651,10 @@ mod tests {
|
|||
randomize_seats: false,
|
||||
auto_rebuy: true,
|
||||
rebuy_threshold_percentage: 25,
|
||||
max_rebuys: 0, // unlimited
|
||||
blind_levels: vec![],
|
||||
prizes: vec![],
|
||||
tournament_rake_percentage: 0,
|
||||
num_tournaments: 1,
|
||||
bots: vec![
|
||||
BotConfig {
|
||||
|
|
@ -671,7 +690,148 @@ mod tests {
|
|||
seat.player.name(),
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ pub struct PlayerStats {
|
|||
pub total_rake_paid: ChipCount,
|
||||
/// Final bankroll balance (includes refill costs and reset adjustments).
|
||||
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 {
|
||||
|
|
@ -54,6 +59,8 @@ impl PlayerStats {
|
|||
final_stack: 0,
|
||||
total_rake_paid: 0,
|
||||
bankroll: 0,
|
||||
prize_won: 0,
|
||||
best_finish: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -104,6 +111,9 @@ pub struct SimulationStats {
|
|||
pub game_type: String,
|
||||
/// Total rake collected across the entire simulation.
|
||||
pub total_rake: ChipCount,
|
||||
/// Total tournament rake collected across all tournaments in the series.
|
||||
#[serde(default)]
|
||||
pub total_tournament_rake: ChipCount,
|
||||
}
|
||||
|
||||
impl SimulationStats {
|
||||
|
|
@ -114,6 +124,7 @@ impl SimulationStats {
|
|||
total_hands: 0,
|
||||
game_type: game_type.to_string(),
|
||||
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
|
||||
/// compute `total_profit` accurately.
|
||||
///
|
||||
/// When `bankroll_mode` is `true`, `total_profit` is computed as
|
||||
/// `bankroll + (final_stack - starting_stack)`, which captures all net
|
||||
/// flows including refills and resets. When `false`, the simple
|
||||
/// `final_stack - starting_stack` formula is used.
|
||||
/// - When `bankroll_mode` is `true`, `total_profit` is computed as
|
||||
/// `bankroll + (final_stack - starting_stack)`, which captures all net
|
||||
/// flows including refills and resets.
|
||||
/// - When `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(
|
||||
&mut self,
|
||||
stacks: &HashMap<PlayerId, ChipCount>,
|
||||
starting_stacks: &HashMap<PlayerId, ChipCount>,
|
||||
bankrolls: &HashMap<PlayerId, i64>,
|
||||
bankroll_mode: bool,
|
||||
auto_rebuy: bool,
|
||||
) {
|
||||
for (&player_id, &final_stack) in stacks {
|
||||
if let Some(stats) = self.players.get_mut(&player_id) {
|
||||
|
|
@ -209,6 +225,12 @@ impl SimulationStats {
|
|||
// refill mode: bankroll = -Σ(refill_costs), final_stack ≠ starting
|
||||
// → total_profit = -refills + (final - starting) ✓
|
||||
stats.total_profit = bankroll + (final_stack as i64 - starting as i64);
|
||||
} else 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 {
|
||||
// Simple mode: profit = final stack minus starting stack.
|
||||
stats.total_profit = final_stack as i64 - starting as i64;
|
||||
|
|
@ -227,20 +249,24 @@ impl SimulationStats {
|
|||
player_names: &HashMap<PlayerId, String>,
|
||||
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).
|
||||
// Placement is the order of elimination (higher = eliminated earlier).
|
||||
// Accumulate tournament rake across the series.
|
||||
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.
|
||||
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
|
||||
let eliminated_ids: Vec<PlayerId> =
|
||||
standings.iter().map(|(id, _)| *id).collect();
|
||||
|
||||
// Record stats for eliminated players
|
||||
for &(player_id, placement) in standings {
|
||||
// Record stats for eliminated players using their finish position directly.
|
||||
for &(player_id, finish_position) in standings {
|
||||
let name = player_names
|
||||
.get(&player_id)
|
||||
.cloned()
|
||||
|
|
@ -251,23 +277,24 @@ impl SimulationStats {
|
|||
.entry(player_id)
|
||||
.or_insert_with(|| PlayerStats::new(player_id, name));
|
||||
|
||||
// In tournament standings, placement 1 = first eliminated (worst),
|
||||
// last entry = last eliminated before winner.
|
||||
// Convert to "finish position": total_players - index
|
||||
// Actually, the standings are ordered by elimination time.
|
||||
// placement seems to be stored as the elimination order.
|
||||
// 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
|
||||
// Note: This is an approximation — the player may have been eliminated
|
||||
// before the tournament ended, so actual hands played may be less.
|
||||
// Per-player hand counts are not currently tracked by Tournament.
|
||||
stats.hands_played += tournament.total_hands();
|
||||
stats.final_stack = 0;
|
||||
|
||||
// Profit = prize - buy_in. We'll compute prize from placement.
|
||||
// For now, just record the buy-in as a loss.
|
||||
if let Some(prize) = tournament.prize_structure().prize_for_placement(placement) {
|
||||
stats.total_profit = prize as i64 - buy_in as i64;
|
||||
if let Some(prize) =
|
||||
tournament.prize_structure().prize_for_placement(finish_position)
|
||||
{
|
||||
stats.prize_won += prize;
|
||||
stats.total_profit += prize as i64 - buy_in as i64;
|
||||
} 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));
|
||||
|
||||
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) {
|
||||
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
|
||||
} 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));
|
||||
} else {
|
||||
lines.push("=== Tournament Results ===".to_string());
|
||||
lines.push(format!("Type: {} | Hands: {}", self.game_type, self.total_hands));
|
||||
lines.push("-".repeat(60));
|
||||
lines.push(format!("Type: {} | Hands: {} | Buy-in: {}", self.game_type, self.total_hands, buy_in));
|
||||
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!(
|
||||
"{:<16} {:>5} {:>8} {:>7}",
|
||||
"Player", "Place", "Profit", "ROI%"
|
||||
"{:<16} {:>5} {:>8} {:>10} {:>10} {:>10}",
|
||||
"Player", "Best", "Buy-in", "Prize Won", "Net Profit", "ROI%"
|
||||
));
|
||||
|
||||
// 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));
|
||||
|
||||
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 {
|
||||
format!("+{}", p.total_profit)
|
||||
} else {
|
||||
|
|
@ -391,12 +429,12 @@ impl SimulationStats {
|
|||
};
|
||||
|
||||
lines.push(format!(
|
||||
"{:<16} {:>5} {:>8} {:>7}",
|
||||
p.name, place, profit_str, roi_str
|
||||
"{:<16} {:>5} {:>8} {:>10} {:>10} {:>10}",
|
||||
p.name, place, buy_in, p.prize_won, profit_str, roi_str
|
||||
));
|
||||
}
|
||||
|
||||
lines.push("-".repeat(60));
|
||||
lines.push("-".repeat(72));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
|
|
@ -585,6 +623,10 @@ mod tests {
|
|||
use crate::game::pot::PotWinner;
|
||||
use crate::evaluator::evaluator::{HandRank, HandValue};
|
||||
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.
|
||||
fn dummy_hand_value() -> HandValue {
|
||||
|
|
@ -778,7 +820,7 @@ mod tests {
|
|||
m.insert(PlayerId(2), 10000);
|
||||
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();
|
||||
assert_eq!(alice.final_stack, 10500);
|
||||
|
|
@ -840,4 +882,87 @@ mod tests {
|
|||
assert!(summary.contains("Alice"));
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,35 +151,54 @@ impl BlindSchedule {
|
|||
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,
|
||||
}
|
||||
|
||||
impl PrizeStructure {
|
||||
/// Creates a custom prize structure from the given prize amounts.
|
||||
///
|
||||
/// 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 {
|
||||
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.
|
||||
///
|
||||
/// - 3+ players: 50/30/20 for top 3
|
||||
/// - 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 {
|
||||
let prizes = vec![
|
||||
(prize_pool as f64 * 0.6).round() as ChipCount,
|
||||
(prize_pool as f64 * 0.4).round() as ChipCount,
|
||||
];
|
||||
Self { prizes }
|
||||
// 60/40 split — first prize gets the rounding remainder
|
||||
let second = prize_pool * 40 / 100;
|
||||
let first = prize_pool - second;
|
||||
let prizes = vec![first, second];
|
||||
Self { prizes, rake_amount }
|
||||
} else {
|
||||
let prizes = vec![
|
||||
(prize_pool as f64 * 0.5).round() as ChipCount,
|
||||
(prize_pool as f64 * 0.3).round() as ChipCount,
|
||||
(prize_pool as f64 * 0.2).round() as ChipCount,
|
||||
];
|
||||
Self { prizes }
|
||||
// 50/30/20 split — first prize gets the rounding remainder
|
||||
let second = prize_pool * 30 / 100;
|
||||
let third = prize_pool * 20 / 100;
|
||||
let first = prize_pool - second - third;
|
||||
let prizes = vec![first, second, third];
|
||||
Self { prizes, rake_amount }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -187,28 +206,35 @@ impl PrizeStructure {
|
|||
///
|
||||
/// Top ~15% of entries are paid with a top-heavy distribution:
|
||||
/// 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);
|
||||
|
||||
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);
|
||||
|
||||
// Fixed top 3 percentages
|
||||
let top3_pcts = [0.20f64, 0.12, 0.08];
|
||||
// Fixed top 3 percentages (20%, 12%, 8%)
|
||||
let top3_pcts = [20u32, 12, 8];
|
||||
for (i, &pct) in top3_pcts.iter().enumerate() {
|
||||
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
|
||||
if num_paid > 3 {
|
||||
let top3_total: f64 = top3_pcts.iter().take(num_paid.min(3)).sum();
|
||||
let remaining_pct = 1.0 - top3_total;
|
||||
let top3_total: u32 = top3_pcts.iter().take(num_paid.min(3)).sum();
|
||||
let remaining_pct = (100 - top3_total) as f64 / 100.0;
|
||||
let remaining_positions = num_paid - 3;
|
||||
|
||||
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.
|
||||
|
|
@ -246,11 +279,16 @@ impl PrizeStructure {
|
|||
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 {
|
||||
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.
|
||||
pub fn num_paid_positions(&self) -> usize {
|
||||
self.prizes.len()
|
||||
|
|
@ -270,12 +308,12 @@ pub struct Tournament {
|
|||
tables: Vec<Table>,
|
||||
/// Blind schedule.
|
||||
blind_schedule: BlindSchedule,
|
||||
/// Prize structure.
|
||||
#[allow(dead_code)] // Used for prize distribution queries in future extensions
|
||||
/// Prize structure (accessed via [`prize_structure()`](Tournament::prize_structure)).
|
||||
prize_structure: PrizeStructure,
|
||||
/// Starting chip count for each player.
|
||||
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)>,
|
||||
/// Current blind level index (0-based).
|
||||
current_level: usize,
|
||||
|
|
@ -399,7 +437,9 @@ impl Tournament {
|
|||
/// Runs one hand on each table that has enough players.
|
||||
///
|
||||
/// 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.
|
||||
/// 3. Checks blind level advancement.
|
||||
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();
|
||||
match result {
|
||||
Ok(hand_result) => {
|
||||
self.handle_bustout(table_index);
|
||||
results.push(Ok(hand_result));
|
||||
}
|
||||
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)
|
||||
let hands_run = results.iter().filter(|r| r.is_ok()).count() as u32;
|
||||
if hands_run > 0 {
|
||||
|
|
@ -532,7 +576,65 @@ impl Tournament {
|
|||
// 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) {
|
||||
if table_index >= self.tables.len() {
|
||||
return;
|
||||
|
|
@ -557,15 +659,17 @@ impl Tournament {
|
|||
return;
|
||||
}
|
||||
|
||||
// Calculate placement: active players + already eliminated + 1
|
||||
// (players busted in the same batch get the same placement)
|
||||
let already_eliminated = self.eliminated.len();
|
||||
// Calculate finish position for this batch of busted players.
|
||||
// total_players = active + busted + already_eliminated (before removal).
|
||||
// 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 placement = (active + busted.len() + already_eliminated) as u32;
|
||||
let finish_position = (active + 1) as u32;
|
||||
|
||||
for player_id in busted {
|
||||
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
|
||||
}
|
||||
|
||||
/// 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)] {
|
||||
&self.eliminated
|
||||
}
|
||||
|
|
@ -991,7 +1101,7 @@ mod tests {
|
|||
#[test]
|
||||
fn test_standard_sng_prizes() {
|
||||
// 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.prize_for_placement(1), Some(300)); // 50% of 600
|
||||
|
|
@ -1005,7 +1115,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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.prize_for_placement(1), Some(120)); // 60% of 200
|
||||
|
|
@ -1015,14 +1125,16 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_standard_mtt_prizes() {
|
||||
// 100-player MTT with 100 buy-in
|
||||
let prizes = PrizeStructure::standard_mtt(100, 100);
|
||||
// 100-player MTT with 100 buy-in, no rake
|
||||
let prizes = PrizeStructure::standard_mtt(100, 100, 0);
|
||||
let prize_pool = 100 * 100; // 10000
|
||||
|
||||
// Top 15% = 15 positions paid
|
||||
assert_eq!(prizes.num_paid_positions(), 15);
|
||||
|
||||
// 1st place = 20% of 10000 = 2000
|
||||
assert_eq!(prizes.prize_for_placement(1), Some(2000));
|
||||
// Total prizes should equal the prize pool (no chips lost to rounding)
|
||||
assert_eq!(prizes.total_prize_pool(), prize_pool);
|
||||
|
||||
// 2nd place = 12% of 10000 = 1200
|
||||
assert_eq!(prizes.prize_for_placement(2), Some(1200));
|
||||
// 3rd place = 8% of 10000 = 800
|
||||
|
|
@ -1042,7 +1154,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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
|
||||
assert_eq!(prizes.prize_for_placement(0), None);
|
||||
|
|
@ -1058,7 +1170,7 @@ mod tests {
|
|||
fn test_sng_registration() {
|
||||
let config = default_table_config();
|
||||
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);
|
||||
|
||||
// Register 6 players
|
||||
|
|
@ -1087,7 +1199,7 @@ mod tests {
|
|||
randomize_seats: false,
|
||||
};
|
||||
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);
|
||||
|
||||
for i in 0..4u64 {
|
||||
|
|
@ -1116,7 +1228,7 @@ mod tests {
|
|||
randomize_seats: false,
|
||||
};
|
||||
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);
|
||||
|
||||
for i in 0..4u64 {
|
||||
|
|
@ -1139,7 +1251,7 @@ mod tests {
|
|||
fn test_mtt_creation() {
|
||||
let config = default_table_config();
|
||||
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);
|
||||
|
||||
assert_eq!(tournament.game_type, GameType::MultiTableTournament);
|
||||
|
|
@ -1151,7 +1263,7 @@ mod tests {
|
|||
fn test_mtt_player_distribution() {
|
||||
let config = default_table_config();
|
||||
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);
|
||||
|
||||
// Register 6 players — should be distributed 3/3 across 2 tables
|
||||
|
|
@ -1182,7 +1294,7 @@ mod tests {
|
|||
randomize_seats: false,
|
||||
};
|
||||
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);
|
||||
|
||||
// Manually register 5 players on table 0 and 1 on table 1
|
||||
|
|
@ -1227,7 +1339,7 @@ mod tests {
|
|||
randomize_seats: false,
|
||||
};
|
||||
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);
|
||||
|
||||
for i in 0..4u64 {
|
||||
|
|
@ -1288,7 +1400,7 @@ mod tests {
|
|||
ante: 0,
|
||||
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);
|
||||
|
||||
tournament
|
||||
|
|
@ -1312,7 +1424,7 @@ mod tests {
|
|||
ante: 0,
|
||||
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);
|
||||
|
||||
tournament
|
||||
|
|
@ -1357,7 +1469,7 @@ mod tests {
|
|||
ante: 0,
|
||||
duration_hands: 5,
|
||||
}]);
|
||||
let prizes = PrizeStructure::standard_sng(100, 4);
|
||||
let prizes = PrizeStructure::standard_sng(100, 4, 0);
|
||||
let mut tournament =
|
||||
Tournament::new_mtt(2, config, schedule, prizes, 1000, 100);
|
||||
|
||||
|
|
@ -1414,4 +1526,192 @@ mod tests {
|
|||
"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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,11 +161,12 @@ fn run_tournament_simulation(config: &holdem_core::game::config::GameConfig) {
|
|||
_ => 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() {
|
||||
eprintln!(
|
||||
"Warning: seed is not yet supported for tournament simulations; \
|
||||
results will not be reproducible."
|
||||
"Warning: seed is not supported for single-tournament runs; \
|
||||
results will not be reproducible. Use num_tournaments > 1 for \
|
||||
reproducible series."
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue