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
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue