5 Commits

Author SHA1 Message Date
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
mamamiyear 5899ea0b89 Revert "feat: add replay server and web client"
This reverts commit 3c027eae0b.
2026-05-21 09:22:04 +08:00
mamamiyear 1ee963ce2e chore: add .codex to .gitignore 2026-05-17 11:23:21 +08:00
mamamiyear 351cac7734 docs: add AGENTS.md 2026-05-15 14:58:54 +08:00
mamamiyear 79dccde963 fix: game service api block when a game is running 2026-05-13 21:42:53 +08:00
20 changed files with 646 additions and 2045 deletions
+4
View File
@@ -32,8 +32,12 @@ htmlcov/
# IDE and editor files
.idea/
.vscode/
.codex/
*.swp
*.swo
# debug resources
debug/
# Node dependencies for browser automation tooling
node_modules/
+32
View File
@@ -0,0 +1,32 @@
# Repository Guidelines
## Project Structure & Module Organization
Core poker service code lives in `texas_holdem/`. Important modules include `engine.py` for Texas Hold'em rules, `service.py` for game management, `server.py` for the HTTP API, `agents.py` for local/HTTP agents, and `ai_client.py` / `human_client.py` for standalone agents. Prompt templates live in `texas_holdem/prompts/`.
Replay UI code lives in `texas_holdem_replay/`, with static browser assets under `texas_holdem_replay/static/`. Tests are in `tests/`, named by feature area such as `test_engine.py`, `test_service.py`, and `test_replay_server.py`. Design notes belong in `docs/`.
## Build, Test, and Development Commands
- `python -m unittest discover -v` runs the full test suite.
- `python -m compileall texas_holdem texas_holdem_replay tests` checks import and syntax validity.
- `python -m texas_holdem.server --host 127.0.0.1 --port 8000` starts the game service.
- `python -m texas_holdem.human_client --port 9001 --keep-history` starts an interactive human HTTP agent.
- `python -m texas_holdem.ai_client --port 9101 --api-key "$OPENAI_API_KEY" --model gpt-4o-mini` starts an OpenAI-compatible AI agent.
- `python -m texas_holdem_replay.server --port 8088` starts the replay viewer.
## Coding Style & Naming Conventions
Use Python 3.11+ standard-library APIs unless a dependency is intentionally added to `pyproject.toml`. Keep modules focused and prefer explicit dataclasses for wire/state models. Use 4-space indentation, type hints, `snake_case` for functions and variables, `PascalCase` for classes, and concise comments only where logic is non-obvious.
## Testing Guidelines
Use `unittest`. Add tests near the behavior changed: engine rules in `tests/test_engine.py`, HTTP/service behavior in `tests/test_service.py`, agent transport in `tests/test_agents.py`, and replay UI server helpers in `tests/test_replay_server.py`. New bug fixes should include a regression test. Avoid tests that require external network access or real LLM calls.
## Commit & Pull Request Guidelines
History uses short Conventional Commit-style subjects, for example `feat: add replay server and web client` and `fix: game service api block when a game is running`. Keep commits scoped to one behavior change. Pull requests should include a short summary, test commands run, linked issue or motivation, and screenshots only for replay UI or visible terminal-output changes.
## Security & Configuration Tips
Do not commit API keys. Pass LLM credentials through `OPENAI_API_KEY` or CLI flags in local shells only. HTTP Agent endpoints are exclusive per active game; preserve this invariant when changing service concurrency.
+16 -2
View File
@@ -11,6 +11,7 @@
- 支持盲注、四条街下注、弃牌、过牌、跟注、下注、加注、全下、边池和摊牌结算。
- 支持本地 Agent 和 HTTP Agent。
- 支持 Human Agent 和 OpenAI-compatible AI Agent 的终端过程输出。
- 游戏运行中可以并发查询状态;查询返回上一手完成后的稳定快照。
## 运行服务
@@ -51,6 +52,12 @@ curl -X POST http://127.0.0.1:8000/games/demo/hands/run \
curl http://127.0.0.1:8000/games/demo
```
也可以使用单数别名:
```bash
curl http://127.0.0.1:8000/game/demo
```
## HTTP Agent 协议
玩家配置可以使用远程 HTTP Agent:
@@ -62,12 +69,19 @@ curl http://127.0.0.1:8000/games/demo
"agent": {
"type": "http",
"endpoint": "http://127.0.0.1:9101",
"timeout_seconds": 10
"timeout_seconds": 10,
"game_update_timeout_seconds": 3,
"retries": 2,
"retry_backoff_seconds": 0.25
}
}
```
服务会向 `endpoint` 发送当前行动玩家的观察 JSON。Agent 返回:
服务会向 `endpoint + /game` 推送每手开始时的游戏快照,向 `endpoint + /act` 发送当前行动玩家的观察 JSON。`endpoint` 也可以传入历史形式的 `/act``/game` 后缀,服务会归一化为 base URL。
同一个 HTTP Agent endpoint 不能同时被不同游戏占用;后创建的游戏会返回错误。服务会给 HTTP Agent 请求自动重试,`/act` 重试仍失败时,规则引擎会按 `check > call > fold` 选择默认动作,避免整桌卡死。
Agent 返回:
```json
{"action": "call"}
-81
View File
@@ -1,81 +0,0 @@
# Texas Hold X 回放视图设计方案
## 目标
构建一个与核心游戏服务和 Agent 解耦的独立 Web 服务,用于读取游戏详情 JSON 并以动画方式回放 Texas Hold'em 对局。它可以部署在任意能运行 Python 标准库 HTTP 服务的环境中,不要求核心服务增加前端路由,也不改变 Human HTTP Agent / AI HTTP Agent 协议。
## 架构
新增 `texas_holdem_replay` 包:
- `texas_holdem_replay.server`:标准库 HTTP 服务,负责托管静态前端文件,并提供 `/api/fetch-game` 抓取代理。
- `texas_holdem_replay/static/index.html`:独立页面入口。
- `texas_holdem_replay/static/styles.css`:像素风牌桌、卡牌、座位和响应式布局。
- `texas_holdem_replay/static/app.js`:数据归一化、手牌时间轴生成、动画播放、上传 JSON、手动抓取和自动轮询。
运行方式:
```bash
python -m texas_holdem_replay.server --host 127.0.0.1 --port 8088
```
也可以通过安装后的脚本启动:
```bash
texas-holdem-replay --host 127.0.0.1 --port 8088
```
## 数据输入
视图支持三种输入方式:
1. 填写核心游戏服务地址和 `game_id`,点击“获取”。
前端请求自身的 `/api/fetch-game?base_url=...&game_id=...`,由回放服务去访问核心服务的 `/games/{game_id}`,避免浏览器跨域限制。
2. 上传静态 JSON 文件。
文件在浏览器本地解析,不依赖核心服务。
3. 开启自动获取。
按指定秒数轮询同一个核心服务和 `game_id`,用于观察正在运行的游戏快照。新快照会先尝试和当前回放位置合并;如果当前手牌追加了 action、showdown 或 award,回放会接续到新增 frame,而不是重头播放。
`/api/fetch-game` 也支持传入完整 `url`,便于未来接入网关或静态 JSON 服务。
## 数据模型与归一化
当前游戏详情返回结构包含:
- `players`:玩家最终状态。
- `hands`:历史手牌列表。
- 每手 `actions`:行动记录,包含 `street``player_id``action``amount`、行动后 `street_bet``stack`
- `awards`:底池分配。
- `showdown_hands`:摊牌玩家手牌。
前端不会依赖核心服务内部对象,只读取 JSON 字段并做归一化:
- 根据 `players``actions` 得到座位顺序。
-`starting_stack` 开始,按历史 `actions.stack``awards` 推演每手牌开始时的筹码。
- 每手牌生成一组离散 frame:开局、跨街发公共牌、玩家行动、摊牌、结算。
- 每个 frame 都有稳定 key。上传 JSON、手动获取和自动获取都会复用同一套合并逻辑:同一 `game_id` 且能找到当前手牌时,保留当前 frame;如果用户停在最新进度末尾,且当前手牌或后续手牌出现新增 frame,则从当前位置接续播放增量。用户正在查看历史手牌时,不会被新轮询强制跳走。
- 非 showdown 玩家手牌显示卡背。
- 已预留 `hand.hole_cards[player_id]``hand.private_hands[player_id]` 兼容点,后续核心服务返回非 showdown 手牌时可直接展示。
## 动画与交互
牌桌采用卡通像素风格:
- 椭圆绿色牌桌、木质像素边框、像素筹码状态条。
- 玩家围绕牌桌分布,当前行动玩家高亮并显示冒泡文字。
- 公共牌按 flop / turn / river 分阶段发出。
- 动作之间默认保留约 1.1-1.5 秒间隔,用户可用“节奏”滑杆调慢或调快。
- 支持上一帧、下一帧、播放/暂停、重置、选择指定手牌。
## 响应式设计
桌面端为三栏布局:数据控制、牌桌、事件日志。中等屏幕下事件日志下移,手机和平板窄屏下改为单列,牌桌高度固定到可观看的移动端比例,座位尺寸通过 CSS clamp 控制,避免文字和控件溢出。
## 解耦边界
回放服务只消费核心服务的公开 HTTP JSON,不导入 `texas_holdem.engine``service` 或 Agent 代码,也不要求游戏服务开放 CORS。未来可以单独部署在 CDN + 轻量代理、容器或任意 Python 运行环境中。
## 后续适配
- 核心服务返回非 showdown 玩家手牌后,只需要让每手 JSON 包含 `hole_cards``private_hands` 映射,前端现有归一化会优先读取。
- 如果服务端未来提供增量事件流,可以在 `app.js` 增加一个 source adapter,把事件流转成同样的 frame 列表,动画层无需重写。
-4
View File
@@ -9,10 +9,6 @@ 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/*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+60
View File
@@ -0,0 +1,60 @@
import json
import unittest
from unittest.mock import patch
from urllib.error import URLError
from texas_holdem.agents import HttpAgent, normalise_http_agent_endpoint
class FakeResponse:
def __init__(self, payload: dict[str, object]) -> None:
self.payload = payload
def read(self) -> bytes:
return json.dumps(self.payload).encode("utf-8")
def __enter__(self) -> "FakeResponse":
return self
def __exit__(self, *args: object) -> None:
return None
class AgentTests(unittest.TestCase):
def test_normalise_http_agent_endpoint_accepts_action_or_game_paths(self) -> None:
self.assertEqual(
normalise_http_agent_endpoint("http://127.0.0.1:9101/act"),
"http://127.0.0.1:9101",
)
self.assertEqual(
normalise_http_agent_endpoint("http://127.0.0.1:9101/game/"),
"http://127.0.0.1:9101",
)
def test_http_agent_post_retries_and_sets_player_header(self) -> None:
calls = []
def fake_urlopen(request, timeout): # type: ignore[no-untyped-def]
calls.append((request, timeout))
if len(calls) == 1:
raise URLError("temporary")
return FakeResponse({"ok": True})
agent = HttpAgent(
"http://agent.test/act",
player_id="p1",
retries=1,
retry_backoff_seconds=0,
)
with patch("texas_holdem.agents.urlopen", fake_urlopen):
payload = agent._post_json("/game", {"game_id": "g1"}, timeout_seconds=2)
self.assertEqual(payload, {"ok": True})
self.assertEqual(len(calls), 2)
self.assertEqual(calls[1][0].headers["X-player-id"], "p1")
self.assertEqual(calls[1][1], 2)
if __name__ == "__main__":
unittest.main()
+40
View File
@@ -100,10 +100,50 @@ class EngineTests(unittest.TestCase):
awards = game._award_pots()
self.assertEqual([award.amount for award in awards], [150, 100])
self.assertEqual(
[contribution["amount"] for contribution in game._last_pot_contributions],
[150, 100],
)
self.assertEqual(
game._last_pot_contributions[0]["contributors"],
{"p1": 50, "p2": 50, "p3": 50},
)
self.assertEqual(
game._last_pot_contributions[1]["contributors"],
{"p2": 50, "p3": 50},
)
self.assertEqual(game.players[0].stack, 150)
self.assertEqual(game.players[1].stack, 100)
self.assertEqual(game.players[2].stack, 0)
def test_hand_summary_includes_full_hand_snapshots(self) -> None:
players = [
("p1", "Player 1", CallingStationAgent()),
("p2", "Player 2", CallingStationAgent()),
("p3", "Player 3", CallingStationAgent()),
]
game = TableGame("g5", players, starting_stack=100, small_blind=5, big_blind=10, rng=Random(23))
summary = game.run_hand()
payload = summary.to_dict()
self.assertEqual(set(summary.hole_cards), {"p1", "p2", "p3"})
self.assertTrue(all(len(cards) == 2 for cards in summary.hole_cards.values()))
self.assertEqual(summary.starting_stacks, {"p1": 100, "p2": 100, "p3": 100})
self.assertEqual(set(summary.ending_stacks), {"p1", "p2", "p3"})
self.assertEqual(sum(summary.starting_stacks.values()), sum(summary.ending_stacks.values()))
self.assertGreaterEqual(len(summary.pot_contributions), 1)
self.assertTrue(
all(
contribution["amount"] == sum(contribution["contributors"].values())
for contribution in summary.pot_contributions
)
)
self.assertEqual(set(payload["hole_cards"]), {"p1", "p2", "p3"})
self.assertEqual(payload["starting_stacks"], {"p1": 100, "p2": 100, "p3": 100})
self.assertIn("ending_stacks", payload)
self.assertIn("pot_contributions", payload)
def test_short_all_in_does_not_reopen_raising_to_prior_actor(self) -> None:
seen: list[tuple[str, str, list[str]]] = []
players = [
-28
View File
@@ -1,28 +0,0 @@
from __future__ import annotations
import unittest
from texas_holdem_replay.server import build_game_url
class ReplayServerTests(unittest.TestCase):
def test_build_game_url_from_base_and_game_id(self) -> None:
self.assertEqual(
build_game_url({"base_url": ["http://127.0.0.1:8000/"], "game_id": ["game 1"]}),
"http://127.0.0.1:8000/games/game%201",
)
def test_build_game_url_accepts_full_url(self) -> None:
self.assertEqual(
build_game_url({"url": ["https://example.test/games/demo"]}),
"https://example.test/games/demo",
)
def test_build_game_url_rejects_non_http_url(self) -> None:
with self.assertRaises(ValueError):
build_game_url({"url": ["file:///tmp/game.json"]})
if __name__ == "__main__":
unittest.main()
+76
View File
@@ -0,0 +1,76 @@
import json
import unittest
from threading import Thread
from urllib.request import Request, urlopen
from texas_holdem import server as poker_server
from texas_holdem.service import GameManager
class ServerTests(unittest.TestCase):
def setUp(self) -> None:
self.previous_manager = poker_server.MANAGER
poker_server.MANAGER = GameManager()
self.server = poker_server.create_server("127.0.0.1", 0)
self.thread = Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
host, port = self.server.server_address
self.base_url = f"http://{host}:{port}"
def tearDown(self) -> None:
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=2)
poker_server.MANAGER = self.previous_manager
def request_json(
self,
method: str,
path: str,
payload: dict[str, object] | None = None,
) -> dict[str, object]:
data = None
headers = {}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(
f"{self.base_url}{path}",
data=data,
headers=headers,
method=method,
)
with urlopen(request, timeout=5) as response:
return json.loads(response.read().decode("utf-8"))
def test_get_hand_route_returns_expanded_hand_summary(self) -> None:
self.request_json(
"POST",
"/game",
{
"game_id": "route-demo",
"seed": 17,
"starting_stack": 200,
"small_blind": 5,
"big_blind": 10,
"players": [
{"id": "a", "type": "calling"},
{"id": "b", "type": "calling"},
],
},
)
self.request_json("POST", "/game/route-demo/hands", {"count": 1})
hand = self.request_json("GET", "/game/route-demo/hands/1")
game = self.request_json("GET", "/game/route-demo")
self.assertEqual(hand["hand_number"], 1)
self.assertEqual(set(hand["hole_cards"]), {"a", "b"})
self.assertEqual(hand["starting_stacks"], {"a": 200, "b": 200})
self.assertIn("ending_stacks", hand)
self.assertIn("pot_contributions", hand)
self.assertEqual(game["hands"][0], hand)
if __name__ == "__main__":
unittest.main()
+82 -1
View File
@@ -1,8 +1,26 @@
import unittest
from threading import Event, Thread
from texas_holdem.agents import PokerAgent
from texas_holdem.models import Observation, PlayerAction
from texas_holdem.service import GameManager
class BlockingAgent(PokerAgent):
def __init__(self, entered: Event, release: Event) -> None:
self.entered = entered
self.release = release
def decide(self, observation: Observation) -> PlayerAction:
self.entered.set()
if not self.release.wait(timeout=5):
raise RuntimeError("test timed out waiting to release blocking agent")
for action in observation.legal_actions:
if action["action"] == "check":
return PlayerAction("check")
return PlayerAction("call")
class ServiceTests(unittest.TestCase):
def test_create_and_run_game(self) -> None:
manager = GameManager()
@@ -22,8 +40,71 @@ class ServiceTests(unittest.TestCase):
hands = manager.run_hands(game.game_id, count=1)
state = manager.get_game_state("demo")
hand = manager.get_hand_state("demo", 1)
self.assertEqual(len(hands), 1)
self.assertEqual(manager.get_game("demo").to_dict()["hand_number"], 1)
self.assertEqual(state["hand_number"], 1)
self.assertEqual(hand, state["hands"][0])
self.assertIn("hole_cards", hand)
self.assertIn("starting_stacks", hand)
self.assertIn("ending_stacks", hand)
self.assertIn("pot_contributions", hand)
def test_get_game_state_does_not_block_during_run(self) -> None:
manager = GameManager()
entered = Event()
release = Event()
game = manager.create_game(
{
"game_id": "blocking",
"seed": 13,
"starting_stack": 200,
"small_blind": 5,
"big_blind": 10,
"players": [
{"id": "a", "type": "calling"},
{"id": "b", "type": "calling"},
],
}
)
manager.run_hands("blocking", count=1)
game.agents["a"] = BlockingAgent(entered, release)
thread = Thread(target=lambda: manager.run_hands("blocking", count=1))
thread.start()
self.assertTrue(entered.wait(timeout=2))
state = manager.get_game_state("blocking")
release.set()
thread.join(timeout=2)
self.assertFalse(thread.is_alive())
self.assertEqual(state["hand_number"], 1)
self.assertEqual(len(state["hands"]), 1)
def test_duplicate_http_agent_endpoint_is_rejected_across_active_games(self) -> None:
manager = GameManager()
payload = {
"starting_stack": 200,
"small_blind": 5,
"big_blind": 10,
"players": [
{
"id": "ai",
"agent": {
"type": "http",
"endpoint": "http://127.0.0.1:9101/act",
},
},
{"id": "b", "type": "calling"},
],
}
manager.create_game({"game_id": "g1", **payload})
with self.assertRaisesRegex(ValueError, "already belongs to game g1"):
manager.create_game({"game_id": "g2", **payload})
if __name__ == "__main__":
+106 -31
View File
@@ -2,10 +2,11 @@ from __future__ import annotations
import json
import sys
import time
from abc import ABC, abstractmethod
from random import Random
from typing import IO, Any
from urllib.error import URLError
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from texas_holdem.human_io import clear_screen, prompt_action, render_observation
@@ -54,6 +55,27 @@ class CallingStationAgent(PokerAgent):
return PlayerAction("fold")
def normalise_http_agent_endpoint(raw: str) -> str:
"""Return the canonical base URL for an HTTP agent endpoint."""
url = raw.rstrip("/")
if url.endswith("/act"):
url = url[: -len("/act")]
if url.endswith("/game"):
url = url[: -len("/game")]
return url
def http_agent_endpoint_from_spec(spec: dict[str, Any]) -> str | None:
"""Extract the canonical HTTP endpoint from an agent spec, if present."""
agent_type = str(spec.get("type", "calling")).lower()
if agent_type != "http":
return None
endpoint = spec.get("endpoint")
if not endpoint:
raise ValueError("http agent requires an endpoint")
return normalise_http_agent_endpoint(str(endpoint))
class HttpAgent(PokerAgent):
"""Remote agent that talks to a base URL exposing ``/act`` and ``/game``.
@@ -66,28 +88,36 @@ class HttpAgent(PokerAgent):
ACT_PATH = "/act"
GAME_PATH = "/game"
def __init__(self, endpoint: str, timeout_seconds: float = 10.0) -> None:
self.base_url = self._normalise_base_url(endpoint)
def __init__(
self,
endpoint: str,
timeout_seconds: float = 10.0,
player_id: str | None = None,
game_update_timeout_seconds: float | None = None,
retries: int = 2,
retry_backoff_seconds: float = 0.25,
) -> None:
self.base_url = normalise_http_agent_endpoint(endpoint)
self.timeout_seconds = timeout_seconds
@staticmethod
def _normalise_base_url(raw: str) -> str:
"""Strip a trailing slash so URL joins do not produce double slashes.
Centralising this also tolerates the legacy "endpoint already points
at /act" mistake by chopping off a redundant ``/act`` suffix.
"""
url = raw.rstrip("/")
if url.endswith("/act"):
url = url[: -len("/act")]
return url
self.player_id = player_id
self.game_update_timeout_seconds = (
float(game_update_timeout_seconds)
if game_update_timeout_seconds is not None
else min(timeout_seconds, 3.0)
)
self.retries = max(0, retries)
self.retry_backoff_seconds = max(0.0, retry_backoff_seconds)
def _url(self, path: str) -> str:
"""Compose a full URL by joining the base with a path component."""
return f"{self.base_url}{path}"
def decide(self, observation: Observation) -> PlayerAction:
payload = self._post_json(self.ACT_PATH, observation.to_dict())
payload = self._post_json(
self.ACT_PATH,
observation.to_dict(),
timeout_seconds=self.timeout_seconds,
)
if not isinstance(payload, dict):
raise RuntimeError("agent endpoint must return a JSON object")
return PlayerAction.from_dict(payload)
@@ -100,30 +130,54 @@ class HttpAgent(PokerAgent):
only by way of the raised exception bubbling to the engine guard.
"""
try:
self._post_json(self.GAME_PATH, game_state)
self._post_json(
self.GAME_PATH,
game_state,
timeout_seconds=self.game_update_timeout_seconds,
)
except RuntimeError:
# ``/game`` is informational; treat any HTTP error as a benign
# drop rather than reraising and aborting the hand loop.
return None
def _post_json(self, path: str, payload: dict[str, Any]) -> Any:
def _post_json(
self,
path: str,
payload: dict[str, Any],
timeout_seconds: float,
) -> Any:
"""POST ``payload`` as JSON to ``base_url + path`` and return parsed body.
Extracted as a tiny helper so ``decide`` and ``on_game_update`` share
identical transport semantics (timeout, error wrapping, content-type).
"""
body = json.dumps(payload).encode("utf-8")
request = Request(
self._url(path),
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urlopen(request, timeout=self.timeout_seconds) as response:
raw = response.read().decode("utf-8")
except (OSError, URLError) as exc:
raise RuntimeError(f"agent endpoint failed: {self._url(path)}") from exc
last_error: BaseException | None = None
raw = ""
for attempt in range(self.retries + 1):
request = Request(
self._url(path),
data=body,
headers=self._headers(),
method="POST",
)
try:
with urlopen(request, timeout=timeout_seconds) as response:
raw = response.read().decode("utf-8")
break
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
last_error = RuntimeError(
f"agent endpoint failed with HTTP {exc.code}: "
f"{self._url(path)} {detail}"
)
except (OSError, URLError) as exc:
last_error = exc
if attempt < self.retries and self.retry_backoff_seconds > 0:
time.sleep(self.retry_backoff_seconds * (2**attempt))
else:
raise RuntimeError(f"agent endpoint failed: {self._url(path)}") from last_error
if not raw:
return None
try:
@@ -133,6 +187,12 @@ class HttpAgent(PokerAgent):
f"agent endpoint returned invalid JSON: {self._url(path)}"
) from exc
def _headers(self) -> dict[str, str]:
headers = {"Content-Type": "application/json", "Connection": "close"}
if self.player_id:
headers["X-Player-Id"] = self.player_id
return headers
class HumanAgent(PokerAgent):
"""Interactive CLI agent for debugging and manual play.
@@ -189,7 +249,11 @@ class HumanAgent(PokerAgent):
return line.rstrip("\n")
def build_agent(spec: dict[str, Any], rng: Random | None = None) -> PokerAgent:
def build_agent(
spec: dict[str, Any],
rng: Random | None = None,
player_id: str | None = None,
) -> PokerAgent:
agent_type = str(spec.get("type", "calling")).lower()
if agent_type == "random":
return RandomAgent(rng)
@@ -199,7 +263,18 @@ def build_agent(spec: dict[str, Any], rng: Random | None = None) -> PokerAgent:
endpoint = spec.get("endpoint")
if not endpoint:
raise ValueError("http agent requires an endpoint")
return HttpAgent(str(endpoint), float(spec.get("timeout_seconds", 10.0)))
return HttpAgent(
str(endpoint),
timeout_seconds=float(spec.get("timeout_seconds", 10.0)),
player_id=player_id,
game_update_timeout_seconds=(
float(spec["game_update_timeout_seconds"])
if "game_update_timeout_seconds" in spec
else None
),
retries=int(spec.get("retries", 2)),
retry_backoff_seconds=float(spec.get("retry_backoff_seconds", 0.25)),
)
if agent_type in {"human", "cli", "interactive"}:
return HumanAgent()
raise ValueError(f"unknown agent type: {agent_type}")
+119 -23
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
from copy import deepcopy
from random import Random
from threading import RLock
from time import time
from texas_holdem.agents import PokerAgent
@@ -54,16 +56,19 @@ class TableGame:
self.small_blind = small_blind
self.big_blind = big_blind
self.rng = rng or Random()
self.lock = RLock()
self.hand_number = 0
self.button_index: int | None = None
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
# to reconstruct the schedule from the outside.
self.blind_history: list[BlindLevel] = []
self._completed_snapshot: dict[str, object] = self._to_dict_unlocked()
@property
def is_complete(self) -> bool:
@@ -83,6 +88,14 @@ class TableGame:
:attr:`blind_history` whenever it changes (including the very first
hand) so external observers can replay the schedule.
"""
with self.lock:
return self._run_hand_locked(small_blind=small_blind, big_blind=big_blind)
def _run_hand_locked(
self,
small_blind: int | None = None,
big_blind: int | None = None,
) -> HandSummary:
if self.is_complete:
raise GameComplete("game is complete")
@@ -104,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
@@ -115,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)
@@ -138,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,
@@ -146,11 +174,16 @@ 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(),
)
self.hand_summaries.append(summary)
self._completed_snapshot = deepcopy(self._to_dict_unlocked())
return summary
def run_hands(
@@ -166,24 +199,47 @@ class TableGame:
with the first hand of this call; subsequent calls can raise them
again. Leaving them ``None`` keeps the current level unchanged.
"""
if max_hands <= 0:
raise ValueError("max_hands must be positive")
summaries = []
for _ in range(max_hands):
if self.is_complete:
break
# Only the first hand of the batch needs to apply the blind
# override; after that the engine reuses the stored values.
summaries.append(
self.run_hand(small_blind=small_blind, big_blind=big_blind)
)
small_blind = None
big_blind = None
if until_one_left and self.is_complete:
break
return summaries
with self.lock:
if max_hands <= 0:
raise ValueError("max_hands must be positive")
summaries = []
for _ in range(max_hands):
if self.is_complete:
break
# Only the first hand of the batch needs to apply the blind
# override; after that the engine reuses the stored values.
summaries.append(
self._run_hand_locked(
small_blind=small_blind,
big_blind=big_blind,
)
)
small_blind = None
big_blind = None
if until_one_left and self.is_complete:
break
return summaries
def to_dict(self) -> dict[str, object]:
with self.lock:
return self._to_dict_unlocked()
def snapshot_completed(self) -> dict[str, object]:
"""Return a stable snapshot from the latest completed hand boundary.
If a hand is currently running under ``self.lock``, this method does
not block. It returns the most recent completed hand summary and
stacks captured in memory, which is exactly what status endpoints
need while a long-running HTTP-agent decision is in progress.
"""
if self.lock.acquire(blocking=False):
try:
return deepcopy(self._to_dict_unlocked())
finally:
self.lock.release()
return deepcopy(self._completed_snapshot)
def _to_dict_unlocked(self) -> dict[str, object]:
return {
"game_id": self.game_id,
"status": "complete" if self.is_complete else "running",
@@ -449,9 +505,18 @@ class TableGame:
try:
requested = agent.decide(observation)
except Exception:
requested = PlayerAction("fold")
requested = self._default_action(observation.legal_actions)
return self._coerce_action(requested, observation.legal_actions)
def _default_action(self, legal_actions: list[dict[str, object]]) -> PlayerAction:
by_action = {str(action["action"]): action for action in legal_actions}
for action_type in ("check", "call", "fold"):
if action_type in by_action:
legal = by_action[action_type]
return PlayerAction(action_type, int(legal.get("amount") or 0))
legal = legal_actions[0]
return PlayerAction(str(legal["action"]), int(legal.get("amount") or 0))
def _coerce_action(
self,
requested: PlayerAction,
@@ -512,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:
@@ -548,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]:
@@ -581,7 +677,7 @@ class TableGame:
swallow individual exceptions so a flaky remote endpoint cannot
break the table flow.
"""
snapshot = self.to_dict()
snapshot = self._to_dict_unlocked()
for agent in self.agents.values():
try:
agent.on_game_update(snapshot)
+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,
}
+16 -8
View File
@@ -25,8 +25,16 @@ class PokerRequestHandler(BaseHTTPRequestHandler):
if path == ["games"]:
self._json({"games": MANAGER.list_games()})
return
if len(path) == 2 and path[0] == "games":
self._json(MANAGER.get_game(path[1]).to_dict())
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:
@@ -35,11 +43,11 @@ class PokerRequestHandler(BaseHTTPRequestHandler):
def do_POST(self) -> None:
path = self._path_parts()
try:
if path == ["games"]:
if path in (["game"], ["games"]):
game = MANAGER.create_game(self._read_json())
self._json(game.to_dict(), HTTPStatus.CREATED)
self._json(game.snapshot_completed(), HTTPStatus.CREATED)
return
if len(path) == 3 and path[0] == "games" and path[2] == "hands":
if len(path) == 3 and path[0] in {"game", "games"} and path[2] == "hands":
body = self._read_json()
count = int(body.get("count", 1))
until_one_left = bool(body.get("until_one_left", False))
@@ -51,9 +59,9 @@ class PokerRequestHandler(BaseHTTPRequestHandler):
small_blind=small_blind,
big_blind=big_blind,
)
self._json({"hands": summaries, "game": MANAGER.get_game(path[1]).to_dict()})
self._json({"hands": summaries, "game": MANAGER.get_game_state(path[1])})
return
if len(path) == 4 and path[0] == "games" and path[2] == "hands" and path[3] == "run":
if len(path) == 4 and path[0] in {"game", "games"} and path[2] == "hands" and path[3] == "run":
body = self._read_json()
count = int(body.get("count", 1))
until_one_left = bool(body.get("until_one_left", False))
@@ -65,7 +73,7 @@ class PokerRequestHandler(BaseHTTPRequestHandler):
small_blind=small_blind,
big_blind=big_blind,
)
self._json({"hands": summaries, "game": MANAGER.get_game(path[1]).to_dict()})
self._json({"hands": summaries, "game": MANAGER.get_game_state(path[1])})
return
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
except KeyError as exc:
+58 -13
View File
@@ -5,13 +5,14 @@ from threading import RLock
from typing import Any
from uuid import uuid4
from texas_holdem.agents import build_agent
from texas_holdem.agents import build_agent, http_agent_endpoint_from_spec
from texas_holdem.engine import TableGame
class GameManager:
def __init__(self) -> None:
self._games: dict[str, TableGame] = {}
self._http_endpoint_owners: dict[str, str] = {}
self._lock = RLock()
def create_game(self, payload: dict[str, Any]) -> TableGame:
@@ -29,12 +30,19 @@ class GameManager:
big_blind = int(payload.get("big_blind", 10))
specs = []
http_endpoints: set[str] = set()
for seat, raw_spec in enumerate(players):
if not isinstance(raw_spec, dict):
raise ValueError("each player must be an object")
player_id = str(raw_spec.get("id") or raw_spec.get("player_id") or f"p{seat + 1}")
name = str(raw_spec.get("name") or player_id)
agent = build_agent(raw_spec.get("agent", raw_spec), rng)
agent_spec = raw_spec.get("agent", raw_spec)
if not isinstance(agent_spec, dict):
raise ValueError("agent spec must be an object")
endpoint = http_agent_endpoint_from_spec(agent_spec)
if endpoint is not None:
http_endpoints.add(endpoint)
agent = build_agent(agent_spec, rng, player_id=player_id)
specs.append((player_id, name, agent))
game = TableGame(
@@ -46,9 +54,18 @@ class GameManager:
rng=rng,
)
with self._lock:
self._release_completed_http_endpoints_locked()
if game_id in self._games:
raise ValueError(f"game already exists: {game_id}")
for endpoint in http_endpoints:
owner = self._http_endpoint_owners.get(endpoint)
if owner is not None and owner != game_id:
raise ValueError(
f"http agent endpoint already belongs to game {owner}: {endpoint}"
)
self._games[game_id] = game
for endpoint in http_endpoints:
self._http_endpoint_owners[endpoint] = game_id
return game
def get_game(self, game_id: str) -> TableGame:
@@ -58,9 +75,20 @@ class GameManager:
except KeyError as exc:
raise KeyError(f"game not found: {game_id}") from exc
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:
return [game.to_dict() for game in self._games.values()]
games = list(self._games.values())
return [game.snapshot_completed() for game in games]
def run_hands(
self,
@@ -78,13 +106,30 @@ class GameManager:
no-argument behaviour.
"""
game = self.get_game(game_id)
with self._lock:
return [
summary.to_dict()
for summary in game.run_hands(
count,
until_one_left=until_one_left,
small_blind=small_blind,
big_blind=big_blind,
)
]
summaries = [
summary.to_dict()
for summary in game.run_hands(
count,
until_one_left=until_one_left,
small_blind=small_blind,
big_blind=big_blind,
)
]
if game.is_complete:
with self._lock:
self._release_http_endpoints_for_game_locked(game_id)
return summaries
def _release_completed_http_endpoints_locked(self) -> None:
for game_id, game in list(self._games.items()):
if game.lock.acquire(blocking=False):
try:
if game.is_complete:
self._release_http_endpoints_for_game_locked(game_id)
finally:
game.lock.release()
def _release_http_endpoints_for_game_locked(self, game_id: str) -> None:
for endpoint, owner in list(self._http_endpoint_owners.items()):
if owner == game_id:
del self._http_endpoint_owners[endpoint]
-2
View File
@@ -1,2 +0,0 @@
"""Standalone web replay viewer for Texas Hold X game snapshots."""
-153
View File
@@ -1,153 +0,0 @@
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 quote, unquote, urlparse, parse_qs
from urllib.request import Request, urlopen
STATIC_DIR = Path(__file__).with_name("static")
def build_game_url(query: dict[str, list[str]]) -> str:
raw_url = _first(query, "url")
if raw_url:
return _validate_http_url(raw_url)
base_url = _first(query, "base_url")
game_id = _first(query, "game_id")
if not base_url or not game_id:
raise ValueError("provide either url or both base_url and game_id")
base = _validate_http_url(base_url).rstrip("/")
safe_game_id = quote(game_id.strip("/"), safe="")
return f"{base}/games/{safe_game_id}"
class ReplayRequestHandler(BaseHTTPRequestHandler):
server_version = "TexasHoldemReplay/0.1"
def do_GET(self) -> None:
parsed = urlparse(self.path)
if parsed.path == "/api/health":
self._json({"ok": True, "service": "texas-holdem-replay"})
return
if parsed.path == "/api/fetch-game":
self._handle_fetch_game(parsed.query)
return
self._serve_static(parsed.path)
def do_OPTIONS(self) -> None:
self.send_response(HTTPStatus.NO_CONTENT)
self._cors_headers()
self.end_headers()
def log_message(self, format: str, *args: Any) -> None:
return
def _handle_fetch_game(self, raw_query: str) -> None:
query = parse_qs(raw_query)
try:
target = build_game_url(query)
timeout = float(_first(query, "timeout") or 8)
request = Request(target, headers={"Accept": "application/json"})
with urlopen(request, timeout=max(1, min(timeout, 30))) as response:
payload = response.read()
content_type = response.headers.get("Content-Type", "")
try:
data = json.loads(payload.decode("utf-8"))
except json.JSONDecodeError as exc:
raise ValueError("target did not return valid JSON") from exc
if not isinstance(data, dict):
raise ValueError("target JSON must be an object")
self._json({"source": target, "content_type": content_type, "game": data})
except HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")[:600]
self._json(
{"error": f"upstream returned HTTP {exc.code}", "detail": body},
HTTPStatus.BAD_GATEWAY,
)
except (URLError, TimeoutError) as exc:
self._json({"error": "failed to reach upstream", "detail": str(exc)}, HTTPStatus.BAD_GATEWAY)
except ValueError as exc:
self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def _serve_static(self, raw_path: str) -> None:
relative = unquote(raw_path.lstrip("/")) or "index.html"
if relative.endswith("/"):
relative += "index.html"
candidate = (STATIC_DIR / relative).resolve()
root = STATIC_DIR.resolve()
if root not in candidate.parents and candidate != root:
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
return
if not candidate.is_file():
candidate = STATIC_DIR / "index.html"
body = candidate.read_bytes()
content_type = mimetypes.guess_type(candidate.name)[0] or "application/octet-stream"
self.send_response(HTTPStatus.OK)
self._cors_headers()
self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "no-store" if candidate.name == "index.html" else "public, max-age=60")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
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._cors_headers()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _cors_headers(self) -> None:
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
def create_server(host: str, port: int) -> ThreadingHTTPServer:
return ThreadingHTTPServer((host, port), ReplayRequestHandler)
def main() -> None:
parser = argparse.ArgumentParser(description="Run the standalone Texas Hold X replay web 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)
print(f"Texas Hold X replay viewer listening on http://{args.host}:{args.port}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
def _first(query: dict[str, list[str]], key: str) -> str | None:
values = query.get(key)
if not values:
return None
value = values[0].strip()
return value or None
def _validate_http_url(value: str) -> str:
parsed = urlparse(value.strip())
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("url must be an absolute http(s) URL")
return value.strip()
if __name__ == "__main__":
main()
-832
View File
@@ -1,832 +0,0 @@
// =============================================================================
// Texas Hold X Replay — viewer logic
// -----------------------------------------------------------------------------
// Architecture overview:
// * `state` — single mutable runtime store; never read directly by render
// helpers, instead passed via the active frame snapshot.
// * `el` — cached DOM references (set once on load, not per render).
// * Frames — pre-computed, immutable per-hand snapshots produced by
// buildFrames(). Each frame carries the full visual state
// (board cards, pot, players, active speaker, action kind),
// making renderFrame() a pure function of state.frameIndex.
// * Avatars — pixel-art sprites styled in CSS via 8x8 box-shadow grids.
// Per-player palette is derived deterministically from
// player_id so the same player always looks identical.
// * Animation — seat sprites get a transient `act-${kind}` class that maps
// to an avatar @keyframes animation in styles.css.
// =============================================================================
const state = {
game: null,
hands: [],
currentHandIndex: 0,
frames: [],
frameIndex: 0,
playing: false,
timer: null,
pollTimer: null,
source: "",
};
const el = {
subtitle: document.querySelector("#subtitle"),
sourceBadge: document.querySelector("#sourceBadge"),
pollBadge: document.querySelector("#pollBadge"),
serverUrl: document.querySelector("#serverUrl"),
gameId: document.querySelector("#gameId"),
fetchBtn: document.querySelector("#fetchBtn"),
fileInput: document.querySelector("#fileInput"),
autoPoll: document.querySelector("#autoPoll"),
pollSeconds: document.querySelector("#pollSeconds"),
handSelect: document.querySelector("#handSelect"),
pace: document.querySelector("#pace"),
prevBtn: document.querySelector("#prevBtn"),
playBtn: document.querySelector("#playBtn"),
nextBtn: document.querySelector("#nextBtn"),
resetBtn: document.querySelector("#resetBtn"),
progressBar: document.querySelector("#progressBar"),
gameStatus: document.querySelector("#gameStatus"),
playerCount: document.querySelector("#playerCount"),
handCount: document.querySelector("#handCount"),
blindLevel: document.querySelector("#blindLevel"),
handBadge: document.querySelector("#handBadge"),
streetLabel: document.querySelector("#streetLabel"),
potLabel: document.querySelector("#potLabel"),
boardCards: document.querySelector("#boardCards"),
tableMessage: document.querySelector("#tableMessage"),
seatLayer: document.querySelector("#seatLayer"),
eventLog: document.querySelector("#eventLog"),
};
// ---------------------------------------------------------------------------
// Static label tables. Kept module-scope so render functions are fully pure.
// ---------------------------------------------------------------------------
const STREET_LABELS = {
setup: "准备",
preflop: "翻牌前",
flop: "翻牌",
turn: "转牌",
river: "河牌",
showdown: "摊牌",
awards: "结算",
};
const ACTION_LABELS = {
small_blind: "小盲",
big_blind: "大盲",
fold: "弃牌",
check: "过牌",
call: "跟注",
bet: "下注",
raise: "加注",
all_in: "全下",
award: "赢得",
showdown: "亮牌",
deal: "发牌",
};
// Bubble color category — bucket many actions into a few visual classes.
// (The .speech.kind-* CSS classes paint distinct backgrounds for fold/
// call/check/bet/raise/all_in/award. Blinds are rendered as bet-style.)
// ---------------------------------------------------------------------------
// Pixel avatar palette — picked from a small 8-bit-friendly set so every
// player is visually distinct. Derivation is deterministic via FNV-1a over
// player_id, so a given player always renders with the same colors across
// frames and hands.
// ---------------------------------------------------------------------------
const AVATAR_PALETTE = {
skin: ["#f7c98a", "#e2a96b", "#c98c5a", "#8b5a3c", "#f2d4ad"],
hair: ["#2a1e16", "#5b3a23", "#a85d2a", "#d8a13a", "#7a3b8e", "#3a4d8a", "#b9362f"],
shirt: ["#c44c4c", "#3a7fbf", "#3a9b62", "#b87a1f", "#7a3b8e", "#2a3a5a", "#d8a13a"],
};
/**
* Hash a string into a non-negative 32-bit integer using FNV-1a.
* Used to deterministically pick avatar palette entries from player_id.
*/
function fnv1a(value) {
let hash = 0x811c9dc5;
const text = String(value);
for (let i = 0; i < text.length; i += 1) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash >>> 0;
}
/**
* Pick a deterministic avatar palette for the given player_id.
* Returns an object with { skin, hair, shirt } CSS colors.
*/
function avatarPalette(playerId) {
const hash = fnv1a(playerId);
const pickFrom = (list, salt) => list[(hash >>> salt) % list.length];
return {
skin: pickFrom(AVATAR_PALETTE.skin, 0),
hair: pickFrom(AVATAR_PALETTE.hair, 5),
shirt: pickFrom(AVATAR_PALETTE.shirt, 11),
};
}
// 8x8 sprite map for the player portrait.
// . = transparent / dark frame backdrop
// H = hair S = skin E = eye M = mouth (red) T = shirt body
// Two side hair pixels on rows 2-3 give a cohesive helmet shape; row 5 mouth
// adds personality. Row 7 is full shirt body so the avatar reads as a bust.
const AVATAR_SPRITE = [
".HHHHHH.",
"HHHHHHHH",
"HSSSSSSH",
".SESSESS",
".SSSSSS.",
".SSMMSS.",
".TTTTTT.",
"TTTTTTTT",
];
/**
* Build an inline SVG string for a pixel-art avatar using the player's
* deterministic palette. Each filled cell becomes a 1x1 <rect> in an 8x8
* viewBox; SVG `shape-rendering="crispEdges"` keeps the squares sharp.
*/
function avatarSvg(playerId) {
const palette = avatarPalette(playerId);
const colors = {
H: palette.hair,
S: palette.skin,
E: "#1c0e08",
M: "#b9362f",
T: palette.shirt,
};
let rects = "";
for (let y = 0; y < AVATAR_SPRITE.length; y += 1) {
const row = AVATAR_SPRITE[y];
for (let x = 0; x < row.length; x += 1) {
const ch = row[x];
const fill = colors[ch];
if (!fill) continue;
rects += `<rect x="${x}" y="${y}" width="1" height="1" fill="${fill}"/>`;
}
}
return `<svg viewBox="0 0 8 8" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" shape-rendering="crispEdges">${rects}</svg>`;
}
// ---------------------------------------------------------------------------
// Card rendering helpers.
// ---------------------------------------------------------------------------
function cardParts(raw) {
if (!raw || raw === "back") return { rank: "", suit: "", red: false, back: true };
const suitCode = raw.slice(-1).toLowerCase();
const rank = raw.slice(0, -1).toUpperCase();
const suits = { h: "♥", d: "♦", c: "♣", s: "♠" };
return {
rank,
suit: suits[suitCode] || suitCode,
red: suitCode === "h" || suitCode === "d",
back: false,
};
}
function renderCard(raw) {
const parts = cardParts(raw);
const card = document.createElement("div");
card.className = `card${parts.red ? " red" : ""}${parts.back ? " back" : ""}`;
if (!parts.back) {
card.innerHTML = `<span class="rank">${parts.rank}</span><span class="suit">${parts.suit}</span><span class="rank bottom">${parts.rank}</span>`;
}
return card;
}
function money(value) {
return Number(value || 0).toLocaleString("en-US");
}
// ---------------------------------------------------------------------------
// Game normalization: hydrate the raw JSON into a uniform shape with
// player list and per-hand starting stacks.
// ---------------------------------------------------------------------------
function uniquePlayers(game) {
const byId = new Map();
for (const player of game.players || []) {
byId.set(player.player_id, {
player_id: player.player_id,
name: player.name || player.player_id,
seat: Number(player.seat || 0),
stack: Number(player.stack || 0),
});
}
for (const hand of game.hands || []) {
for (const action of hand.actions || []) {
if (!byId.has(action.player_id)) {
byId.set(action.player_id, {
player_id: action.player_id,
name: action.player_id,
seat: byId.size,
stack: Number(game.starting_stack || 0),
});
}
}
}
return Array.from(byId.values()).sort((a, b) => a.seat - b.seat);
}
function inferHandStarts(game) {
const players = uniquePlayers(game);
const stacks = new Map(players.map((player) => [player.player_id, Number(game.starting_stack || player.stack || 0)]));
const starts = new Map();
for (const hand of game.hands || []) {
starts.set(hand.hand_number, new Map(stacks));
for (const action of hand.actions || []) {
if (stacks.has(action.player_id)) stacks.set(action.player_id, Number(action.stack || 0));
}
for (const award of hand.awards || []) {
const winners = award.winners || [];
const share = winners.length ? Math.floor(Number(award.amount || 0) / winners.length) : 0;
for (const winner of winners) stacks.set(winner, Number(stacks.get(winner) || 0) + share);
}
}
return starts;
}
function normalizeGame(raw) {
const game = raw.game && raw.game.hands ? raw.game : raw;
if (!game || !Array.isArray(game.hands)) throw new Error("JSON 中未找到 hands 数组");
const players = uniquePlayers(game);
const starts = inferHandStarts(game);
const hands = game.hands.map((hand) => ({ ...hand, startingStacks: starts.get(hand.hand_number) || new Map() }));
return { ...game, players, hands };
}
function clonePlayersForHand(game, hand) {
return game.players.map((player) => ({
...player,
stack: Number(hand.startingStacks.get(player.player_id) ?? game.starting_stack ?? player.stack ?? 0),
folded: false,
all_in: false,
in_hand: Number(hand.startingStacks.get(player.player_id) ?? game.starting_stack ?? player.stack ?? 0) > 0,
street_bet: 0,
total_bet: 0,
hole_cards: [],
}));
}
// ---------------------------------------------------------------------------
// Frame builder — converts a hand's chronological action list into a list of
// fully-resolved frame snapshots. Each frame is independent and renderable.
// ---------------------------------------------------------------------------
function buildFrames(game, hand) {
const players = clonePlayersForHand(game, hand);
const byId = new Map(players.map((player) => [player.player_id, player]));
const frames = [];
const pot = { value: 0 };
let street = "preflop";
let boardCount = 0;
// `actionKind` is what drives the seat sprite animation classes. It maps
// the raw action verb (fold/call/raise/...) onto a CSS animation token.
const snapshot = (type, message, activePlayerId = null, key = `${type}:${frames.length}`, extras = {}) => ({
key: `hand:${hand.hand_number}:${key}`,
type,
message,
activePlayerId,
street,
pot: pot.value,
board: (hand.board || []).slice(0, boardCount),
players: players.map((player) => ({ ...player, hole_cards: [...player.hole_cards] })),
...extras,
});
frames.push(snapshot("setup", `${hand.hand_number} 手牌开始`, null, "setup"));
const revealStreet = (nextStreet) => {
const counts = { flop: 3, turn: 4, river: 5 };
const nextCount = counts[nextStreet] || boardCount;
for (const player of players) player.street_bet = 0;
if (nextCount > boardCount) {
street = nextStreet;
boardCount = Math.min(nextCount, (hand.board || []).length);
frames.push(snapshot("deal", `${STREET_LABELS[nextStreet]}发出`, null, `deal:${nextStreet}:${boardCount}`));
} else {
street = nextStreet;
}
};
for (const [actionIndex, action] of (hand.actions || []).entries()) {
if (action.street !== street && action.street !== "preflop") revealStreet(action.street);
const player = byId.get(action.player_id);
if (!player) continue;
const previousTotal = Number(player.total_bet || 0);
player.street_bet = Number(action.street_bet || 0);
player.stack = Number(action.stack || 0);
if (action.action === "fold") player.folded = true;
if (action.action === "all_in" || player.stack === 0) player.all_in = true;
player.total_bet = previousTotal + Math.max(0, Number(action.amount || 0));
pot.value += Math.max(0, Number(action.amount || 0));
frames.push(snapshot(
"action",
actionText(action),
action.player_id,
actionKey(action, actionIndex),
{ actionKind: action.action, bubbleText: bubbleTextFor(action) },
));
}
if ((hand.board || []).length > boardCount) {
for (const nextStreet of ["flop", "turn", "river"]) {
if (({ flop: 3, turn: 4, river: 5 }[nextStreet] || 0) > boardCount) revealStreet(nextStreet);
}
}
const shown = hand.showdown_hands || {};
for (const [playerId, cards] of Object.entries(shown)) {
const player = byId.get(playerId);
if (player) player.hole_cards = cards;
}
if (Object.keys(shown).length) {
street = "showdown";
frames.push(snapshot("showdown", "摊牌亮牌", null, `showdown:${Object.keys(shown).sort().join(",")}`));
}
street = "awards";
for (const [awardIndex, award] of (hand.awards || []).entries()) {
const winners = award.winners || [];
const share = winners.length ? Math.floor(Number(award.amount || 0) / winners.length) : 0;
const remainder = winners.length ? Number(award.amount || 0) % winners.length : 0;
winners.forEach((winnerId, index) => {
const player = byId.get(winnerId);
if (player) player.stack += share + (index < remainder ? 1 : 0);
});
pot.value = Math.max(0, pot.value - Number(award.amount || 0));
frames.push(snapshot(
"award",
awardText(award),
winners[0] || null,
awardKey(award, awardIndex),
{ actionKind: "award", bubbleText: `赢得 ${money(award.amount)}` },
));
}
if (frames.length === 1) frames.push(snapshot("empty", "这手牌没有可回放动作", null, "empty"));
return frames;
}
function actionKey(action, index) {
return [
"action", index, action.street, action.player_id, action.action,
Number(action.amount || 0), Number(action.street_bet || 0), Number(action.stack || 0),
].join(":");
}
function awardKey(award, index) {
return [
"award", index, Number(award.amount || 0),
(award.winners || []).join(","),
award.hand_value?.name || "",
].join(":");
}
function actionText(action) {
const label = ACTION_LABELS[action.action] || action.action;
if (["check", "fold"].includes(action.action)) return `${action.player_id} ${label}`;
return `${action.player_id} ${label} ${money(action.amount)}`;
}
/**
* Build a SHORT speech-bubble label (player_id is implicit since the bubble
* already points at the seat). Keeps text readable inside narrow bubbles.
*/
function bubbleTextFor(action) {
const label = ACTION_LABELS[action.action] || action.action;
if (["check", "fold"].includes(action.action)) return label;
return `${label} ${money(action.amount)}`;
}
function awardText(award) {
const winners = (award.winners || []).join(", ");
const handName = award.hand_value?.name ? ` · ${award.hand_value.name}` : "";
return `${winners} 赢得 ${money(award.amount)}${handName}`;
}
// ---------------------------------------------------------------------------
// Seat layout — six radial presets, with mobile fallback.
// Returned (x, y) values are percentages relative to the poker-table box
// (NOT the felt-shell). The poker-table reserves vertical padding so the
// top/bottom seats and their bubbles never overlap the table headers.
// ---------------------------------------------------------------------------
function seatPosition(index, count) {
const mobile = window.matchMedia("(max-width: 760px)").matches;
const presets = mobile ? mobileSeatPreset(count) : desktopSeatPreset(count);
if (presets[index]) return presets[index];
const radiusX = mobile ? 36 : 39;
const radiusY = mobile ? 41 : 39;
const start = -90;
const angle = (start + index * (360 / Math.max(count, 1))) * Math.PI / 180;
return {
x: 50 + Math.cos(angle) * radiusX,
y: 50 + Math.sin(angle) * radiusY,
};
}
// Coordinates expressed in percent of the poker-table box (which already
// includes vertical padding around the felt). `y < ~25` lands above the
// felt (top rail), `y > ~75` lands below the felt (bottom rail).
function desktopSeatPreset(count) {
const presets = {
2: [{ x: 50, y: 18 }, { x: 50, y: 82 }],
3: [{ x: 50, y: 18 }, { x: 80, y: 72 }, { x: 20, y: 72 }],
4: [{ x: 50, y: 18 }, { x: 84, y: 60 }, { x: 50, y: 84 }, { x: 16, y: 60 }],
5: [{ x: 50, y: 18 }, { x: 84, y: 44 }, { x: 72, y: 84 }, { x: 28, y: 84 }, { x: 16, y: 44 }],
6: [{ x: 50, y: 18 }, { x: 82, y: 33 }, { x: 82, y: 70 }, { x: 50, y: 86 }, { x: 18, y: 70 }, { x: 18, y: 33 }],
};
return presets[count] || [];
}
function mobileSeatPreset(count) {
const presets = {
2: [{ x: 50, y: 16 }, { x: 50, y: 84 }],
3: [{ x: 50, y: 15 }, { x: 80, y: 78 }, { x: 20, y: 78 }],
4: [{ x: 50, y: 14 }, { x: 83, y: 60 }, { x: 50, y: 86 }, { x: 17, y: 60 }],
};
return presets[count] || [];
}
/**
* Map a seat coordinate to a `seat-top|bottom|left|right|mid` zone class.
* The CSS uses this to flip the speech bubble below the seat for top-rail
* seats so it never extends outside the visible table area.
*/
function seatZone(pos) {
if (pos.y < 32) return "seat-top";
if (pos.y > 70) return "seat-bottom";
if (pos.x < 34) return "seat-left";
if (pos.x > 66) return "seat-right";
return "seat-mid";
}
// ---------------------------------------------------------------------------
// DOM rendering: assemble a single seat element from a frame's player snapshot.
// Extracted into its own function so renderFrame stays declarative.
// ---------------------------------------------------------------------------
function renderSeat(player, position, frame, hand) {
const zone = seatZone(position);
const isActive = player.player_id === frame.activePlayerId;
const isDealer = player.seat === hand.button_seat;
const isCurrentActor = isActive && (frame.type === "action" || frame.type === "award");
const seat = document.createElement("div");
seat.className = [
"seat",
zone,
isActive ? "active" : "",
player.folded ? "folded" : "",
isDealer ? "dealer-seat" : "",
// The transient act-* class is what triggers the avatar's reaction
// animation (shake/nod/fold/cheer). Only apply on the actor's frame.
isCurrentActor && frame.actionKind ? `act-${frame.actionKind}` : "",
].filter(Boolean).join(" ");
seat.style.setProperty("--x", `${position.x}%`);
seat.style.setProperty("--y", `${position.y}%`);
// Speech bubble — shown on the active player's action/award frames using
// a SHORT label (e.g. "加注 50") so it fits the small bubble width.
const bubbleHtml = (isCurrentActor && frame.bubbleText)
? `<div class="speech kind-${frame.actionKind || "info"}">${escapeHtml(frame.bubbleText)}</div>`
: "";
// Avatar — inline SVG pixel-art sprite, deterministic per player_id so
// the same player keeps a stable look across hands.
const avatarMarkup = `<div class="avatar" aria-hidden="true">${avatarSvg(player.player_id)}</div>`;
seat.innerHTML = `
${bubbleHtml}
<div class="player-box">
<div class="player-head">
${avatarMarkup}
<span class="player-name">${escapeHtml(player.name || player.player_id)}</span>
<span class="dealer">D</span>
</div>
<div class="player-meta">
<span class="stack">Stack ${money(player.stack)}</span>
<span class="bet">${player.folded ? "Folded" : player.all_in ? "All In" : player.street_bet ? `Bet ${money(player.street_bet)}` : ""}</span>
</div>
</div>
`;
// Hole cards live inside the player-box so they share its layout flow.
const hole = document.createElement("div");
hole.className = "card-row hole-cards";
for (const card of knownCardsForPlayer(player, hand, frame)) hole.appendChild(renderCard(card));
seat.querySelector(".player-box").appendChild(hole);
return seat;
}
function renderFrame() {
const hand = state.hands[state.currentHandIndex];
const frame = state.frames[state.frameIndex];
const game = state.game;
const hasData = Boolean(game && hand && frame);
el.handBadge.textContent = hasData ? `Hand ${hand.hand_number}` : "Hand -";
el.streetLabel.textContent = hasData ? (STREET_LABELS[frame.street] || frame.street) : "未加载";
el.potLabel.textContent = hasData ? `Pot ${money(frame.pot)}` : "Pot 0";
el.tableMessage.textContent = hasData ? frame.message : "上传 JSON 或从游戏服务获取快照";
el.tableMessage.style.display = hasData ? "" : "block";
el.progressBar.style.width = hasData ? `${((state.frameIndex + 1) / state.frames.length) * 100}%` : "0";
el.boardCards.replaceChildren();
for (const card of hasData ? frame.board : []) el.boardCards.appendChild(renderCard(card));
el.seatLayer.replaceChildren();
if (hasData) {
frame.players.forEach((player, index) => {
const position = seatPosition(index, frame.players.length);
el.seatLayer.appendChild(renderSeat(player, position, frame, hand));
});
}
renderLog();
syncControls();
}
function knownCardsForPlayer(player, hand, frame) {
const futureHoleCards = hand.hole_cards?.[player.player_id] || hand.private_hands?.[player.player_id];
const canRevealShowdown = ["showdown", "awards"].includes(frame.street) || ["showdown", "award"].includes(frame.type);
const showdownCards = canRevealShowdown ? hand.showdown_hands?.[player.player_id] : null;
const shown = futureHoleCards || showdownCards || player.hole_cards;
if (shown && shown.length) return shown;
if (!player.in_hand && !player.folded) return [];
return ["back", "back"];
}
// ---------------------------------------------------------------------------
// Event log — one line per visible event, with auto-scroll keeping the
// current item in view. Items past the current frame index are dimmed via
// the `.past` class.
// ---------------------------------------------------------------------------
function renderLog() {
const hand = state.hands[state.currentHandIndex];
el.eventLog.replaceChildren();
if (!hand) return;
const events = [
{ text: `${hand.hand_number} 手牌开始`, kind: "setup" },
...(hand.actions || []).map((action) => ({ text: actionText(action), kind: "action" })),
...(hand.awards || []).map((award) => ({ text: awardText(award), kind: "award" })),
];
let currentLi = null;
events.forEach((event, index) => {
const li = document.createElement("li");
li.textContent = `${index + 1}. ${event.text}`;
if (index < state.frameIndex) li.classList.add("past");
if (index === state.frameIndex) {
li.classList.add("current");
currentLi = li;
}
el.eventLog.appendChild(li);
});
// Keep the focused event visible without yanking the page when an event
// is already in view.
if (currentLi) {
currentLi.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}
function syncControls() {
const loaded = Boolean(state.game);
const hasPreviousFrame = state.frameIndex > 0 || state.currentHandIndex > 0;
const hasNextFrame = state.frameIndex < state.frames.length - 1 || state.currentHandIndex < state.hands.length - 1;
el.playBtn.disabled = !loaded;
el.prevBtn.disabled = !loaded || !hasPreviousFrame;
el.nextBtn.disabled = !loaded || !hasNextFrame;
el.resetBtn.disabled = !loaded;
el.playBtn.textContent = state.playing ? "Ⅱ" : "▶";
}
// ---------------------------------------------------------------------------
// Game lifecycle: load / merge / select hand / step / play.
// ---------------------------------------------------------------------------
function loadGame(raw, source, options = {}) {
const nextGame = normalizeGame(raw);
const wasPlaying = state.playing;
const mergeResult = options.allowMerge !== false ? mergeGame(nextGame) : { merged: false, advanced: false };
if (!mergeResult.merged) {
pause();
state.game = nextGame;
state.hands = state.game.hands;
state.currentHandIndex = Math.max(0, state.hands.length - 1);
state.frames = buildFrames(state.game, state.hands[state.currentHandIndex]);
state.frameIndex = 0;
}
state.source = source;
el.sourceBadge.textContent = source;
el.subtitle.textContent = `${state.game.game_id || "未命名游戏"} · ${state.hands.length} hands`;
renderSummary();
populateHands();
el.handSelect.value = String(state.currentHandIndex);
renderFrame();
if (mergeResult.merged && (wasPlaying || mergeResult.advanced)) play();
}
function mergeGame(nextGame) {
if (!state.game || state.game.game_id !== nextGame.game_id) return { merged: false, advanced: false };
const currentHand = state.hands[state.currentHandIndex];
const nextHandIndex = nextGame.hands.findIndex((hand) => hand.hand_number === currentHand?.hand_number);
if (nextHandIndex < 0) return { merged: false, advanced: false };
const oldFrame = state.frames[state.frameIndex];
const nextFrames = buildFrames(nextGame, nextGame.hands[nextHandIndex]);
const oldKeyIndex = oldFrame ? nextFrames.findIndex((frame) => frame.key === oldFrame.key) : -1;
const atKnownFrame = oldKeyIndex >= 0;
const wasAtTail = state.frameIndex >= state.frames.length - 1;
const hadNewFramesOnCurrentHand = nextFrames.length > state.frames.length;
const currentWasLatestHand = state.currentHandIndex >= state.hands.length - 1;
const hasNewLaterHandFromCurrent = currentWasLatestHand && nextGame.hands.length > state.hands.length;
let advanced = false;
state.game = nextGame;
state.hands = nextGame.hands;
state.currentHandIndex = nextHandIndex;
state.frames = nextFrames;
if (atKnownFrame) {
advanced = shouldStepIntoIncrement(wasAtTail, hadNewFramesOnCurrentHand, hasNewLaterHandFromCurrent);
state.frameIndex = advanced
? Math.min(oldKeyIndex + 1, nextFrames.length - 1)
: oldKeyIndex;
} else {
state.frameIndex = Math.min(state.frameIndex, nextFrames.length - 1);
}
if (state.frameIndex >= state.frames.length - 1 && hasNewLaterHandFromCurrent) {
state.currentHandIndex += 1;
state.frames = buildFrames(state.game, state.hands[state.currentHandIndex]);
state.frameIndex = 0;
advanced = true;
}
return { merged: true, advanced };
}
function shouldStepIntoIncrement(wasAtTail, hadNewFramesOnCurrentHand, hasLaterHands) {
return wasAtTail && (hadNewFramesOnCurrentHand || hasLaterHands);
}
function renderSummary() {
const game = state.game;
const hand = state.hands[state.currentHandIndex];
const blinds = hand?.blinds || game;
el.gameStatus.textContent = game?.status || "-";
el.playerCount.textContent = game?.players?.length ?? "-";
el.handCount.textContent = game?.hands?.length ?? "-";
el.blindLevel.textContent = game ? `${money(blinds.small_blind)} / ${money(blinds.big_blind)}` : "-";
}
function populateHands() {
el.handSelect.replaceChildren();
state.hands.forEach((hand, index) => {
const option = document.createElement("option");
option.value = String(index);
option.textContent = `Hand ${hand.hand_number} · ${hand.actions?.length || 0} actions`;
el.handSelect.appendChild(option);
});
}
function selectHand(index) {
pause();
state.currentHandIndex = Math.max(0, Math.min(index, state.hands.length - 1));
state.frames = buildFrames(state.game, state.hands[state.currentHandIndex]);
state.frameIndex = 0;
el.handSelect.value = String(state.currentHandIndex);
renderSummary();
renderFrame();
}
function nextFrame() {
if (!state.frames.length) return;
if (state.frameIndex < state.frames.length - 1) {
state.frameIndex += 1;
renderFrame();
return;
}
if (state.currentHandIndex < state.hands.length - 1) {
state.currentHandIndex += 1;
state.frames = buildFrames(state.game, state.hands[state.currentHandIndex]);
state.frameIndex = 0;
el.handSelect.value = String(state.currentHandIndex);
renderSummary();
renderFrame();
return;
}
pause();
}
function prevFrame() {
pause();
if (state.frameIndex === 0 && state.currentHandIndex > 0) {
state.currentHandIndex -= 1;
state.frames = buildFrames(state.game, state.hands[state.currentHandIndex]);
state.frameIndex = state.frames.length - 1;
el.handSelect.value = String(state.currentHandIndex);
renderSummary();
renderFrame();
return;
}
state.frameIndex = Math.max(0, state.frameIndex - 1);
renderFrame();
}
function play() {
if (!state.game || state.playing) return;
state.playing = true;
tick();
renderFrame();
}
function pause() {
state.playing = false;
if (state.timer) window.clearTimeout(state.timer);
state.timer = null;
syncControls();
}
function tick() {
if (!state.playing) return;
const frame = state.frames[state.frameIndex];
const baseDelay = frame?.type === "deal" || frame?.type === "award" ? 1500 : 1150;
const delay = baseDelay * Number(el.pace.value || 1);
state.timer = window.setTimeout(() => {
nextFrame();
tick();
}, delay);
}
// ---------------------------------------------------------------------------
// Network / file I/O.
// ---------------------------------------------------------------------------
async function fetchFromServer() {
const base = el.serverUrl.value.trim();
const gameId = el.gameId.value.trim();
if (!base || !gameId) throw new Error("请填写游戏服务地址和 Game ID");
const url = `/api/fetch-game?${new URLSearchParams({ base_url: base, game_id: gameId })}`;
const response = await fetch(url);
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "获取失败");
loadGame(payload.game, `Server · ${gameId}`);
}
function setAutoPoll(enabled) {
if (state.pollTimer) window.clearInterval(state.pollTimer);
state.pollTimer = null;
el.pollBadge.textContent = enabled ? `Auto ${el.pollSeconds.value}s` : "Auto Off";
if (!enabled) return;
const interval = Math.max(5, Number(el.pollSeconds.value || 12)) * 1000;
state.pollTimer = window.setInterval(() => {
fetchFromServer().catch((error) => showMessage(error.message));
}, interval);
}
function showMessage(message) {
el.tableMessage.textContent = message;
el.sourceBadge.textContent = "Error";
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
// ---------------------------------------------------------------------------
// Wiring.
// ---------------------------------------------------------------------------
el.fetchBtn.addEventListener("click", () => {
fetchFromServer().catch((error) => showMessage(error.message));
});
el.fileInput.addEventListener("change", async (event) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const raw = JSON.parse(await file.text());
loadGame(raw, `File · ${file.name}`);
} catch (error) {
showMessage(error.message);
} finally {
event.target.value = "";
}
});
el.autoPoll.addEventListener("change", () => setAutoPoll(el.autoPoll.checked));
el.pollSeconds.addEventListener("change", () => setAutoPoll(el.autoPoll.checked));
el.handSelect.addEventListener("change", () => selectHand(Number(el.handSelect.value)));
el.playBtn.addEventListener("click", () => state.playing ? pause() : play());
el.nextBtn.addEventListener("click", () => nextFrame());
el.prevBtn.addEventListener("click", () => prevFrame());
el.resetBtn.addEventListener("click", () => selectHand(state.currentHandIndex));
window.addEventListener("resize", () => renderFrame());
syncControls();
-132
View File
@@ -1,132 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Texas Hold X Replay</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div class="brand-lockup">
<div class="chip-mark" aria-hidden="true">TX</div>
<div class="brand-meta">
<h1>Texas Hold X Replay</h1>
<p id="subtitle">等待加载游戏数据</p>
</div>
</div>
<div class="status-strip" aria-live="polite">
<span id="sourceBadge" class="badge badge-gold">No Data</span>
<span id="pollBadge" class="badge badge-blue">Auto Off</span>
</div>
</header>
<main class="layout-grid">
<!-- Stage zone: pure visualization (table, seats, animations).
Placed first in DOM so mobile/tablet layouts keep it on top. -->
<section class="stage-zone" aria-label="牌桌动画回放">
<div class="stage-head">
<div class="stage-head-left">
<span id="handBadge" class="badge badge-gold">Hand -</span>
<strong id="streetLabel">未加载</strong>
</div>
<div class="stage-head-right">
<span id="potLabel" class="badge badge-gold">Pot 0</span>
</div>
</div>
<div id="table" class="poker-table">
<!-- felt-shell encapsulates the rounded green felt with overflow:hidden,
so player speech bubbles drawn in seat-layer can overflow freely
above and below the table without being clipped. -->
<div class="felt-shell" aria-hidden="true">
<div class="felt-rail"></div>
<div class="felt-surface">
<div class="felt-grid"></div>
<div class="felt-glow"></div>
<div class="felt-mark">TX</div>
</div>
</div>
<div class="community-area">
<div id="boardCards" class="card-row board"></div>
<div id="tableMessage" class="table-message">上传 JSON 或从游戏服务获取快照</div>
</div>
<div id="seatLayer" class="seat-layer"></div>
</div>
</section>
<!-- Interaction zone: data source + replay controls + summary. -->
<section class="control-panel" aria-label="数据与播放控制">
<div class="panel-section">
<h2>数据源</h2>
<label>
<span>游戏服务</span>
<input id="serverUrl" type="url" value="http://127.0.0.1:8000" placeholder="http://127.0.0.1:8000" />
</label>
<label>
<span>Game ID</span>
<input id="gameId" type="text" value="game1" placeholder="game1" />
</label>
<div class="button-row">
<button id="fetchBtn" class="primary-btn" type="button">获取</button>
<label class="file-btn">
上传 JSON
<input id="fileInput" type="file" accept="application/json,.json" />
</label>
</div>
<div class="auto-grid">
<label class="toggle-line">
<input id="autoPoll" type="checkbox" />
<span>自动获取</span>
</label>
<label>
<span>间隔秒</span>
<input id="pollSeconds" type="number" min="5" max="300" value="12" />
</label>
</div>
</div>
<div class="panel-section">
<h2>回放</h2>
<label>
<span>手牌</span>
<select id="handSelect"></select>
</label>
<label>
<span>节奏</span>
<input id="pace" type="range" min="0.75" max="1.8" step="0.05" value="1" />
</label>
<div class="transport-row">
<button id="prevBtn" type="button" title="上一帧"></button>
<button id="playBtn" class="primary-btn" type="button" title="播放/暂停"></button>
<button id="nextBtn" type="button" title="下一帧"></button>
<button id="resetBtn" type="button" title="重置"></button>
</div>
<div class="progress-wrap">
<div id="progressBar"></div>
</div>
</div>
<div class="panel-section dense">
<h2>牌局摘要</h2>
<dl class="stat-list">
<div><dt>状态</dt><dd id="gameStatus">-</dd></div>
<div><dt>玩家</dt><dd id="playerCount">-</dd></div>
<div><dt>总手数</dt><dd id="handCount">-</dd></div>
<div><dt>盲注</dt><dd id="blindLevel">-</dd></div>
</dl>
</div>
</section>
<aside class="event-panel" aria-label="事件日志">
<div class="panel-section">
<h2>事件</h2>
<ol id="eventLog" class="event-log"></ol>
</div>
</aside>
</main>
</div>
<script src="/app.js" type="module"></script>
</body>
</html>
-735
View File
@@ -1,735 +0,0 @@
/* =========================================================================
Texas Hold X Replay — Pixel-art skin & responsive layout
-------------------------------------------------------------------------
Design goals:
1. Stage zone (table + seats + animations) is visually isolated from the
interaction zone (controls + event log). Each zone has independent
overflow rules so speech bubbles never get clipped.
2. Pixel-art aesthetic: hard edges, stepped shadows (no blur), 8-bit
palette, monospace typography.
3. Three responsive breakpoints (desktop 3-col → tablet 2-col → mobile
stacked) — see media queries at the bottom of this file.
========================================================================= */
:root {
color-scheme: dark;
/* Palette — keep limited and high-contrast for that 8-bit look. */
--ink: #f7efd2;
--ink-dim: #c8b98a;
--muted: #8e8466;
--panel: #221a17;
--panel-2: #2d231f;
--panel-3: #3a2c25;
--line: #5d4638;
--line-soft: #3b2c25;
/* Felt greens. */
--felt: #1d8a5f;
--felt-dark: #0c4a37;
--felt-light: #4ec089;
--felt-rail: #6c3a20;
--felt-rail-dark: #3a1d10;
/* Accents. */
--gold: #f0b64d;
--gold-dark: #b87a1f;
--red: #e24b4b;
--blue: #53a6de;
--green: #63cb73;
--purple: #b773d3;
--shadow: rgba(8, 6, 5, 0.55);
--pixel: 3px;
/* Seat sizing scales with the stage width via container query fallback
(clamp on viewport). */
--seat-size: clamp(96px, 13vw, 138px);
--avatar-size: 44px;
}
* { box-sizing: border-box; }
body {
min-height: 100vh;
margin: 0;
color: var(--ink);
/* Pixel-art monospace stack — keeps numerals crisp & blocky. */
font-family: "Courier New", "Lucida Console", "Press Start 2P", monospace;
font-size: 13px;
letter-spacing: 0.2px;
background:
/* Tiny noise made from offset diagonals for that CRT pixel-grid feel. */
linear-gradient(45deg, rgba(255,255,255,0.025) 25%, transparent 25%) 0 0 / 8px 8px,
linear-gradient(135deg, rgba(255,255,255,0.025) 25%, transparent 25%) 0 0 / 8px 8px,
radial-gradient(ellipse at 50% 0%, #2a1f1a 0%, #14100e 60%, #0d0a08 100%);
image-rendering: pixelated;
-webkit-font-smoothing: none;
-moz-osx-font-smoothing: grayscale;
}
button, input, select { font: inherit; }
/* ---------- Buttons (chunky pixel "press" feel) ---------- */
button, .file-btn {
min-height: 42px;
border: var(--pixel) solid #17100d;
color: var(--ink);
background: #3a2b24;
box-shadow:
inset -3px -3px 0 rgba(0,0,0,0.35),
inset 3px 3px 0 rgba(255,255,255,0.08),
0 4px 0 #120d0b;
cursor: pointer;
transition: transform 90ms steps(2), filter 90ms steps(2);
}
button:hover, .file-btn:hover { filter: brightness(1.12); }
button:active, .file-btn:active {
transform: translateY(3px);
box-shadow:
inset -3px -3px 0 rgba(0,0,0,0.35),
inset 3px 3px 0 rgba(255,255,255,0.08),
0 1px 0 #120d0b;
}
button:disabled { opacity: 0.4; cursor: not-allowed; }
input, select {
width: 100%;
min-height: 40px;
border: 2px solid var(--line);
border-radius: 0;
color: var(--ink);
background: #15110f;
padding: 9px 10px;
outline: none;
}
input:focus, select:focus { border-color: var(--gold); }
/* ---------- Shell / Topbar ---------- */
.app-shell {
width: min(1640px, 100%);
margin: 0 auto;
padding: 18px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 14px 16px;
border: var(--pixel) solid #49382e;
background:
linear-gradient(180deg, #322620 0%, #1f1715 100%);
box-shadow: 0 8px 0 #0d0908, 0 18px 34px var(--shadow);
}
.brand-lockup { display: flex; gap: 14px; align-items: center; min-width: 0; }
.brand-meta { min-width: 0; }
.chip-mark {
display: grid;
place-items: center;
width: 58px;
height: 58px;
flex: 0 0 auto;
border: 4px dashed #fff4bc;
border-radius: 50%;
color: #20130b;
background: radial-gradient(circle, #ffe28a 0 42%, #c73f3d 43% 100%);
font-weight: 900;
text-shadow: 1px 1px 0 rgba(255,255,255,0.45);
/* Slow 8-bit chip rotation when idle for a touch of life. */
animation: chipSpin 6s linear infinite;
}
h1, h2, p { margin: 0; }
h1 { font-size: clamp(18px, 2.3vw, 28px); }
h2 { margin-bottom: 12px; color: var(--gold); font-size: 15px; }
#subtitle { margin-top: 5px; color: var(--ink-dim); font-size: 12px; }
.status-strip {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
/* Generic pixel "tag" badge — shared by status, hand, pot, etc. */
.badge {
display: inline-block;
border: 2px solid #16100d;
padding: 7px 10px;
color: #1b120c;
font-weight: 700;
white-space: nowrap;
box-shadow: inset -2px -2px 0 rgba(0,0,0,0.18);
}
.badge-gold { background: var(--gold); }
.badge-blue { background: var(--blue); color: #0f1c2e; }
/* ---------- Layout Grid ---------- */
/* Desktop: stage in middle, controls left, events right. The stage column
is the dominant area (1.4fr) so the table breathes. */
.layout-grid {
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(0, 1.4fr) minmax(280px, 340px);
grid-template-areas: "controls stage events";
gap: 16px;
margin-top: 18px;
align-items: start;
}
.control-panel { grid-area: controls; display: grid; gap: 14px; }
.stage-zone { grid-area: stage; min-width: 0; }
.event-panel { grid-area: events; display: grid; gap: 14px; }
/* ---------- Panel sections (the chunky bordered cards) ---------- */
.panel-section {
border: var(--pixel) solid #49382e;
background: linear-gradient(180deg, var(--panel-2), var(--panel));
padding: 14px;
box-shadow: 0 7px 0 #0e0a08, 0 14px 26px var(--shadow);
}
.panel-section label {
display: grid;
gap: 6px;
margin-bottom: 10px;
color: var(--ink-dim);
font-size: 12px;
}
.button-row, .transport-row, .auto-grid { display: grid; gap: 9px; }
.button-row { grid-template-columns: 1fr 1fr; }
.transport-row { grid-template-columns: repeat(4, minmax(42px, 1fr)); }
.auto-grid { grid-template-columns: 1fr 86px; align-items: end; }
.toggle-line { display: flex !important; flex-direction: row; align-items: center; min-height: 40px; }
.toggle-line input { width: auto; min-height: auto; accent-color: var(--gold); }
.primary-btn { color: #1b120c; background: var(--gold); }
.file-btn {
display: grid; place-items: center; text-align: center;
position: relative; overflow: hidden;
}
.file-btn input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.progress-wrap {
height: 12px; margin-top: 14px;
border: 2px solid #120d0b; background: #14100e;
}
#progressBar {
width: 0; height: 100%;
background: linear-gradient(90deg, var(--red), var(--gold), var(--green));
transition: width 220ms linear;
}
.stat-list { display: grid; gap: 8px; margin: 0; }
.stat-list div {
display: flex; justify-content: space-between; gap: 12px;
border-bottom: 1px solid rgba(255,255,255,0.08);
padding-bottom: 7px;
}
.stat-list dt { color: var(--ink-dim); }
.stat-list dd { margin: 0; text-align: right; }
/* ---------- Stage zone ---------- */
.stage-head {
display: flex; justify-content: space-between; align-items: center;
gap: 10px; margin-bottom: 12px;
}
.stage-head-left { display: flex; gap: 10px; align-items: center; min-width: 0; }
#streetLabel { font-size: clamp(16px, 2vw, 22px); color: var(--ink); }
/* The poker-table is the positioning context for seats. Its overflow MUST
stay visible so seats placed near the table edges (and their speech
bubbles) can extend slightly outside the green felt without being clipped.
The visual clipping is handled by .felt-shell instead. */
.poker-table {
position: relative;
/* Reserve vertical breathing room above/below the felt for seats that
visually sit on the rail and for speech bubbles. */
padding: 70px 12px 80px;
/* Explicit responsive height — required because seats and felt are
absolutely positioned and the inner content (community area) is
center-aligned. clamp() keeps it usable from 640px to ~820px. */
height: clamp(620px, 64vw, 820px);
overflow: visible;
}
/* Felt shell — actual visible green table. Absolutely positioned and
inset within poker-table so seats/bubbles can spill outside. */
.felt-shell {
position: absolute;
inset: 60px 0 70px;
border: 6px solid var(--felt-rail);
border-radius: 46% / 36%;
background: var(--felt-rail-dark);
box-shadow:
inset 0 0 0 10px var(--felt-rail),
inset 0 0 0 16px #2c1b12,
0 10px 0 #130c09,
0 24px 44px var(--shadow);
overflow: hidden;
pointer-events: none;
}
.felt-rail {
/* Decorative pixel "studs" running along the rail. */
position: absolute; inset: -2px;
background:
repeating-linear-gradient(90deg,
transparent 0 22px, rgba(255,220,160,0.18) 22px 24px),
repeating-linear-gradient(0deg,
transparent 0 22px, rgba(255,220,160,0.18) 22px 24px);
mix-blend-mode: screen;
opacity: 0.4;
}
.felt-surface {
position: absolute;
inset: 12px;
border-radius: 44% / 32%;
background:
radial-gradient(ellipse at 50% 50%, var(--felt-light) 0%, var(--felt) 38%, var(--felt-dark) 100%);
overflow: hidden;
}
.felt-grid {
position: absolute; inset: 0;
opacity: 0.18;
/* 1px-wide pixel grid for the felt — gives a chess-board-like 8-bit feel. */
background:
linear-gradient(90deg, transparent calc(100% - 2px), rgba(255,255,255,0.4) 0) 0 0 / 28px 28px,
linear-gradient(180deg, transparent calc(100% - 2px), rgba(255,255,255,0.28) 0) 0 0 / 28px 28px;
image-rendering: pixelated;
}
.felt-glow {
position: absolute; inset: 0;
background: radial-gradient(ellipse at 50% 45%, rgba(255,255,255,0.18), transparent 55%);
pointer-events: none;
}
.felt-mark {
position: absolute;
left: 50%; top: 50%;
transform: translate(-50%, -50%);
font-size: clamp(48px, 7vw, 96px);
font-weight: 900;
color: rgba(0,0,0,0.18);
letter-spacing: 6px;
text-shadow: 2px 2px 0 rgba(255,255,255,0.06);
user-select: none;
}
/* ---------- Community area (board + message) ---------- */
.community-area {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: min(60%, 560px);
display: grid;
justify-items: center;
gap: 14px;
z-index: 3;
pointer-events: none;
}
.card-row {
display: flex;
justify-content: center;
gap: clamp(6px, 1vw, 12px);
min-height: 76px;
}
.card-row.board { min-height: 92px; }
/* ---------- Cards ---------- */
.card {
display: grid;
grid-template-rows: auto 1fr auto;
width: clamp(48px, 5.4vw, 70px);
height: clamp(66px, 7.4vw, 96px);
border: 3px solid #1c1411;
background: #fff7df;
color: #17100d;
padding: 4px 6px;
box-shadow:
inset -2px -2px 0 rgba(0,0,0,0.12),
inset 2px 2px 0 rgba(255,255,255,0.6),
0 5px 0 rgba(0,0,0,0.42);
transform-origin: center;
animation: cardDeal 520ms cubic-bezier(.2,.9,.2,1);
font-family: "Courier New", monospace;
}
.card.red { color: #b92732; }
.card.back {
background:
/* 8-bit checker pattern for card back. */
linear-gradient(45deg, #2d579a 25%, transparent 25%) 0 0 / 8px 8px,
linear-gradient(-45deg, #2d579a 25%, transparent 25%) 0 0 / 8px 8px,
linear-gradient(45deg, transparent 75%, #2d579a 75%) 0 0 / 8px 8px,
linear-gradient(-45deg, transparent 75%, #2d579a 75%) 0 0 / 8px 8px,
#173a72;
}
.card .rank { font-size: clamp(12px, 1.2vw, 16px); font-weight: 900; line-height: 1; }
.card .suit { display: grid; place-items: center; font-size: clamp(18px, 2vw, 28px); }
.card .rank.bottom { transform: rotate(180deg); justify-self: end; }
.table-message {
min-height: 32px;
max-width: min(540px, 82%);
border: 3px solid #14100d;
padding: 8px 12px;
color: #1b120c;
background: #ffe28a;
text-align: center;
box-shadow: 0 4px 0 rgba(0,0,0,0.38);
}
/* ---------- Seats ---------- */
.seat-layer {
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
}
.seat {
--x: 50%;
--y: 50%;
position: absolute;
left: var(--x);
top: var(--y);
width: var(--seat-size);
transform: translate(-50%, -50%);
transition: transform 220ms steps(4), filter 220ms ease;
pointer-events: auto;
}
/* Active seat — slight lift + glow + sprite "hop" animation. */
.seat.active {
filter: drop-shadow(0 0 14px rgba(240,182,77,0.85));
z-index: 6;
}
.seat.active .avatar { animation: avatarHop 520ms ease; }
.seat.folded { opacity: 0.55; filter: grayscale(0.6); }
.seat.folded .avatar { transform: rotate(-8deg); }
/* Action-driven sprite reactions (added by JS as transient classes). */
.seat.act-fold .avatar { animation: avatarFold 600ms ease forwards; }
.seat.act-bet .avatar,
.seat.act-raise .avatar,
.seat.act-all_in .avatar { animation: avatarShake 480ms ease; }
.seat.act-call .avatar,
.seat.act-check .avatar { animation: avatarNod 480ms ease; }
.seat.act-award .avatar { animation: avatarCheer 900ms ease; }
/* Speech bubble — placed above the seat by default; below for top seats so
it does not punch out of the table viewport. */
.speech {
position: absolute;
left: 50%;
bottom: calc(100% + 8px);
min-width: 72px;
max-width: 160px;
transform: translateX(-50%);
border: 3px solid #160f0c;
padding: 6px 10px;
color: #1a110b;
background: #fff2b7;
text-align: center;
font-weight: 700;
font-size: 12px;
box-shadow: 0 4px 0 rgba(0,0,0,0.38);
animation: bubblePop 380ms ease;
z-index: 8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Bubble tail — pixel-art triangle made from a rotated solid square. */
.speech::after {
content: "";
position: absolute;
left: 50%; top: 100%;
transform: translate(-50%, -3px) rotate(45deg);
width: 12px; height: 12px;
background: #fff2b7;
border-right: 3px solid #160f0c;
border-bottom: 3px solid #160f0c;
}
/* For top-row seats, flip the bubble below so it does not get clipped. */
.seat.seat-top .speech {
bottom: auto;
top: calc(100% + 8px);
}
.seat.seat-top .speech::after {
top: auto; bottom: 100%;
transform: translate(-50%, 3px) rotate(225deg);
}
/* Color the bubble by action category. */
.speech.kind-fold { background: #d8d2bc; }
.speech.kind-call,
.speech.kind-check { background: #c2e6c8; }
.speech.kind-bet,
.speech.kind-raise,
.speech.kind-all_in { background: #ffc7a8; color: #5a1f0a; }
.speech.kind-award { background: #ffe28a; }
/* Player name+avatar+stack box. */
.player-box {
border: 3px solid #15100d;
background: linear-gradient(180deg, #423128, #261c18);
padding: 8px;
box-shadow:
inset -3px -3px 0 rgba(0,0,0,0.35),
inset 3px 3px 0 rgba(255,255,255,0.08),
0 6px 0 #120d0b;
position: relative;
}
.player-head {
display: grid;
grid-template-columns: var(--avatar-size) 1fr auto;
gap: 8px;
align-items: center;
min-width: 0;
}
/* Avatar — wraps an inline SVG pixel-art portrait generated by JS. The SVG
is an 8x8 grid of <rect> elements; the host element only provides the
square frame, border, and animation hook. */
.avatar {
width: var(--avatar-size);
height: var(--avatar-size);
flex: 0 0 auto;
position: relative;
border: 2px solid #15100d;
background: #211814;
image-rendering: pixelated;
display: grid;
place-items: stretch;
overflow: hidden;
box-shadow: inset -2px -2px 0 rgba(0,0,0,0.35);
}
.avatar svg {
width: 100%;
height: 100%;
display: block;
shape-rendering: crispEdges;
}
.player-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 900;
font-size: 12px;
color: var(--ink);
}
.dealer {
display: none;
flex: 0 0 auto;
border: 2px solid #130e0b;
padding: 2px 6px;
color: #17100d;
background: var(--gold);
font-weight: 900;
font-size: 11px;
}
.seat.dealer-seat .dealer { display: inline-block; }
.player-meta {
display: flex;
justify-content: space-between;
gap: 8px;
margin-top: 6px;
font-size: 11px;
}
.stack { color: var(--green); }
.bet { min-height: 14px; color: var(--gold); }
.hole-cards {
justify-content: flex-start;
min-height: 38px;
margin-top: 6px;
gap: 4px;
}
.hole-cards .card {
width: clamp(26px, 2.8vw, 36px);
height: clamp(36px, 4vw, 50px);
padding: 2px 3px;
border-width: 2px;
box-shadow:
inset -1px -1px 0 rgba(0,0,0,0.12),
inset 1px 1px 0 rgba(255,255,255,0.6),
0 3px 0 rgba(0,0,0,0.42);
}
.hole-cards .card .rank { font-size: 10px; }
.hole-cards .card .suit { font-size: 14px; }
/* Chip stack indicator drawn near the player's bet (gives action visual
weight even before the bubble). */
.chip-pile {
position: absolute;
left: 50%;
top: -14px;
transform: translateX(-50%);
display: flex;
gap: 2px;
pointer-events: none;
opacity: 0;
transition: opacity 200ms steps(2);
}
.chip-pile.visible { opacity: 1; }
.chip-pile .chip {
width: 12px; height: 12px;
border-radius: 50%;
border: 2px solid #160f0c;
background: var(--gold);
box-shadow: 0 2px 0 rgba(0,0,0,0.5);
}
.chip-pile .chip:nth-child(2) { background: var(--red); }
.chip-pile .chip:nth-child(3) { background: var(--blue); }
/* ---------- Event log ---------- */
.event-panel .panel-section { display: flex; flex-direction: column; }
.event-log {
display: grid;
gap: 8px;
/* Use viewport-relative max-height for desktop, with a fallback minimum
so the log never collapses to nothing. The actual scrollable height is
plenty for 20+ events while still aligning with the table's height. */
max-height: clamp(420px, 70vh, 760px);
overflow: auto;
margin: 0;
padding: 0 4px 0 26px;
scrollbar-width: thin;
scrollbar-color: var(--gold-dark) #1a1310;
}
.event-log::-webkit-scrollbar { width: 10px; }
.event-log::-webkit-scrollbar-track { background: #1a1310; }
.event-log::-webkit-scrollbar-thumb {
background: var(--gold-dark);
border: 2px solid #1a1310;
}
.event-log li {
border-left: 4px solid var(--line);
padding: 7px 8px;
color: var(--ink-dim);
background: rgba(0,0,0,0.18);
word-break: break-word;
white-space: normal;
line-height: 1.4;
}
.event-log li.current {
border-color: var(--gold);
color: var(--ink);
background: rgba(240,182,77,0.14);
box-shadow: inset 2px 0 0 var(--gold);
}
.event-log li.past { opacity: 0.85; }
/* ---------- Animations ---------- */
@keyframes cardDeal {
from { opacity: 0; transform: translateY(-18px) rotate(-6deg) scale(0.86); }
to { opacity: 1; transform: translateY(0) rotate(0) scale(1); }
}
@keyframes bubblePop {
from { opacity: 0; transform: translate(-50%, 8px) scale(0.8); }
to { opacity: 1; transform: translate(-50%, 0) scale(1); }
}
@keyframes chipSpin {
0%, 90%, 100% { transform: rotate(0); }
95% { transform: rotate(8deg); }
}
@keyframes avatarHop {
0%, 100% { transform: translateY(0); }
40% { transform: translateY(-6px); }
70% { transform: translateY(-2px); }
}
@keyframes avatarShake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-3px) rotate(-3deg); }
40% { transform: translateX(3px) rotate(3deg); }
60% { transform: translateX(-2px) rotate(-2deg); }
80% { transform: translateX(2px) rotate(2deg); }
}
@keyframes avatarNod {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(3px); }
}
@keyframes avatarFold {
0% { transform: rotate(0) translateY(0); }
100% { transform: rotate(-12deg) translateY(2px); }
}
@keyframes avatarCheer {
0%, 100% { transform: translateY(0) rotate(0); }
20% { transform: translateY(-8px) rotate(-6deg); }
50% { transform: translateY(-4px) rotate(6deg); }
80% { transform: translateY(-6px) rotate(-3deg); }
}
/* =========================================================================
Responsive breakpoints
- Tablet (<=1180px): drop to 2 columns. Stage on top spans full width;
controls + events sit side-by-side underneath.
- Mobile (<=760px): single column; stage first, then controls, then log.
========================================================================= */
@media (max-width: 1180px) {
.layout-grid {
grid-template-columns: minmax(260px, 1fr) minmax(260px, 1fr);
grid-template-areas:
"stage stage"
"controls events";
}
.event-log { max-height: 360px; }
:root { --seat-size: clamp(96px, 16vw, 130px); }
}
@media (max-width: 760px) {
.app-shell { padding: 10px; }
.topbar {
display: grid;
grid-template-columns: 1fr;
}
.status-strip { justify-content: flex-start; }
.layout-grid {
grid-template-columns: 1fr;
grid-template-areas:
"stage"
"controls"
"events";
}
.stage-head {
flex-wrap: wrap;
}
.poker-table {
padding: 56px 4px 64px;
height: 600px;
}
.felt-shell {
inset: 48px 0 56px;
border-radius: 28px;
}
.felt-surface { border-radius: 22px; }
.community-area { width: min(76%, 420px); }
.event-log { max-height: 240px; }
:root {
--seat-size: clamp(86px, 30vw, 120px);
--avatar-size: 36px;
}
}
/* Reduce-motion users get static sprites (no shake/hop). */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}