Files
texas_hold_x/texas_holdem/models.py
mamamiyear c0bc5384f4 feat: add hand detail API and enrich hand summary fields
- HandSummary: add hole_cards, starting_stacks, ending_stacks, pot_contributions
- Engine: capture all players' hole cards (not just showdown), pre/post hand stacks, per-level pot contributions
- Server: new GET /game/<game_id>/hands/<hand_number> route
- Service: add get_hand_state() method
- Tests: add ServerTests for new endpoint, update existing tests
- Existing GET /game/<game_id> auto-inherits new fields via shared to_dict()
2026-05-23 22:11:45 +08:00

241 lines
7.6 KiB
Python

from __future__ import annotations
from dataclasses import dataclass, field
from time import time
from typing import Any
from texas_holdem.cards import Card
from texas_holdem.evaluator import HandValue
@dataclass(slots=True)
class PlayerAction:
action: str
amount: int = 0
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "PlayerAction":
return cls(str(payload.get("action", "")).lower(), int(payload.get("amount") or 0))
def to_dict(self) -> dict[str, object]:
return {"action": self.action, "amount": self.amount}
@dataclass(slots=True)
class PlayerState:
player_id: str
name: str
stack: int
seat: int
hole_cards: list[Card] = field(default_factory=list)
folded: bool = False
all_in: bool = False
in_hand: bool = False
street_bet: int = 0
total_bet: int = 0
def reset_for_hand(self) -> None:
self.hole_cards = []
self.folded = False
self.all_in = False
self.in_hand = self.stack > 0
self.street_bet = 0
self.total_bet = 0
def reset_for_street(self) -> None:
self.street_bet = 0
def commit(self, amount: int) -> int:
committed = max(0, min(amount, self.stack))
self.stack -= committed
self.street_bet += committed
self.total_bet += committed
if self.stack == 0 and self.in_hand and not self.folded:
self.all_in = True
return committed
def public_dict(self) -> dict[str, object]:
return {
"player_id": self.player_id,
"name": self.name,
"seat": self.seat,
"stack": self.stack,
"folded": self.folded,
"all_in": self.all_in,
"in_hand": self.in_hand,
"street_bet": self.street_bet,
"total_bet": self.total_bet,
}
@dataclass(slots=True)
class ActionRecord:
hand_number: int
street: str
player_id: str
action: str
amount: int
street_bet: int
stack: int
def to_dict(self) -> dict[str, object]:
return {
"hand_number": self.hand_number,
"street": self.street,
"player_id": self.player_id,
"action": self.action,
"amount": self.amount,
"street_bet": self.street_bet,
"stack": self.stack,
}
@dataclass(slots=True)
class Observation:
game_id: str
hand_number: int
street: str
player_id: str
seat: int
button_seat: int
small_blind: int
big_blind: int
board: list[Card]
hole_cards: list[Card]
players: list[dict[str, object]]
pot: int
to_call: int
min_raise_to: int | None
legal_actions: list[dict[str, object]]
action_history: list[ActionRecord]
def to_dict(self) -> dict[str, object]:
return {
"game_id": self.game_id,
"hand_number": self.hand_number,
"street": self.street,
"player_id": self.player_id,
"seat": self.seat,
"button_seat": self.button_seat,
"small_blind": self.small_blind,
"big_blind": self.big_blind,
"board": [str(card) for card in self.board],
"hole_cards": [str(card) for card in self.hole_cards],
"players": self.players,
"pot": self.pot,
"to_call": self.to_call,
"min_raise_to": self.min_raise_to,
"legal_actions": self.legal_actions,
"action_history": [record.to_dict() for record in self.action_history],
}
@dataclass(slots=True)
class BlindLevel:
"""A snapshot of the blind configuration that took effect at a given hand.
The structure is intentionally append-only: every time the blinds change
(or the very first hand seeds the initial values) we push a new
``BlindLevel`` so callers can reconstruct how the stakes evolved over the
course of the game without losing any prior state.
"""
hand_number: int
small_blind: int
big_blind: int
def to_dict(self) -> dict[str, object]:
return {
"hand_number": self.hand_number,
"small_blind": self.small_blind,
"big_blind": self.big_blind,
}
@dataclass(slots=True)
class PotAward:
amount: int
winners: list[str]
hand_value: HandValue | None
def to_dict(self) -> dict[str, object]:
return {
"amount": self.amount,
"winners": self.winners,
"hand_value": self.hand_value.to_dict() if self.hand_value else None,
}
@dataclass(slots=True)
class HandSummary:
game_id: str
hand_number: int
button_seat: int
board: list[Card]
actions: list[ActionRecord]
awards: list[PotAward]
# ``blinds`` records the exact blind level used by this hand. Storing it
# on the summary (rather than only on the game) guarantees historical
# hands remain self-describing even after the blinds are raised later.
blinds: BlindLevel | None = None
hole_cards: dict[str, list[Card]] = field(default_factory=dict)
starting_stacks: dict[str, int] = field(default_factory=dict)
ending_stacks: dict[str, int] = field(default_factory=dict)
pot_contributions: list[dict[str, Any]] = field(default_factory=list)
showdown_hands: dict[str, list[Card]] = field(default_factory=dict)
started_at: float = field(default_factory=time)
finished_at: float = field(default_factory=time)
def to_dict(self) -> dict[str, object]:
return {
"game_id": self.game_id,
"hand_number": self.hand_number,
"button_seat": self.button_seat,
"blinds": self.blinds.to_dict() if self.blinds else None,
"board": [str(card) for card in self.board],
"actions": [record.to_dict() for record in self.actions],
"awards": [award.to_dict() for award in self.awards],
"hole_cards": {
player_id: [str(card) for card in cards]
for player_id, cards in self.hole_cards.items()
},
"starting_stacks": dict(self.starting_stacks),
"ending_stacks": dict(self.ending_stacks),
"pot_contributions": [
self._pot_contribution_to_dict(contribution)
for contribution in self.pot_contributions
],
# ``showdown_hands`` is only populated when more than one player
# remained eligible for a pot; empty dict means the hand ended
# without a showdown (e.g. everyone folded but the winner).
"showdown_hands": {
player_id: [str(card) for card in cards]
for player_id, cards in self.showdown_hands.items()
},
"started_at": self.started_at,
"finished_at": self.finished_at,
}
@staticmethod
def _pot_contribution_to_dict(contribution: dict[str, Any]) -> dict[str, object]:
hand_value = contribution.get("hand_value")
if isinstance(hand_value, HandValue):
hand_value = hand_value.to_dict()
elif isinstance(hand_value, dict):
hand_value = dict(hand_value)
raw_contributors = contribution.get("contributors") or {}
contributors = {
str(player_id): int(amount)
for player_id, amount in dict(raw_contributors).items()
}
return {
"amount": int(contribution.get("amount") or 0),
"contributors": contributors,
"winners": [
str(player_id)
for player_id in contribution.get("winners", [])
],
"hand_value": hand_value,
}