Texas Hold X
+diff --git a/README.md b/README.md index 5675072..0d6b8a5 100644 --- a/README.md +++ b/README.md @@ -126,3 +126,22 @@ AI Agent 会在终端输出: ```bash python -m unittest discover -v ``` + +## Web 回放与控制台 + +启动核心游戏服务后,可以单独启动 Web 回放服务: + +```bash +python -m texas_holdem_replay.server --host 127.0.0.1 --port 8088 +``` + +打开 `http://127.0.0.1:8088`。页面通过自身的代理接口访问核心服务, +避免浏览器跨域限制;它不会导入或耦合 `texas_holdem.engine` 内部代码。 + +页面支持: + +- 拉取 `GET /games/{game_id}` 快照并按 `hands[].actions` 生成逐帧回放。 +- 通过代理调用核心服务运行指定数量手牌。 +- 可选覆盖下一批手牌的大小盲。 +- 上传或粘贴静态 JSON 快照进行离线回放。 +- 自动轮询正在运行的游戏,保留当前历史查看位置。 diff --git a/pyproject.toml b/pyproject.toml index 45f6c8d..b91300b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [] texas-holdem-server = "texas_holdem.server:main" texas-holdem-human = "texas_holdem.human_client:main" texas-holdem-ai = "texas_holdem.ai_client:main" +texas-holdem-replay = "texas_holdem_replay.server:main" [tool.setuptools.package-data] texas_holdem_replay = ["static/*"] diff --git a/tests/test_replay_server.py b/tests/test_replay_server.py index fbdf9e9..878dddc 100644 --- a/tests/test_replay_server.py +++ b/tests/test_replay_server.py @@ -2,7 +2,7 @@ from __future__ import annotations import unittest -from texas_holdem_replay.server import build_game_url +from texas_holdem_replay.server import build_core_url, build_game_url class ReplayServerTests(unittest.TestCase): @@ -22,7 +22,12 @@ class ReplayServerTests(unittest.TestCase): with self.assertRaises(ValueError): build_game_url({"url": ["file:///tmp/game.json"]}) + def test_build_core_url_preserves_base_path(self) -> None: + self.assertEqual( + build_core_url("http://127.0.0.1:8000/api/", "/games/demo/hands/run"), + "http://127.0.0.1:8000/api/games/demo/hands/run", + ) + if __name__ == "__main__": unittest.main() - diff --git a/texas_holdem_replay/__init__.py b/texas_holdem_replay/__init__.py new file mode 100644 index 0000000..222c1b5 --- /dev/null +++ b/texas_holdem_replay/__init__.py @@ -0,0 +1,2 @@ +"""Standalone web replay viewer for Texas Hold X.""" + diff --git a/texas_holdem_replay/server.py b/texas_holdem_replay/server.py new file mode 100644 index 0000000..a84ef86 --- /dev/null +++ b/texas_holdem_replay/server.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import argparse +import json +import mimetypes +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qs, quote, urlencode, urlparse +from urllib.request import ProxyHandler, Request, build_opener + +STATIC_DIR = Path(__file__).resolve().parent / "static" +DEFAULT_CORE_BASE_URL = "http://127.0.0.1:8000" +NO_PROXY_OPENER = build_opener(ProxyHandler({})) + + +def build_game_url(params: dict[str, list[str]]) -> str: + """Build a core-service game URL from query parameters. + + Supported forms: + - ``url=https://host/games/demo`` for callers that already have a full URL. + - ``base_url=http://host&game_id=demo`` for the common local service case. + """ + raw_url = _first(params, "url") + if raw_url: + return _validate_http_url(raw_url) + + base_url = _first(params, "base_url") or DEFAULT_CORE_BASE_URL + game_id = _first(params, "game_id") + if not game_id: + raise ValueError("game_id is required when url is not provided") + + parsed = urlparse(_validate_http_url(base_url.rstrip("/"))) + base_path = parsed.path.rstrip("/") + game_path = f"{base_path}/games/{quote(game_id, safe='')}" + return parsed._replace(path=game_path, query="", fragment="").geturl() + + +def build_core_url(base_url: str, path: str) -> str: + parsed = urlparse(_validate_http_url(base_url.rstrip("/"))) + base_path = parsed.path.rstrip("/") + target_path = f"{base_path}/{path.lstrip('/')}" + return parsed._replace(path=target_path, query="", fragment="").geturl() + + +def _first(params: dict[str, list[str]], key: str) -> str | None: + values = params.get(key) + if not values: + return None + return values[0] + + +def _validate_http_url(value: str) -> str: + parsed = urlparse(value) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("url must be an absolute http(s) URL") + return value + + +class ReplayRequestHandler(BaseHTTPRequestHandler): + server_version = "TexasHoldemReplay/0.1" + + def do_GET(self) -> None: + parsed = urlparse(self.path) + if parsed.path == "/health": + self._json({"ok": True}) + return + if parsed.path == "/api/fetch-game": + self._handle_fetch_game(parse_qs(parsed.query)) + return + self._serve_static(parsed.path) + + def do_POST(self) -> None: + parsed = urlparse(self.path) + if parsed.path == "/api/create-game": + self._proxy_json_request("POST", "/games") + return + if parsed.path == "/api/run-hands": + try: + payload = self._read_json() + base_url = str(payload.pop("base_url", DEFAULT_CORE_BASE_URL)) + game_id = str(payload.pop("game_id", "")).strip() + if not game_id: + raise ValueError("game_id is required") + path = f"/games/{quote(game_id, safe='')}/hands/run" + self._proxy_json_request("POST", path, base_url=base_url, payload=payload) + except ValueError as exc: + self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + return + self._json({"error": "not found"}, HTTPStatus.NOT_FOUND) + + def log_message(self, format: str, *args: Any) -> None: + return + + def _handle_fetch_game(self, params: dict[str, list[str]]) -> None: + try: + target_url = build_game_url(params) + payload, status = self._request_json("GET", target_url) + self._json(payload, HTTPStatus(status)) + except ValueError as exc: + self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + except RuntimeError as exc: + self._json({"error": str(exc)}, HTTPStatus.BAD_GATEWAY) + + def _proxy_json_request( + self, + method: str, + path: str, + base_url: str | None = None, + payload: dict[str, Any] | None = None, + ) -> None: + try: + if payload is None: + payload = self._read_json() + base_url = str(payload.pop("base_url", DEFAULT_CORE_BASE_URL)) + target_url = build_core_url(base_url or DEFAULT_CORE_BASE_URL, path) + response_payload, status = self._request_json(method, target_url, payload) + self._json(response_payload, HTTPStatus(status)) + except ValueError as exc: + self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + except RuntimeError as exc: + self._json({"error": str(exc)}, HTTPStatus.BAD_GATEWAY) + + def _request_json( + self, + method: str, + url: str, + payload: dict[str, Any] | None = None, + ) -> tuple[dict[str, Any], int]: + data = None + headers = {"Accept": "application/json"} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + request = Request(url, data=data, headers=headers, method=method) + try: + with NO_PROXY_OPENER.open(request, timeout=20) as response: + raw = response.read().decode("utf-8") + status = response.status + except HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + status = exc.code + except (OSError, URLError) as exc: + raise RuntimeError(f"core service request failed: {url}") from exc + try: + parsed = json.loads(raw) if raw else {} + except json.JSONDecodeError as exc: + raise RuntimeError(f"core service returned invalid JSON: {url}") from exc + if not isinstance(parsed, dict): + raise RuntimeError("core service response must be a JSON object") + return parsed, status + + def _serve_static(self, path: str) -> None: + relative = "index.html" if path in {"", "/"} else path.lstrip("/") + if relative.startswith("static/"): + relative = relative[len("static/") :] + if "/" in relative: + safe_parts = [part for part in relative.split("/") if part not in {"", ".", ".."}] + relative = "/".join(safe_parts) + target = STATIC_DIR / relative + try: + target.relative_to(STATIC_DIR) + except ValueError: + self._json({"error": "not found"}, HTTPStatus.NOT_FOUND) + return + if not target.is_file(): + self._json({"error": "not found"}, HTTPStatus.NOT_FOUND) + return + content_type = mimetypes.guess_type(str(target))[0] or "application/octet-stream" + body = target.read_bytes() + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _read_json(self) -> dict[str, Any]: + length = int(self.headers.get("Content-Length", "0")) + if length <= 0: + return {} + try: + payload = json.loads(self.rfile.read(length).decode("utf-8")) + except json.JSONDecodeError as exc: + raise ValueError("request body must be valid JSON") from exc + if not isinstance(payload, dict): + raise ValueError("request body must be a JSON object") + return payload + + def _json(self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +def create_server(host: str, port: int) -> ThreadingHTTPServer: + return ThreadingHTTPServer((host, port), ReplayRequestHandler) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run the Texas Hold X web replay viewer.") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", default=8088, type=int) + args = parser.parse_args() + + server = create_server(args.host, args.port) + query = urlencode({"base_url": DEFAULT_CORE_BASE_URL}) + print(f"Texas Hold X replay viewer listening on http://{args.host}:{args.port}/?{query}") + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/texas_holdem_replay/static/app.js b/texas_holdem_replay/static/app.js new file mode 100644 index 0000000..e145848 --- /dev/null +++ b/texas_holdem_replay/static/app.js @@ -0,0 +1,796 @@ +const STREET_ORDER = ["preflop", "flop", "turn", "river"]; +const STREET_BOARD_COUNTS = { preflop: 0, flop: 3, turn: 4, river: 5 }; +const SUITS = { c: "♣", d: "♦", h: "♥", s: "♠" }; + +const state = { + rawGame: null, + replay: null, + handIndex: 0, + frameIndex: 0, + playing: false, + timer: null, + polling: false, + pollTimer: null, +}; + +const els = { + connectionStatus: document.getElementById("connectionStatus"), + gameStatus: document.getElementById("gameStatus"), + handCounter: document.getElementById("handCounter"), + baseUrlInput: document.getElementById("baseUrlInput"), + gameIdInput: document.getElementById("gameIdInput"), + fetchButton: document.getElementById("fetchButton"), + togglePollButton: document.getElementById("togglePollButton"), + runCountInput: document.getElementById("runCountInput"), + pollSecondsInput: document.getElementById("pollSecondsInput"), + smallBlindInput: document.getElementById("smallBlindInput"), + bigBlindInput: document.getElementById("bigBlindInput"), + untilOneLeftInput: document.getElementById("untilOneLeftInput"), + runButton: document.getElementById("runButton"), + createGameInput: document.getElementById("createGameInput"), + createGameButton: document.getElementById("createGameButton"), + fileInput: document.getElementById("fileInput"), + jsonInput: document.getElementById("jsonInput"), + loadJsonButton: document.getElementById("loadJsonButton"), + handSelect: document.getElementById("handSelect"), + speedInput: document.getElementById("speedInput"), + tableFelt: document.getElementById("tableFelt"), + seatLayer: document.getElementById("seatLayer"), + potDisplay: document.getElementById("potDisplay"), + boardCards: document.getElementById("boardCards"), + frameCaption: document.getElementById("frameCaption"), + resetButton: document.getElementById("resetButton"), + prevButton: document.getElementById("prevButton"), + playButton: document.getElementById("playButton"), + nextButton: document.getElementById("nextButton"), + frameCounter: document.getElementById("frameCounter"), + tableStats: document.getElementById("tableStats"), + playerList: document.getElementById("playerList"), + eventLog: document.getElementById("eventLog"), +}; + +function init() { + const params = new URLSearchParams(window.location.search); + if (params.get("base_url")) els.baseUrlInput.value = params.get("base_url"); + if (params.get("game_id")) els.gameIdInput.value = params.get("game_id"); + + els.fetchButton.addEventListener("click", () => fetchGame()); + els.togglePollButton.addEventListener("click", togglePolling); + els.runButton.addEventListener("click", runHands); + els.createGameButton.addEventListener("click", createGame); + els.loadJsonButton.addEventListener("click", loadJsonText); + els.fileInput.addEventListener("change", loadJsonFile); + els.handSelect.addEventListener("change", () => { + state.handIndex = Number(els.handSelect.value || 0); + state.frameIndex = 0; + stopPlayback(); + render(); + }); + els.resetButton.addEventListener("click", () => setFrame(0)); + els.prevButton.addEventListener("click", () => setFrame(state.frameIndex - 1)); + els.nextButton.addEventListener("click", () => setFrame(state.frameIndex + 1)); + els.playButton.addEventListener("click", togglePlayback); + + els.createGameInput.value = JSON.stringify(defaultGameSpec(), null, 2); + renderEmpty(); +} + +async function fetchGame(options = {}) { + const baseUrl = els.baseUrlInput.value.trim(); + const gameId = els.gameIdInput.value.trim(); + if (!gameId) { + setStatus("error", "Missing game id"); + return; + } + setStatus("neutral", "Fetching"); + try { + const params = new URLSearchParams({ base_url: baseUrl, game_id: gameId }); + const response = await fetch(`/api/fetch-game?${params.toString()}`); + const payload = await readJsonResponse(response); + loadGame(payload, { preserveTail: Boolean(options.preserveTail) }); + setStatus("ok", "Connected"); + } catch (error) { + setStatus("error", error.message); + } +} + +async function runHands() { + const gameId = els.gameIdInput.value.trim(); + if (!gameId) { + setStatus("error", "Missing game id"); + return; + } + const payload = { + base_url: els.baseUrlInput.value.trim(), + game_id: gameId, + count: Number(els.runCountInput.value || 1), + until_one_left: els.untilOneLeftInput.checked, + }; + const smallBlind = els.smallBlindInput.value.trim(); + const bigBlind = els.bigBlindInput.value.trim(); + if (smallBlind || bigBlind) { + payload.small_blind = Number(smallBlind); + payload.big_blind = Number(bigBlind); + } + setStatus("neutral", "Running"); + els.runButton.disabled = true; + try { + const response = await fetch("/api/run-hands", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const result = await readJsonResponse(response); + loadGame(result.game || result, { preserveTail: true }); + setStatus("ok", "Updated"); + } catch (error) { + setStatus("error", error.message); + } finally { + els.runButton.disabled = false; + } +} + +async function createGame() { + let gameSpec; + try { + gameSpec = JSON.parse(els.createGameInput.value); + } catch (error) { + setStatus("error", error.message); + return; + } + if (!gameSpec || typeof gameSpec !== "object" || Array.isArray(gameSpec)) { + setStatus("error", "Game spec must be a JSON object"); + return; + } + const payload = { ...gameSpec, base_url: els.baseUrlInput.value.trim() }; + setStatus("neutral", "Creating"); + els.createGameButton.disabled = true; + try { + const response = await fetch("/api/create-game", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const created = await readJsonResponse(response); + loadGame(created); + setStatus("ok", "Created"); + } catch (error) { + setStatus("error", error.message); + } finally { + els.createGameButton.disabled = false; + } +} + +function togglePolling() { + state.polling = !state.polling; + els.togglePollButton.classList.toggle("primary", state.polling); + els.togglePollButton.textContent = state.polling ? "Stop" : "Auto"; + if (state.polling) { + fetchGame({ preserveTail: true }); + schedulePoll(); + } else if (state.pollTimer) { + clearTimeout(state.pollTimer); + state.pollTimer = null; + } +} + +function schedulePoll() { + if (!state.polling) return; + const delay = Math.max(1, Number(els.pollSecondsInput.value || 3)) * 1000; + state.pollTimer = setTimeout(async () => { + await fetchGame({ preserveTail: true }); + schedulePoll(); + }, delay); +} + +async function readJsonResponse(response) { + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.error || `HTTP ${response.status}`); + } + return payload; +} + +function loadJsonText() { + try { + const payload = JSON.parse(els.jsonInput.value); + loadGame(payload); + setStatus("ok", "Loaded JSON"); + } catch (error) { + setStatus("error", error.message); + } +} + +function loadJsonFile() { + const file = els.fileInput.files && els.fileInput.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + try { + const payload = JSON.parse(String(reader.result || "{}")); + els.jsonInput.value = JSON.stringify(payload, null, 2); + loadGame(payload); + setStatus("ok", "Loaded file"); + } catch (error) { + setStatus("error", error.message); + } + }; + reader.readAsText(file); +} + +function loadGame(rawGame, options = {}) { + const wasAtTail = isAtReplayTail(); + const currentHandNumber = currentHand()?.hand_number; + const currentFrameKey = currentFrame()?.key; + state.rawGame = rawGame; + state.replay = buildReplay(rawGame); + syncInputsFromGame(rawGame); + populateHandSelect(); + + if (options.preserveTail && wasAtTail && state.replay.hands.length) { + state.handIndex = state.replay.hands.length - 1; + state.frameIndex = Math.max(0, currentHand().frames.length - 1); + } else if (currentHandNumber) { + const handIndex = state.replay.hands.findIndex((hand) => hand.hand_number === currentHandNumber); + state.handIndex = handIndex >= 0 ? handIndex : Math.max(0, state.replay.hands.length - 1); + const frameIndex = currentFrameKey + ? currentHand().frames.findIndex((frame) => frame.key === currentFrameKey) + : 0; + state.frameIndex = frameIndex >= 0 ? frameIndex : 0; + } else { + state.handIndex = Math.max(0, state.replay.hands.length - 1); + state.frameIndex = 0; + } + + render(); +} + +function buildReplay(game) { + const players = normalizePlayers(game.players || []); + const hands = []; + let stacks = Object.fromEntries(players.map((player) => [player.player_id, numberOr(player.stack, game.starting_stack || 0)])); + + const historicalHands = Array.isArray(game.hands) ? game.hands : []; + if (historicalHands.length > 0) { + stacks = Object.fromEntries(players.map((player) => [player.player_id, numberOr(game.starting_stack, player.stack)])); + } + + for (const hand of historicalHands) { + const startStacks = { ...stacks }; + const replayHand = buildHandReplay(game, players, hand, startStacks); + hands.push(replayHand); + stacks = { ...replayHand.endingStacks }; + } + + const currentFrame = buildCurrentTableFrame(game, players); + return { game, players, hands, currentFrame }; +} + +function normalizePlayers(players) { + return players + .map((player, index) => ({ + player_id: String(player.player_id || player.id || `p${index + 1}`), + name: String(player.name || player.player_id || player.id || `Player ${index + 1}`), + seat: numberOr(player.seat, index), + stack: numberOr(player.stack, 0), + street_bet: numberOr(player.street_bet, 0), + total_bet: numberOr(player.total_bet, 0), + folded: Boolean(player.folded), + all_in: Boolean(player.all_in), + in_hand: Boolean(player.in_hand), + })) + .sort((a, b) => a.seat - b.seat); +} + +function buildHandReplay(game, players, hand, startStacks) { + const handPlayers = players.map((player) => ({ + ...player, + stack: numberOr(startStacks[player.player_id], player.stack), + street_bet: 0, + total_bet: 0, + folded: false, + all_in: false, + in_hand: numberOr(startStacks[player.player_id], player.stack) > 0, + hole_cards: holeCardsFor(hand, player.player_id), + })); + const playerState = new Map(handPlayers.map((player) => [player.player_id, player])); + const frames = []; + const actions = Array.isArray(hand.actions) ? hand.actions : []; + const blinds = hand.blinds || {}; + + frames.push(snapshotFrame({ + key: `h${hand.hand_number}:start`, + type: "start", + hand, + players: handPlayers, + board: [], + pot: 0, + activePlayerId: null, + caption: `Hand ${hand.hand_number} starts. Blinds ${blinds.small_blind || game.small_blind}/${blinds.big_blind || game.big_blind}.`, + event: `Hand ${hand.hand_number} started`, + })); + + let currentStreet = "preflop"; + let boardCount = 0; + + actions.forEach((action, index) => { + const street = String(action.street || currentStreet); + if (street !== currentStreet) { + currentStreet = street; + boardCount = STREET_BOARD_COUNTS[street] || boardCount; + frames.push(snapshotFrame({ + key: `h${hand.hand_number}:${street}:deal`, + type: "street", + hand, + players: handPlayers, + board: (hand.board || []).slice(0, boardCount), + pot: totalPot(handPlayers), + activePlayerId: null, + caption: `${streetLabel(street)} dealt.`, + event: `${streetLabel(street)} board`, + })); + resetStreetBets(handPlayers); + } + + const player = playerState.get(String(action.player_id)); + if (player) { + const amount = numberOr(action.amount, 0); + player.stack = numberOr(action.stack, player.stack - amount); + player.street_bet = numberOr(action.street_bet, player.street_bet + amount); + player.total_bet += amount; + if (action.action === "fold") player.folded = true; + if (player.stack <= 0 && !player.folded) player.all_in = true; + } + + frames.push(snapshotFrame({ + key: `h${hand.hand_number}:a${index}`, + type: "action", + hand, + players: handPlayers, + board: (hand.board || []).slice(0, STREET_BOARD_COUNTS[street] || boardCount), + pot: totalPot(handPlayers), + activePlayerId: String(action.player_id), + caption: formatAction(action, player), + event: formatEvent(action, player), + action, + })); + }); + + const finalBoard = hand.board || []; + if (finalBoard.length && boardCount < finalBoard.length) { + frames.push(snapshotFrame({ + key: `h${hand.hand_number}:board-final`, + type: "street", + hand, + players: handPlayers, + board: finalBoard, + pot: totalPot(handPlayers), + activePlayerId: null, + caption: "Final board is visible.", + event: "Final board", + })); + } + + const showdown = hand.showdown_hands || {}; + if (Object.keys(showdown).length > 0) { + for (const [playerId, cards] of Object.entries(showdown)) { + const player = playerState.get(playerId); + if (player) player.hole_cards = Array.isArray(cards) ? cards : []; + } + frames.push(snapshotFrame({ + key: `h${hand.hand_number}:showdown`, + type: "showdown", + hand, + players: handPlayers, + board: finalBoard, + pot: totalPot(handPlayers), + activePlayerId: null, + caption: "Showdown.", + event: "Showdown", + })); + } + + const awards = Array.isArray(hand.awards) ? hand.awards : []; + for (const award of awards) { + const amount = numberOr(award.amount, 0); + const winners = Array.isArray(award.winners) ? award.winners : []; + const share = winners.length ? Math.floor(amount / winners.length) : 0; + let remainder = winners.length ? amount % winners.length : 0; + winners.forEach((winnerId) => { + const winner = playerState.get(String(winnerId)); + if (!winner) return; + winner.stack += share + (remainder > 0 ? 1 : 0); + remainder -= 1; + }); + frames.push(snapshotFrame({ + key: `h${hand.hand_number}:award:${frames.length}`, + type: "award", + hand, + players: handPlayers, + board: finalBoard, + pot: 0, + activePlayerId: winners[0] ? String(winners[0]) : null, + caption: formatAward(award), + event: formatAward(award), + })); + } + + const endingStacks = Object.fromEntries(handPlayers.map((player) => [player.player_id, player.stack])); + if (frames.length === 1) { + frames.push(snapshotFrame({ + key: `h${hand.hand_number}:empty`, + type: "empty", + hand, + players: handPlayers, + board: finalBoard, + pot: 0, + activePlayerId: null, + caption: "No actions recorded for this hand.", + event: "No actions", + })); + } + return { hand_number: hand.hand_number, hand, frames, endingStacks }; +} + +function buildCurrentTableFrame(game, players) { + return { + key: "current-table", + type: "current", + hand_number: game.hand_number || 0, + button_seat: game.button_seat, + players: players.map((player) => ({ + ...player, + street_bet: numberOr(player.street_bet, 0), + total_bet: numberOr(player.total_bet, 0), + hole_cards: [], + })), + board: [], + pot: players.reduce((sum, player) => sum + numberOr(player.total_bet, 0), 0), + activePlayerId: null, + caption: "Current table snapshot. Run hands or load history to replay actions.", + event: "Current table snapshot", + action: null, + }; +} + +function snapshotFrame({ key, type, hand, players, board, pot, activePlayerId, caption, event, action }) { + return { + key, + type, + hand_number: hand.hand_number, + button_seat: hand.button_seat, + players: players.map((player) => ({ ...player, hole_cards: [...(player.hole_cards || [])] })), + board: [...(board || [])], + pot, + activePlayerId, + caption, + event, + action: action ? { ...action } : null, + }; +} + +function holeCardsFor(hand, playerId) { + const explicit = hand.hole_cards || hand.private_hands || {}; + const showdown = hand.showdown_hands || {}; + const cards = explicit[playerId] || showdown[playerId] || []; + return Array.isArray(cards) ? cards : []; +} + +function totalPot(players) { + return players.reduce((sum, player) => sum + numberOr(player.total_bet, 0), 0); +} + +function resetStreetBets(players) { + players.forEach((player) => { + player.street_bet = 0; + }); +} + +function syncInputsFromGame(game) { + if (game.game_id) els.gameIdInput.value = game.game_id; +} + +function populateHandSelect() { + els.handSelect.innerHTML = ""; + const hands = state.replay ? state.replay.hands : []; + if (!hands.length) { + const option = document.createElement("option"); + option.value = "0"; + option.textContent = "No hands"; + els.handSelect.append(option); + return; + } + hands.forEach((hand, index) => { + const option = document.createElement("option"); + option.value = String(index); + option.textContent = `Hand ${hand.hand_number}`; + els.handSelect.append(option); + }); +} + +function render() { + if (!state.replay) { + renderEmpty(); + return; + } + const hand = currentHand(); + const frame = currentFrame(); + els.handSelect.value = state.replay.hands.length ? String(state.handIndex) : "0"; + renderHeader(); + renderStats(); + renderPlayers(frame); + renderSeats(frame); + renderBoard(frame); + renderEvents(hand, frame); + renderTransport(hand, frame); +} + +function renderEmpty() { + els.gameStatus.textContent = "No game"; + els.handCounter.textContent = "Hand 0"; + els.tableStats.innerHTML = ""; + els.playerList.innerHTML = ""; + els.seatLayer.innerHTML = ""; + els.boardCards.innerHTML = ""; + els.potDisplay.textContent = "Pot 0"; + els.frameCaption.textContent = "Load a game snapshot"; + els.frameCounter.textContent = "0 / 0"; + els.eventLog.innerHTML = ""; +} + +function renderHeader() { + const game = state.replay.game; + els.gameStatus.textContent = `${game.game_id || "game"} · ${game.status || "unknown"}`; + els.gameStatus.className = `pill ${game.status === "complete" ? "ok" : ""}`; + els.handCounter.textContent = `Hand ${game.hand_number || 0}`; +} + +function renderStats() { + const game = state.replay.game; + const hands = state.replay.hands.length; + const current = currentHand(); + const stats = [ + ["Players", state.replay.players.length], + ["Hands", hands], + ["Blinds", `${game.small_blind || 0}/${game.big_blind || 0}`], + ["Button", current && current.hand ? `Seat ${current.hand.button_seat}` : game.button_seat == null ? "-" : `Seat ${game.button_seat}`], + ]; + els.tableStats.innerHTML = stats + .map(([label, value]) => `
Texas Hold X
+