Compare commits
6 Commits
bd81207bc7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ee963ce2e | |||
| 351cac7734 | |||
| 79dccde963 | |||
| 3c027eae0b | |||
| 09c42e9fa3 | |||
| e22586aa2f |
@@ -32,5 +32,12 @@ htmlcov/
|
|||||||
# IDE and editor files
|
# IDE and editor files
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.codex/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
|
# debug resources
|
||||||
|
debug/
|
||||||
|
|
||||||
|
# Node dependencies for browser automation tooling
|
||||||
|
node_modules/
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
- 支持盲注、四条街下注、弃牌、过牌、跟注、下注、加注、全下、边池和摊牌结算。
|
- 支持盲注、四条街下注、弃牌、过牌、跟注、下注、加注、全下、边池和摊牌结算。
|
||||||
- 支持本地 Agent 和 HTTP Agent。
|
- 支持本地 Agent 和 HTTP Agent。
|
||||||
- 支持 Human Agent 和 OpenAI-compatible AI 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
|
curl http://127.0.0.1:8000/games/demo
|
||||||
```
|
```
|
||||||
|
|
||||||
|
也可以使用单数别名:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/game/demo
|
||||||
|
```
|
||||||
|
|
||||||
## HTTP Agent 协议
|
## HTTP Agent 协议
|
||||||
|
|
||||||
玩家配置可以使用远程 HTTP Agent:
|
玩家配置可以使用远程 HTTP Agent:
|
||||||
@@ -62,12 +69,19 @@ curl http://127.0.0.1:8000/games/demo
|
|||||||
"agent": {
|
"agent": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"endpoint": "http://127.0.0.1:9101",
|
"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
|
```json
|
||||||
{"action": "call"}
|
{"action": "call"}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# 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 列表,动画层无需重写。
|
||||||
@@ -9,6 +9,10 @@ dependencies = []
|
|||||||
texas-holdem-server = "texas_holdem.server:main"
|
texas-holdem-server = "texas_holdem.server:main"
|
||||||
texas-holdem-human = "texas_holdem.human_client:main"
|
texas-holdem-human = "texas_holdem.human_client:main"
|
||||||
texas-holdem-ai = "texas_holdem.ai_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]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
+74
-1
@@ -1,8 +1,26 @@
|
|||||||
import unittest
|
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
|
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):
|
class ServiceTests(unittest.TestCase):
|
||||||
def test_create_and_run_game(self) -> None:
|
def test_create_and_run_game(self) -> None:
|
||||||
manager = GameManager()
|
manager = GameManager()
|
||||||
@@ -23,7 +41,62 @@ class ServiceTests(unittest.TestCase):
|
|||||||
hands = manager.run_hands(game.game_id, count=1)
|
hands = manager.run_hands(game.game_id, count=1)
|
||||||
|
|
||||||
self.assertEqual(len(hands), 1)
|
self.assertEqual(len(hands), 1)
|
||||||
self.assertEqual(manager.get_game("demo").to_dict()["hand_number"], 1)
|
self.assertEqual(manager.get_game_state("demo")["hand_number"], 1)
|
||||||
|
|
||||||
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+106
-31
@@ -2,10 +2,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from random import Random
|
from random import Random
|
||||||
from typing import IO, Any
|
from typing import IO, Any
|
||||||
from urllib.error import URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
from texas_holdem.human_io import clear_screen, prompt_action, render_observation
|
from texas_holdem.human_io import clear_screen, prompt_action, render_observation
|
||||||
@@ -54,6 +55,27 @@ class CallingStationAgent(PokerAgent):
|
|||||||
return PlayerAction("fold")
|
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):
|
class HttpAgent(PokerAgent):
|
||||||
"""Remote agent that talks to a base URL exposing ``/act`` and ``/game``.
|
"""Remote agent that talks to a base URL exposing ``/act`` and ``/game``.
|
||||||
|
|
||||||
@@ -66,28 +88,36 @@ class HttpAgent(PokerAgent):
|
|||||||
ACT_PATH = "/act"
|
ACT_PATH = "/act"
|
||||||
GAME_PATH = "/game"
|
GAME_PATH = "/game"
|
||||||
|
|
||||||
def __init__(self, endpoint: str, timeout_seconds: float = 10.0) -> None:
|
def __init__(
|
||||||
self.base_url = self._normalise_base_url(endpoint)
|
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
|
self.timeout_seconds = timeout_seconds
|
||||||
|
self.player_id = player_id
|
||||||
@staticmethod
|
self.game_update_timeout_seconds = (
|
||||||
def _normalise_base_url(raw: str) -> str:
|
float(game_update_timeout_seconds)
|
||||||
"""Strip a trailing slash so URL joins do not produce double slashes.
|
if game_update_timeout_seconds is not None
|
||||||
|
else min(timeout_seconds, 3.0)
|
||||||
Centralising this also tolerates the legacy "endpoint already points
|
)
|
||||||
at /act" mistake by chopping off a redundant ``/act`` suffix.
|
self.retries = max(0, retries)
|
||||||
"""
|
self.retry_backoff_seconds = max(0.0, retry_backoff_seconds)
|
||||||
url = raw.rstrip("/")
|
|
||||||
if url.endswith("/act"):
|
|
||||||
url = url[: -len("/act")]
|
|
||||||
return url
|
|
||||||
|
|
||||||
def _url(self, path: str) -> str:
|
def _url(self, path: str) -> str:
|
||||||
"""Compose a full URL by joining the base with a path component."""
|
"""Compose a full URL by joining the base with a path component."""
|
||||||
return f"{self.base_url}{path}"
|
return f"{self.base_url}{path}"
|
||||||
|
|
||||||
def decide(self, observation: Observation) -> PlayerAction:
|
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):
|
if not isinstance(payload, dict):
|
||||||
raise RuntimeError("agent endpoint must return a JSON object")
|
raise RuntimeError("agent endpoint must return a JSON object")
|
||||||
return PlayerAction.from_dict(payload)
|
return PlayerAction.from_dict(payload)
|
||||||
@@ -100,30 +130,54 @@ class HttpAgent(PokerAgent):
|
|||||||
only by way of the raised exception bubbling to the engine guard.
|
only by way of the raised exception bubbling to the engine guard.
|
||||||
"""
|
"""
|
||||||
try:
|
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:
|
except RuntimeError:
|
||||||
# ``/game`` is informational; treat any HTTP error as a benign
|
# ``/game`` is informational; treat any HTTP error as a benign
|
||||||
# drop rather than reraising and aborting the hand loop.
|
# drop rather than reraising and aborting the hand loop.
|
||||||
return None
|
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.
|
"""POST ``payload`` as JSON to ``base_url + path`` and return parsed body.
|
||||||
|
|
||||||
Extracted as a tiny helper so ``decide`` and ``on_game_update`` share
|
Extracted as a tiny helper so ``decide`` and ``on_game_update`` share
|
||||||
identical transport semantics (timeout, error wrapping, content-type).
|
identical transport semantics (timeout, error wrapping, content-type).
|
||||||
"""
|
"""
|
||||||
body = json.dumps(payload).encode("utf-8")
|
body = json.dumps(payload).encode("utf-8")
|
||||||
request = Request(
|
last_error: BaseException | None = None
|
||||||
self._url(path),
|
raw = ""
|
||||||
data=body,
|
for attempt in range(self.retries + 1):
|
||||||
headers={"Content-Type": "application/json"},
|
request = Request(
|
||||||
method="POST",
|
self._url(path),
|
||||||
)
|
data=body,
|
||||||
try:
|
headers=self._headers(),
|
||||||
with urlopen(request, timeout=self.timeout_seconds) as response:
|
method="POST",
|
||||||
raw = response.read().decode("utf-8")
|
)
|
||||||
except (OSError, URLError) as exc:
|
try:
|
||||||
raise RuntimeError(f"agent endpoint failed: {self._url(path)}") from exc
|
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:
|
if not raw:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -133,6 +187,12 @@ class HttpAgent(PokerAgent):
|
|||||||
f"agent endpoint returned invalid JSON: {self._url(path)}"
|
f"agent endpoint returned invalid JSON: {self._url(path)}"
|
||||||
) from exc
|
) 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):
|
class HumanAgent(PokerAgent):
|
||||||
"""Interactive CLI agent for debugging and manual play.
|
"""Interactive CLI agent for debugging and manual play.
|
||||||
@@ -189,7 +249,11 @@ class HumanAgent(PokerAgent):
|
|||||||
return line.rstrip("\n")
|
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()
|
agent_type = str(spec.get("type", "calling")).lower()
|
||||||
if agent_type == "random":
|
if agent_type == "random":
|
||||||
return RandomAgent(rng)
|
return RandomAgent(rng)
|
||||||
@@ -199,7 +263,18 @@ def build_agent(spec: dict[str, Any], rng: Random | None = None) -> PokerAgent:
|
|||||||
endpoint = spec.get("endpoint")
|
endpoint = spec.get("endpoint")
|
||||||
if not endpoint:
|
if not endpoint:
|
||||||
raise ValueError("http agent requires an 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"}:
|
if agent_type in {"human", "cli", "interactive"}:
|
||||||
return HumanAgent()
|
return HumanAgent()
|
||||||
raise ValueError(f"unknown agent type: {agent_type}")
|
raise ValueError(f"unknown agent type: {agent_type}")
|
||||||
|
|||||||
+169
-3
@@ -69,6 +69,109 @@ ANSI_RESET = "\x1b[0m"
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _ThinkingIndicator:
|
||||||
|
"""Animated "thinking..." marquee for the AI agent console.
|
||||||
|
|
||||||
|
Design rationale:
|
||||||
|
- Encapsulated as its own class so the animation lifecycle (timer
|
||||||
|
thread, frame state, screen erase sequence) does not pollute the
|
||||||
|
surrounding console class.
|
||||||
|
- Runs in a daemon background thread driven by ``threading.Event`` so
|
||||||
|
``stop`` returns promptly even if the current frame is mid-sleep.
|
||||||
|
- Uses ANSI ``\\r`` plus a clearing escape sequence to overwrite the
|
||||||
|
previous frame in place, avoiding scrollback noise. The frames
|
||||||
|
cycle through 0/1/2/3 dots every 0.5s as requested.
|
||||||
|
- ``start``/``stop`` are idempotent so the higher-level console can
|
||||||
|
call ``stop`` defensively (e.g. on the fallback path) without
|
||||||
|
tracking whether a marquee is actually running.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Frame interval in seconds; matches the user-visible cadence.
|
||||||
|
_FRAME_INTERVAL = 0.5
|
||||||
|
# 0..3 dots, looping.
|
||||||
|
_FRAMES = ("thinking", "thinking.", "thinking..", "thinking...")
|
||||||
|
# ANSI escape that clears from the cursor to the end of the line; we
|
||||||
|
# combine it with a leading carriage return to redraw the frame in
|
||||||
|
# place.
|
||||||
|
_ERASE_LINE = "\r\x1b[K"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
write_fn: Callable[[str], None],
|
||||||
|
gray_fn: Callable[[str], str],
|
||||||
|
) -> None:
|
||||||
|
self._write = write_fn
|
||||||
|
self._gray = gray_fn
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
# ``_active`` reflects whether a frame is currently visible on
|
||||||
|
# screen; ``stop`` uses it to decide whether to emit the final
|
||||||
|
# erase sequence.
|
||||||
|
self._active = False
|
||||||
|
# Guard against concurrent start/stop calls from different
|
||||||
|
# threads (e.g. content-delta handler vs. end_llm_stream).
|
||||||
|
self._lifecycle_lock = threading.Lock()
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Begin the marquee in a background thread.
|
||||||
|
|
||||||
|
Calling ``start`` while already running is a no-op.
|
||||||
|
"""
|
||||||
|
with self._lifecycle_lock:
|
||||||
|
if self._thread is not None and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._active = True
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=self._run,
|
||||||
|
name="ai-thinking-indicator",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self._thread = thread
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop the marquee and erase the current frame from the screen.
|
||||||
|
|
||||||
|
Safe to call when not running.
|
||||||
|
"""
|
||||||
|
with self._lifecycle_lock:
|
||||||
|
thread = self._thread
|
||||||
|
if thread is None:
|
||||||
|
return
|
||||||
|
self._stop_event.set()
|
||||||
|
self._thread = None
|
||||||
|
# Wait for the worker outside the lifecycle lock so an in-flight
|
||||||
|
# ``_render_frame`` cannot deadlock against ``start`` from
|
||||||
|
# another thread.
|
||||||
|
thread.join()
|
||||||
|
if self._active:
|
||||||
|
# Wipe the last frame so the model's actual content begins on
|
||||||
|
# a clean line.
|
||||||
|
self._write(self._ERASE_LINE)
|
||||||
|
self._active = False
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
"""Background loop: redraw the next frame every ``_FRAME_INTERVAL``."""
|
||||||
|
index = 0
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
self._render_frame(self._FRAMES[index % len(self._FRAMES)])
|
||||||
|
index += 1
|
||||||
|
# ``Event.wait`` returns immediately when ``set`` is called,
|
||||||
|
# so ``stop`` is responsive even mid-frame.
|
||||||
|
if self._stop_event.wait(self._FRAME_INTERVAL):
|
||||||
|
return
|
||||||
|
|
||||||
|
def _render_frame(self, label: str) -> None:
|
||||||
|
"""Emit one frame in place using carriage-return + erase-EOL."""
|
||||||
|
self._write(f"{self._ERASE_LINE}{self._gray(label)}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AI agent console
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class AIAgentConsole:
|
class AIAgentConsole:
|
||||||
"""Serialised terminal output for the standalone AI agent.
|
"""Serialised terminal output for the standalone AI agent.
|
||||||
|
|
||||||
@@ -84,11 +187,30 @@ class AIAgentConsole:
|
|||||||
output_stream: IO[str] | None = None,
|
output_stream: IO[str] | None = None,
|
||||||
keep_history: bool = False,
|
keep_history: bool = False,
|
||||||
use_color: bool = True,
|
use_color: bool = True,
|
||||||
|
show_reasoning: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._output = output_stream if output_stream is not None else sys.stdout
|
self._output = output_stream if output_stream is not None else sys.stdout
|
||||||
self._keep_history = keep_history
|
self._keep_history = keep_history
|
||||||
self._use_color = use_color
|
self._use_color = use_color
|
||||||
|
# ``show_reasoning`` controls whether the LLM's chain-of-thought
|
||||||
|
# ("reasoning") deltas are printed to the terminal. The final
|
||||||
|
# answer ("content") is always printed so operators can still see
|
||||||
|
# the action being chosen.
|
||||||
|
self._show_reasoning = show_reasoning
|
||||||
|
# ``_lock`` serialises whole act/game render blocks (coarse grain).
|
||||||
|
# ``_io_lock`` is a finer-grained mutex protecting just the
|
||||||
|
# ``self._output.write`` calls so the thinking-indicator background
|
||||||
|
# thread can interleave safely with the main rendering thread
|
||||||
|
# without being blocked by the coarse lock.
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
self._io_lock = threading.Lock()
|
||||||
|
# Animated "thinking..." marquee shown while reasoning output is
|
||||||
|
# suppressed. Created up-front so callers can ``start``/``stop``
|
||||||
|
# idempotently regardless of the show_reasoning flag.
|
||||||
|
self._thinking = _ThinkingIndicator(
|
||||||
|
write_fn=self._write,
|
||||||
|
gray_fn=self._gray,
|
||||||
|
)
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def act_log(self, observation: dict[str, Any]) -> Iterator[None]:
|
def act_log(self, observation: dict[str, Any]) -> Iterator[None]:
|
||||||
@@ -106,13 +228,32 @@ class AIAgentConsole:
|
|||||||
|
|
||||||
def begin_llm_stream(self) -> None:
|
def begin_llm_stream(self) -> None:
|
||||||
self._write(self._gray("AI MODEL STREAM\n"))
|
self._write(self._gray("AI MODEL STREAM\n"))
|
||||||
|
# When reasoning output is hidden, immediately start the marquee
|
||||||
|
# so the user sees liveness while the model is "thinking" before
|
||||||
|
# any content delta arrives.
|
||||||
|
if not self._show_reasoning:
|
||||||
|
self._thinking.start()
|
||||||
|
|
||||||
def write_llm_delta(self, kind: str, text: str) -> None:
|
def write_llm_delta(self, kind: str, text: str) -> None:
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
|
# Skip "reasoning" deltas entirely when reasoning output is hidden;
|
||||||
|
# this keeps the terminal focused on the final answer for users
|
||||||
|
# who do not care about chain-of-thought traces.
|
||||||
|
if kind == "reasoning" and not self._show_reasoning:
|
||||||
|
return
|
||||||
|
# First non-reasoning delta means the model has started speaking
|
||||||
|
# the actual answer; tear down the marquee before printing so the
|
||||||
|
# animation does not collide with the content stream.
|
||||||
|
if kind == "content" and not self._show_reasoning:
|
||||||
|
self._thinking.stop()
|
||||||
self._write(self._gray(text))
|
self._write(self._gray(text))
|
||||||
|
|
||||||
def end_llm_stream(self) -> None:
|
def end_llm_stream(self) -> None:
|
||||||
|
# Defensive stop in case the request finished without ever
|
||||||
|
# producing a content delta (e.g. fallback path / error).
|
||||||
|
if not self._show_reasoning:
|
||||||
|
self._thinking.stop()
|
||||||
self._write(self._gray("\n"))
|
self._write(self._gray("\n"))
|
||||||
|
|
||||||
def announce_action(
|
def announce_action(
|
||||||
@@ -120,11 +261,17 @@ class AIAgentConsole:
|
|||||||
action: dict[str, Any],
|
action: dict[str, Any],
|
||||||
source: str = "model",
|
source: str = "model",
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# Defensive stop: error / fallback paths bypass end_llm_stream, so
|
||||||
|
# we ensure the marquee never leaks into action / warning output.
|
||||||
|
self._thinking.stop()
|
||||||
body = json.dumps(action, ensure_ascii=False)
|
body = json.dumps(action, ensure_ascii=False)
|
||||||
self._write(f"\nAI ACTION ({source}) -> {body}\n")
|
self._write(f"\nAI ACTION ({source}) -> {body}\n")
|
||||||
self._write("~" * 60 + "\n\n")
|
self._write("~" * 60 + "\n\n")
|
||||||
|
|
||||||
def announce_warning(self, message: str) -> None:
|
def announce_warning(self, message: str) -> None:
|
||||||
|
# Same defensive stop as ``announce_action`` - warnings can fire
|
||||||
|
# before the LLM stream closes (HTTP error, JSON parse error...).
|
||||||
|
self._thinking.stop()
|
||||||
self._write(f"\nAI WARNING -> {message}\n")
|
self._write(f"\nAI WARNING -> {message}\n")
|
||||||
|
|
||||||
def _gray(self, text: str) -> str:
|
def _gray(self, text: str) -> str:
|
||||||
@@ -133,8 +280,12 @@ class AIAgentConsole:
|
|||||||
return f"{ANSI_GRAY}{text}{ANSI_RESET}"
|
return f"{ANSI_GRAY}{text}{ANSI_RESET}"
|
||||||
|
|
||||||
def _write(self, text: str) -> None:
|
def _write(self, text: str) -> None:
|
||||||
self._output.write(text)
|
# The thinking-indicator background thread writes from a different
|
||||||
self._output.flush()
|
# thread than the main /act handler; the fine-grained ``_io_lock``
|
||||||
|
# avoids tearing of escape sequences and keeps stdout consistent.
|
||||||
|
with self._io_lock:
|
||||||
|
self._output.write(text)
|
||||||
|
self._output.flush()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -296,8 +447,11 @@ def _format_action_history(history: list[dict[str, Any]]) -> str:
|
|||||||
return "(no actions yet)"
|
return "(no actions yet)"
|
||||||
# The engine never produces unbounded history within a single hand, but
|
# The engine never produces unbounded history within a single hand, but
|
||||||
# we cap defensively so a malformed payload cannot blow up token usage.
|
# we cap defensively so a malformed payload cannot blow up token usage.
|
||||||
|
# The cap is sized to comfortably cover the worst realistic case (a
|
||||||
|
# 12-handed table running ~10 betting rounds within one hand) so the
|
||||||
|
# LLM never sees a silently truncated history at full ring tables.
|
||||||
rows = []
|
rows = []
|
||||||
for record in history[-32:]:
|
for record in history[-128:]:
|
||||||
rows.append(
|
rows.append(
|
||||||
f"- [{record.get('street')}] {record.get('player_id')} -> "
|
f"- [{record.get('street')}] {record.get('player_id')} -> "
|
||||||
f"{record.get('action')} amount={record.get('amount', 0)}"
|
f"{record.get('action')} amount={record.get('amount', 0)}"
|
||||||
@@ -927,6 +1081,16 @@ def main() -> None:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Disable ANSI gray coloring for streamed LLM output.",
|
help="Disable ANSI gray coloring for streamed LLM output.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--hide-reasoning",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Hide the LLM's reasoning/chain-of-thought stream from the "
|
||||||
|
"terminal. The model still performs reasoning; only its "
|
||||||
|
"terminal output is suppressed. The final answer (content) "
|
||||||
|
"is still printed so operators can see the chosen action."
|
||||||
|
),
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if not args.api_key:
|
if not args.api_key:
|
||||||
@@ -944,6 +1108,7 @@ def main() -> None:
|
|||||||
console = AIAgentConsole(
|
console = AIAgentConsole(
|
||||||
keep_history=args.keep_history,
|
keep_history=args.keep_history,
|
||||||
use_color=not args.no_color,
|
use_color=not args.no_color,
|
||||||
|
show_reasoning=not args.hide_reasoning,
|
||||||
)
|
)
|
||||||
service = AIAgentService(LLMClient(config), prompts, console=console)
|
service = AIAgentService(LLMClient(config), prompts, console=console)
|
||||||
server = create_server(args.host, args.port, service, default_player_id=args.player_id)
|
server = create_server(args.host, args.port, service, default_player_id=args.player_id)
|
||||||
@@ -956,6 +1121,7 @@ def main() -> None:
|
|||||||
f" base_url : {config.base_url}\n"
|
f" base_url : {config.base_url}\n"
|
||||||
f" player_id : {args.player_id}\n"
|
f" player_id : {args.player_id}\n"
|
||||||
f" stream : {'on' if config.stream else 'off'}\n"
|
f" stream : {'on' if config.stream else 'off'}\n"
|
||||||
|
f" reasoning : {'hidden (output suppressed)' if args.hide_reasoning else 'visible'}\n"
|
||||||
f" clear-screen: {'off (keep history)' if args.keep_history else 'on'}",
|
f" clear-screen: {'off (keep history)' if args.keep_history else 'on'}",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
flush=True,
|
flush=True,
|
||||||
|
|||||||
+158
-14
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
from random import Random
|
from random import Random
|
||||||
|
from threading import RLock
|
||||||
from time import time
|
from time import time
|
||||||
|
|
||||||
from texas_holdem.agents import PokerAgent
|
from texas_holdem.agents import PokerAgent
|
||||||
@@ -8,6 +10,7 @@ from texas_holdem.cards import Deck
|
|||||||
from texas_holdem.evaluator import evaluate
|
from texas_holdem.evaluator import evaluate
|
||||||
from texas_holdem.models import (
|
from texas_holdem.models import (
|
||||||
ActionRecord,
|
ActionRecord,
|
||||||
|
BlindLevel,
|
||||||
HandSummary,
|
HandSummary,
|
||||||
Observation,
|
Observation,
|
||||||
PlayerAction,
|
PlayerAction,
|
||||||
@@ -53,21 +56,59 @@ class TableGame:
|
|||||||
self.small_blind = small_blind
|
self.small_blind = small_blind
|
||||||
self.big_blind = big_blind
|
self.big_blind = big_blind
|
||||||
self.rng = rng or Random()
|
self.rng = rng or Random()
|
||||||
|
self.lock = RLock()
|
||||||
self.hand_number = 0
|
self.hand_number = 0
|
||||||
self.button_index: int | None = None
|
self.button_index: int | None = None
|
||||||
self.board = []
|
self.board = []
|
||||||
self.action_history: list[ActionRecord] = []
|
self.action_history: list[ActionRecord] = []
|
||||||
self.hand_summaries: list[HandSummary] = []
|
self.hand_summaries: list[HandSummary] = []
|
||||||
|
# ``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
|
@property
|
||||||
def is_complete(self) -> bool:
|
def is_complete(self) -> bool:
|
||||||
return len([player for player in self.players if player.stack > 0]) < 2
|
return len([player for player in self.players if player.stack > 0]) < 2
|
||||||
|
|
||||||
def run_hand(self) -> HandSummary:
|
def run_hand(
|
||||||
|
self,
|
||||||
|
small_blind: int | None = None,
|
||||||
|
big_blind: int | None = None,
|
||||||
|
) -> HandSummary:
|
||||||
|
"""Play a single hand.
|
||||||
|
|
||||||
|
``small_blind`` / ``big_blind`` allow callers to bump the stakes
|
||||||
|
between hands without rebuilding the table. Either both must be
|
||||||
|
provided or both omitted (in which case the previously configured
|
||||||
|
blinds carry over). The resolved blind level is appended to
|
||||||
|
: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:
|
if self.is_complete:
|
||||||
raise GameComplete("game is complete")
|
raise GameComplete("game is complete")
|
||||||
|
|
||||||
|
self._apply_blinds_for_hand(small_blind, big_blind)
|
||||||
|
|
||||||
self.hand_number += 1
|
self.hand_number += 1
|
||||||
|
# Stamp the active blind level onto the upcoming summary so a hand
|
||||||
|
# remains self-describing even after the blinds change later on.
|
||||||
|
active_blinds = BlindLevel(
|
||||||
|
hand_number=self.hand_number,
|
||||||
|
small_blind=self.small_blind,
|
||||||
|
big_blind=self.big_blind,
|
||||||
|
)
|
||||||
|
self._record_blind_level_if_new(active_blinds)
|
||||||
started_at = time()
|
started_at = time()
|
||||||
self.board = []
|
self.board = []
|
||||||
self.action_history = []
|
self.action_history = []
|
||||||
@@ -116,26 +157,69 @@ class TableGame:
|
|||||||
board=list(self.board),
|
board=list(self.board),
|
||||||
actions=list(self.action_history),
|
actions=list(self.action_history),
|
||||||
awards=awards,
|
awards=awards,
|
||||||
|
blinds=active_blinds,
|
||||||
showdown_hands=self._collect_showdown_hands(),
|
showdown_hands=self._collect_showdown_hands(),
|
||||||
started_at=started_at,
|
started_at=started_at,
|
||||||
finished_at=time(),
|
finished_at=time(),
|
||||||
)
|
)
|
||||||
self.hand_summaries.append(summary)
|
self.hand_summaries.append(summary)
|
||||||
|
self._completed_snapshot = deepcopy(self._to_dict_unlocked())
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
def run_hands(self, max_hands: int, until_one_left: bool = False) -> list[HandSummary]:
|
def run_hands(
|
||||||
if max_hands <= 0:
|
self,
|
||||||
raise ValueError("max_hands must be positive")
|
max_hands: int,
|
||||||
summaries = []
|
until_one_left: bool = False,
|
||||||
for _ in range(max_hands):
|
small_blind: int | None = None,
|
||||||
if self.is_complete:
|
big_blind: int | None = None,
|
||||||
break
|
) -> list[HandSummary]:
|
||||||
summaries.append(self.run_hand())
|
"""Play up to ``max_hands`` hands using a single blind configuration.
|
||||||
if until_one_left and self.is_complete:
|
|
||||||
break
|
Passing ``small_blind`` / ``big_blind`` bumps the stakes starting
|
||||||
return summaries
|
with the first hand of this call; subsequent calls can raise them
|
||||||
|
again. Leaving them ``None`` keeps the current level unchanged.
|
||||||
|
"""
|
||||||
|
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]:
|
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 {
|
return {
|
||||||
"game_id": self.game_id,
|
"game_id": self.game_id,
|
||||||
"status": "complete" if self.is_complete else "running",
|
"status": "complete" if self.is_complete else "running",
|
||||||
@@ -143,8 +227,18 @@ class TableGame:
|
|||||||
"button_seat": None
|
"button_seat": None
|
||||||
if self.button_index is None
|
if self.button_index is None
|
||||||
else self.players[self.button_index].seat,
|
else self.players[self.button_index].seat,
|
||||||
|
# ``small_blind`` / ``big_blind`` mirror the *current* level so
|
||||||
|
# legacy callers keep working. New consumers should prefer the
|
||||||
|
# structured ``blinds`` block which carries the full schedule.
|
||||||
"small_blind": self.small_blind,
|
"small_blind": self.small_blind,
|
||||||
"big_blind": self.big_blind,
|
"big_blind": self.big_blind,
|
||||||
|
"blinds": {
|
||||||
|
"current": {
|
||||||
|
"small_blind": self.small_blind,
|
||||||
|
"big_blind": self.big_blind,
|
||||||
|
},
|
||||||
|
"history": [level.to_dict() for level in self.blind_history],
|
||||||
|
},
|
||||||
"starting_stack": self.starting_stack,
|
"starting_stack": self.starting_stack,
|
||||||
"players": [player.public_dict() for player in self.players],
|
"players": [player.public_dict() for player in self.players],
|
||||||
# ``hands`` exposes every finished hand (each entry is the same
|
# ``hands`` exposes every finished hand (each entry is the same
|
||||||
@@ -153,6 +247,47 @@ class TableGame:
|
|||||||
"hands": [summary.to_dict() for summary in self.hand_summaries],
|
"hands": [summary.to_dict() for summary in self.hand_summaries],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _apply_blinds_for_hand(
|
||||||
|
self,
|
||||||
|
small_blind: int | None,
|
||||||
|
big_blind: int | None,
|
||||||
|
) -> None:
|
||||||
|
"""Validate and apply optional per-hand blind overrides.
|
||||||
|
|
||||||
|
Splitting this out keeps :meth:`run_hand` focused on the table flow
|
||||||
|
while letting us reuse the validation rules originally enforced by
|
||||||
|
``__init__``. We require both values to be supplied together so the
|
||||||
|
configuration cannot drift into an inconsistent half-update.
|
||||||
|
"""
|
||||||
|
if small_blind is None and big_blind is None:
|
||||||
|
return
|
||||||
|
if small_blind is None or big_blind is None:
|
||||||
|
raise ValueError(
|
||||||
|
"small_blind and big_blind must be provided together"
|
||||||
|
)
|
||||||
|
if small_blind <= 0 or big_blind <= 0 or small_blind > big_blind:
|
||||||
|
raise ValueError("blinds must satisfy 0 < small_blind <= big_blind")
|
||||||
|
self.small_blind = int(small_blind)
|
||||||
|
self.big_blind = int(big_blind)
|
||||||
|
|
||||||
|
def _record_blind_level_if_new(self, level: BlindLevel) -> None:
|
||||||
|
"""Append ``level`` to :attr:`blind_history` when it differs.
|
||||||
|
|
||||||
|
Comparing against the latest entry (rather than blindly appending)
|
||||||
|
keeps the log compact: stretches of unchanged stakes only contribute
|
||||||
|
a single record. The very first hand always seeds an entry because
|
||||||
|
the history starts empty.
|
||||||
|
"""
|
||||||
|
if not self.blind_history:
|
||||||
|
self.blind_history.append(level)
|
||||||
|
return
|
||||||
|
latest = self.blind_history[-1]
|
||||||
|
if (
|
||||||
|
latest.small_blind != level.small_blind
|
||||||
|
or latest.big_blind != level.big_blind
|
||||||
|
):
|
||||||
|
self.blind_history.append(level)
|
||||||
|
|
||||||
def _advance_button(self) -> None:
|
def _advance_button(self) -> None:
|
||||||
if self.button_index is None:
|
if self.button_index is None:
|
||||||
self.button_index = self._next_index(0, lambda index: self.players[index].stack > 0)
|
self.button_index = self._next_index(0, lambda index: self.players[index].stack > 0)
|
||||||
@@ -350,9 +485,18 @@ class TableGame:
|
|||||||
try:
|
try:
|
||||||
requested = agent.decide(observation)
|
requested = agent.decide(observation)
|
||||||
except Exception:
|
except Exception:
|
||||||
requested = PlayerAction("fold")
|
requested = self._default_action(observation.legal_actions)
|
||||||
return self._coerce_action(requested, 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(
|
def _coerce_action(
|
||||||
self,
|
self,
|
||||||
requested: PlayerAction,
|
requested: PlayerAction,
|
||||||
@@ -482,7 +626,7 @@ class TableGame:
|
|||||||
swallow individual exceptions so a flaky remote endpoint cannot
|
swallow individual exceptions so a flaky remote endpoint cannot
|
||||||
break the table flow.
|
break the table flow.
|
||||||
"""
|
"""
|
||||||
snapshot = self.to_dict()
|
snapshot = self._to_dict_unlocked()
|
||||||
for agent in self.agents.values():
|
for agent in self.agents.values():
|
||||||
try:
|
try:
|
||||||
agent.on_game_update(snapshot)
|
agent.on_game_update(snapshot)
|
||||||
|
|||||||
@@ -130,6 +130,28 @@ class Observation:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class BlindLevel:
|
||||||
|
"""A snapshot of the blind configuration that took effect at a given hand.
|
||||||
|
|
||||||
|
The structure is intentionally append-only: every time the blinds change
|
||||||
|
(or the very first hand seeds the initial values) we push a new
|
||||||
|
``BlindLevel`` so callers can reconstruct how the stakes evolved over the
|
||||||
|
course of the game without losing any prior state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
hand_number: int
|
||||||
|
small_blind: int
|
||||||
|
big_blind: int
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"hand_number": self.hand_number,
|
||||||
|
"small_blind": self.small_blind,
|
||||||
|
"big_blind": self.big_blind,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class PotAward:
|
class PotAward:
|
||||||
amount: int
|
amount: int
|
||||||
@@ -152,6 +174,10 @@ class HandSummary:
|
|||||||
board: list[Card]
|
board: list[Card]
|
||||||
actions: list[ActionRecord]
|
actions: list[ActionRecord]
|
||||||
awards: list[PotAward]
|
awards: list[PotAward]
|
||||||
|
# ``blinds`` records the exact blind level used by this hand. Storing it
|
||||||
|
# on the summary (rather than only on the game) guarantees historical
|
||||||
|
# hands remain self-describing even after the blinds are raised later.
|
||||||
|
blinds: BlindLevel | None = None
|
||||||
showdown_hands: dict[str, list[Card]] = field(default_factory=dict)
|
showdown_hands: dict[str, list[Card]] = field(default_factory=dict)
|
||||||
started_at: float = field(default_factory=time)
|
started_at: float = field(default_factory=time)
|
||||||
finished_at: float = field(default_factory=time)
|
finished_at: float = field(default_factory=time)
|
||||||
@@ -161,6 +187,7 @@ class HandSummary:
|
|||||||
"game_id": self.game_id,
|
"game_id": self.game_id,
|
||||||
"hand_number": self.hand_number,
|
"hand_number": self.hand_number,
|
||||||
"button_seat": self.button_seat,
|
"button_seat": self.button_seat,
|
||||||
|
"blinds": self.blinds.to_dict() if self.blinds else None,
|
||||||
"board": [str(card) for card in self.board],
|
"board": [str(card) for card in self.board],
|
||||||
"actions": [record.to_dict() for record in self.actions],
|
"actions": [record.to_dict() for record in self.actions],
|
||||||
"awards": [award.to_dict() for award in self.awards],
|
"awards": [award.to_dict() for award in self.awards],
|
||||||
|
|||||||
+43
-10
@@ -25,8 +25,8 @@ class PokerRequestHandler(BaseHTTPRequestHandler):
|
|||||||
if path == ["games"]:
|
if path == ["games"]:
|
||||||
self._json({"games": MANAGER.list_games()})
|
self._json({"games": MANAGER.list_games()})
|
||||||
return
|
return
|
||||||
if len(path) == 2 and path[0] == "games":
|
if len(path) == 2 and path[0] in {"game", "games"}:
|
||||||
self._json(MANAGER.get_game(path[1]).to_dict())
|
self._json(MANAGER.get_game_state(path[1]))
|
||||||
return
|
return
|
||||||
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
@@ -35,23 +35,37 @@ class PokerRequestHandler(BaseHTTPRequestHandler):
|
|||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
path = self._path_parts()
|
path = self._path_parts()
|
||||||
try:
|
try:
|
||||||
if path == ["games"]:
|
if path in (["game"], ["games"]):
|
||||||
game = MANAGER.create_game(self._read_json())
|
game = MANAGER.create_game(self._read_json())
|
||||||
self._json(game.to_dict(), HTTPStatus.CREATED)
|
self._json(game.snapshot_completed(), HTTPStatus.CREATED)
|
||||||
return
|
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()
|
body = self._read_json()
|
||||||
count = int(body.get("count", 1))
|
count = int(body.get("count", 1))
|
||||||
until_one_left = bool(body.get("until_one_left", False))
|
until_one_left = bool(body.get("until_one_left", False))
|
||||||
summaries = MANAGER.run_hands(path[1], count, until_one_left)
|
small_blind, big_blind = self._extract_blinds(body)
|
||||||
self._json({"hands": summaries, "game": MANAGER.get_game(path[1]).to_dict()})
|
summaries = MANAGER.run_hands(
|
||||||
|
path[1],
|
||||||
|
count,
|
||||||
|
until_one_left,
|
||||||
|
small_blind=small_blind,
|
||||||
|
big_blind=big_blind,
|
||||||
|
)
|
||||||
|
self._json({"hands": summaries, "game": MANAGER.get_game_state(path[1])})
|
||||||
return
|
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()
|
body = self._read_json()
|
||||||
count = int(body.get("count", 1))
|
count = int(body.get("count", 1))
|
||||||
until_one_left = bool(body.get("until_one_left", False))
|
until_one_left = bool(body.get("until_one_left", False))
|
||||||
summaries = MANAGER.run_hands(path[1], count, until_one_left)
|
small_blind, big_blind = self._extract_blinds(body)
|
||||||
self._json({"hands": summaries, "game": MANAGER.get_game(path[1]).to_dict()})
|
summaries = MANAGER.run_hands(
|
||||||
|
path[1],
|
||||||
|
count,
|
||||||
|
until_one_left,
|
||||||
|
small_blind=small_blind,
|
||||||
|
big_blind=big_blind,
|
||||||
|
)
|
||||||
|
self._json({"hands": summaries, "game": MANAGER.get_game_state(path[1])})
|
||||||
return
|
return
|
||||||
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
@@ -78,6 +92,25 @@ class PokerRequestHandler(BaseHTTPRequestHandler):
|
|||||||
raise ValueError("request body must be a JSON object")
|
raise ValueError("request body must be a JSON object")
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_blinds(body: dict[str, Any]) -> tuple[int | None, int | None]:
|
||||||
|
"""Parse optional blind overrides from a /hands POST body.
|
||||||
|
|
||||||
|
Callers may omit both keys (keep current level), or supply both to
|
||||||
|
raise the blinds for the upcoming batch. Providing only one is
|
||||||
|
treated as a client error and surfaced via ``ValueError`` so the
|
||||||
|
handler can reply with 400.
|
||||||
|
"""
|
||||||
|
raw_small = body.get("small_blind")
|
||||||
|
raw_big = body.get("big_blind")
|
||||||
|
if raw_small is None and raw_big is None:
|
||||||
|
return None, None
|
||||||
|
if raw_small is None or raw_big is None:
|
||||||
|
raise ValueError(
|
||||||
|
"small_blind and big_blind must be provided together"
|
||||||
|
)
|
||||||
|
return int(raw_small), int(raw_big)
|
||||||
|
|
||||||
def _json(self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK) -> None:
|
def _json(self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK) -> None:
|
||||||
body = json.dumps(payload, ensure_ascii=True).encode("utf-8")
|
body = json.dumps(payload, ensure_ascii=True).encode("utf-8")
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
|
|||||||
+66
-9
@@ -5,13 +5,14 @@ from threading import RLock
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
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
|
from texas_holdem.engine import TableGame
|
||||||
|
|
||||||
|
|
||||||
class GameManager:
|
class GameManager:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._games: dict[str, TableGame] = {}
|
self._games: dict[str, TableGame] = {}
|
||||||
|
self._http_endpoint_owners: dict[str, str] = {}
|
||||||
self._lock = RLock()
|
self._lock = RLock()
|
||||||
|
|
||||||
def create_game(self, payload: dict[str, Any]) -> TableGame:
|
def create_game(self, payload: dict[str, Any]) -> TableGame:
|
||||||
@@ -29,12 +30,19 @@ class GameManager:
|
|||||||
big_blind = int(payload.get("big_blind", 10))
|
big_blind = int(payload.get("big_blind", 10))
|
||||||
|
|
||||||
specs = []
|
specs = []
|
||||||
|
http_endpoints: set[str] = set()
|
||||||
for seat, raw_spec in enumerate(players):
|
for seat, raw_spec in enumerate(players):
|
||||||
if not isinstance(raw_spec, dict):
|
if not isinstance(raw_spec, dict):
|
||||||
raise ValueError("each player must be an object")
|
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}")
|
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)
|
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))
|
specs.append((player_id, name, agent))
|
||||||
|
|
||||||
game = TableGame(
|
game = TableGame(
|
||||||
@@ -46,9 +54,18 @@ class GameManager:
|
|||||||
rng=rng,
|
rng=rng,
|
||||||
)
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._release_completed_http_endpoints_locked()
|
||||||
if game_id in self._games:
|
if game_id in self._games:
|
||||||
raise ValueError(f"game already exists: {game_id}")
|
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
|
self._games[game_id] = game
|
||||||
|
for endpoint in http_endpoints:
|
||||||
|
self._http_endpoint_owners[endpoint] = game_id
|
||||||
return game
|
return game
|
||||||
|
|
||||||
def get_game(self, game_id: str) -> TableGame:
|
def get_game(self, game_id: str) -> TableGame:
|
||||||
@@ -58,14 +75,54 @@ class GameManager:
|
|||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise KeyError(f"game not found: {game_id}") from 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 list_games(self) -> list[dict[str, object]]:
|
def list_games(self) -> list[dict[str, object]]:
|
||||||
with self._lock:
|
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, game_id: str, count: int = 1, until_one_left: bool = False) -> list[dict[str, object]]:
|
def run_hands(
|
||||||
|
self,
|
||||||
|
game_id: str,
|
||||||
|
count: int = 1,
|
||||||
|
until_one_left: bool = False,
|
||||||
|
small_blind: int | None = None,
|
||||||
|
big_blind: int | None = None,
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
"""Run ``count`` hands, optionally raising the blinds first.
|
||||||
|
|
||||||
|
``small_blind`` / ``big_blind`` are forwarded to the engine so the
|
||||||
|
blinds can change between batches. Leaving them as ``None`` keeps
|
||||||
|
the previously configured level, which preserves the original
|
||||||
|
no-argument behaviour.
|
||||||
|
"""
|
||||||
game = self.get_game(game_id)
|
game = self.get_game(game_id)
|
||||||
with self._lock:
|
summaries = [
|
||||||
return [
|
summary.to_dict()
|
||||||
summary.to_dict()
|
for summary in game.run_hands(
|
||||||
for summary in game.run_hands(count, until_one_left=until_one_left)
|
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]
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Standalone web replay viewer for Texas Hold X game snapshots."""
|
||||||
|
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,832 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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();
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,735 @@
|
|||||||
|
/* =========================================================================
|
||||||
|
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; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user