# 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 = 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, /// 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, rake_amount: ChipCount) -> Self { Self { prizes, rake_amount } } ``` **Updated existing constructor:** ```rust pub fn custom(prizes: Vec) -> 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::() != 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.