From 7071eaa12b244058453834f695b6066b26d46555 Mon Sep 17 00:00:00 2001 From: jianghaiying Date: Tue, 30 Sep 2025 18:26:06 +0800 Subject: [PATCH] shortdeck1.1 --- .../__pycache__/card.cpython-313.pyc | Bin 2984 -> 2984 bytes .../__pycache__/game_stage.cpython-313.pyc | Bin 0 -> 4069 bytes .../__pycache__/simulation.cpython-313.pyc | Bin 4532 -> 13667 bytes shortdeck_arena/card.py | 2 + shortdeck_arena/game_stage.py | 91 +++++++ shortdeck_arena/hand_evaluator.py | 115 +++++++++ shortdeck_arena/hand_ranking.py | 57 +++++ shortdeck_arena/simulation.py | 240 +++++++++++++++++- .../__pycache__/arena_adapter.cpython-313.pyc | Bin 5233 -> 8238 bytes shortdeck_server/arena_adapter.py | 145 ++++++++--- shortdeck_server/game_stage.py | 42 +++ shortdeck_server/main.py | 2 +- shortdeck_server/random_cli.py | 10 +- shortdeck_server/tests/test_game.py | 20 +- 14 files changed, 665 insertions(+), 59 deletions(-) create mode 100644 shortdeck_arena/__pycache__/game_stage.cpython-313.pyc create mode 100644 shortdeck_arena/game_stage.py create mode 100644 shortdeck_arena/hand_evaluator.py create mode 100644 shortdeck_arena/hand_ranking.py create mode 100644 shortdeck_server/game_stage.py diff --git a/shortdeck_arena/__pycache__/card.cpython-313.pyc b/shortdeck_arena/__pycache__/card.cpython-313.pyc index 3e09a519884cb1bb658096cce1148062ae55bf8e..bb2f87c24939850cd9d04217c79113c35c14d695 100644 GIT binary patch delta 19 ZcmZ1>zCxVqGcPX}0}xDFw~=ceHvlzH1w;S< delta 19 ZcmZ1>zCxVqGcPX}0}wRK-^ewO8vrzz1tS0e diff --git a/shortdeck_arena/__pycache__/game_stage.cpython-313.pyc b/shortdeck_arena/__pycache__/game_stage.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8653e8f2a73c73f807cc7c477e93f709e82b49e1 GIT binary patch literal 4069 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 literal 0 HcmV?d00001 diff --git a/shortdeck_arena/__pycache__/simulation.cpython-313.pyc b/shortdeck_arena/__pycache__/simulation.cpython-313.pyc index 1009cfcc5c478d98d59e3c06c22ce46434c94d9d..b0ea0ebd89588da097e573c6fb3122cea4b1ee76 100644 GIT binary patch literal 13667 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 T5+f5M;k(*6jol#t@}&O&N2FOh diff --git a/shortdeck_arena/card.py b/shortdeck_arena/card.py index a57bf94..3da1e94 100644 --- a/shortdeck_arena/card.py +++ b/shortdeck_arena/card.py @@ -50,3 +50,5 @@ class Card: for s in Suit: cards.append(Card(r, s)) return cards + + diff --git a/shortdeck_arena/game_stage.py b/shortdeck_arena/game_stage.py new file mode 100644 index 0000000..8406d57 --- /dev/null +++ b/shortdeck_arena/game_stage.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from enum import Enum +from typing import List, Optional + + +class GameStage(Enum): + PREFLOP = "preflop" + FLOP = "flop" + TURN = "turn" + RIVER = "river" + SHOWDOWN = "showdown" + FINISHED = "finished" + + def __str__(self) -> str: + return self.value + + @classmethod + def get_next_stage(cls, current: 'GameStage') -> Optional['GameStage']: + stage_order = [cls.PREFLOP, cls.FLOP, cls.TURN, cls.RIVER, cls.SHOWDOWN, cls.FINISHED] + current_idx = stage_order.index(current) + if current_idx < len(stage_order) - 1: + return stage_order[current_idx + 1] + return None + + def get_board_card_count(self) -> int: + if self == GameStage.PREFLOP: + return 0 + elif self == GameStage.FLOP: + return 3 + elif self == GameStage.TURN: + return 4 + elif self in (GameStage.RIVER, GameStage.SHOWDOWN): + return 5 + return 0 + + +class PlayerState(Enum): + ACTIVE = "active" # 可行动 + FOLDED = "folded" # 已弃牌 + ALLIN = "allin" # 全下 + CALLED = "called" # 已跟注 + RAISE = "raised" # 已加注 + WAITING = "waiting" # 等待其他玩家 + OUT = "out" # 出局 + + def __str__(self) -> str: + return self.value + + +class BlindConfig: + # 对抗玩法是否需要前注? + def __init__(self, small_blind: int = 1, big_blind: int = 2, ante: int = 0): + self.small_blind = small_blind + self.big_blind = big_blind + self.ante = ante + + + def get_sb_position(self, num_players, dealer_position) -> int: + """ + 获取小盲位置 + heads-up时小盲为庄位 + """ + if num_players == 2: + return dealer_position # heads-up: 小盲 = 庄位 + else: + return (dealer_position + 1) % num_players + + def get_bb_position(self, num_players, dealer_position) -> int: + """ + 获取大盲位置 + """ + if num_players == 2: + return (dealer_position + 1) % 2 # heads-up: 大盲 = 庄位 + return (dealer_position + 2) % num_players # 多人: 大盲为庄位后两位 + + def get_first_to_act(self, stage: GameStage, num_players) -> int: + """ + 获取首个行动玩家位置 + 区分preflop和postflop + 1. preflop: 大盲后第一位 (UTG) + 2. postflop: 小盲位先行动 + """ + if stage == GameStage.PREFLOP: + if num_players == 2: + return self.get_sb_position(num_players) # heads-up: 小盲先行动 + else: # preflop: 大盲后第一位 + return (self.get_bb_position(num_players) + 1) % num_players + else: + # flop/river/turn: 小盲位先行动 + return self.get_sb_position(num_players) \ No newline at end of file diff --git a/shortdeck_arena/hand_evaluator.py b/shortdeck_arena/hand_evaluator.py new file mode 100644 index 0000000..455f884 --- /dev/null +++ b/shortdeck_arena/hand_evaluator.py @@ -0,0 +1,115 @@ +from typing import List, Tuple, Dict +from collections import Counter +from itertools import combinations +from .card import Card, Rank, Suit +from .hand_ranking import HandRanking, HandType + + +class HandEvaluator: + @staticmethod + def evaluateHand(cards) -> HandRanking: + """ + 从7张牌中找出最好的5张牌组合 + """ + if len(cards) != 7: + raise ValueError(f"Expected 7 cards, got {len(cards)}") + + best_ranking = None + best_cards = None + + # 所有可能的5张牌组合 + for five_cards in combinations(cards, 5): + ranking = HandEvaluator.evaluate5Cards(list(five_cards)) + + if best_ranking is None or ranking > best_ranking: + best_ranking = ranking + best_cards = list(five_cards) + best_ranking.cards = best_cards + return best_ranking + + @staticmethod + def evaluate5Cards(cards) -> HandRanking: + + if len(cards) != 5: + raise ValueError(f"Expected 5 cards, got {len(cards)}") + + # 按点数排序(降序) + sorted_cards = sorted(cards, key=lambda c: c.rank.numeric_value, reverse=True) + ranks = [card.rank for card in sorted_cards] + suits = [card.suit for card in sorted_cards] + + # 统计点数出现次数 + rank_counts = Counter(ranks) + count_values = sorted(rank_counts.values(), reverse=True) + + # 同花 + is_flush = len(set(suits)) == 1 + + # 顺子 + is_straight, straight_high = HandEvaluator._isStraight(ranks) + + # 根据牌型返回相应的HandRanking + if is_straight and is_flush: + if straight_high == Rank.ACE and ranks == [Rank.ACE, Rank.KING, Rank.QUEEN, Rank.JACK, Rank.TEN]: + return HandRanking(HandType.ROYAL_FLUSH, [Rank.ACE], sorted_cards) # 皇家同花顺 + else: + return HandRanking(HandType.STRAIGHT_FLUSH, [straight_high], sorted_cards) # 同花顺 + + elif count_values == [4, 1]: # 四条 + quad_rank = [rank for rank, count in rank_counts.items() if count == 4][0] + kicker = [rank for rank, count in rank_counts.items() if count == 1][0] + return HandRanking(HandType.FOUR_OF_A_KIND, [quad_rank, kicker], sorted_cards) + + elif count_values == [3, 2]: # 三条+一对 + trips_rank = [rank for rank, count in rank_counts.items() if count == 3][0] + pair_rank = [rank for rank, count in rank_counts.items() if count == 2][0] + return HandRanking(HandType.FULL_HOUSE, [trips_rank, pair_rank], sorted_cards) + + elif is_flush: # 同花 + return HandRanking(HandType.FLUSH, ranks, sorted_cards) + + elif is_straight: # 顺子 + return HandRanking(HandType.STRAIGHT, [straight_high], sorted_cards) + + elif count_values == [3, 1, 1]: # 三条 + trips_rank = [rank for rank, count in rank_counts.items() if count == 3][0] + kickers = sorted([rank for rank, count in rank_counts.items() if count == 1], reverse=True) + return HandRanking(HandType.THREE_OF_A_KIND, [trips_rank] + kickers, sorted_cards) + + elif count_values == [2, 2, 1]: # 两对 + pairs = sorted([rank for rank, count in rank_counts.items() if count == 2], reverse=True) + kicker = [rank for rank, count in rank_counts.items() if count == 1][0] + return HandRanking(HandType.TWO_PAIR, pairs + [kicker], sorted_cards) + + elif count_values == [2, 1, 1, 1]: # 一对 + pair_rank = [rank for rank, count in rank_counts.items() if count == 2][0] + kickers = sorted([rank for rank, count in rank_counts.items() if count == 1], reverse=True) + return HandRanking(HandType.ONE_PAIR, [pair_rank] + kickers, sorted_cards) + + else: # 高牌 + return HandRanking(HandType.HIGH_CARD, ranks, sorted_cards) + + @staticmethod + def _isStraight(ranks: List[Rank]) -> Tuple[bool, Rank]: + + values = sorted([rank.numeric_value for rank in ranks], reverse=True) + + is_regular_straight = True + for i in range(1, len(values)): + if values[i-1] - values[i] != 1: + is_regular_straight = False + break + + if is_regular_straight: + # 返回最高牌 + highest_rank = None + for rank in ranks: + if rank.numeric_value == values[0]: + highest_rank = rank + break + return True, highest_rank + + if values == [14, 5, 4, 3, 2]: # A, 5, 4, 3, 2 + return True, Rank.FIVE + + return False, None \ No newline at end of file diff --git a/shortdeck_arena/hand_ranking.py b/shortdeck_arena/hand_ranking.py new file mode 100644 index 0000000..9a29768 --- /dev/null +++ b/shortdeck_arena/hand_ranking.py @@ -0,0 +1,57 @@ +from enum import Enum +from typing import List, Tuple +from .card import Card, Rank + + +class HandType(Enum): + HIGH_CARD = (1, "High Card") + ONE_PAIR = (2, "Pair") + TWO_PAIR = (3, "Two Pair") + THREE_OF_A_KIND = (4, "Three of a Kind") + STRAIGHT = (5, "Straight") + FLUSH = (6, "Flush") + FULL_HOUSE = (7, "Full House") + FOUR_OF_A_KIND = (8, "Four of a Kind") + STRAIGHT_FLUSH = (9, "Straight Flush") + ROYAL_FLUSH = (10, "Royal Flush") + + def __new__(cls, strength, name): + obj = object.__new__(cls) + obj._value_ = strength + obj.strength = strength + obj.type_name = name + return obj + + + +class HandRanking: + + def __init__(self, hand_type: HandType, key_ranks: List[Rank], cards: List[Card]): + self.hand_type = hand_type + self.key_ranks = key_ranks # 用于比较的关键点数 + self.cards = cards # 组成这个ranking的5张牌 + + def __str__(self): + if self.hand_type == HandType.FOUR_OF_A_KIND: + return f"Quad({self.key_ranks[0].symbol})" + elif self.hand_type == HandType.FULL_HOUSE: + return f"Full House({self.key_ranks[0].symbol} over {self.key_ranks[1].symbol})" + elif self.hand_type == HandType.FLUSH: + return f"Flush({self.key_ranks[0].symbol} high)" + elif self.hand_type == HandType.STRAIGHT: + return f"Straight({self.key_ranks[0].symbol} high)" + elif self.hand_type == HandType.STRAIGHT_FLUSH: + if self.key_ranks[0] == Rank.ACE: + return "Royal Flush" + else: + return f"Straight Flush({self.key_ranks[0].symbol} high)" + elif self.hand_type == HandType.ROYAL_FLUSH: + return "Royal Flush" + elif self.hand_type == HandType.THREE_OF_A_KIND: + return f"Three of a Kind({self.key_ranks[0].symbol})" + elif self.hand_type == HandType.TWO_PAIR: + return f"Two Pair({self.key_ranks[0].symbol} and {self.key_ranks[1].symbol})" + elif self.hand_type == HandType.ONE_PAIR: + return f"Pair({self.key_ranks[0].symbol})" + else: + return f"High Card({self.key_ranks[0].symbol})" diff --git a/shortdeck_arena/simulation.py b/shortdeck_arena/simulation.py index ef39d8c..8b6a390 100644 --- a/shortdeck_arena/simulation.py +++ b/shortdeck_arena/simulation.py @@ -9,26 +9,100 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from .agent import Agent from .card import Card +from .game_stage import GameStage, PlayerState, BlindConfig class Simulation: - def __init__(self, agents: List[Agent]): + def __init__(self, agents: List[Agent], blind_config: Optional[BlindConfig] = None): self.agents = agents self.history: List[Dict] = [] self.cards: List[Card] = [] self.saved = False + + # 游戏状态管理 + self.current_stage = GameStage.PREFLOP + self.player_states: List[PlayerState] = [PlayerState.ACTIVE] * len(agents) + self.current_turn = 0 + self.betting_round_complete = False + + # 盲注配置 + self.blind_config = blind_config or BlindConfig() + + # 筹码和底池管理 + self.pot: List[int] = [0] * len(agents) # 每个玩家在当前轮的投入 + self.total_pot = 0 + self.last_raise_amount = 0 + self.min_raise = self.blind_config.big_blind + self.dealer_position = -1 + self.new_round() def new_round(self): self.history = [] self.cards = Card.all_short() - random.shuffle(self.cards) + random.shuffle(self.cards) # 洗牌 self.saved = False + + # 重置游戏状态 + self.current_stage = GameStage.PREFLOP + self.player_states = [PlayerState.ACTIVE] * len(self.agents) + self.betting_round_complete = False + + # 重置下注状态 + self.pot = [0] * len(self.agents) + self.total_pot = 0 + self.last_raise_amount = 0 + self.min_raise = self.blind_config.big_blind + + # 设置盲注 + self._setup_blinds() - def player_cards(self, pid: int) -> List[Card]: - return self.cards[pid * 2 : pid * 2 + 2] + # 庄家位置 + self.dealer_position = random.choice(range(len(self.agents))) + + def _setup_blinds(self): + """设置盲注""" + num_players = len(self.agents) + + # 至少需要2个玩家才能设置盲注 + if num_players < 2: + self.current_turn = 0 if num_players > 0 else 0 + return - def board_cards(self, street: str) -> List[Card]: + sb_pos = self.blind_config.get_sb_position(num_players,self.dealer_position) + bb_pos = self.blind_config.get_bb_position(num_players,self.dealer_position) + + # 确保位置有效 + if sb_pos >= num_players or bb_pos >= num_players: + self.current_turn = 0 + return + + # 扣除小盲 + self.pot[sb_pos] = self.blind_config.small_blind + self.total_pot += self.blind_config.small_blind + self.history.append({ + "pid": sb_pos, + "action": "small_blind", + "amount": self.blind_config.small_blind + }) + + # 扣除大盲 + self.pot[bb_pos] = self.blind_config.big_blind + self.total_pot += self.blind_config.big_blind + self.history.append({ + "pid": bb_pos, + "action": "big_blind", + "amount": self.blind_config.big_blind + }) + + # 首个行动玩家 + self.current_turn = self.blind_config.get_first_to_act(self.current_stage, num_players) + self.last_raise_amount = self.blind_config.big_blind + + def player_cards(self, pid) -> List[Card]: + return self.cards[pid * 2 : (pid * 2 + 2)] + + def board_cards(self, street) -> List[Card]: nplayers = len(self.agents) idx_start = nplayers * 2 if street == "flop": @@ -39,11 +113,159 @@ class Simulation: return self.cards[idx_start: idx_start + 5] return [] - def node_info(self) -> Dict: - return {"bet_min": 1, "bet_max": 100} + def get_current_max_bet(self) -> int: + """ + 获取当前最高下注额, 用于计算跟注金额 + """ + return max(self.pot) if self.pot else 0 + + def get_call_amount(self, pid) -> int: + """ + 计算玩家跟注所需金额 + """ + if pid >= len(self.pot): + return 0 + max_pot = self.get_current_max_bet() + return max(0, max_pot - self.pot[pid]) + + def is_betting_round_complete(self) -> bool: + """ + 检查当前下注轮是否完成 + """ + active_players = [i for i, state in enumerate(self.player_states) + if state in (PlayerState.ACTIVE, PlayerState.CALLED)] + + if len(active_players) <= 1: + return True + + # 检查所有active玩家是否都已投入相同金额 + max_pot = self.get_current_max_bet() + for i in active_players: # allin玩家不要求跟注 + if self.pot[i] < max_pot and self.player_states[i] != PlayerState.ALLIN: + return False + return True + + def advance_to_next_street(self): + if self.current_stage == GameStage.FINISHED: + return + + next_stage = GameStage.get_next_stage(self.current_stage) + if next_stage is None: + self.current_stage = GameStage.FINISHED + return + + self.current_stage = next_stage + + # 重置下注轮状态 + self.betting_round_complete = False + + #### 重置pot为累计投入 (不清零,为了计算边池) - def apply_action(self, pid: int, action: str, amount: Optional[int] = None): + # 重置行动状态 + for i, state in enumerate(self.player_states): + if state == PlayerState.CALLED: + self.player_states[i] = PlayerState.ACTIVE + + # 设置首个行动玩家 + num_players = len(self.agents) + self.current_turn = self.blind_config.get_first_to_act(self.current_stage, num_players) + self.last_raise_amount = 0 + self.min_raise = self.blind_config.big_blind + + def get_next_active_player(self, start_pos) -> Optional[int]: + for i in range(len(self.agents)): + pos = (start_pos + i) % len(self.agents) + if self.player_states[pos] == PlayerState.ACTIVE: + return pos + return None + + def node_info(self) -> Dict: + if self.current_turn >= len(self.pot): + return {"bet_min": self.min_raise, "bet_max": 0, "call_amount": 0} + # 跟注 + call_amount = self.get_call_amount(self.current_turn) + return { + "bet_min": max(self.min_raise, call_amount + self.min_raise), + "bet_max": 100, ########## 需要从ArenaGame获取实际大小 + "call_amount": call_amount + } + + def apply_action(self, pid, action, amount): + """ + 应用玩家动作 + """ + if pid != self.current_turn: + raise ValueError(f"不是玩家 {pid} 的回合") + + if self.player_states[pid] not in (PlayerState.ACTIVE,): + raise ValueError(f"玩家 {pid} 无法行动,当前状态: {self.player_states[pid]}") + + action = action.lower() + self.history.append({"pid": pid, "action": action, "amount": amount}) + + if action == "fold": + self.player_states[pid] = PlayerState.FOLDED + + elif action == "call": + call_amount = self.get_call_amount(pid) + if call_amount == 0: + # check + self.history[-1]["action"] = "check" + else: + self.pot[pid] += call_amount + self.total_pot += call_amount + self.player_states[pid] = PlayerState.CALLED + + elif action == "check": + call_amount = self.get_call_amount(pid) + if call_amount > 0: + raise ValueError("跟注金额>0, 无法过牌,需要跟注或弃牌") + self.player_states[pid] = PlayerState.CALLED + + elif action in ("bet", "raise"): + if amount is None: + raise ValueError(f"{action} 需要指定金额") + # 加注需要补齐跟注金额 + call_amount = self.get_call_amount(pid) + total_amount = call_amount + (amount or 0) + + self.pot[pid] += total_amount + self.total_pot += total_amount + + if amount and amount > 0: + self.last_raise_amount = amount + self.min_raise = amount + self.player_states[pid] = PlayerState.CALLED + + # 其他跟注的玩家 + for i, state in enumerate(self.player_states): + if i != pid and state == PlayerState.CALLED: + self.player_states[i] = PlayerState.ACTIVE + + else: + raise ValueError(f"未知动作: {action}") + + # 下一个玩家 + self._advance_turn() + + def _advance_turn(self): + """ + 推进回合 + """ + # 检查下注轮是否完成 + if self.is_betting_round_complete(): + self.betting_round_complete = True + self.advance_to_next_street() + else: + # 找到下一个可行动玩家 + next_player = self.get_next_active_player(self.current_turn + 1) + if next_player is not None: + self.current_turn = next_player + else: + # 没有玩家需要行动,结束下注轮 + self.betting_round_complete = True + self.advance_to_next_street() def to_save_data(self) -> Dict: players = [f"Agent{a.pid}" for a in self.agents] @@ -60,6 +282,6 @@ class Simulation: if path is None: path = Path.cwd() / "shortdeck_arena_history.jsonl" with path.open("a", encoding="utf-8") as f: - f.write(json.dumps(self.to_save_data(), ensure_ascii=False)) + f.write(json.dumps(self.to_save_data())) f.write("\n") self.saved = True diff --git a/shortdeck_server/__pycache__/arena_adapter.cpython-313.pyc b/shortdeck_server/__pycache__/arena_adapter.cpython-313.pyc index 00a859a200bd846cfcd31c31e91b6d135131673d..6f4923232fc0b02f6a80b4102a1393c050d7f1f8 100644 GIT binary patch literal 8238 zcmcgxeQ;Y0{qi4gl_3} z|L8gI=}EF9L-&vNCcf|7uX8`nIrsd|)t$03CxK*XzI`%KL&(2j!AKUJVD2mcagl^c zm>DG_3}pgHn?_9|W@;X>P|JvwTD3a!sBOef?HX+vWk(#;q0!b+=ZK5CG}<=Gjg--{ z5jS;@c&JCKvyYb3@@mpZ!mN*k9sMSKX1Zm@9&~Hv zs^$JfEUmIf=dhYjLO07XK7A6t&TwLOKB;xb((wE&FYcca#59a`{aG?0#t)>#>BNlc z7~*GzaGIYHRM)X2e_EhWNDE^zeGz(Y)<4Xh=YY6KP7un32{nZoYOW)7B<#W*zFgSk zBad4iXQ(Ay7B(YItzmcA6Sn5+ZBQx?+j1rQ2{ybXY{yc6Za(VJ>fj`b>YPyL4LjD= zxx!A$%~*mJs$)MDL>~9?1qWPW)*LJR*x(OV&)m5N#6{9YA|#3RTGC34QJN@E=1!iF zi9j;9X9CiOF*$7@I*0rWlV$nQePJeSy4MVS?MB_?mOSM(sPU}^Rrod%WB2{J)c_Zl z(LO_h<}r#(L;3PzS_pEg4Fo2pC6$}gWEK@+_mb+C(mYKk#F;1%u~VvRmS2eKJtWmE zC1zEd)*w;TdzG6)MTo|9O)yJos#@mf6LHmwd7o;Z0j|+RJZM!dl8~IHxOQ5JLtWA= zpG-zI2C8E!F{6_bo=gCf8j25`cq&CtNppNmI3b-((R5shohndKNuW;(^aPL6i}G=P zE-lcVbEj1{8cm3abTq1Z^OD_Z)Zu|kw~uCv>ey3XrtjZ)Kc zovl`aJD`bOvysLo?DMsa)V8g--Z?J&hqTU6QC)MT@9Myd13%rDscKua{Div$TuCgi z15iMzYufWOkjq6fnb(yF887aK10fZpH=frQy#@1uw+P`us*`#B2s6eAf%)I^7EPt+ z9(NRGGN?$Ne}GKp^(kU9W)(qO$I)aqq>{YM#;5?;!?=6H5Ga-$MaIkT?pfe8Sz)x6 zUJ2BeuovToEVhz}sfh$lV`rRcekv&hrstDMFcWi95Gl@~YpwGkJ~=NOq%=jrg68G~ zF;4N^D4Hj&!c0?f7O85??tv_5p|$W)Eofw_c@7OkH$RjzDmx8eyW{axLX4u#s1*gp z%~#ezAL$BY&ypK1_hLHh3CNy6#?`R=V79qmZtl;x`fqssmrrE8?Wc z=3&OUzKFR<%}cJcl)4+720#n=ZQA-b%!S2LBO_+K?~cL_L~Fz}?zwwd!3BhwiGw@f z+BKBdm^DC-OcCNDV4`_rShtIB*}K65&Y+nPFz4d^=gl1V6^HXV0=hl19amiXL?h-_ zXgdNKt_EaagFky6i7-=t4sMW%Fp_D|G%%o_(z-R4GSb{-sh#Q`I3-hn9PNXd?>;0l zj;6OE6)vv|@(?STx`?BA@PZ8EQgJY&6aMj?-MVrXNs!Px0H%1n|<7k;1 zlIYlEe8Yy0P>eW?HT=mdwTo;w5uI?tzXaKompq%syJsAj02wT8s;MRRIN4-y8E-MD z!gt^);P=F){DOs^233%b=!!5!y0A{`c(C}1hP;h%o1AHf!L{%;0FhWW@lRn3>Se?g zDGOUQ?JWyiw68173=D&2+9F8(6(NI+aS!3l0W$H{Ks&79eQXQcciQ2k|Af0K0swcD z6_=37COta~dorj^dX|t{Db0*HBesZr6COK_ISOCUNq4dJr2f4W*Mjv(Cea;cce$OGQ^C%K1~MA^<)}uhwtcb*ZQB{(BC~xf{uCgwx2)u z#;tRIef!$A+l%Ku`{C=ipZ)%)Z@+Z=`@hh@uWFy8!gMk<7j#lQDky8vSJM2p%Ebjf zDNsO;abZE_V)GPWWjdOkry{uQR9dwH@`WJB&d0PEDvz9FJdI1LYbpibs9vy73F#<6 zXH_Q>;)6lE>WU%YqsGvv3O^)0)05`ek=i;#3xE`NKDw9y1 zQ@kYP;+1k3is49_CZ^_5Oht#m#OMKFs-MxlxF`?*B%eqk+|!5CA*@9?!)c)}Mrc-) zbd+Y%mg+75G%TZ)O8*Q#5~2!E#kqxZ3pZ=(ukKsgr_|JEYdYncPQ~X}0uA`Ts#@9B zsMORdfkvgi;mb7X_PXF!GFFpNnZSUD5i~E(H-oKAsYkIrw z&9=8Y-|Won9?J9#T~1$JSX#LH%+fO}`(OOa-yL7&{0isGat$)q@bdO$e#IvTAILNg zeasDiX(e@iUptAn>ILa?-_^ZKdoxw7A8%;`&YtoYde8qwrlRE|Pm9vfB{%e(w_I{v za9!U2yc>pjgKI3Q_APP>1WK+oo5fM5c&cSj)5?~W#7DuAx3*u4zkTY>Q<;I0kAovh zci$WAYizdrpxk}%oy7I-W10HnA9f#G+4Zt{dH>ICa`&;MTO3{W z9bI#hipoX1!d1dcmAjOxI>p}xL8`K6?;8uREoAo``EbvXOzjxtE5g-NS5C=$ zj>xrRUsiGD&Nae0od5OJ0fq#|nQv}*s_zg6WLQ)Cqr+=vp#Jz3M{17cTWUc^e>93M zwSW9-m?3pX8R}uyS;_<+olF(7&x5JX}HE^;RBsTi$Io0sX$e z@^DM_`@5~s`u<+-u*>{`lfm3=h1L%$xWn7cA2c$M$H2el6FOrvKC*lY`FBPLaG^{> zKRp$Pz?nxQGvi#Rk)&Z>gBWO?vYvMcB;4r225uNv{eQRCP2&%IN!XAkyb*JM&LRSv z?W=ynASg}-jJoj>c(m}S7xV0!{`$%pyO;{#3&@#Tc&!ecPR-K*La@$2 z4rOSifxwwc>=fXWRN&*M1Jm%{C!G}H`YWeNH3MQoqwytum;Lef--?azdo_KR2J2);6j<4uQi)4KF{O zZQdg{@5#9K;HcWZjJJQ4>j$2@`Zl7L?je95p`{SQq80OpHJt!k;H4EDbH`H0a!mHM zukIL>!7+PaxVQR}|APPdx<%Vf*Ot}F=8UU(rA2Pvchggu@w6)2+RocP>bHs`cvGdue800W`c^7fS{uXDQrkT}ZDv1eKR{S2OkLobGYI ze$Ve7YB#^vZUs69!pkS5!Q7cU=&-)iT|)1f&74%;i1WM&zJeA7Q@4Ko{H?P;`TV6H zgMI3TnzJ$8Ixzlth0#=3!e!|%nVP*6E29x2peers-6VW5d6s~j%eNv}Rw}kC2%FC? zWIas)Fjv_o?IH31dE1Ejb1~$z|8VP97jM6|yl%JfRzN6^>o3|X#!G5h!SEmic@?{S z%N84CPs1wP@GV@;q6OJTFQesAE01CXtgX(ixhL#p+%r+$^K*R~9I9p3)>138A`+T*(Z%#Lne4@S}T z2Q7)uLDRoLF!cqV`4_k=7>|vrIRSxQB~bJfW_aK_g}zRs;N=}3bf^}y+Ngl~r#CQT z&~^yg{!nK8`Z)>g9;Hd_kJu+@(=KD3;|YTceH5u#Y&YqBaqb*_#)-eGPU4TMj|R)| zcPM=Ehu@)`(dcX{J`aIrROO=4C+7KNuEi6LPA908h9A-dF%^x{2ISv?*>TLYYn`2= zsX2kBPZ!1enj|Wt7`dhsu_&L`V_rduMqeS?G3$N-u^q(>J(GmH`vFlN7*ay_u9-d7 zpi1Iu4Ga@LC9`^Olo^Y@RLI;@A7wMQ6YbKfyptj%}KK&>IQ#^vf7^L35+ zx`0-PBLezDaQdJZPholB(;|c?I%p#pX1R`e4x?}il&j;j#&UQmEEVy z?mqf76j2MMPRQWaW|&V%<7cE*Cas^4>RV*jC#3UV$%srw{)M>y(>7;f0$&l#HE#a{ Dc70u2 literal 5233 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 G)$?D+y#PW0 diff --git a/shortdeck_server/arena_adapter.py b/shortdeck_server/arena_adapter.py index 08d18fb..c0aa5e6 100644 --- a/shortdeck_server/arena_adapter.py +++ b/shortdeck_server/arena_adapter.py @@ -5,35 +5,43 @@ from pathlib import Path from shortdeck_arena.simulation import Simulation from shortdeck_arena.agent import HumanAgent +from shortdeck_arena.game_stage import BlindConfig, GameStage, PlayerState import uuid class ArenaGame: - def __init__(self, starting_stack: int = 1000, max_players: int = 6): + def __init__(self, starting_stack: int = 1000, max_players: int = 6, + small_blind: int = 1, big_blind: int = 2): self.agents = [] self.player_names: List[str] = [] self.starting_stack = starting_stack self.max_players = max_players self.sim: Optional[Simulation] = None + + # 筹码管理 self.stacks: List[int] = [] - self.current_turn: int = 0 - self.pot: int = 0 - self.game_id = [str(name) for name in self.player_names] + + # 盲注配置 + self.blind_config = BlindConfig(small_blind, big_blind, ante=0) + + # 游戏标识 + self.game_id = str(uuid.uuid4()) def join_game(self, name: str) -> int: if len(self.player_names) >= self.max_players: raise ValueError("table full") + pid = len(self.player_names) self.player_names.append(name) agent = HumanAgent(pid) self.agents.append(agent) self.stacks.append(self.starting_stack) - self.sim = Simulation(self.agents) + self.sim = Simulation(self.agents, self.blind_config) return pid - def info(self, player_id) -> Dict: - + def info(self, player_id: Optional[int] = None) -> Dict: + """获取游戏状态信息""" if not self.sim: return { "game_id": self.game_id, @@ -48,62 +56,123 @@ class ArenaGame: "board_cards": [], } + # 更新栈大小 (扣除已投入底池的金额) + updated_stacks = [] + for i, base_stack in enumerate(self.stacks): + pot_contribution = self.sim.pot[i] if i < len(self.sim.pot) else 0 + updated_stacks.append(max(0, base_stack - pot_contribution)) + + for i in range(len(self.stacks)): + if i < len(updated_stacks): + self.stacks[i] = updated_stacks[i] + player_cards = [] board_cards = [] + + # 获取玩家手牌 try: if player_id is not None and 0 <= player_id < len(self.agents): player_cards = [str(c) for c in self.sim.player_cards(player_id)] except Exception: player_cards = [] + # 获取公共牌 (根据当前阶段) try: - - board_cards = [str(c) for c in self.sim.board_cards("river")] + current_stage = self.sim.current_stage.value + board_cards = [str(c) for c in self.sim.board_cards(current_stage)] except Exception: board_cards = [] + + # 获取可用动作信息 + actions = {} + if (player_id is not None and player_id == self.sim.current_turn and + self.sim.current_stage != GameStage.FINISHED): + + call_amount = self.sim.get_call_amount(player_id) + available_stack = updated_stacks[player_id] if player_id < len(updated_stacks) else 0 + + # 更新node_info以包含实际栈信息 + node_info = self.sim.node_info() + node_info["bet_max"] = available_stack + + actions = { + "call_amount": call_amount, + "bet_min": node_info["bet_min"], + "bet_max": node_info["bet_max"], + "can_check": call_amount == 0, + "can_fold": True, + "can_call": call_amount > 0 and call_amount <= available_stack, + "can_bet": available_stack > call_amount, + } return { "game_id": self.game_id, "players": self.player_names, - "stacks": list(self.stacks), - "dealer_index": 0, - "current_turn": self.current_turn, - "pot": self.pot, - "stage": "preflop", - "actions": {"bet_min": 1, "bet_max": 100}, + "stacks": updated_stacks, + "dealer_index": 0, # 简化:固定庄家位置, (优化轮询) + "current_turn": self.sim.current_turn, + "pot": self.sim.total_pot, + "stage": self.sim.current_stage.value, + "actions": actions, "player_cards": player_cards, "board_cards": board_cards, + "player_states": [state.value for state in self.sim.player_states], } def apply_action(self, pid: int, action: str, amount: Optional[int] = None): if not self.sim: raise ValueError("no game") - if pid != self.current_turn: - raise ValueError("not your turn") - + + # 验证动作合法性 + if pid != self.sim.current_turn: + raise ValueError(f"not your turn, current turn: {self.sim.current_turn}") + + if self.sim.current_stage == GameStage.FINISHED: + raise ValueError("game already finished") + + # 获取玩家可用筹码 + pot_contribution = self.sim.pot[pid] if pid < len(self.sim.pot) else 0 + available_stack = self.stacks[pid] - pot_contribution + + # 预处理动作和金额 action = action.lower() - if action == "check": - pass - elif action == "bet": - if amount is None: - raise ValueError("bet requires amount") - if amount < 0: - raise ValueError("invalid amount") - if amount > self.stacks[pid]: - - amount = self.stacks[pid] - self.stacks[pid] -= amount - self.pot += amount - elif action == "fold": - self.stacks[pid] = 0 - else: - raise ValueError(f"unknown action: {action}") - - self.sim.apply_action(pid, action, amount) + + if action in ("bet", "raise") and amount is not None: + # 限制下注金额不超过可用筹码 + if amount > available_stack: + amount = available_stack + # 如果全下,可能需要标记为allin + if amount == available_stack and available_stack > 0: + self.sim.player_states[pid] = PlayerState.ALLIN + + elif action == "call": + # call动作的金额验证 + call_amount = self.sim.get_call_amount(pid) + if call_amount > available_stack: + # 不够跟注,自动allin + amount = available_stack + if available_stack > 0: + self.sim.player_states[pid] = PlayerState.ALLIN + + try: + self.sim.apply_action(pid, action, amount) + except ValueError as e: + raise ValueError(f"invalid action: {e}") + self.sim.dump_data(Path.cwd() / "shortdeck_arena_history.jsonl") - if len(self.agents) > 0: - self.current_turn = (self.current_turn + 1) % len(self.agents) + @property + def current_turn(self) -> int: + if not self.sim: + return 0 + return self.sim.current_turn + + @property + def pot(self) -> int: + if not self.sim: + return 0 + return self.sim.total_pot + @property def history(self) -> List[Dict]: if not self.sim: diff --git a/shortdeck_server/game_stage.py b/shortdeck_server/game_stage.py new file mode 100644 index 0000000..2010fd1 --- /dev/null +++ b/shortdeck_server/game_stage.py @@ -0,0 +1,42 @@ + + +class GameStage(Enum): + PREFLOP = "preflop" + FLOP = "flop" + TURN = "turn" + RIVER = "river" + SHOWDOWN = "showdown" + + # def setup_preflop(self): + # # 自动扣除小盲/大盲 + # # 设置首个行动玩家 + # pass + + def advance_street(self): + # 检查下注轮是否结束 + # 发放公共牌 + # 重置行动顺序 + pass + + # def get_call_amount(self, pid): + # # 计算跟注所需金额 + # pass + + # def get_min_raise(self, pid): + # # 计算最小加注金额 + # pass + + def handle_all_in(self, pid, amount): + # 创建边池 + # 标记玩家状态 + pass + + def evaluate_hand(self, hole_cards, board_cards): + # 短牌手牌强度排序 + # A-6 低顺特殊处理 + pass + + def determine_winners(self, active_players): + # 边池分配 + # 短牌规则下的比牌 + pass diff --git a/shortdeck_server/main.py b/shortdeck_server/main.py index 9bb5a6f..5ee71bb 100644 --- a/shortdeck_server/main.py +++ b/shortdeck_server/main.py @@ -18,7 +18,7 @@ class JoinResponse(BaseModel): class ActionRequest(BaseModel): player_id: int - action: str + action: str # "fold", "call", "raise", "check", "bet" amount: int | None = None diff --git a/shortdeck_server/random_cli.py b/shortdeck_server/random_cli.py index a083cbf..94032ed 100644 --- a/shortdeck_server/random_cli.py +++ b/shortdeck_server/random_cli.py @@ -12,13 +12,13 @@ BASE_PATH = "/get_game_state" APPLY_PATH = "/apply_action" -def fetch_game_state(base_url: str, player_id: int) -> Dict[str, Any]: +def fetch_game_state(base_url, player_id) -> Dict[str, Any]: resp = requests.get(f"{base_url.rstrip('/')}{BASE_PATH}", params={"player_id": player_id}, timeout=5) resp.raise_for_status() return resp.json() -def post_action(base_url: str, player_id: int, action: str, amount: Optional[int]): +def post_action(base_url, player_id, action, amount): payload = {"player_id": player_id, "action": action} if amount is not None: payload["amount"] = amount @@ -27,7 +27,7 @@ def post_action(base_url: str, player_id: int, action: str, amount: Optional[int return resp.json() -def choose_random_action(info: Dict[str, Any]) -> Optional[tuple[str, Optional[int]]]: +def choose_random_action(info) -> Optional[tuple[str, Optional[int]]]: actions = info.get("actions") if not actions: return None @@ -48,7 +48,7 @@ def choose_random_action(info: Dict[str, Any]) -> Optional[tuple[str, Optional[i return random.choice(choices) -def run_loop(base_url: str, player_id: int, interval: float = 2.0, seed: Optional[int] = None): +def run_loop(base_url, player_id, interval, seed): if seed is not None: random.seed(seed) @@ -77,7 +77,7 @@ def run_loop(base_url: str, player_id: int, interval: float = 2.0, seed: Optiona time.sleep(interval) -def main(argv: list[str] | None = None) -> int: +def main(argv) -> int: parser = argparse.ArgumentParser() parser.add_argument("--server", default="http://localhost:8000") parser.add_argument("--player_id", type=int, default=1) diff --git a/shortdeck_server/tests/test_game.py b/shortdeck_server/tests/test_game.py index 73a4e6a..6281683 100644 --- a/shortdeck_server/tests/test_game.py +++ b/shortdeck_server/tests/test_game.py @@ -4,23 +4,31 @@ from shortdeck_server.arena_adapter import ArenaGame def test_join_and_actions(): - g = ArenaGame(starting_stack=100, max_players=3) + g = ArenaGame(starting_stack=100, max_players=3, small_blind=1, big_blind=2) pid0 = g.join_game("aa") pid1 = g.join_game("bb") assert pid0 == 0 assert pid1 == 1 state = g.info() - assert state["stacks"] == [100, 100] + # 在短牌扑克中,玩家加入后盲注已自动扣除 + # 小盲(pid0): 100-1=99, 大盲(pid1): 100-2=98 + assert state["stacks"] == [99, 98] + + # 验证轮次管理:heads-up时小盲先行动 + assert g.current_turn == 0 + + # 测试错误的玩家尝试行动 try: g.apply_action(1, "fold") except ValueError as e: assert "not your turn" in str(e) - g.apply_action(0, "check") + # 小盲玩家call (跟注到大盲) + g.apply_action(0, "call") assert g.current_turn == 1 - g.apply_action(1, "bet", 10) - assert g.pot == 10 - assert g.stacks[1] == 90 + # 大盲玩家加注 + g.apply_action(1, "bet", 10) + assert g.pot >= 10 # 底池至少包含加注金额 assert g.history[-1]["action"] == "bet"