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]) => `
${escapeHtml(label)}
${escapeHtml(value)}
`) + .join(""); +} + +function renderPlayers(frame) { + const players = frame ? frame.players : state.replay.players; + els.playerList.innerHTML = players + .map((player) => ` +
+ ${player.seat} + ${escapeHtml(player.name)} + ${formatChips(player.stack)} +
+ `) + .join(""); +} + +function renderSeats(frame) { + if (!frame) { + els.seatLayer.innerHTML = ""; + return; + } + const count = Math.max(2, frame.players.length); + els.seatLayer.innerHTML = frame.players + .map((player, index) => { + const pos = seatPosition(index, count); + const classes = ["seat"]; + if (frame.activePlayerId === player.player_id) classes.push("active"); + if (player.folded) classes.push("folded"); + const tags = []; + if (player.seat === frame.button_seat) tags.push("BTN"); + if (player.folded) tags.push("FOLD"); + if (player.all_in) tags.push("ALL IN"); + return ` +
+
+ ${escapeHtml(player.name)} + ${escapeHtml(tags[0] || `S${player.seat}`)} +
+
+ stk ${formatChips(player.stack)} + bet ${formatChips(player.street_bet)} + tot ${formatChips(player.total_bet)} + ${player.in_hand ? "in" : "out"} +
+
${renderHoleCards(player)}
+
+ `; + }) + .join(""); +} + +function seatPosition(index, count) { + const angle = -90 + (360 / count) * index; + const rad = (angle * Math.PI) / 180; + return { + x: 50 + 42 * Math.cos(rad), + y: 50 + 39 * Math.sin(rad), + }; +} + +function renderBoard(frame) { + if (!frame) return; + els.potDisplay.textContent = `Pot ${formatChips(frame.pot)}`; + els.boardCards.innerHTML = renderCards(frame.board, 5); + els.frameCaption.textContent = frame.caption || ""; +} + +function renderTransport(hand, frame) { + const frames = hand ? hand.frames : []; + els.frameCounter.textContent = frames.length ? `${state.frameIndex + 1} / ${frames.length}` : "0 / 0"; + els.prevButton.disabled = state.frameIndex <= 0; + els.resetButton.disabled = state.frameIndex <= 0; + els.nextButton.disabled = !frames.length || state.frameIndex >= frames.length - 1; + els.playButton.disabled = frames.length <= 1; + els.playButton.textContent = state.playing ? "Pause" : "Play"; + if (!frame) els.frameCaption.textContent = "No hand selected"; +} + +function renderEvents(hand, frame) { + const frames = hand ? hand.frames : []; + els.eventLog.innerHTML = frames + .map((item, index) => `
  • ${escapeHtml(item.event || item.caption || `Frame ${index + 1}`)}
  • `) + .join(""); +} + +function setFrame(index) { + const hand = currentHand(); + if (!hand) return; + state.frameIndex = clamp(index, 0, hand.frames.length - 1); + render(); +} + +function togglePlayback() { + if (state.playing) { + stopPlayback(); + render(); + } else { + state.playing = true; + render(); + scheduleNextFrame(); + } +} + +function stopPlayback() { + state.playing = false; + if (state.timer) { + clearTimeout(state.timer); + state.timer = null; + } +} + +function scheduleNextFrame() { + if (!state.playing) return; + const hand = currentHand(); + if (!hand || state.frameIndex >= hand.frames.length - 1) { + stopPlayback(); + render(); + return; + } + const pace = Number(els.speedInput.value || 1); + const delay = Math.round(1100 / pace); + state.timer = setTimeout(() => { + state.frameIndex += 1; + render(); + scheduleNextFrame(); + }, delay); +} + +function currentHand() { + if (!state.replay || !state.replay.hands.length) return null; + return state.replay.hands[clamp(state.handIndex, 0, state.replay.hands.length - 1)]; +} + +function currentFrame() { + const hand = currentHand(); + if (!hand || !hand.frames.length) return state.replay ? state.replay.currentFrame : null; + return hand.frames[clamp(state.frameIndex, 0, hand.frames.length - 1)]; +} + +function isAtReplayTail() { + const hand = currentHand(); + if (!state.replay || !hand) return true; + return state.handIndex === state.replay.hands.length - 1 && state.frameIndex >= hand.frames.length - 1; +} + +function renderHoleCards(player) { + if (player.hole_cards && player.hole_cards.length) return renderCards(player.hole_cards, 2); + if (player.in_hand && !player.folded) return `${renderCardBack()}${renderCardBack()}`; + return ""; +} + +function renderCards(cards, padTo = 0) { + const rendered = (cards || []).map(renderCard).join(""); + const missing = Math.max(0, padTo - (cards || []).length); + return rendered + Array.from({ length: missing }, () => ``).join(""); +} + +function renderCardBack() { + return ``; +} + +function renderCard(label) { + const value = String(label || ""); + const rank = value.slice(0, 1).toUpperCase(); + const suit = value.slice(1, 2).toLowerCase(); + const red = suit === "h" || suit === "d"; + return `${escapeHtml(rank + (SUITS[suit] || suit))}`; +} + +function formatAction(action, player) { + const name = player ? player.name : action.player_id; + const verb = String(action.action || "").replace("_", " "); + const amount = numberOr(action.amount, 0); + if (amount > 0) return `${name} ${verb} ${formatChips(amount)}.`; + return `${name} ${verb}.`; +} + +function formatEvent(action, player) { + const street = streetLabel(action.street || "preflop"); + return `${street}: ${formatAction(action, player)}`; +} + +function formatAward(award) { + const winners = (award.winners || []).join(", ") || "unknown"; + const handValue = award.hand_value && award.hand_value.name ? ` with ${award.hand_value.name}` : ""; + return `${winners} win ${formatChips(award.amount)}${handValue}.`; +} + +function streetLabel(street) { + const index = STREET_ORDER.indexOf(street); + if (index < 0) return String(street || "").toUpperCase(); + return STREET_ORDER[index].toUpperCase(); +} + +function numberOr(value, fallback) { + const num = Number(value); + return Number.isFinite(num) ? num : fallback; +} + +function formatChips(value) { + return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(numberOr(value, 0)); +} + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function setStatus(kind, text) { + els.connectionStatus.className = `pill ${kind === "neutral" ? "neutral" : kind}`; + els.connectionStatus.textContent = text; +} + +function defaultGameSpec() { + return { + game_id: "demo", + seed: 42, + starting_stack: 1000, + small_blind: 5, + big_blind: 10, + players: [ + { id: "agent_1", name: "Agent 1", type: "calling" }, + { id: "agent_2", name: "Agent 2", type: "random" }, + { id: "agent_3", name: "Agent 3", type: "calling" }, + ], + }; +} + +init(); diff --git a/texas_holdem_replay/static/index.html b/texas_holdem_replay/static/index.html new file mode 100644 index 0000000..da15b1f --- /dev/null +++ b/texas_holdem_replay/static/index.html @@ -0,0 +1,144 @@ + + + + + + Texas Hold X Replay + + + +
    +
    +
    +

    Texas Hold X

    +

    Game Replay Control

    +
    +
    + Idle + No game + Hand 0 +
    +
    + +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    Pot 0
    +
    +
    Load a game snapshot
    +
    +
    +
    + +
    + + + + + 0 / 0 +
    +
    + + +
    +
    + + + + diff --git a/texas_holdem_replay/static/styles.css b/texas_holdem_replay/static/styles.css new file mode 100644 index 0000000..b5845e1 --- /dev/null +++ b/texas_holdem_replay/static/styles.css @@ -0,0 +1,609 @@ +:root { + color-scheme: dark; + --bg: #07100e; + --surface: #101b18; + --surface-2: #15231f; + --line: #294038; + --text: #f2f7f4; + --muted: #9fb3aa; + --green: #22c55e; + --green-2: #15803d; + --amber: #f59e0b; + --red: #ef4444; + --blue: #38bdf8; + --felt: #0f6b42; + --felt-dark: #06452c; + --wood: #7c4a1e; + --shadow: 0 16px 40px rgba(0, 0, 0, 0.34); + --radius: 8px; + --font: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px), + linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px), + var(--bg); + background-size: 24px 24px; + color: var(--text); + font-family: var(--font); +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + border: 1px solid var(--line); + border-radius: var(--radius); + min-height: 38px; + padding: 8px 12px; + background: #172621; + color: var(--text); + cursor: pointer; + transition: border-color 160ms ease, background 160ms ease, transform 160ms ease; +} + +button:hover { + border-color: var(--green); + background: #1e322b; +} + +button:active { + transform: translateY(1px); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +button.primary { + border-color: #35d772; + background: linear-gradient(180deg, #28c864, #179249); + color: #04100a; + font-weight: 700; +} + +button.wide { + width: 100%; +} + +input, +select, +textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #0a1411; + color: var(--text); + min-height: 38px; + padding: 8px 10px; +} + +textarea { + resize: vertical; + min-height: 120px; + font-family: var(--mono); + font-size: 12px; + line-height: 1.45; +} + +input:focus, +select:focus, +textarea:focus, +button:focus-visible { + outline: 2px solid rgba(34, 197, 94, 0.75); + outline-offset: 2px; +} + +.shell { + width: min(1720px, calc(100vw - 32px)); + margin: 0 auto; + padding: 18px 0 24px; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-bottom: 16px; +} + +.eyebrow { + margin: 0 0 4px; + color: var(--green); + font-family: var(--mono); + font-size: 12px; + text-transform: uppercase; +} + +h1, +h2 { + margin: 0; + letter-spacing: 0; +} + +h1 { + font-size: 26px; +} + +h2 { + font-size: 14px; +} + +.status-strip { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.pill, +.metric { + display: inline-flex; + align-items: center; + min-height: 30px; + border: 1px solid var(--line); + border-radius: 999px; + padding: 5px 10px; + background: rgba(16, 27, 24, 0.9); + color: var(--muted); + font-family: var(--mono); + font-size: 12px; + white-space: nowrap; +} + +.pill.ok { + border-color: rgba(34, 197, 94, 0.6); + color: #86efac; +} + +.pill.error { + border-color: rgba(239, 68, 68, 0.7); + color: #fecaca; +} + +.workspace { + display: grid; + grid-template-columns: 300px minmax(520px, 1fr) 340px; + gap: 14px; + align-items: start; +} + +.panel { + border: 1px solid var(--line); + border-radius: var(--radius); + background: rgba(16, 27, 24, 0.94); + box-shadow: var(--shadow); + padding: 14px; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.panel-header.compact { + margin-top: 4px; +} + +.field { + display: grid; + gap: 6px; + margin-bottom: 10px; +} + +.field span, +.select-wrap label { + color: var(--muted); + font-size: 12px; +} + +.form-grid, +.button-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.check-field { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 13px; + margin: 4px 0 10px; +} + +.check-field input { + width: 16px; + min-height: 16px; +} + +.divider { + height: 1px; + background: var(--line); + margin: 14px 0; +} + +.file-input { + margin-bottom: 10px; +} + +.table-zone { + min-width: 0; +} + +.table-toolbar, +.transport { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} + +.select-wrap { + display: grid; + grid-template-columns: auto minmax(120px, 1fr); + align-items: center; + gap: 8px; +} + +.felt-stage { + min-height: 560px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: radial-gradient(circle at 50% 45%, rgba(56, 189, 248, 0.12), transparent 46%), #08120f; + box-shadow: var(--shadow); + padding: 20px; +} + +.table-felt { + position: relative; + height: 520px; + min-height: 520px; + border: 16px solid var(--wood); + border-radius: 48%; + background: + radial-gradient(ellipse at center, rgba(255, 255, 255, 0.1), transparent 58%), + radial-gradient(ellipse at center, var(--felt), var(--felt-dark)); + box-shadow: inset 0 0 0 6px rgba(255, 255, 255, 0.08), inset 0 0 32px rgba(0, 0, 0, 0.45); + overflow: visible; +} + +.table-felt::after { + content: ""; + position: absolute; + inset: 58px 80px; + border: 1px dashed rgba(255, 255, 255, 0.18); + border-radius: 50%; + pointer-events: none; +} + +.seat-layer { + position: absolute; + inset: 0; +} + +.seat { + position: absolute; + width: 154px; + min-height: 106px; + transform: translate(-50%, -50%); + border: 1px solid rgba(148, 163, 184, 0.34); + border-radius: var(--radius); + background: rgba(6, 16, 12, 0.9); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.36); + padding: 8px; + transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} + +.seat.active { + border-color: var(--green); + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.24), 0 0 24px rgba(34, 197, 94, 0.28); +} + +.seat.folded { + opacity: 0.66; +} + +.seat .name-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.seat .name { + min-width: 0; + overflow: hidden; + color: var(--text); + font-size: 13px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tag { + border-radius: 999px; + padding: 2px 6px; + background: rgba(34, 197, 94, 0.16); + color: #bbf7d0; + font-family: var(--mono); + font-size: 10px; + white-space: nowrap; +} + +.seat-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; + color: var(--muted); + font-family: var(--mono); + font-size: 11px; +} + +.hole-cards { + margin-top: 7px; +} + +.cards { + display: flex; + justify-content: center; + gap: 6px; +} + +.card { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 58px; + border: 1px solid #d7dee3; + border-radius: 6px; + background: #f8fafc; + color: #111827; + font-family: var(--mono); + font-size: 18px; + font-weight: 800; + box-shadow: 0 5px 10px rgba(0, 0, 0, 0.22); +} + +.card.red { + color: #b91c1c; +} + +.card.back { + border-color: #38bdf8; + background: + linear-gradient(45deg, rgba(255, 255, 255, 0.16) 25%, transparent 25%), + linear-gradient(-45deg, rgba(255, 255, 255, 0.16) 25%, transparent 25%), + #123a52; + background-size: 10px 10px; + color: transparent; +} + +.board-zone { + position: absolute; + left: 50%; + top: 50%; + display: grid; + width: min(420px, 52%); + transform: translate(-50%, -50%); + justify-items: center; + gap: 12px; + z-index: 3; +} + +.pot-display { + border: 1px solid rgba(245, 158, 11, 0.55); + border-radius: 999px; + padding: 6px 12px; + background: rgba(31, 20, 5, 0.7); + color: #fde68a; + font-family: var(--mono); + font-size: 13px; +} + +.board-cards { + min-height: 58px; +} + +.frame-caption { + width: 100%; + min-height: 42px; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: var(--radius); + padding: 9px 12px; + background: rgba(3, 7, 18, 0.62); + color: var(--text); + text-align: center; +} + +.transport { + justify-content: center; + margin: 10px 0 0; +} + +.transport button { + min-width: 48px; +} + +.stats-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.stat { + border: 1px solid var(--line); + border-radius: var(--radius); + background: #0a1411; + padding: 9px; +} + +.stat .label { + color: var(--muted); + font-size: 11px; +} + +.stat .value { + margin-top: 4px; + font-family: var(--mono); + font-size: 16px; +} + +.player-list { + display: grid; + gap: 8px; +} + +.player-row { + display: grid; + grid-template-columns: 28px 1fr auto; + gap: 8px; + align-items: center; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #0a1411; + padding: 8px; +} + +.player-row .seat-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + background: rgba(34, 197, 94, 0.14); + color: #bbf7d0; + font-family: var(--mono); + font-size: 12px; +} + +.player-row .name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.player-row .stack { + color: var(--muted); + font-family: var(--mono); + font-size: 12px; +} + +.event-log { + display: grid; + max-height: 392px; + margin: 0; + padding: 0 0 0 20px; + gap: 7px; + overflow: auto; +} + +.event-log li { + border-left: 2px solid rgba(34, 197, 94, 0.35); + padding: 2px 0 2px 8px; + color: var(--muted); + font-size: 13px; +} + +.event-log li.current { + color: var(--text); + border-left-color: var(--green); +} + +@media (max-width: 1180px) { + .workspace { + grid-template-columns: 300px minmax(0, 1fr); + } + + .log-panel { + grid-column: 1 / -1; + } +} + +@media (max-width: 820px) { + .shell { + width: min(100vw - 20px, 760px); + padding-top: 12px; + } + + .topbar { + align-items: flex-start; + flex-direction: column; + } + + .status-strip { + justify-content: flex-start; + } + + .workspace { + grid-template-columns: 1fr; + } + + .felt-stage { + min-height: 430px; + padding: 12px; + } + + .table-felt { + height: 390px; + min-height: 390px; + border-width: 10px; + } + + .seat { + width: 124px; + min-height: 94px; + padding: 7px; + } + + .seat-stats { + grid-template-columns: 1fr; + } + + .card { + width: 34px; + height: 48px; + font-size: 15px; + } + + .board-zone { + width: 62%; + } + + .table-toolbar { + align-items: stretch; + flex-direction: column; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +}