From 8d1934a985cbb40b28ad02fbbc774f300c90bc91 Mon Sep 17 00:00:00 2001 From: jianrontan <116509027+jianrontan@users.noreply.github.com> Date: Mon, 18 May 2026 17:25:00 +0800 Subject: [PATCH] Fix heads-up blind assignment and betting order - Assign the small blind to the button seat in heads-up play; keep the seat left of the button for 3+ player games - Detect heads-up via the count of chip-holding players taken before ante zeroing, so a game shrinking from 3 to 2 players switches correctly - Apply the fix in both blind code paths: dealer.py and emulator.py - Seed postflop action from the seat left of the button (small blind for 3+ players, big blind heads-up) - Seed preflop action from the seat left of the big blind (UTG for 3+ players, small blind/button heads-up) - Guard the __publish_messages result in play_round before unpacking - Update heads-up test expectations that encoded the old behavior; add coverage for a 3-to-2 player transition into a correct heads-up round --- pypokerengine/api/emulator.py | 6 ++- pypokerengine/engine/dealer.py | 10 ++++- pypokerengine/engine/round_manager.py | 10 ++--- tests/pypokerengine/api/emulator_test.py | 47 ++++++++++++----------- tests/pypokerengine/api/game_test.py | 12 +++--- tests/pypokerengine/engine/dealer_test.py | 13 ++++--- 6 files changed, 57 insertions(+), 41 deletions(-) diff --git a/pypokerengine/api/emulator.py b/pypokerengine/api/emulator.py index 6cb1c3f..5501302 100644 --- a/pypokerengine/api/emulator.py +++ b/pypokerengine/api/emulator.py @@ -171,12 +171,16 @@ def exclude_short_of_money_players(table, ante, sb_amount): def _steal_money_from_poor_player(table, ante, sb_amount): players = table.seats.players + # count players holding chips before ante zeroing to detect heads-up + pre_ante_active = len([p for p in players if p.stack > 0]) # exclude player who cannot pay ante for player in [p for p in players if p.stack < ante]: player.stack = 0 if players[table.dealer_btn].stack == 0: table.shift_dealer_btn() search_targets = players + players + players - search_targets = search_targets[table.dealer_btn+1:table.dealer_btn+1+len(players)] + # heads-up: dealer button posts the small blind. otherwise sb sits left of the button + sb_offset = 0 if pre_ante_active == 2 else 1 + search_targets = search_targets[table.dealer_btn+sb_offset:table.dealer_btn+sb_offset+len(players)] # exclude player who cannot pay small blind sb_player = _find_first_elligible_player(search_targets, sb_amount + ante) sb_relative_pos = search_targets.index(sb_player) diff --git a/pypokerengine/engine/dealer.py b/pypokerengine/engine/dealer.py index 524ebb4..7fa4f07 100644 --- a/pypokerengine/engine/dealer.py +++ b/pypokerengine/engine/dealer.py @@ -45,7 +45,9 @@ def play_round(self, round_count, blind_amount, ante, table): while True: self.__message_check(msgs, state["street"]) if state["street"] != Const.Street.FINISHED: # continue the round - action, bet_amount = self.__publish_messages(msgs) + result = self.__publish_messages(msgs) + assert(result is not None) # ongoing round always ends on an ask message + action, bet_amount = result state, msgs = RoundManager.apply_action(state, action, bet_amount) else: # finish the round after publish round result self.__publish_messages(msgs) @@ -111,12 +113,16 @@ def __exclude_short_of_money_players(self, table, ante, sb_amount): def __steal_money_from_poor_player(self, table, ante, sb_amount): players = table.seats.players + # count players holding chips before ante zeroing to detect heads-up + pre_ante_active = len([p for p in players if p.stack > 0]) # exclude player who cannot pay ante for player in [p for p in players if p.stack < ante]: player.stack = 0 if players[table.dealer_btn].stack == 0: table.shift_dealer_btn() search_targets = players + players + players - search_targets = search_targets[table.dealer_btn+1:table.dealer_btn+1+len(players)] + # heads-up: dealer button posts the small blind. otherwise sb sits left of the button + sb_offset = 0 if pre_ante_active == 2 else 1 + search_targets = search_targets[table.dealer_btn+sb_offset:table.dealer_btn+sb_offset+len(players)] # exclude player who cannot pay small blind sb_player = self.__find_first_elligible_player(search_targets, sb_amount + ante) sb_relative_pos = search_targets.index(sb_player) diff --git a/pypokerengine/engine/round_manager.py b/pypokerengine/engine/round_manager.py index 5c2cdf3..8c5d32c 100644 --- a/pypokerengine/engine/round_manager.py +++ b/pypokerengine/engine/round_manager.py @@ -70,12 +70,12 @@ def __deal_holecard(self, deck, players): @classmethod def __start_street(self, state): - next_player_pos = state["table"].next_ask_waiting_player_pos(state["table"].sb_pos()-1) - state["next_player"] = next_player_pos street = state["street"] if street == Const.Street.PREFLOP: return self.__preflop(state) - elif street == Const.Street.FLOP: + # postflop the first player to act sits left of the button (sb in 3+ player, bb heads-up) + state["next_player"] = state["table"].next_ask_waiting_player_pos(state["table"].dealer_btn) + if street == Const.Street.FLOP: return self.__flop(state) elif street == Const.Street.TURN: return self.__turn(state) @@ -88,8 +88,8 @@ def __start_street(self, state): @classmethod def __preflop(self, state): - for i in range(2): - state["next_player"] = state["table"].next_ask_waiting_player_pos(state["next_player"]) + # preflop the first player to act sits left of the big blind (utg in 3+ player, sb heads-up) + state["next_player"] = state["table"].next_ask_waiting_player_pos(state["table"].bb_pos()) return self.__forward_street(state) @classmethod diff --git a/tests/pypokerengine/api/emulator_test.py b/tests/pypokerengine/api/emulator_test.py index 718272b..b0a2824 100644 --- a/tests/pypokerengine/api/emulator_test.py +++ b/tests/pypokerengine/api/emulator_test.py @@ -42,24 +42,26 @@ def test_blind_structure(self): game_state = attach_hole_card_from_deck(game_state, "pwtwlmfciymjdoljkhagxa") self.emu.set_game_rule(2, 10, 5, 0) self.emu.set_blind_structure({5: { "ante": 5, "small_blind": 60 } }) - p1 = TestPlayer([("fold", 0), ('raise', 55), ('call', 0)]) - p2 = TestPlayer([("call", 15), ("call", 55), ('fold', 0)]) + p1 = TestPlayer([]) + p2 = TestPlayer([("fold", 0), ("fold", 0)]) self.emu.register_player("tojrbxmkuzrarnniosuhct", p1) self.emu.register_player("pwtwlmfciymjdoljkhagxa", p2) game_state, events = self.emu.run_until_round_finish(game_state) - self.eq(65, game_state["table"].seats.players[0].stack) - self.eq(135, game_state["table"].seats.players[1].stack) + self.eq(120, game_state["table"].seats.players[0].stack) + self.eq(80, game_state["table"].seats.players[1].stack) game_state, events = self.emu.start_new_round(game_state) game_state, events = self.emu.run_until_round_finish(game_state) - self.eq(120, game_state["table"].seats.players[0].stack) - self.eq(80, game_state["table"].seats.players[1].stack) + self.eq(125, game_state["table"].seats.players[0].stack) + self.eq(75, game_state["table"].seats.players[1].stack) + # round 5 uses the updated blind level (small_blind 60); player[1] + # can no longer cover the big blind, so the game finishes. game_state, events = self.emu.start_new_round(game_state) self.eq("event_game_finish", events[0]["type"]) - self.eq(0, game_state["table"].seats.players[0].stack) - self.eq(80, game_state["table"].seats.players[1].stack) + self.eq(125, game_state["table"].seats.players[0].stack) + self.eq(0, game_state["table"].seats.players[1].stack) def test_blind_structure_update(self): self.emu.set_game_rule(2, 8, 5, 3) @@ -143,8 +145,8 @@ def test_apply_action_start_next_round(self): game_state, events = self.emu.apply_action(game_state, "raise", 20) self.eq("event_ask_player", events[-1]["type"]) - self.eq(100, game_state["table"].seats.players[0].stack) - self.eq(70, game_state["table"].seats.players[1].stack) + self.eq(110, game_state["table"].seats.players[0].stack) + self.eq(60, game_state["table"].seats.players[1].stack) @raises(Exception) def test_apply_action_when_game_finished(self): @@ -166,7 +168,7 @@ def test_run_until_round_finish(self): game_state = attach_hole_card_from_deck(game_state, "pwtwlmfciymjdoljkhagxa") self.emu.set_game_rule(2, 10, 5, 0) p1 = TestPlayer([("fold", 0)]) - p2 = TestPlayer([("call", 15)]) + p2 = TestPlayer([("call", 15), ("fold", 0)]) self.emu.register_player("tojrbxmkuzrarnniosuhct", p1) self.emu.register_player("pwtwlmfciymjdoljkhagxa", p2) @@ -181,7 +183,7 @@ def test_run_until_round_finish_when_already_finished(self): game_state = attach_hole_card_from_deck(game_state, "pwtwlmfciymjdoljkhagxa") self.emu.set_game_rule(2, 10, 5, 0) p1 = TestPlayer([("fold", 0)]) - p2 = TestPlayer([("call", 15)]) + p2 = TestPlayer([("call", 15), ("fold", 0)]) self.emu.register_player("tojrbxmkuzrarnniosuhct", p1) self.emu.register_player("pwtwlmfciymjdoljkhagxa", p2) game_state, events = self.emu.run_until_round_finish(game_state) @@ -220,8 +222,8 @@ def test_run_until_game_finish(self): game_state, events = self.emu.run_until_game_finish(game_state) self.eq("event_game_finish", events[-1]["type"]) - self.eq(114, game_state["table"].seats.players[0].stack) - self.eq(86, game_state["table"].seats.players[1].stack) + self.eq(126, game_state["table"].seats.players[0].stack) + self.eq(74, game_state["table"].seats.players[1].stack) def test_run_until_game_finish_when_one_player_is_left(self): uuids = ["ruypwwoqwuwdnauiwpefsw", "sqmfwdkpcoagzqxpxnmxwm", "uxrdiwvctvilasinweqven"] @@ -230,9 +232,10 @@ def test_run_until_game_finish_when_one_player_is_left(self): game_state = reduce(lambda state, item: attach_hole_card(state, item[0], item[1]), zip(uuids, holecards), game_state) sb_amount, ante = 5, 7 self.emu.set_game_rule(3, 10, sb_amount, ante) - p1_acts = [("fold",0), ("call", 10), ('call', 0), ('call', 10), ("fold",0)] + # p3 raises big in round 3 (heads-up, p3=btn/SB); p1 folds out of chips by round 5 + p1_acts = [("fold",0), ("fold",0), ("fold",0)] p2_acts = [] - p3_acts = [("raise", 10)] + p3_acts = [("raise", 200)] players = [TestPlayer(acts) for acts in [p1_acts, p2_acts, p3_acts]] [self.emu.register_player(uuid, player) for uuid, player in zip(uuids, players)] game_state["table"].deck.deck.append(Card.from_str("C7")) @@ -240,7 +243,7 @@ def test_run_until_game_finish_when_one_player_is_left(self): self.eq("event_game_finish", events[-1]["type"]) self.eq(0, game_state["table"].seats.players[0].stack) self.eq(0, game_state["table"].seats.players[1].stack) - self.eq(292, game_state["table"].seats.players[2].stack) + self.eq(294, game_state["table"].seats.players[2].stack) def test_run_until_game_finish_when_final_round(self): uuids = ["ruypwwoqwuwdnauiwpefsw", "sqmfwdkpcoagzqxpxnmxwm", "uxrdiwvctvilasinweqven"] @@ -287,11 +290,11 @@ def test_start_new_round(self): self.eq(4, game_state["round_count"]) self.eq(1, game_state["table"].dealer_btn) self.eq(0, game_state["street"]) - self.eq(0, game_state["next_player"]) + self.eq(1, game_state["next_player"]) self.eq("event_new_street", events[0]["type"]) self.eq("event_ask_player", events[1]["type"]) self.eq("preflop", events[0]["street"]) - self.eq("tojrbxmkuzrarnniosuhct", events[1]["uuid"]) + self.eq("pwtwlmfciymjdoljkhagxa", events[1]["uuid"]) def test_start_new_round_exclude_no_money_players(self): uuids = ["ruypwwoqwuwdnauiwpefsw", "sqmfwdkpcoagzqxpxnmxwm", "uxrdiwvctvilasinweqven"] @@ -378,9 +381,9 @@ def test_generate_initial_game_state(self): state, events = self.emu.start_new_round(state) self.eq(0, state["table"].dealer_btn) - self.eq(1, state["table"].sb_pos()) - self.eq(0, state["table"].bb_pos()) - self.eq(1, state["next_player"]) + self.eq(0, state["table"].sb_pos()) + self.eq(1, state["table"].bb_pos()) + self.eq(0, state["next_player"]) state, events = self.emu.apply_action(state, "call", 10) self.eq(1, state["next_player"]) diff --git a/tests/pypokerengine/api/game_test.py b/tests/pypokerengine/api/game_test.py index 65462c5..d4cdc5b 100644 --- a/tests/pypokerengine/api/game_test.py +++ b/tests/pypokerengine/api/game_test.py @@ -13,9 +13,9 @@ def test_start_poker(self): result = G.start_poker(config) p1, p2 = [result["players"][i] for i in range(2)] self.eq("p1", p1["name"]) - self.eq(110, p1["stack"]) + self.eq(90, p1["stack"]) self.eq("p2", p2["name"]) - self.eq(90, p2["stack"]) + self.eq(110, p2["stack"]) def test_start_poker_with_ante(self): config = G.setup_config(1, 100, 10, 15) @@ -24,9 +24,9 @@ def test_start_poker_with_ante(self): result = G.start_poker(config) p1, p2 = [result["players"][i] for i in range(2)] self.eq("p1", p1["name"]) - self.eq(125, p1["stack"]) + self.eq(75, p1["stack"]) self.eq("p2", p2["name"]) - self.eq(75, p2["stack"]) + self.eq(125, p2["stack"]) def test_set_blind_structure(self): config = G.setup_config(1, 100, 10) @@ -35,8 +35,8 @@ def test_set_blind_structure(self): config.set_blind_structure({ 1: { "ante":5, "small_blind":10 } }) result = G.start_poker(config) p1, p2 = [result["players"][i] for i in range(2)] - self.eq(115, p1["stack"]) - self.eq(85, p2["stack"]) + self.eq(85, p1["stack"]) + self.eq(115, p2["stack"]) def test_start_poker_validation_when_no_player(self): config = G.setup_config(1, 100, 10) diff --git a/tests/pypokerengine/engine/dealer_test.py b/tests/pypokerengine/engine/dealer_test.py index 250c5f2..13f8713 100644 --- a/tests/pypokerengine/engine/dealer_test.py +++ b/tests/pypokerengine/engine/dealer_test.py @@ -45,10 +45,11 @@ def test_publish_msg(self): "receive_round_result_message" ] + # heads-up: btn (player[1]) posts the small blind and is asked to act first for i, expected in enumerate(first_player_expected): - self.eq(expected, algos[0].received_msgs[i]) - for i, expected in enumerate(second_player_expected): self.eq(expected, algos[1].received_msgs[i]) + for i, expected in enumerate(second_player_expected): + self.eq(expected, algos[0].received_msgs[i]) def test_play_a_round(self): algos = [FoldMan() for _ in range(2)] @@ -57,8 +58,9 @@ def test_play_a_round(self): self.dealer.table.dealer_btn = 1 summary = self.dealer.start_game(1) player_state = summary["message"]["game_information"]["seats"] - self.eq(95, player_state[0]["stack"]) - self.eq(105, player_state[1]["stack"]) + # heads-up: btn (player[1]) posts small blind, player[0] is the big blind + self.eq(105, player_state[0]["stack"]) + self.eq(95, player_state[1]["stack"]) def test_play_two_round(self): algos = [FoldMan() for _ in range(2)] @@ -113,8 +115,9 @@ def test_exclude_short_of_money_player_when_ante_on(self): self.eq(fetch_stacks(result), [1060, 0, 0, 1025, 40]) result = dealer.start_game(3) self.eq(fetch_stacks(result), [1100, 0, 0, 985, 0]) + # round 4 is heads-up (only p0 and p3 remain); p3 (btn) posts SB per HU rules result = dealer.start_game(4) - self.eq(fetch_stacks(result), [1060, 0, 0, 1025, 0]) + self.eq(fetch_stacks(result), [1140, 0, 0, 945, 0]) def test_exclude_short_of_money_player_when_ante_on2(self): dealer = Dealer(5, 100, 20)