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.