rusty_marvin/docs/ARCHITECTURE_TOURNAMENT_RAKE.md
Jan-Peter v. Hunnius 7c79ae4966 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
2026-05-22 14:32:24 +02:00

433 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.