Girgie's picture
Deploy Token Hold'em Space
81c1867 verified
Raw
History Blame Contribute Delete
16.3 kB
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any
from token_holdem.cards import cards_label, new_deck
from token_holdem.evaluator import best_players, hand_summary
from token_holdem.logging_utils import get_logger, log_event
logger = get_logger("token_holdem.engine")
STARTING_STACK = 1_000
SMALL_BLIND = 10
BIG_BLIND = 20
class Street(StrEnum):
PREFLOP = "preflop"
FLOP = "flop"
TURN = "turn"
RIVER = "river"
SHOWDOWN = "showdown"
COMPLETE = "complete"
@dataclass
class PlayerState:
id: str
name: str
stack: int = STARTING_STACK
hole_cards: list[str] = field(default_factory=list)
folded: bool = False
all_in: bool = False
bet: int = 0
contributed: int = 0
is_human: bool = False
@dataclass
class HandResult:
winners: list[str]
pot: int
board: list[str]
hand_name: str
deltas: dict[str, int]
summary: str
showdown: dict[str, str] = field(default_factory=dict)
pot_awards: list[dict[str, Any]] = field(default_factory=list)
@dataclass
class GameState:
players: list[PlayerState]
dealer_index: int = 0
small_blind: int = SMALL_BLIND
big_blind: int = BIG_BLIND
seed: int | None = None
session_id: str = ""
hand_id: str = ""
orbit_id: str = ""
deck: list[str] = field(default_factory=list)
community_cards: list[str] = field(default_factory=list)
street: Street = Street.PREFLOP
current_bet: int = 0
current_player_index: int = 0
small_blind_index: int | None = None
big_blind_index: int | None = None
last_raise: int = BIG_BLIND
action_log: list[str] = field(default_factory=list)
acted_player_ids: set[str] = field(default_factory=set)
result: HandResult | None = None
starting_stacks: dict[str, int] = field(default_factory=dict)
def start_hand(self) -> None:
self.deck = new_deck(self.seed)
self.community_cards = []
self.street = Street.PREFLOP
self.current_bet = self.big_blind
self.last_raise = self.big_blind
self.action_log = []
self.acted_player_ids = set()
self.result = None
self.starting_stacks = {player.id: player.stack for player in self.players}
for player in self.players:
player.hole_cards = [self.deck.pop(), self.deck.pop()]
player.folded = False
player.all_in = False
player.bet = 0
player.contributed = 0
if len(self.active_players()) < 2:
raise ValueError("At least two players with chips are required")
sb_index = self.dealer_index if len(self.active_players()) == 2 else self.next_active_index(self.dealer_index)
bb_index = self.next_active_index(sb_index)
self.small_blind_index = sb_index
self.big_blind_index = bb_index
self.post_blind(sb_index, self.small_blind, "small blind")
self.post_blind(bb_index, self.big_blind, "big blind")
self.current_player_index = self.next_active_index(bb_index)
log_event(
logger,
"hand_started",
seed=self.seed,
session_id=self.session_id,
hand_id=self.hand_id,
orbit_id=self.orbit_id,
players=[player.name for player in self.players],
button=self.players[self.dealer_index].name,
small_blind=self.players[sb_index].name,
big_blind=self.players[bb_index].name,
pot=self.pot(),
)
def active_players(self) -> list[PlayerState]:
return [player for player in self.players if player.stack > 0 or player.contributed > 0]
def live_players(self) -> list[PlayerState]:
return [player for player in self.players if not player.folded]
def actionable_players(self) -> list[PlayerState]:
return [player for player in self.live_players() if not player.all_in and player.stack > 0]
def player(self, player_id: str) -> PlayerState:
return next(player for player in self.players if player.id == player_id)
def current_player(self) -> PlayerState:
return self.players[self.current_player_index]
def pot(self) -> int:
return sum(player.contributed for player in self.players)
def amount_to_call(self, player: PlayerState) -> int:
return max(0, self.current_bet - player.bet)
def legal_actions(self, player_id: str | None = None) -> dict[str, Any]:
player = self.player(player_id) if player_id else self.current_player()
to_call = min(self.amount_to_call(player), player.stack)
actions: list[str] = []
if to_call == 0:
actions.append("check")
else:
actions.extend(["fold", "call"])
if player.stack > to_call:
actions.append("raise")
if player.stack > 0:
actions.append("all_in")
min_raise_to = self.current_bet + self.last_raise
min_raise_amount = max(0, min_raise_to - player.bet)
half_pot = to_call + max(self.big_blind, self.pot() // 2)
pot_raise = to_call + max(self.big_blind, self.pot())
presets = {
"min": min(player.stack, max(min_raise_amount, to_call + self.big_blind)),
"half_pot": min(player.stack, max(half_pot, min_raise_amount)),
"pot": min(player.stack, max(pot_raise, min_raise_amount)),
"all_in": player.stack,
}
if "raise" not in actions:
presets = {"all_in": player.stack}
return {"actions": actions, "to_call": to_call, "raise_presets": presets}
def apply_action(self, action: str, amount: int = 0) -> None:
if self.street in {Street.SHOWDOWN, Street.COMPLETE} or self.result:
return
player = self.current_player()
legal = self.legal_actions(player.id)
requested_action = action
requested_amount = amount
action = self._repair_action(action, amount, legal)
amount = self._repair_amount(action, amount, legal)
log_event(
logger,
"action_applied",
player=player.name,
street=self.street.value,
requested_action=requested_action,
requested_amount=requested_amount,
action=action,
amount=amount,
legal=legal,
session_id=self.session_id,
hand_id=self.hand_id,
orbit_id=self.orbit_id,
pot_before=self.pot(),
stack_before=player.stack,
)
if action == "fold":
player.folded = True
self.acted_player_ids.add(player.id)
self.action_log.append(f"{player.name} folds")
elif action == "check":
self.acted_player_ids.add(player.id)
self.action_log.append(f"{player.name} checks")
elif action == "call":
paid = self.take_chips(player, legal["to_call"])
self.acted_player_ids.add(player.id)
self.action_log.append(f"{player.name} calls {paid}")
elif action == "raise":
previous_bet = player.bet
previous_current_bet = self.current_bet
paid = self.take_chips(player, amount)
self.current_bet = max(self.current_bet, player.bet)
self.last_raise = max(self.big_blind, self.current_bet - previous_bet)
self.acted_player_ids = {player.id}
if previous_current_bet == 0:
self.action_log.append(f"{player.name} bets {paid}")
else:
self.action_log.append(f"{player.name} raises to {player.bet}")
elif action == "all_in":
previous_bet = player.bet
previous_current_bet = self.current_bet
paid = self.take_chips(player, player.stack)
if player.bet > self.current_bet:
self.current_bet = player.bet
self.last_raise = max(self.big_blind, self.current_bet - previous_bet)
self.acted_player_ids = {player.id}
else:
self.acted_player_ids.add(player.id)
if player.bet > previous_current_bet:
self.action_log.append(f"{player.name} jams for {paid} more ({player.bet} total)")
else:
self.action_log.append(f"{player.name} calls all-in for {paid}")
self.advance_after_action()
def post_blind(self, index: int, amount: int, label: str) -> None:
player = self.players[index]
paid = self.take_chips(player, amount)
self.action_log.append(f"{player.name} posts {label} {paid}")
def take_chips(self, player: PlayerState, amount: int) -> int:
paid = min(max(0, amount), player.stack)
player.stack -= paid
player.bet += paid
player.contributed += paid
if player.stack == 0:
player.all_in = True
return paid
def advance_after_action(self) -> None:
if len(self.live_players()) == 1:
self.finish_without_showdown()
return
if self.betting_round_complete():
self.advance_street()
return
self.current_player_index = self.next_actionable_index(self.current_player_index)
def betting_round_complete(self) -> bool:
actionable = self.actionable_players()
if not actionable:
return True
return all(player.bet == self.current_bet and player.id in self.acted_player_ids for player in actionable)
def advance_street(self) -> None:
for player in self.players:
player.bet = 0
self.acted_player_ids = set()
self.current_bet = 0
self.last_raise = self.big_blind
if self.street == Street.PREFLOP:
self.community_cards.extend([self.deck.pop(), self.deck.pop(), self.deck.pop()])
self.street = Street.FLOP
self.action_log.append(f"Flop: {cards_label(self.community_cards)}")
elif self.street == Street.FLOP:
self.community_cards.append(self.deck.pop())
self.street = Street.TURN
self.action_log.append(f"Turn: {cards_label(self.community_cards)}")
elif self.street == Street.TURN:
self.community_cards.append(self.deck.pop())
self.street = Street.RIVER
self.action_log.append(f"River: {cards_label(self.community_cards)}")
elif self.street == Street.RIVER:
self.finish_showdown()
return
if not self.actionable_players():
self.advance_street()
return
self.current_player_index = self.next_actionable_index(self.dealer_index)
def finish_without_showdown(self) -> None:
winner = self.live_players()[0]
pot = self.pot()
winner.stack += pot
self.street = Street.COMPLETE
self._set_result([winner.id], pot, "No showdown", f"{winner.name} wins {pot} after everyone else folds.")
def finish_showdown(self) -> None:
live_players = self.live_players()
contenders = {player.id: player.hole_cards for player in live_players}
winners, _score, hand_name = best_players(contenders, self.community_cards)
showdown = {player.id: hand_summary(player.hole_cards, self.community_cards)[2] for player in self.live_players()}
pot = self.pot()
awards = self._award_showdown_pots()
result_winners = []
for award in awards:
for player_id in award["winners"]:
if player_id not in result_winners:
result_winners.append(player_id)
for award in awards:
share = award["amount"] // len(award["winners"])
remainder = award["amount"] % len(award["winners"])
for idx, player_id in enumerate(award["winners"]):
self.player(player_id).stack += share + (1 if idx < remainder else 0)
names = ", ".join(self.player(player_id).name for player_id in winners)
self.street = Street.COMPLETE
summary = f"{names} win {pot} with {hand_name}."
if len(awards) > 1 or set(awards[0]["winners"]) != set(winners):
parts = []
for award in awards:
award_names = ", ".join(self.player(player_id).name for player_id in award["winners"])
parts.append(f"{award_names} win {award['amount']} with {award['hand_name']}")
summary = "Side pots: " + "; ".join(parts) + "."
self._set_result(result_winners or winners, pot, hand_name, summary, showdown=showdown, pot_awards=awards)
def _award_showdown_pots(self) -> list[dict[str, Any]]:
awards: list[dict[str, Any]] = []
previous_level = 0
levels = sorted({player.contributed for player in self.players if player.contributed > 0})
for level in levels:
contributors = [player for player in self.players if player.contributed >= level]
amount = (level - previous_level) * len(contributors)
eligible = [player for player in contributors if not player.folded]
previous_level = level
if amount <= 0 or not eligible:
continue
contenders = {player.id: player.hole_cards for player in eligible}
winners, _score, hand_name = best_players(contenders, self.community_cards)
awards.append({"amount": amount, "winners": winners, "hand_name": hand_name})
return awards
def _set_result(self, winners: list[str], pot: int, hand_name: str, summary: str, showdown: dict[str, str] | None = None, pot_awards: list[dict[str, Any]] | None = None) -> None:
deltas = {player.id: player.stack - self.starting_stacks[player.id] for player in self.players}
self.result = HandResult(winners=winners, pot=pot, board=list(self.community_cards), hand_name=hand_name, deltas=deltas, summary=summary, showdown=showdown or {}, pot_awards=pot_awards or [])
self.action_log.append(summary)
log_event(
logger,
"hand_completed",
winners=winners,
session_id=self.session_id,
hand_id=self.hand_id,
orbit_id=self.orbit_id,
pot=pot,
hand_name=hand_name,
board=self.community_cards,
deltas=deltas,
showdown=showdown or {},
pot_awards=pot_awards or [],
summary=summary,
)
def next_active_index(self, start_index: int) -> int:
return self._next_index(start_index, lambda player: player.stack > 0)
def next_actionable_index(self, start_index: int) -> int:
return self._next_index(start_index, lambda player: not player.folded and not player.all_in and player.stack > 0)
def _next_index(self, start_index: int, predicate) -> int:
for offset in range(1, len(self.players) + 1):
idx = (start_index + offset) % len(self.players)
if predicate(self.players[idx]):
return idx
return start_index
def _repair_action(self, action: str, amount: int, legal: dict[str, Any]) -> str:
action = (action or "").lower().strip()
if action in legal["actions"]:
return action
if legal["to_call"] == 0:
return "check"
if "call" in legal["actions"]:
return "call"
return "fold"
def _repair_amount(self, action: str, amount: int, legal: dict[str, Any]) -> int:
if action != "raise":
return 0
presets = legal["raise_presets"]
legal_amounts = [value for key, value in presets.items() if key != "all_in" and value > legal["to_call"]]
if not legal_amounts:
return 0
if amount <= 0:
return min(legal_amounts)
return min(legal_amounts, key=lambda value: abs(value - amount))
def make_players(names: list[str], human_name: str | None = None) -> list[PlayerState]:
players: list[PlayerState] = []
if human_name:
players.append(PlayerState(id="human", name=human_name, is_human=True))
for idx, name in enumerate(names):
players.append(PlayerState(id=f"ai_{idx}", name=name))
return players