From 853e2f0890b2ec77499dbeb49a08b21cc75d97e7 Mon Sep 17 00:00:00 2001 From: Linerre Date: Sat, 6 Sep 2025 02:29:20 +1000 Subject: [PATCH 1/2] Fix arrange_waitbb bug Waitbb players should be sorted according to their positions relative to the btn. Also simplify the code logic --- base/src/game.rs | 195 +++++++++++++++++++-------------- base/tests/waitbb_tests.rs | 214 +++++++++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+), 82 deletions(-) diff --git a/base/src/game.rs b/base/src/game.rs index af7cc1e..253225f 100644 --- a/base/src/game.rs +++ b/base/src/game.rs @@ -1,7 +1,4 @@ //! Game state machine (or handler) of Holdem: the core of this lib. -use race_api::prelude::*; -use std::collections::BTreeMap; -use std::mem::take; use crate::account::HoldemAccount; use crate::errors; use crate::essential::{ @@ -12,15 +9,19 @@ use crate::essential::{ }; use crate::evaluator::{compare_hands, create_cards, evaluate_cards, PlayerHand}; use crate::hand_history::{BlindBet, BlindType, HandHistory, PlayerAction, Showdown}; +use race_api::prelude::*; +use std::collections::BTreeMap; +use std::mem::take; -macro_rules! id_pos { +// get relative position of player to button +macro_rules! rel_pos { ($p:ident, $last_pos:ident) => { if $p.position > $last_pos { ($p.id, $p.position, $p.position - $last_pos) } else { ($p.id, $p.position, $p.position + 100) } - } + }; } // Holdem: the game state @@ -163,9 +164,9 @@ impl Holdem { pub fn reset_player_map_status(&mut self) -> Result<(), HandleError> { for player in self.player_map.values_mut() { match player.status { - PlayerStatus::Out => player.timeout += 1, - PlayerStatus::Waitbb => {}, - _ => player.status = PlayerStatus::Wait + PlayerStatus::Out => player.timeout += 1, + PlayerStatus::Waitbb => {} + _ => player.status = PlayerStatus::Wait, } } Ok(()) @@ -244,10 +245,12 @@ impl Holdem { // Players with status `Waitbb` will not be used to calculate next BTN. // BTN moves clockwise. pub fn get_next_btn(&mut self) -> Result { - let mut player_positions: Vec = - self.player_map.values() + let mut player_positions: Vec = self + .player_map + .values() .filter(|p| p.status != PlayerStatus::Waitbb) - .map(|p| p.position).collect(); + .map(|p| p.position) + .collect(); // There are only `Waitbb` players, default btn to 0 as it will be re-calced if player_positions.is_empty() { return Ok(0); @@ -365,23 +368,24 @@ impl Holdem { }) .collect(); player_pos.sort_by(|(_, pos1), (_, pos2)| pos1.cmp(pos2)); - let player_order: Vec = player_pos.into_iter().map(|(id, _)| id ).collect(); + let player_order: Vec = player_pos.into_iter().map(|(id, _)| id).collect(); println!("Sorted players: {player_order:?}"); self.player_order = player_order; Ok(()) } /// Similar to arrange_player, but runs only once after next btn is known. - /// It checks if the next player can be the actual BB based on positions: + /// It checks if the new player can be the actual BB based on positions: /// 1. SB < NP < BB, then NP should be the actual BB /// 2. NP > SB > BB, then NP should be the actual BB (a ring) /// 3. SB > BB > NP, then NP should be the actual BB (a ring) fn arrange_waitbbs(&mut self, next_btn: u8) -> Result<(), HandleError> { - // tuple: (id, player_pos, modified_pos) + // get waitbbs in a vec of tuples: (id, player_pos, relative_btn_pos) let mut waitbbs: Vec<(u64, u8, u8)> = self - .player_map.values() + .player_map + .values() .filter(|p| matches!(p.status, PlayerStatus::Waitbb)) - .map(|p| id_pos!(p, next_btn)) + .map(|p| rel_pos!(p, next_btn)) .collect(); if waitbbs.is_empty() { return Ok(()); @@ -389,10 +393,10 @@ impl Holdem { // get current in-game players (with any status but Waitbb) let mut player_pos: Vec<(u64, u8, u8)> = self - .player_map + .player_map .values() .filter(|p| p.status != PlayerStatus::Waitbb) - .map(|p| id_pos!(p, next_btn)) + .map(|p| rel_pos!(p, next_btn)) .collect(); player_pos.sort_by(|(.., pos1), (.., pos2)| pos1.cmp(pos2)); println!("Sorted players without Waitbbs {player_pos:?}"); @@ -400,66 +404,74 @@ impl Holdem { if player_pos.is_empty() { let mut new_btn = 0; for p @ (id, wpos, _) in waitbbs { - let wp = self.player_map.get_mut(&id) + let wp = self + .player_map + .get_mut(&id) .ok_or(errors::internal_player_not_found())?; wp.status = PlayerStatus::Wait; player_pos.push(p); new_btn = wpos; } self.btn = new_btn; - } else if player_pos.len() == 1 && waitbbs.len() == 1 { - // heads-up, wait player becomes sb and btn - let (_, new_btn, _) = player_pos.first().cloned() - .ok_or_else(|| errors::btn_not_found_in_player_order())?; - self.btn = new_btn; - // waitbb player becomes bb - let (id, _, _) = waitbbs.first().cloned() - .ok_or_else(|| errors::bb_not_found_in_waitbbs())?; - let wp = self.player_map.get_mut(&id) - .ok_or(errors::internal_player_not_found())?; - wp.status = PlayerStatus::Wait; - player_pos.append(&mut waitbbs); - } else if player_pos.len() == 1 && waitbbs.len() > 1 { - // make the single player with `Wait` status the next btn - let btn_player @ (_, new_btn, _) = player_pos.first().cloned() + } else if player_pos.len() == 1 { + // Mark the only original player as btn + let (_, new_btn, _) = player_pos + .first() + .cloned() .ok_or_else(|| errors::btn_not_found_in_player_order())?; self.btn = new_btn; - // re-order waitbbs according to the next btn - player_pos = waitbbs.iter().map(|&(id, pos, _)| { - if pos > new_btn { - (id, pos, pos - new_btn) - } else { - (id, pos, pos + 100) - } - }).collect(); - // add all waitbb players to game - for (id, _, _) in waitbbs { - let wp = self.player_map.get_mut(&id) + let heads_up: bool = waitbbs.len() == 1; + + // Reorder waitbbs according to the new btn + waitbbs = waitbbs + .into_iter() + .map(|(id, pos, _)| { + if pos > new_btn { + (id, pos, pos - new_btn) + } else { + (id, pos, pos + 100) + } + }) + .collect(); + waitbbs.sort_by(|(.., pos1), (.., pos2)| pos1.cmp(pos2)); + + // Mark all waitbb players as normal ones + for (id, _, _) in &waitbbs { + let wp = self + .player_map + .get_mut(id) .ok_or(errors::internal_player_not_found())?; wp.status = PlayerStatus::Wait; } - player_pos.sort_by(|(.., pos1), (.., pos2)| pos1.cmp(pos2)); - player_pos.push(btn_player); + + // Merge all players. If heads up, new btn also becomes sb. + // Otherwise, move the new btn to the last position + player_pos.append(&mut waitbbs); + if !heads_up { + player_pos.rotate_left(1); + } } else { - let (_, sb_pos, _) = player_pos.first().cloned() + let (_, sb_pos, _) = player_pos + .first() + .cloned() .ok_or_else(|| errors::sb_not_found_in_player_order())?; - let (_, bb_pos, _) = player_pos.get(1).cloned() + let (_, bb_pos, _) = player_pos + .get(1) + .cloned() .ok_or_else(|| errors::bb_not_found_in_player_order())?; - for p @ (id, wpos, _) in waitbbs { - if sb_pos < wpos && wpos < bb_pos { - player_pos.push(p); - let wp = self.player_map.get_mut(&id) - .ok_or(errors::internal_player_not_found())?; - wp.status = PlayerStatus::Wait; - break; - } - if sb_pos > bb_pos && (wpos > sb_pos || wpos < bb_pos) { - player_pos.push(p); - let wp = self.player_map.get_mut(&id) - .ok_or(errors::internal_player_not_found())?; - wp.status = PlayerStatus::Wait; - break; - } + // sort waitbbs relative to btn + waitbbs.sort_by(|(.., rel_pos1), (.., rel_pos2)| rel_pos1.cmp(rel_pos2)); + // find one eligible waitbb to become the actual bb + if let Some(p @ (id, ..)) = waitbbs.into_iter().find(|(_, wpos, _)| { + (sb_pos < bb_pos && sb_pos < *wpos && *wpos < bb_pos) + || (sb_pos > bb_pos && (*wpos > sb_pos || *wpos < bb_pos)) + }) { + player_pos.push(p); + let wp = self + .player_map + .get_mut(&id) + .ok_or(errors::internal_player_not_found())?; + wp.status = PlayerStatus::Wait; } player_pos.sort_by(|(.., pos1), (.., pos2)| pos1.cmp(pos2)); } @@ -482,8 +494,12 @@ impl Holdem { if allin { self.set_player_status(player_id, PlayerStatus::Allin)?; } - self.hand_history - .add_blinds_info(BlindBet::new(player_id, BlindType::Ante, real_ante, allin)); + self.hand_history.add_blinds_info(BlindBet::new( + player_id, + BlindType::Ante, + real_ante, + allin, + )); } Ok(total_ante) @@ -710,9 +726,7 @@ impl Holdem { pub fn count_ingame_players(&self) -> usize { self.player_map .values() - .filter(|p| !matches!(p.status, - PlayerStatus::Init | PlayerStatus::Waitbb) - ) + .filter(|p| !matches!(p.status, PlayerStatus::Init | PlayerStatus::Waitbb)) .count() } @@ -1031,7 +1045,6 @@ impl Holdem { player_hands.sort_by(|(_, h1), (_, h2)| compare_hands(&h2.value, &h1.value)); - println!("Player Hands from strong to weak {:?}", player_hands); // Winners example: [[w1], [w2, w3], ... ] where w2 == w3, i.e. a draw/tie @@ -1043,7 +1056,10 @@ impl Holdem { current_value = Some(hand.value.clone()); winners.push(vec![]); } - winners.last_mut().ok_or(errors::strongest_hand_not_found())?.push(id); + winners + .last_mut() + .ok_or(errors::strongest_hand_not_found())? + .push(id); } println!("Player rankings in order: {:?}", winners); @@ -1301,7 +1317,10 @@ impl Holdem { self.set_player_acted(sender, allin)?; self.min_raise = amount; self.street_bet = amount; - self.hand_history.add_action(self.street, PlayerAction::new_bet(player_id, real_bet_amount, allin))?; + self.hand_history.add_action( + self.street, + PlayerAction::new_bet(player_id, real_bet_amount, allin), + )?; self.reduce_time_cards(effect)?; } @@ -1317,7 +1336,10 @@ impl Holdem { let call_amount = self.street_bet - betted; let (allin, real_call_amount) = self.take_bet(sender.clone(), call_amount)?; self.set_player_acted(sender, allin)?; - self.hand_history.add_action(self.street, PlayerAction::new_call(player_id, real_call_amount, allin))?; + self.hand_history.add_action( + self.street, + PlayerAction::new_call(player_id, real_call_amount, allin), + )?; self.reduce_time_cards(effect)?; } @@ -1332,7 +1354,8 @@ impl Holdem { return Err(errors::player_cant_check()); } self.set_player_status(sender, PlayerStatus::Acted)?; - self.hand_history.add_action(self.street, PlayerAction::new_check(player_id))?; + self.hand_history + .add_action(self.street, PlayerAction::new_check(player_id))?; self.reduce_time_cards(effect)?; } @@ -1341,7 +1364,8 @@ impl Holdem { return Err(errors::not_the_acting_player_to_fold()); } self.set_player_status(sender, PlayerStatus::Fold)?; - self.hand_history.add_action(self.street, PlayerAction::new_fold(player_id))?; + self.hand_history + .add_action(self.street, PlayerAction::new_fold(player_id))?; self.reduce_time_cards(effect)?; } @@ -1366,7 +1390,10 @@ impl Holdem { self.street_bet = new_street_bet; } self.min_raise = new_min_raise; - self.hand_history.add_action(self.street, PlayerAction::new_raise(player_id, betted + real_raise_amount, allin))?; + self.hand_history.add_action( + self.street, + PlayerAction::new_raise(player_id, betted + real_raise_amount, allin), + )?; self.reduce_time_cards(effect)?; } @@ -1559,7 +1586,6 @@ impl Holdem { return None; } - /// Find a position from this table, but prefer to return a /// position which is not between BTN and BB. pub fn find_position(&self) -> Option { @@ -1714,7 +1740,8 @@ impl GameHandler for Holdem { // MAX_ACTION_TIMEOUT_COUNT times with `Leave` status if self.mode == GameMode::Cash { self.set_player_status(player_id, PlayerStatus::Leave)?; - self.hand_history.add_action(street, PlayerAction::new_fold(player_id))?; + self.hand_history + .add_action(street, PlayerAction::new_fold(player_id))?; return self.next_state(effect); } else { player.is_afk = true; @@ -1734,12 +1761,14 @@ impl GameHandler for Holdem { if bet == street_bet { self.set_player_status(player_id, PlayerStatus::Acted)?; - self.hand_history.add_action(street, PlayerAction::new_check(player_id))?; + self.hand_history + .add_action(street, PlayerAction::new_check(player_id))?; self.next_state(effect)?; Ok(()) } else { self.set_player_status(player_id, PlayerStatus::Fold)?; - self.hand_history.add_action(street, PlayerAction::new_fold(player_id))?; + self.hand_history + .add_action(street, PlayerAction::new_fold(player_id))?; self.next_state(effect)?; Ok(()) } @@ -1761,12 +1790,14 @@ impl GameHandler for Holdem { if self.player_map.len() <= 1 { for p in players.into_iter() { - let player = Player::init(p.id(), 0, p.position() as u8, PlayerStatus::Init); + let player = + Player::init(p.id(), 0, p.position() as u8, PlayerStatus::Init); self.player_map.insert(player.id, player); } } else { for p in players.into_iter() { - let player = Player::init(p.id(), 0, p.position() as u8, PlayerStatus::Waitbb); + let player = + Player::init(p.id(), 0, p.position() as u8, PlayerStatus::Waitbb); self.player_map.insert(player.id, player); } } diff --git a/base/tests/waitbb_tests.rs b/base/tests/waitbb_tests.rs index 26658b7..b62b8cb 100644 --- a/base/tests/waitbb_tests.rs +++ b/base/tests/waitbb_tests.rs @@ -542,3 +542,217 @@ fn test_multi_wait_without_waitbb() -> HandleResult<()> { Ok(()) } + +// When there are multiple waitbbs, the one near (right to) the btn/sb plays first. +// This test covers the case where NP > SB > BB +#[test] +fn test_multi_waitbbs_play_order1() -> HandleResult<()> { + // players + let a1 = Player { id: 1, position: 1, status: PlayerStatus::Wait, ..Player::default() }; + let b2 = Player { id: 2, position: 2, status: PlayerStatus::Wait, ..Player::default() }; + let c3 = Player { id: 3, position: 3, status: PlayerStatus::Wait, ..Player::default() }; + let d4 = Player { id: 4, position: 4, status: PlayerStatus::Wait, .. Player::default() }; + let e5 = Player { id: 5, position: 5, status: PlayerStatus::Waitbb, .. Player::default() }; + let f6 = Player { id: 6, position: 6, status: PlayerStatus::Waitbb, .. Player::default() }; + + let player_map = BTreeMap::from([ + (1, a1), + (2, b2), + (3, c3), + (4, d4), + (5, e5), + (6, f6) + ]); + + // snapshot of game state + let mut game = Holdem { + hand_id: 1, + deck_random_id: 1, + max_deposit: 2000, + sb: 100, + bb: 200, + ante: 20, + min_raise: 1000, + btn: 2, // current btn; next btn will be 3 + rake: 10, + rake_cap: 25, + stage: HoldemStage::Play, + street: Street::Preflop, + street_bet: 200, + board: Vec::::with_capacity(5), + hand_index_map: BTreeMap::>::new(), + bet_map: BTreeMap::::new(), + total_bet_map: BTreeMap::::new(), + prize_map: BTreeMap::::new(), + player_map, + player_order: vec![], + pots: Vec::::new(), + acting_player: None, + winners: Vec::::new(), + display: Vec::::new(), + mode: GameMode::Cash, + table_size: 6, + hand_history: HandHistory::default(), + next_game_start: 0, + rake_collected: 0, + }; + + { + let event = Event::GameStart; + let mut effect = Effect::default(); + game.handle_event(&mut effect, event).unwrap(); + + assert_eq!(game.btn, 3); + // Without waitbbs, players are supposed to act in the below order + // [d4, a1, b2, c3] or [4, 1, 2, 3] or [sb, bb, utg, btn] + // Since e4 and f5 are all eligible for the actual bb, the actual order is + // [d4, e5, a1, b2, c3] or [4, 5, 1, 2, 3] + assert_eq!(game.player_order, [4, 5, 1, 2, 3]); + } + + Ok(()) +} + +// When there are multiple waitbbs, the one near (right to) the btn/sb plays first. +// This test covers the case where SB > BB > NP +#[test] +fn test_multi_waitbbs_play_order2() -> HandleResult<()> { + // players + let a1 = Player { id: 1, position: 1, status: PlayerStatus::Waitbb, ..Player::default() }; + let b2 = Player { id: 2, position: 2, status: PlayerStatus::Waitbb, ..Player::default() }; + let c3 = Player { id: 3, position: 3, status: PlayerStatus::Wait, ..Player::default() }; + let d4 = Player { id: 4, position: 4, status: PlayerStatus::Wait, .. Player::default() }; + let e5 = Player { id: 5, position: 5, status: PlayerStatus::Wait, .. Player::default() }; + let f6 = Player { id: 6, position: 6, status: PlayerStatus::Wait, .. Player::default() }; + + let player_map = BTreeMap::from([ + (1, a1), + (2, b2), + (3, c3), + (4, d4), + (5, e5), + (6, f6) + ]); + + // snapshot of game state + let mut game = Holdem { + hand_id: 1, + deck_random_id: 1, + max_deposit: 2000, + sb: 100, + bb: 200, + ante: 20, + min_raise: 1000, + btn: 4, // current btn; next btn will be 5 + rake: 10, + rake_cap: 25, + stage: HoldemStage::Play, + street: Street::Preflop, + street_bet: 200, + board: Vec::::with_capacity(5), + hand_index_map: BTreeMap::>::new(), + bet_map: BTreeMap::::new(), + total_bet_map: BTreeMap::::new(), + prize_map: BTreeMap::::new(), + player_map, + player_order: vec![], + pots: Vec::::new(), + acting_player: None, + winners: Vec::::new(), + display: Vec::::new(), + mode: GameMode::Cash, + table_size: 6, + hand_history: HandHistory::default(), + next_game_start: 0, + rake_collected: 0, + }; + + { + let event = Event::GameStart; + let mut effect = Effect::default(); + game.handle_event(&mut effect, event).unwrap(); + + assert_eq!(game.btn, 5); + // Without waitbbs, players are supposed to act in the below order + // [f6, c3, d4, e5] or [6, 3, 4, 5] or [sb, bb, utg, btn] + // Since a1 and b2 are all eligible for the actual bb, the actual order is + // [f5, a1, c3, d4, e5] or [6, 1, 3, 4, 5] + assert_eq!(game.player_order, [6, 1, 3, 4, 5]); + } + + Ok(()) +} + +// This test targets a bug where the would-be-bb was skipped +// Suppose next btn is 6 and thus a1 will be sb +// b2 (Suts) buys in early but joins the table late (thus further from btn) +// c3 (Loso) buys in late but joins the table early (thus near btn) +// In such case, c3 should become the actual big blind +#[test] +fn test_multi_waitbbs_play_order3() -> HandleResult<()> { + // players + let a1 = Player { id: 1, position: 1, status: PlayerStatus::Wait, ..Player::default() }; + let b2 = Player { id: 2, position: 3, status: PlayerStatus::Waitbb, ..Player::default() }; + let c3 = Player { id: 3, position: 2, status: PlayerStatus::Waitbb, ..Player::default() }; + let d4 = Player { id: 4, position: 4, status: PlayerStatus::Wait, .. Player::default() }; + let e5 = Player { id: 5, position: 5, status: PlayerStatus::Wait, .. Player::default() }; + let f6 = Player { id: 6, position: 6, status: PlayerStatus::Wait, .. Player::default() }; + + let player_map = BTreeMap::from([ + (1, a1), + (2, b2), + (3, c3), + (4, d4), + (5, e5), + (6, f6) + ]); + + // snapshot of game state + let mut game = Holdem { + hand_id: 1, + deck_random_id: 1, + max_deposit: 2000, + sb: 100, + bb: 200, + ante: 20, + min_raise: 1000, + btn: 5, // current btn; next btn will be 6 + rake: 10, + rake_cap: 25, + stage: HoldemStage::Play, + street: Street::Preflop, + street_bet: 200, + board: Vec::::with_capacity(5), + hand_index_map: BTreeMap::>::new(), + bet_map: BTreeMap::::new(), + total_bet_map: BTreeMap::::new(), + prize_map: BTreeMap::::new(), + player_map, + player_order: vec![], + pots: Vec::::new(), + acting_player: None, + winners: Vec::::new(), + display: Vec::::new(), + mode: GameMode::Cash, + table_size: 6, + hand_history: HandHistory::default(), + next_game_start: 0, + rake_collected: 0, + }; + + { + let event = Event::GameStart; + let mut effect = Effect::default(); + game.handle_event(&mut effect, event).unwrap(); + + assert_eq!(game.btn, 6); + assert_eq!(game.player_map.get(&2).unwrap().status, PlayerStatus::Waitbb); + // Without waitbbs, players are supposed to act in the below order + // [a1, d4, e5, f6] or [1, 4, 5, 6] or [sb, bb, utg, btn] + // c3 should become the actual bb so the actual order is + // [a1, c3, d4, e5, f6] or [1, 3, 4, 5, 6] + assert_eq!(game.player_order, [1, 3, 4, 5, 6]); + } + + Ok(()) +} From c1752f49fd369c68b02059738a74e9d9f888bc6b Mon Sep 17 00:00:00 2001 From: Linerre Date: Sun, 7 Sep 2025 01:29:20 +1000 Subject: [PATCH 2/2] Rewrite `arrange_waitbbs` to simplify the process In internal game start, there are 3 steps before randomness generation 1. move btn 2. process waitbbs (mainly update the eligible waitbbs' status) 3. arrange player action orders according to btn Each step handles one concern only --- base/src/errors.rs | 4 +- base/src/game.rs | 185 +++++++++++++++---------------------- base/tests/waitbb_tests.rs | 85 +++++++++++++++-- 3 files changed, 156 insertions(+), 118 deletions(-) diff --git a/base/src/errors.rs b/base/src/errors.rs index baa84ba..5f97809 100644 --- a/base/src/errors.rs +++ b/base/src/errors.rs @@ -51,7 +51,7 @@ custom_err!(river_card_error); custom_err!(wait_timeout_error_in_settle); custom_err!(time_card_already_in_use); custom_err!(no_time_cards); -custom_err!(sb_not_found_in_player_order); -custom_err!(bb_not_found_in_player_order); +custom_err!(sb_not_found_in_non_waitbbs); +custom_err!(bb_not_found_in_non_waitbbs); custom_err!(btn_not_found_in_player_order); custom_err!(bb_not_found_in_waitbbs); diff --git a/base/src/game.rs b/base/src/game.rs index 253225f..9779cef 100644 --- a/base/src/game.rs +++ b/base/src/game.rs @@ -13,17 +13,6 @@ use race_api::prelude::*; use std::collections::BTreeMap; use std::mem::take; -// get relative position of player to button -macro_rules! rel_pos { - ($p:ident, $last_pos:ident) => { - if $p.position > $last_pos { - ($p.id, $p.position, $p.position - $last_pos) - } else { - ($p.id, $p.position, $p.position + 100) - } - }; -} - // Holdem: the game state #[derive(BorshSerialize, BorshDeserialize, Default, Debug, PartialEq, Clone)] pub struct Holdem { @@ -241,19 +230,25 @@ impl Holdem { } } - // The next BTN is calculated based on the current one. - // Players with status `Waitbb` will not be used to calculate next BTN. + // Move BTN by calculating the next btn based on the previous one and // BTN moves clockwise. - pub fn get_next_btn(&mut self) -> Result { + // If there are only `Waitbb` players, make the first of them the btn. + pub fn move_btn(&mut self) -> Result<(), HandleError> { let mut player_positions: Vec = self .player_map .values() .filter(|p| p.status != PlayerStatus::Waitbb) .map(|p| p.position) .collect(); - // There are only `Waitbb` players, default btn to 0 as it will be re-calced + if player_positions.is_empty() { - return Ok(0); + let next_btn = self + .player_map + .first_key_value() + .map(|(_, p)| p.position) + .ok_or_else(|| errors::btn_not_found_in_player_order())?; + self.btn = next_btn; + return Ok(()); } player_positions.sort(); @@ -268,12 +263,14 @@ impl Holdem { let Some(next_btn) = player_positions.first() else { return Err(errors::next_button_player_not_found()); }; - Ok(*next_btn) + self.btn = *next_btn; + Ok(()) } else { if let Some(next_btn) = next_btn_candidates.first() { - Ok(*next_btn) + self.btn = *next_btn; + Ok(()) } else { - return Err(errors::next_button_position_not_found()); + Err(errors::next_button_position_not_found()) } } } @@ -352,10 +349,23 @@ impl Holdem { } } - /// According to players position, place them in the following order: + // Sort the given players by their relative positions to the btn + fn sort_by_relative_position(&self, players: &mut Vec<(u64, u8)>) { + players.sort_by_key(|&(_, pos)| { + if pos > self.btn { + pos - self.btn + } else { + pos + 100 + } + }); + } + + /// According to players position, place them in the below order at the start /// SB, BB, UTG (1st-to-act), MID (2nd-to-act), ..., BTN (last-to-act). + /// During the game, player acts in turn clockwise, thus the order looks like + /// [acting, ..., most-recently-acted] pub fn arrange_players(&mut self, last_pos: u8) -> Result<(), HandleError> { - let mut player_pos: Vec<(u64, u8)> = self + let mut player_relative_pos: Vec<(u64, u8)> = self .player_map .values() .filter(|p| p.status != PlayerStatus::Waitbb) @@ -367,118 +377,75 @@ impl Holdem { } }) .collect(); - player_pos.sort_by(|(_, pos1), (_, pos2)| pos1.cmp(pos2)); - let player_order: Vec = player_pos.into_iter().map(|(id, _)| id).collect(); - println!("Sorted players: {player_order:?}"); - self.player_order = player_order; + player_relative_pos.sort_by(|(_, pos1), (_, pos2)| pos1.cmp(pos2)); + println!("Sorted player relative positions: {:?}", player_relative_pos); + self.player_order = player_relative_pos.into_iter().map(|(id, _)| id).collect(); Ok(()) } /// Similar to arrange_player, but runs only once after next btn is known. /// It checks if the new player can be the actual BB based on positions: - /// 1. SB < NP < BB, then NP should be the actual BB - /// 2. NP > SB > BB, then NP should be the actual BB (a ring) - /// 3. SB > BB > NP, then NP should be the actual BB (a ring) - fn arrange_waitbbs(&mut self, next_btn: u8) -> Result<(), HandleError> { - // get waitbbs in a vec of tuples: (id, player_pos, relative_btn_pos) - let mut waitbbs: Vec<(u64, u8, u8)> = self + fn arrange_waitbbs(&mut self) -> Result<(), HandleError> { + // Get waitbb players and sort by their relative pos to btn + let mut waitbbs: Vec<(u64, u8)> = self .player_map .values() .filter(|p| matches!(p.status, PlayerStatus::Waitbb)) - .map(|p| rel_pos!(p, next_btn)) + .map(|p| (p.id, p.position)) .collect(); + self.sort_by_relative_position(&mut waitbbs); + + // When no waitbb players, do nothing if waitbbs.is_empty() { return Ok(()); } - // get current in-game players (with any status but Waitbb) - let mut player_pos: Vec<(u64, u8, u8)> = self + // Otherwise, get non-waitbb players and sort by their relative pos to btn + let mut non_waitbbs: Vec<(u64, u8)> = self .player_map .values() .filter(|p| p.status != PlayerStatus::Waitbb) - .map(|p| rel_pos!(p, next_btn)) + .map(|p| (p.id, p.position)) .collect(); - player_pos.sort_by(|(.., pos1), (.., pos2)| pos1.cmp(pos2)); - println!("Sorted players without Waitbbs {player_pos:?}"); + self.sort_by_relative_position(&mut non_waitbbs); - if player_pos.is_empty() { - let mut new_btn = 0; - for p @ (id, wpos, _) in waitbbs { + // Process waitbb players + if non_waitbbs.len() <= 1 { + for (id, _) in waitbbs { let wp = self .player_map .get_mut(&id) - .ok_or(errors::internal_player_not_found())?; - wp.status = PlayerStatus::Wait; - player_pos.push(p); - new_btn = wpos; - } - self.btn = new_btn; - } else if player_pos.len() == 1 { - // Mark the only original player as btn - let (_, new_btn, _) = player_pos - .first() - .cloned() - .ok_or_else(|| errors::btn_not_found_in_player_order())?; - self.btn = new_btn; - let heads_up: bool = waitbbs.len() == 1; - - // Reorder waitbbs according to the new btn - waitbbs = waitbbs - .into_iter() - .map(|(id, pos, _)| { - if pos > new_btn { - (id, pos, pos - new_btn) - } else { - (id, pos, pos + 100) - } - }) - .collect(); - waitbbs.sort_by(|(.., pos1), (.., pos2)| pos1.cmp(pos2)); - - // Mark all waitbb players as normal ones - for (id, _, _) in &waitbbs { - let wp = self - .player_map - .get_mut(id) - .ok_or(errors::internal_player_not_found())?; + .ok_or_else(|| errors::internal_player_not_found())?; wp.status = PlayerStatus::Wait; } - - // Merge all players. If heads up, new btn also becomes sb. - // Otherwise, move the new btn to the last position - player_pos.append(&mut waitbbs); - if !heads_up { - player_pos.rotate_left(1); - } } else { - let (_, sb_pos, _) = player_pos - .first() + let (_, sb_pos) = non_waitbbs + .get(0) .cloned() - .ok_or_else(|| errors::sb_not_found_in_player_order())?; - let (_, bb_pos, _) = player_pos + .ok_or_else(|| errors::sb_not_found_in_non_waitbbs())?; + let (_, bb_pos) = non_waitbbs .get(1) .cloned() - .ok_or_else(|| errors::bb_not_found_in_player_order())?; - // sort waitbbs relative to btn - waitbbs.sort_by(|(.., rel_pos1), (.., rel_pos2)| rel_pos1.cmp(rel_pos2)); - // find one eligible waitbb to become the actual bb - if let Some(p @ (id, ..)) = waitbbs.into_iter().find(|(_, wpos, _)| { - (sb_pos < bb_pos && sb_pos < *wpos && *wpos < bb_pos) - || (sb_pos > bb_pos && (*wpos > sb_pos || *wpos < bb_pos)) - }) { - player_pos.push(p); - let wp = self + .ok_or_else(|| errors::bb_not_found_in_non_waitbbs())?; + // Try to find an eligible waitbb to become the actual bb + // 1. SB < NP < BB, then NP should be the actual BB + // 2. NP > SB > BB, then NP should be the actual BB (a ring) + // 3. SB > BB > NP, then NP should be the actual BB (a ring) + for (new_bb_id, new_bb_pos) in waitbbs { + let new_bb = self .player_map - .get_mut(&id) - .ok_or(errors::internal_player_not_found())?; - wp.status = PlayerStatus::Wait; + .get_mut(&new_bb_id) + .ok_or_else(|| errors::internal_player_not_found())?; + if sb_pos < new_bb_pos && new_bb_pos < bb_pos { + new_bb.status = PlayerStatus::Wait; + break; + } else if sb_pos > bb_pos && (new_bb_pos > sb_pos || bb_pos > new_bb_pos) { + new_bb.status = PlayerStatus::Wait; + break; + } } - player_pos.sort_by(|(.., pos1), (.., pos2)| pos1.cmp(pos2)); } - println!("Sorted players with one potential new bb {player_pos:?}"); - let player_order: Vec = player_pos.into_iter().map(|(id, ..)| id).collect(); - self.player_order = player_order; Ok(()) } @@ -1638,10 +1605,6 @@ impl Holdem { self.reset_state()?; self.fill_player_chips_with_deposits(); - let next_btn = self.get_next_btn()?; - effect.info(format!("Game starts and next BTN: {}", next_btn)); - self.btn = next_btn; - // Only start game when there are at least two available players if self .player_map @@ -1650,8 +1613,15 @@ impl Holdem { .count() >= 2 { + // Move btn + self.move_btn()?; + effect.info(format!("Next BTN = {}", self.btn)); + // Process Waitbb players - self.arrange_waitbbs(next_btn)?; + self.arrange_waitbbs()?; + + // Arrange players to set the player order + self.arrange_players(self.btn)?; // Prepare randomness (shuffling cards) let rnd_spec = RandomSpec::deck_of_cards(); @@ -1853,7 +1823,6 @@ impl GameHandler for Holdem { } Event::RandomnessReady { .. } => { - self.arrange_players(self.btn)?; for (idx, id) in self.player_order.iter().enumerate() { effect.assign(self.deck_random_id, *id, vec![idx * 2, idx * 2 + 1])?; self.hand_index_map.insert(*id, vec![idx * 2, idx * 2 + 1]); diff --git a/base/tests/waitbb_tests.rs b/base/tests/waitbb_tests.rs index b62b8cb..55746cc 100644 --- a/base/tests/waitbb_tests.rs +++ b/base/tests/waitbb_tests.rs @@ -337,7 +337,7 @@ fn test_wait_waitbb_headsup() -> HandleResult<()> { assert_eq!(alice.status, PlayerStatus::Wait); assert_eq!(bob.status, PlayerStatus::Wait); assert_eq!(game.btn, 6); - assert_eq!(game.player_order, vec![3, 6]); + assert_eq!(game.player_order, vec![6, 3]); } Ok(()) @@ -418,7 +418,7 @@ fn test_one_wait_multi_waitbbs() -> HandleResult<()> { // Test the game scenario where all eligbile players are with `Waitbb` status // They should all be added to the game with `Wait` status #[test] -fn test_multi_waitbbs_without_wait() -> HandleResult<()> { +fn test_no_waits_multi_waitbbs() -> HandleResult<()> { // players let alice = Player { id: 3, position: 6, status: PlayerStatus::Waitbb, ..Player::default() }; let bob = Player { id: 6, position: 3, status: PlayerStatus::Waitbb, ..Player::default() }; @@ -475,8 +475,7 @@ fn test_multi_waitbbs_without_wait() -> HandleResult<()> { let _carol = game.player_map.get(&8).unwrap(); let _dave = game.player_map.get(&9).unwrap(); assert!(game.player_map.values().all(|p| matches!(p.status, PlayerStatus::Wait))); - assert_eq!(game.btn, 4); - // TODO: test who are sb and bb, respectively? + assert_eq!(game.btn, 6); } Ok(()) @@ -484,7 +483,7 @@ fn test_multi_waitbbs_without_wait() -> HandleResult<()> { // No `Waitbb` players so `Wait` players move on as usual #[test] -fn test_multi_wait_without_waitbb() -> HandleResult<()> { +fn test_multi_waits_no_waitbbs() -> HandleResult<()> { // players let alice = Player { id: 3, position: 6, status: PlayerStatus::Wait, ..Player::default() }; let bob = Player { id: 6, position: 3, status: PlayerStatus::Wait, ..Player::default() }; @@ -543,6 +542,76 @@ fn test_multi_wait_without_waitbb() -> HandleResult<()> { Ok(()) } +#[test] +fn test_first_waitbb_between_btn_and_sb() -> HandleResult<()> { + // players + let a1 = Player { id: 1, position: 1, status: PlayerStatus::Waitbb, ..Player::default() }; + let b2 = Player { id: 2, position: 2, status: PlayerStatus::Wait, ..Player::default() }; + let c3 = Player { id: 3, position: 3, status: PlayerStatus::Waitbb, ..Player::default() }; + let d4 = Player { id: 4, position: 4, status: PlayerStatus::Waitbb, .. Player::default() }; + let e5 = Player { id: 5, position: 5, status: PlayerStatus::Wait, .. Player::default() }; + let f6 = Player { id: 6, position: 6, status: PlayerStatus::Wait, .. Player::default() }; + + let player_map = BTreeMap::from([ + (1, a1), + (2, b2), + (3, c3), + (4, d4), + (5, e5), + (6, f6) + ]); + + // snapshot of game state + let mut game = Holdem { + hand_id: 1, + deck_random_id: 1, + max_deposit: 2000, + sb: 100, + bb: 200, + ante: 20, + min_raise: 1000, + btn: 5, // current btn; next btn will be 6 + rake: 10, + rake_cap: 25, + stage: HoldemStage::Play, + street: Street::Preflop, + street_bet: 200, + board: Vec::::with_capacity(5), + hand_index_map: BTreeMap::>::new(), + bet_map: BTreeMap::::new(), + total_bet_map: BTreeMap::::new(), + prize_map: BTreeMap::::new(), + player_map, + player_order: vec![], + pots: Vec::::new(), + acting_player: None, + winners: Vec::::new(), + display: Vec::::new(), + mode: GameMode::Cash, + table_size: 6, + hand_history: HandHistory::default(), + next_game_start: 0, + rake_collected: 0, + }; + + { + let event = Event::GameStart; + let mut effect = Effect::default(); + game.handle_event(&mut effect, event).unwrap(); + + assert_eq!(game.btn, 6); + // Without waitbbs, players are supposed to act in the below order + // [b2, e5, f6] or [2, 5, 6] or [sb, bb, btn] + // a1 should be skipped and c3 should be the new bb: + // [b2, c3, e5, f6] or [2, 3, 5, 6] + assert_eq!(game.player_map.get(&1).unwrap().status, PlayerStatus::Waitbb); + assert_eq!(game.player_map.get(&4).unwrap().status, PlayerStatus::Waitbb); + assert_eq!(game.player_order, [2, 3, 5, 6]); + } + + Ok(()) +} + // When there are multiple waitbbs, the one near (right to) the btn/sb plays first. // This test covers the case where NP > SB > BB #[test] @@ -675,8 +744,8 @@ fn test_multi_waitbbs_play_order2() -> HandleResult<()> { assert_eq!(game.btn, 5); // Without waitbbs, players are supposed to act in the below order // [f6, c3, d4, e5] or [6, 3, 4, 5] or [sb, bb, utg, btn] - // Since a1 and b2 are all eligible for the actual bb, the actual order is - // [f5, a1, c3, d4, e5] or [6, 1, 3, 4, 5] + // a1 and b2 are all eligible for the actual bb, but a1 is the near (to btn) + // [f6, a1, c3, d4, e5] or [6, 1, 3, 4, 5] assert_eq!(game.player_order, [6, 1, 3, 4, 5]); } @@ -687,7 +756,7 @@ fn test_multi_waitbbs_play_order2() -> HandleResult<()> { // Suppose next btn is 6 and thus a1 will be sb // b2 (Suts) buys in early but joins the table late (thus further from btn) // c3 (Loso) buys in late but joins the table early (thus near btn) -// In such case, c3 should become the actual big blind +// In such case, c3 (near sb/btn) should become the actual big blind #[test] fn test_multi_waitbbs_play_order3() -> HandleResult<()> { // players