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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue