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()
This commit is contained in:
2026-05-23 22:11:45 +08:00
parent 5899ea0b89
commit c0bc5384f4
7 changed files with 233 additions and 6 deletions
+56 -5
View File
@@ -62,6 +62,7 @@ class TableGame:
self.board = []
self.action_history: list[ActionRecord] = []
self.hand_summaries: list[HandSummary] = []
self._last_pot_contributions: list[dict[str, object]] = []
# ``blind_history`` is an append-only log of every blind level change
# (including the initial one). Each entry's ``hand_number`` is the
# first hand that played under those stakes, which makes it trivial
@@ -116,6 +117,11 @@ class TableGame:
for player in self.players:
player.reset_for_hand()
starting_stacks = {
player.player_id: player.stack
for player in self.players
if player.in_hand
}
self._advance_button()
assert self.button_index is not None
@@ -127,6 +133,11 @@ class TableGame:
self._broadcast_game_update()
self._deal_hole_cards(deck)
hole_cards = {
player.player_id: list(player.hole_cards)
for player in self.players
if player.in_hand
}
small_blind_index, big_blind_index = self._blind_indexes()
self._post_blind(small_blind_index, "small_blind", self.small_blind)
self._post_blind(big_blind_index, "big_blind", self.big_blind)
@@ -150,6 +161,11 @@ class TableGame:
self._betting_round(street, start_index, self.big_blind)
awards = self._award_pots()
ending_stacks = {
player.player_id: player.stack
for player in self.players
if player.player_id in starting_stacks
}
summary = HandSummary(
game_id=self.game_id,
hand_number=self.hand_number,
@@ -158,6 +174,10 @@ class TableGame:
actions=list(self.action_history),
awards=awards,
blinds=active_blinds,
hole_cards=hole_cards,
starting_stacks=starting_stacks,
ending_stacks=ending_stacks,
pot_contributions=deepcopy(self._last_pot_contributions),
showdown_hands=self._collect_showdown_hands(),
started_at=started_at,
finished_at=time(),
@@ -557,21 +577,43 @@ class TableGame:
return current_bet, min_raise, full_raise
def _award_pots(self) -> list[PotAward]:
self._last_pot_contributions = []
total_pot = sum(player.total_bet for player in self.players)
live_players = [player for player in self.players if self._is_live(player)]
if not live_players or total_pot <= 0:
return []
if len(live_players) == 1:
live_players[0].stack += total_pot
return [PotAward(total_pot, [live_players[0].player_id], None)]
levels = sorted({player.total_bet for player in self.players if player.total_bet > 0})
if len(live_players) == 1:
winner = live_players[0]
winner.stack += total_pot
previous_level = 0
for level in levels:
contributors = [player for player in self.players if player.total_bet >= level]
pot_amount = (level - previous_level) * len(contributors)
self._last_pot_contributions.append(
{
"amount": pot_amount,
"contributors": {
player.player_id: level - previous_level
for player in contributors
},
"winners": [winner.player_id],
"hand_value": None,
}
)
previous_level = level
return [PotAward(total_pot, [winner.player_id], None)]
previous_level = 0
awards: list[PotAward] = []
for level in levels:
contributors = [player for player in self.players if player.total_bet >= level]
pot_amount = (level - previous_level) * len(contributors)
level_contributions = {
player.player_id: level - previous_level
for player in contributors
}
previous_level = level
contenders = [player for player in contributors if self._is_live(player)]
if not contenders or pot_amount <= 0:
@@ -593,13 +635,22 @@ class TableGame:
winner.stack += share
for winner in ordered_winners[:remainder]:
winner.stack += 1
winner_ids = [winner.player_id for winner in ordered_winners]
awards.append(
PotAward(
amount=pot_amount,
winners=[winner.player_id for winner in ordered_winners],
winners=winner_ids,
hand_value=best_value,
)
)
self._last_pot_contributions.append(
{
"amount": pot_amount,
"contributors": level_contributions,
"winners": winner_ids,
"hand_value": best_value,
}
)
return awards
def _collect_showdown_hands(self) -> dict[str, list]:
+37
View File
@@ -178,6 +178,10 @@ class HandSummary:
# 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)
@@ -191,6 +195,16 @@ class HandSummary:
"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).
@@ -201,3 +215,26 @@ class HandSummary:
"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,
}
+8
View File
@@ -28,6 +28,14 @@ class PokerRequestHandler(BaseHTTPRequestHandler):
if len(path) == 2 and path[0] in {"game", "games"}:
self._json(MANAGER.get_game_state(path[1]))
return
if len(path) == 4 and path[0] in {"game", "games"} and path[2] == "hands":
try:
hand_number = int(path[3])
except ValueError:
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
return
self._json(MANAGER.get_hand_state(path[1], hand_number))
return
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
except KeyError as exc:
self._json({"error": str(exc)}, HTTPStatus.NOT_FOUND)
+7
View File
@@ -78,6 +78,13 @@ class GameManager:
def get_game_state(self, game_id: str) -> dict[str, object]:
return self.get_game(game_id).snapshot_completed()
def get_hand_state(self, game_id: str, hand_number: int) -> dict[str, object]:
state = self.get_game_state(game_id)
for hand in state.get("hands", []):
if hand.get("hand_number") == hand_number:
return hand
raise KeyError(f"hand not found: {game_id} #{hand_number}")
def list_games(self) -> list[dict[str, object]]:
with self._lock:
games = list(self._games.values())