1 Commits

Author SHA1 Message Date
mamamiyear 5899ea0b89 Revert "feat: add replay server and web client"
This reverts commit 3c027eae0b.
2026-05-21 09:22:04 +08:00
10 changed files with 6 additions and 1912 deletions
+6 -2
View File
@@ -4,12 +4,16 @@
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
@@ -17,11 +21,11 @@ Use Python 3.11+ standard-library APIs unless a dependency is intentionally adde
## 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`. New bug fixes should include a regression test. Avoid tests that require external network access or real LLM calls.
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 server and agent 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 visible terminal-output changes.
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
-19
View File
@@ -126,22 +126,3 @@ AI Agent 会在终端输出:
```bash
python -m unittest discover -v
```
## Web 回放与控制台
启动核心游戏服务后,可以单独启动 Web 回放服务:
```bash
python -m texas_holdem_replay.server --host 127.0.0.1 --port 8088
```
打开 `http://127.0.0.1:8088`。页面通过自身的代理接口访问核心服务,
避免浏览器跨域限制;它不会导入或耦合 `texas_holdem.engine` 内部代码。
页面支持:
- 拉取 `GET /games/{game_id}` 快照并按 `hands[].actions` 生成逐帧回放。
- 通过代理调用核心服务运行指定数量手牌。
- 可选覆盖下一批手牌的大小盲。
- 上传或粘贴静态 JSON 快照进行离线回放。
- 自动轮询正在运行的游戏,保留当前历史查看位置。
-81
View File
@@ -1,81 +0,0 @@
# Texas Hold X 回放视图设计方案
## 目标
构建一个与核心游戏服务和 Agent 解耦的独立 Web 服务,用于读取游戏详情 JSON 并以动画方式回放 Texas Hold'em 对局。它可以部署在任意能运行 Python 标准库 HTTP 服务的环境中,不要求核心服务增加前端路由,也不改变 Human HTTP Agent / AI HTTP Agent 协议。
## 架构
新增 `texas_holdem_replay` 包:
- `texas_holdem_replay.server`:标准库 HTTP 服务,负责托管静态前端文件,并提供 `/api/fetch-game` 抓取代理。
- `texas_holdem_replay/static/index.html`:独立页面入口。
- `texas_holdem_replay/static/styles.css`:像素风牌桌、卡牌、座位和响应式布局。
- `texas_holdem_replay/static/app.js`:数据归一化、手牌时间轴生成、动画播放、上传 JSON、手动抓取和自动轮询。
运行方式:
```bash
python -m texas_holdem_replay.server --host 127.0.0.1 --port 8088
```
也可以通过安装后的脚本启动:
```bash
texas-holdem-replay --host 127.0.0.1 --port 8088
```
## 数据输入
视图支持三种输入方式:
1. 填写核心游戏服务地址和 `game_id`,点击“获取”。
前端请求自身的 `/api/fetch-game?base_url=...&game_id=...`,由回放服务去访问核心服务的 `/games/{game_id}`,避免浏览器跨域限制。
2. 上传静态 JSON 文件。
文件在浏览器本地解析,不依赖核心服务。
3. 开启自动获取。
按指定秒数轮询同一个核心服务和 `game_id`,用于观察正在运行的游戏快照。新快照会先尝试和当前回放位置合并;如果当前手牌追加了 action、showdown 或 award,回放会接续到新增 frame,而不是重头播放。
`/api/fetch-game` 也支持传入完整 `url`,便于未来接入网关或静态 JSON 服务。
## 数据模型与归一化
当前游戏详情返回结构包含:
- `players`:玩家最终状态。
- `hands`:历史手牌列表。
- 每手 `actions`:行动记录,包含 `street``player_id``action``amount`、行动后 `street_bet``stack`
- `awards`:底池分配。
- `showdown_hands`:摊牌玩家手牌。
前端不会依赖核心服务内部对象,只读取 JSON 字段并做归一化:
- 根据 `players``actions` 得到座位顺序。
-`starting_stack` 开始,按历史 `actions.stack``awards` 推演每手牌开始时的筹码。
- 每手牌生成一组离散 frame:开局、跨街发公共牌、玩家行动、摊牌、结算。
- 每个 frame 都有稳定 key。上传 JSON、手动获取和自动获取都会复用同一套合并逻辑:同一 `game_id` 且能找到当前手牌时,保留当前 frame;如果用户停在最新进度末尾,且当前手牌或后续手牌出现新增 frame,则从当前位置接续播放增量。用户正在查看历史手牌时,不会被新轮询强制跳走。
- 非 showdown 玩家手牌显示卡背。
- 已预留 `hand.hole_cards[player_id]``hand.private_hands[player_id]` 兼容点,后续核心服务返回非 showdown 手牌时可直接展示。
## 动画与交互
牌桌采用卡通像素风格:
- 椭圆绿色牌桌、木质像素边框、像素筹码状态条。
- 玩家围绕牌桌分布,当前行动玩家高亮并显示冒泡文字。
- 公共牌按 flop / turn / river 分阶段发出。
- 动作之间默认保留约 1.1-1.5 秒间隔,用户可用“节奏”滑杆调慢或调快。
- 支持上一帧、下一帧、播放/暂停、重置、选择指定手牌。
## 响应式设计
桌面端为三栏布局:数据控制、牌桌、事件日志。中等屏幕下事件日志下移,手机和平板窄屏下改为单列,牌桌高度固定到可观看的移动端比例,座位尺寸通过 CSS clamp 控制,避免文字和控件溢出。
## 解耦边界
回放服务只消费核心服务的公开 HTTP JSON,不导入 `texas_holdem.engine``service` 或 Agent 代码,也不要求游戏服务开放 CORS。未来可以单独部署在 CDN + 轻量代理、容器或任意 Python 运行环境中。
## 后续适配
- 核心服务返回非 showdown 玩家手牌后,只需要让每手 JSON 包含 `hole_cards``private_hands` 映射,前端现有归一化会优先读取。
- 如果服务端未来提供增量事件流,可以在 `app.js` 增加一个 source adapter,把事件流转成同样的 frame 列表,动画层无需重写。
-4
View File
@@ -9,10 +9,6 @@ dependencies = []
texas-holdem-server = "texas_holdem.server:main"
texas-holdem-human = "texas_holdem.human_client:main"
texas-holdem-ai = "texas_holdem.ai_client:main"
texas-holdem-replay = "texas_holdem_replay.server:main"
[tool.setuptools.package-data]
texas_holdem_replay = ["static/*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
-33
View File
@@ -1,33 +0,0 @@
from __future__ import annotations
import unittest
from texas_holdem_replay.server import build_core_url, 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"]})
def test_build_core_url_preserves_base_path(self) -> None:
self.assertEqual(
build_core_url("http://127.0.0.1:8000/api/", "/games/demo/hands/run"),
"http://127.0.0.1:8000/api/games/demo/hands/run",
)
if __name__ == "__main__":
unittest.main()
-2
View File
@@ -1,2 +0,0 @@
"""Standalone web replay viewer for Texas Hold X."""
-222
View File
@@ -1,222 +0,0 @@
from __future__ import annotations
import argparse
import json
import mimetypes
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import parse_qs, quote, urlencode, urlparse
from urllib.request import ProxyHandler, Request, build_opener
STATIC_DIR = Path(__file__).resolve().parent / "static"
DEFAULT_CORE_BASE_URL = "http://127.0.0.1:8000"
NO_PROXY_OPENER = build_opener(ProxyHandler({}))
def build_game_url(params: dict[str, list[str]]) -> str:
"""Build a core-service game URL from query parameters.
Supported forms:
- ``url=https://host/games/demo`` for callers that already have a full URL.
- ``base_url=http://host&game_id=demo`` for the common local service case.
"""
raw_url = _first(params, "url")
if raw_url:
return _validate_http_url(raw_url)
base_url = _first(params, "base_url") or DEFAULT_CORE_BASE_URL
game_id = _first(params, "game_id")
if not game_id:
raise ValueError("game_id is required when url is not provided")
parsed = urlparse(_validate_http_url(base_url.rstrip("/")))
base_path = parsed.path.rstrip("/")
game_path = f"{base_path}/games/{quote(game_id, safe='')}"
return parsed._replace(path=game_path, query="", fragment="").geturl()
def build_core_url(base_url: str, path: str) -> str:
parsed = urlparse(_validate_http_url(base_url.rstrip("/")))
base_path = parsed.path.rstrip("/")
target_path = f"{base_path}/{path.lstrip('/')}"
return parsed._replace(path=target_path, query="", fragment="").geturl()
def _first(params: dict[str, list[str]], key: str) -> str | None:
values = params.get(key)
if not values:
return None
return values[0]
def _validate_http_url(value: str) -> str:
parsed = urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("url must be an absolute http(s) URL")
return value
class ReplayRequestHandler(BaseHTTPRequestHandler):
server_version = "TexasHoldemReplay/0.1"
def do_GET(self) -> None:
parsed = urlparse(self.path)
if parsed.path == "/health":
self._json({"ok": True})
return
if parsed.path == "/api/fetch-game":
self._handle_fetch_game(parse_qs(parsed.query))
return
self._serve_static(parsed.path)
def do_POST(self) -> None:
parsed = urlparse(self.path)
if parsed.path == "/api/create-game":
self._proxy_json_request("POST", "/games")
return
if parsed.path == "/api/run-hands":
try:
payload = self._read_json()
base_url = str(payload.pop("base_url", DEFAULT_CORE_BASE_URL))
game_id = str(payload.pop("game_id", "")).strip()
if not game_id:
raise ValueError("game_id is required")
path = f"/games/{quote(game_id, safe='')}/hands/run"
self._proxy_json_request("POST", path, base_url=base_url, payload=payload)
except ValueError as exc:
self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
def log_message(self, format: str, *args: Any) -> None:
return
def _handle_fetch_game(self, params: dict[str, list[str]]) -> None:
try:
target_url = build_game_url(params)
payload, status = self._request_json("GET", target_url)
self._json(payload, HTTPStatus(status))
except ValueError as exc:
self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except RuntimeError as exc:
self._json({"error": str(exc)}, HTTPStatus.BAD_GATEWAY)
def _proxy_json_request(
self,
method: str,
path: str,
base_url: str | None = None,
payload: dict[str, Any] | None = None,
) -> None:
try:
if payload is None:
payload = self._read_json()
base_url = str(payload.pop("base_url", DEFAULT_CORE_BASE_URL))
target_url = build_core_url(base_url or DEFAULT_CORE_BASE_URL, path)
response_payload, status = self._request_json(method, target_url, payload)
self._json(response_payload, HTTPStatus(status))
except ValueError as exc:
self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except RuntimeError as exc:
self._json({"error": str(exc)}, HTTPStatus.BAD_GATEWAY)
def _request_json(
self,
method: str,
url: str,
payload: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], int]:
data = None
headers = {"Accept": "application/json"}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(url, data=data, headers=headers, method=method)
try:
with NO_PROXY_OPENER.open(request, timeout=20) as response:
raw = response.read().decode("utf-8")
status = response.status
except HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
status = exc.code
except (OSError, URLError) as exc:
raise RuntimeError(f"core service request failed: {url}") from exc
try:
parsed = json.loads(raw) if raw else {}
except json.JSONDecodeError as exc:
raise RuntimeError(f"core service returned invalid JSON: {url}") from exc
if not isinstance(parsed, dict):
raise RuntimeError("core service response must be a JSON object")
return parsed, status
def _serve_static(self, path: str) -> None:
relative = "index.html" if path in {"", "/"} else path.lstrip("/")
if relative.startswith("static/"):
relative = relative[len("static/") :]
if "/" in relative:
safe_parts = [part for part in relative.split("/") if part not in {"", ".", ".."}]
relative = "/".join(safe_parts)
target = STATIC_DIR / relative
try:
target.relative_to(STATIC_DIR)
except ValueError:
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
return
if not target.is_file():
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
return
content_type = mimetypes.guess_type(str(target))[0] or "application/octet-stream"
body = target.read_bytes()
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _read_json(self) -> dict[str, Any]:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0:
return {}
try:
payload = json.loads(self.rfile.read(length).decode("utf-8"))
except json.JSONDecodeError as exc:
raise ValueError("request body must be valid JSON") from exc
if not isinstance(payload, dict):
raise ValueError("request body must be a JSON object")
return payload
def _json(self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def create_server(host: str, port: int) -> ThreadingHTTPServer:
return ThreadingHTTPServer((host, port), ReplayRequestHandler)
def main() -> None:
parser = argparse.ArgumentParser(description="Run the Texas Hold X web replay viewer.")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", default=8088, type=int)
args = parser.parse_args()
server = create_server(args.host, args.port)
query = urlencode({"base_url": DEFAULT_CORE_BASE_URL})
print(f"Texas Hold X replay viewer listening on http://{args.host}:{args.port}/?{query}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
if __name__ == "__main__":
main()
-796
View File
@@ -1,796 +0,0 @@
const STREET_ORDER = ["preflop", "flop", "turn", "river"];
const STREET_BOARD_COUNTS = { preflop: 0, flop: 3, turn: 4, river: 5 };
const SUITS = { c: "♣", d: "♦", h: "♥", s: "♠" };
const state = {
rawGame: null,
replay: null,
handIndex: 0,
frameIndex: 0,
playing: false,
timer: null,
polling: false,
pollTimer: null,
};
const els = {
connectionStatus: document.getElementById("connectionStatus"),
gameStatus: document.getElementById("gameStatus"),
handCounter: document.getElementById("handCounter"),
baseUrlInput: document.getElementById("baseUrlInput"),
gameIdInput: document.getElementById("gameIdInput"),
fetchButton: document.getElementById("fetchButton"),
togglePollButton: document.getElementById("togglePollButton"),
runCountInput: document.getElementById("runCountInput"),
pollSecondsInput: document.getElementById("pollSecondsInput"),
smallBlindInput: document.getElementById("smallBlindInput"),
bigBlindInput: document.getElementById("bigBlindInput"),
untilOneLeftInput: document.getElementById("untilOneLeftInput"),
runButton: document.getElementById("runButton"),
createGameInput: document.getElementById("createGameInput"),
createGameButton: document.getElementById("createGameButton"),
fileInput: document.getElementById("fileInput"),
jsonInput: document.getElementById("jsonInput"),
loadJsonButton: document.getElementById("loadJsonButton"),
handSelect: document.getElementById("handSelect"),
speedInput: document.getElementById("speedInput"),
tableFelt: document.getElementById("tableFelt"),
seatLayer: document.getElementById("seatLayer"),
potDisplay: document.getElementById("potDisplay"),
boardCards: document.getElementById("boardCards"),
frameCaption: document.getElementById("frameCaption"),
resetButton: document.getElementById("resetButton"),
prevButton: document.getElementById("prevButton"),
playButton: document.getElementById("playButton"),
nextButton: document.getElementById("nextButton"),
frameCounter: document.getElementById("frameCounter"),
tableStats: document.getElementById("tableStats"),
playerList: document.getElementById("playerList"),
eventLog: document.getElementById("eventLog"),
};
function init() {
const params = new URLSearchParams(window.location.search);
if (params.get("base_url")) els.baseUrlInput.value = params.get("base_url");
if (params.get("game_id")) els.gameIdInput.value = params.get("game_id");
els.fetchButton.addEventListener("click", () => fetchGame());
els.togglePollButton.addEventListener("click", togglePolling);
els.runButton.addEventListener("click", runHands);
els.createGameButton.addEventListener("click", createGame);
els.loadJsonButton.addEventListener("click", loadJsonText);
els.fileInput.addEventListener("change", loadJsonFile);
els.handSelect.addEventListener("change", () => {
state.handIndex = Number(els.handSelect.value || 0);
state.frameIndex = 0;
stopPlayback();
render();
});
els.resetButton.addEventListener("click", () => setFrame(0));
els.prevButton.addEventListener("click", () => setFrame(state.frameIndex - 1));
els.nextButton.addEventListener("click", () => setFrame(state.frameIndex + 1));
els.playButton.addEventListener("click", togglePlayback);
els.createGameInput.value = JSON.stringify(defaultGameSpec(), null, 2);
renderEmpty();
}
async function fetchGame(options = {}) {
const baseUrl = els.baseUrlInput.value.trim();
const gameId = els.gameIdInput.value.trim();
if (!gameId) {
setStatus("error", "Missing game id");
return;
}
setStatus("neutral", "Fetching");
try {
const params = new URLSearchParams({ base_url: baseUrl, game_id: gameId });
const response = await fetch(`/api/fetch-game?${params.toString()}`);
const payload = await readJsonResponse(response);
loadGame(payload, { preserveTail: Boolean(options.preserveTail) });
setStatus("ok", "Connected");
} catch (error) {
setStatus("error", error.message);
}
}
async function runHands() {
const gameId = els.gameIdInput.value.trim();
if (!gameId) {
setStatus("error", "Missing game id");
return;
}
const payload = {
base_url: els.baseUrlInput.value.trim(),
game_id: gameId,
count: Number(els.runCountInput.value || 1),
until_one_left: els.untilOneLeftInput.checked,
};
const smallBlind = els.smallBlindInput.value.trim();
const bigBlind = els.bigBlindInput.value.trim();
if (smallBlind || bigBlind) {
payload.small_blind = Number(smallBlind);
payload.big_blind = Number(bigBlind);
}
setStatus("neutral", "Running");
els.runButton.disabled = true;
try {
const response = await fetch("/api/run-hands", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await readJsonResponse(response);
loadGame(result.game || result, { preserveTail: true });
setStatus("ok", "Updated");
} catch (error) {
setStatus("error", error.message);
} finally {
els.runButton.disabled = false;
}
}
async function createGame() {
let gameSpec;
try {
gameSpec = JSON.parse(els.createGameInput.value);
} catch (error) {
setStatus("error", error.message);
return;
}
if (!gameSpec || typeof gameSpec !== "object" || Array.isArray(gameSpec)) {
setStatus("error", "Game spec must be a JSON object");
return;
}
const payload = { ...gameSpec, base_url: els.baseUrlInput.value.trim() };
setStatus("neutral", "Creating");
els.createGameButton.disabled = true;
try {
const response = await fetch("/api/create-game", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const created = await readJsonResponse(response);
loadGame(created);
setStatus("ok", "Created");
} catch (error) {
setStatus("error", error.message);
} finally {
els.createGameButton.disabled = false;
}
}
function togglePolling() {
state.polling = !state.polling;
els.togglePollButton.classList.toggle("primary", state.polling);
els.togglePollButton.textContent = state.polling ? "Stop" : "Auto";
if (state.polling) {
fetchGame({ preserveTail: true });
schedulePoll();
} else if (state.pollTimer) {
clearTimeout(state.pollTimer);
state.pollTimer = null;
}
}
function schedulePoll() {
if (!state.polling) return;
const delay = Math.max(1, Number(els.pollSecondsInput.value || 3)) * 1000;
state.pollTimer = setTimeout(async () => {
await fetchGame({ preserveTail: true });
schedulePoll();
}, delay);
}
async function readJsonResponse(response) {
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
return payload;
}
function loadJsonText() {
try {
const payload = JSON.parse(els.jsonInput.value);
loadGame(payload);
setStatus("ok", "Loaded JSON");
} catch (error) {
setStatus("error", error.message);
}
}
function loadJsonFile() {
const file = els.fileInput.files && els.fileInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const payload = JSON.parse(String(reader.result || "{}"));
els.jsonInput.value = JSON.stringify(payload, null, 2);
loadGame(payload);
setStatus("ok", "Loaded file");
} catch (error) {
setStatus("error", error.message);
}
};
reader.readAsText(file);
}
function loadGame(rawGame, options = {}) {
const wasAtTail = isAtReplayTail();
const currentHandNumber = currentHand()?.hand_number;
const currentFrameKey = currentFrame()?.key;
state.rawGame = rawGame;
state.replay = buildReplay(rawGame);
syncInputsFromGame(rawGame);
populateHandSelect();
if (options.preserveTail && wasAtTail && state.replay.hands.length) {
state.handIndex = state.replay.hands.length - 1;
state.frameIndex = Math.max(0, currentHand().frames.length - 1);
} else if (currentHandNumber) {
const handIndex = state.replay.hands.findIndex((hand) => hand.hand_number === currentHandNumber);
state.handIndex = handIndex >= 0 ? handIndex : Math.max(0, state.replay.hands.length - 1);
const frameIndex = currentFrameKey
? currentHand().frames.findIndex((frame) => frame.key === currentFrameKey)
: 0;
state.frameIndex = frameIndex >= 0 ? frameIndex : 0;
} else {
state.handIndex = Math.max(0, state.replay.hands.length - 1);
state.frameIndex = 0;
}
render();
}
function buildReplay(game) {
const players = normalizePlayers(game.players || []);
const hands = [];
let stacks = Object.fromEntries(players.map((player) => [player.player_id, numberOr(player.stack, game.starting_stack || 0)]));
const historicalHands = Array.isArray(game.hands) ? game.hands : [];
if (historicalHands.length > 0) {
stacks = Object.fromEntries(players.map((player) => [player.player_id, numberOr(game.starting_stack, player.stack)]));
}
for (const hand of historicalHands) {
const startStacks = { ...stacks };
const replayHand = buildHandReplay(game, players, hand, startStacks);
hands.push(replayHand);
stacks = { ...replayHand.endingStacks };
}
const currentFrame = buildCurrentTableFrame(game, players);
return { game, players, hands, currentFrame };
}
function normalizePlayers(players) {
return players
.map((player, index) => ({
player_id: String(player.player_id || player.id || `p${index + 1}`),
name: String(player.name || player.player_id || player.id || `Player ${index + 1}`),
seat: numberOr(player.seat, index),
stack: numberOr(player.stack, 0),
street_bet: numberOr(player.street_bet, 0),
total_bet: numberOr(player.total_bet, 0),
folded: Boolean(player.folded),
all_in: Boolean(player.all_in),
in_hand: Boolean(player.in_hand),
}))
.sort((a, b) => a.seat - b.seat);
}
function buildHandReplay(game, players, hand, startStacks) {
const handPlayers = players.map((player) => ({
...player,
stack: numberOr(startStacks[player.player_id], player.stack),
street_bet: 0,
total_bet: 0,
folded: false,
all_in: false,
in_hand: numberOr(startStacks[player.player_id], player.stack) > 0,
hole_cards: holeCardsFor(hand, player.player_id),
}));
const playerState = new Map(handPlayers.map((player) => [player.player_id, player]));
const frames = [];
const actions = Array.isArray(hand.actions) ? hand.actions : [];
const blinds = hand.blinds || {};
frames.push(snapshotFrame({
key: `h${hand.hand_number}:start`,
type: "start",
hand,
players: handPlayers,
board: [],
pot: 0,
activePlayerId: null,
caption: `Hand ${hand.hand_number} starts. Blinds ${blinds.small_blind || game.small_blind}/${blinds.big_blind || game.big_blind}.`,
event: `Hand ${hand.hand_number} started`,
}));
let currentStreet = "preflop";
let boardCount = 0;
actions.forEach((action, index) => {
const street = String(action.street || currentStreet);
if (street !== currentStreet) {
currentStreet = street;
boardCount = STREET_BOARD_COUNTS[street] || boardCount;
frames.push(snapshotFrame({
key: `h${hand.hand_number}:${street}:deal`,
type: "street",
hand,
players: handPlayers,
board: (hand.board || []).slice(0, boardCount),
pot: totalPot(handPlayers),
activePlayerId: null,
caption: `${streetLabel(street)} dealt.`,
event: `${streetLabel(street)} board`,
}));
resetStreetBets(handPlayers);
}
const player = playerState.get(String(action.player_id));
if (player) {
const amount = numberOr(action.amount, 0);
player.stack = numberOr(action.stack, player.stack - amount);
player.street_bet = numberOr(action.street_bet, player.street_bet + amount);
player.total_bet += amount;
if (action.action === "fold") player.folded = true;
if (player.stack <= 0 && !player.folded) player.all_in = true;
}
frames.push(snapshotFrame({
key: `h${hand.hand_number}:a${index}`,
type: "action",
hand,
players: handPlayers,
board: (hand.board || []).slice(0, STREET_BOARD_COUNTS[street] || boardCount),
pot: totalPot(handPlayers),
activePlayerId: String(action.player_id),
caption: formatAction(action, player),
event: formatEvent(action, player),
action,
}));
});
const finalBoard = hand.board || [];
if (finalBoard.length && boardCount < finalBoard.length) {
frames.push(snapshotFrame({
key: `h${hand.hand_number}:board-final`,
type: "street",
hand,
players: handPlayers,
board: finalBoard,
pot: totalPot(handPlayers),
activePlayerId: null,
caption: "Final board is visible.",
event: "Final board",
}));
}
const showdown = hand.showdown_hands || {};
if (Object.keys(showdown).length > 0) {
for (const [playerId, cards] of Object.entries(showdown)) {
const player = playerState.get(playerId);
if (player) player.hole_cards = Array.isArray(cards) ? cards : [];
}
frames.push(snapshotFrame({
key: `h${hand.hand_number}:showdown`,
type: "showdown",
hand,
players: handPlayers,
board: finalBoard,
pot: totalPot(handPlayers),
activePlayerId: null,
caption: "Showdown.",
event: "Showdown",
}));
}
const awards = Array.isArray(hand.awards) ? hand.awards : [];
for (const award of awards) {
const amount = numberOr(award.amount, 0);
const winners = Array.isArray(award.winners) ? award.winners : [];
const share = winners.length ? Math.floor(amount / winners.length) : 0;
let remainder = winners.length ? amount % winners.length : 0;
winners.forEach((winnerId) => {
const winner = playerState.get(String(winnerId));
if (!winner) return;
winner.stack += share + (remainder > 0 ? 1 : 0);
remainder -= 1;
});
frames.push(snapshotFrame({
key: `h${hand.hand_number}:award:${frames.length}`,
type: "award",
hand,
players: handPlayers,
board: finalBoard,
pot: 0,
activePlayerId: winners[0] ? String(winners[0]) : null,
caption: formatAward(award),
event: formatAward(award),
}));
}
const endingStacks = Object.fromEntries(handPlayers.map((player) => [player.player_id, player.stack]));
if (frames.length === 1) {
frames.push(snapshotFrame({
key: `h${hand.hand_number}:empty`,
type: "empty",
hand,
players: handPlayers,
board: finalBoard,
pot: 0,
activePlayerId: null,
caption: "No actions recorded for this hand.",
event: "No actions",
}));
}
return { hand_number: hand.hand_number, hand, frames, endingStacks };
}
function buildCurrentTableFrame(game, players) {
return {
key: "current-table",
type: "current",
hand_number: game.hand_number || 0,
button_seat: game.button_seat,
players: players.map((player) => ({
...player,
street_bet: numberOr(player.street_bet, 0),
total_bet: numberOr(player.total_bet, 0),
hole_cards: [],
})),
board: [],
pot: players.reduce((sum, player) => sum + numberOr(player.total_bet, 0), 0),
activePlayerId: null,
caption: "Current table snapshot. Run hands or load history to replay actions.",
event: "Current table snapshot",
action: null,
};
}
function snapshotFrame({ key, type, hand, players, board, pot, activePlayerId, caption, event, action }) {
return {
key,
type,
hand_number: hand.hand_number,
button_seat: hand.button_seat,
players: players.map((player) => ({ ...player, hole_cards: [...(player.hole_cards || [])] })),
board: [...(board || [])],
pot,
activePlayerId,
caption,
event,
action: action ? { ...action } : null,
};
}
function holeCardsFor(hand, playerId) {
const explicit = hand.hole_cards || hand.private_hands || {};
const showdown = hand.showdown_hands || {};
const cards = explicit[playerId] || showdown[playerId] || [];
return Array.isArray(cards) ? cards : [];
}
function totalPot(players) {
return players.reduce((sum, player) => sum + numberOr(player.total_bet, 0), 0);
}
function resetStreetBets(players) {
players.forEach((player) => {
player.street_bet = 0;
});
}
function syncInputsFromGame(game) {
if (game.game_id) els.gameIdInput.value = game.game_id;
}
function populateHandSelect() {
els.handSelect.innerHTML = "";
const hands = state.replay ? state.replay.hands : [];
if (!hands.length) {
const option = document.createElement("option");
option.value = "0";
option.textContent = "No hands";
els.handSelect.append(option);
return;
}
hands.forEach((hand, index) => {
const option = document.createElement("option");
option.value = String(index);
option.textContent = `Hand ${hand.hand_number}`;
els.handSelect.append(option);
});
}
function render() {
if (!state.replay) {
renderEmpty();
return;
}
const hand = currentHand();
const frame = currentFrame();
els.handSelect.value = state.replay.hands.length ? String(state.handIndex) : "0";
renderHeader();
renderStats();
renderPlayers(frame);
renderSeats(frame);
renderBoard(frame);
renderEvents(hand, frame);
renderTransport(hand, frame);
}
function renderEmpty() {
els.gameStatus.textContent = "No game";
els.handCounter.textContent = "Hand 0";
els.tableStats.innerHTML = "";
els.playerList.innerHTML = "";
els.seatLayer.innerHTML = "";
els.boardCards.innerHTML = "";
els.potDisplay.textContent = "Pot 0";
els.frameCaption.textContent = "Load a game snapshot";
els.frameCounter.textContent = "0 / 0";
els.eventLog.innerHTML = "";
}
function renderHeader() {
const game = state.replay.game;
els.gameStatus.textContent = `${game.game_id || "game"} · ${game.status || "unknown"}`;
els.gameStatus.className = `pill ${game.status === "complete" ? "ok" : ""}`;
els.handCounter.textContent = `Hand ${game.hand_number || 0}`;
}
function renderStats() {
const game = state.replay.game;
const hands = state.replay.hands.length;
const current = currentHand();
const stats = [
["Players", state.replay.players.length],
["Hands", hands],
["Blinds", `${game.small_blind || 0}/${game.big_blind || 0}`],
["Button", current && current.hand ? `Seat ${current.hand.button_seat}` : game.button_seat == null ? "-" : `Seat ${game.button_seat}`],
];
els.tableStats.innerHTML = stats
.map(([label, value]) => `<div class="stat"><div class="label">${escapeHtml(label)}</div><div class="value">${escapeHtml(value)}</div></div>`)
.join("");
}
function renderPlayers(frame) {
const players = frame ? frame.players : state.replay.players;
els.playerList.innerHTML = players
.map((player) => `
<div class="player-row">
<span class="seat-num">${player.seat}</span>
<span class="name">${escapeHtml(player.name)}</span>
<span class="stack">${formatChips(player.stack)}</span>
</div>
`)
.join("");
}
function renderSeats(frame) {
if (!frame) {
els.seatLayer.innerHTML = "";
return;
}
const count = Math.max(2, frame.players.length);
els.seatLayer.innerHTML = frame.players
.map((player, index) => {
const pos = seatPosition(index, count);
const classes = ["seat"];
if (frame.activePlayerId === player.player_id) classes.push("active");
if (player.folded) classes.push("folded");
const tags = [];
if (player.seat === frame.button_seat) tags.push("BTN");
if (player.folded) tags.push("FOLD");
if (player.all_in) tags.push("ALL IN");
return `
<article class="${classes.join(" ")}" style="left:${pos.x}%;top:${pos.y}%">
<div class="name-row">
<span class="name">${escapeHtml(player.name)}</span>
<span class="tag">${escapeHtml(tags[0] || `S${player.seat}`)}</span>
</div>
<div class="seat-stats">
<span>stk ${formatChips(player.stack)}</span>
<span>bet ${formatChips(player.street_bet)}</span>
<span>tot ${formatChips(player.total_bet)}</span>
<span>${player.in_hand ? "in" : "out"}</span>
</div>
<div class="cards hole-cards">${renderHoleCards(player)}</div>
</article>
`;
})
.join("");
}
function seatPosition(index, count) {
const angle = -90 + (360 / count) * index;
const rad = (angle * Math.PI) / 180;
return {
x: 50 + 42 * Math.cos(rad),
y: 50 + 39 * Math.sin(rad),
};
}
function renderBoard(frame) {
if (!frame) return;
els.potDisplay.textContent = `Pot ${formatChips(frame.pot)}`;
els.boardCards.innerHTML = renderCards(frame.board, 5);
els.frameCaption.textContent = frame.caption || "";
}
function renderTransport(hand, frame) {
const frames = hand ? hand.frames : [];
els.frameCounter.textContent = frames.length ? `${state.frameIndex + 1} / ${frames.length}` : "0 / 0";
els.prevButton.disabled = state.frameIndex <= 0;
els.resetButton.disabled = state.frameIndex <= 0;
els.nextButton.disabled = !frames.length || state.frameIndex >= frames.length - 1;
els.playButton.disabled = frames.length <= 1;
els.playButton.textContent = state.playing ? "Pause" : "Play";
if (!frame) els.frameCaption.textContent = "No hand selected";
}
function renderEvents(hand, frame) {
const frames = hand ? hand.frames : [];
els.eventLog.innerHTML = frames
.map((item, index) => `<li class="${frame && item.key === frame.key ? "current" : ""}">${escapeHtml(item.event || item.caption || `Frame ${index + 1}`)}</li>`)
.join("");
}
function setFrame(index) {
const hand = currentHand();
if (!hand) return;
state.frameIndex = clamp(index, 0, hand.frames.length - 1);
render();
}
function togglePlayback() {
if (state.playing) {
stopPlayback();
render();
} else {
state.playing = true;
render();
scheduleNextFrame();
}
}
function stopPlayback() {
state.playing = false;
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
}
function scheduleNextFrame() {
if (!state.playing) return;
const hand = currentHand();
if (!hand || state.frameIndex >= hand.frames.length - 1) {
stopPlayback();
render();
return;
}
const pace = Number(els.speedInput.value || 1);
const delay = Math.round(1100 / pace);
state.timer = setTimeout(() => {
state.frameIndex += 1;
render();
scheduleNextFrame();
}, delay);
}
function currentHand() {
if (!state.replay || !state.replay.hands.length) return null;
return state.replay.hands[clamp(state.handIndex, 0, state.replay.hands.length - 1)];
}
function currentFrame() {
const hand = currentHand();
if (!hand || !hand.frames.length) return state.replay ? state.replay.currentFrame : null;
return hand.frames[clamp(state.frameIndex, 0, hand.frames.length - 1)];
}
function isAtReplayTail() {
const hand = currentHand();
if (!state.replay || !hand) return true;
return state.handIndex === state.replay.hands.length - 1 && state.frameIndex >= hand.frames.length - 1;
}
function renderHoleCards(player) {
if (player.hole_cards && player.hole_cards.length) return renderCards(player.hole_cards, 2);
if (player.in_hand && !player.folded) return `${renderCardBack()}${renderCardBack()}`;
return "";
}
function renderCards(cards, padTo = 0) {
const rendered = (cards || []).map(renderCard).join("");
const missing = Math.max(0, padTo - (cards || []).length);
return rendered + Array.from({ length: missing }, () => `<span class="card back"></span>`).join("");
}
function renderCardBack() {
return `<span class="card back"></span>`;
}
function renderCard(label) {
const value = String(label || "");
const rank = value.slice(0, 1).toUpperCase();
const suit = value.slice(1, 2).toLowerCase();
const red = suit === "h" || suit === "d";
return `<span class="card ${red ? "red" : ""}">${escapeHtml(rank + (SUITS[suit] || suit))}</span>`;
}
function formatAction(action, player) {
const name = player ? player.name : action.player_id;
const verb = String(action.action || "").replace("_", " ");
const amount = numberOr(action.amount, 0);
if (amount > 0) return `${name} ${verb} ${formatChips(amount)}.`;
return `${name} ${verb}.`;
}
function formatEvent(action, player) {
const street = streetLabel(action.street || "preflop");
return `${street}: ${formatAction(action, player)}`;
}
function formatAward(award) {
const winners = (award.winners || []).join(", ") || "unknown";
const handValue = award.hand_value && award.hand_value.name ? ` with ${award.hand_value.name}` : "";
return `${winners} win ${formatChips(award.amount)}${handValue}.`;
}
function streetLabel(street) {
const index = STREET_ORDER.indexOf(street);
if (index < 0) return String(street || "").toUpperCase();
return STREET_ORDER[index].toUpperCase();
}
function numberOr(value, fallback) {
const num = Number(value);
return Number.isFinite(num) ? num : fallback;
}
function formatChips(value) {
return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(numberOr(value, 0));
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function setStatus(kind, text) {
els.connectionStatus.className = `pill ${kind === "neutral" ? "neutral" : kind}`;
els.connectionStatus.textContent = text;
}
function defaultGameSpec() {
return {
game_id: "demo",
seed: 42,
starting_stack: 1000,
small_blind: 5,
big_blind: 10,
players: [
{ id: "agent_1", name: "Agent 1", type: "calling" },
{ id: "agent_2", name: "Agent 2", type: "random" },
{ id: "agent_3", name: "Agent 3", type: "calling" },
],
};
}
init();
-144
View File
@@ -1,144 +0,0 @@
<!doctype html>
<html lang="en">
<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>
<main class="shell">
<header class="topbar" aria-label="Application header">
<div>
<p class="eyebrow">Texas Hold X</p>
<h1>Game Replay Control</h1>
</div>
<div class="status-strip" aria-live="polite">
<span id="connectionStatus" class="pill neutral">Idle</span>
<span id="gameStatus" class="pill">No game</span>
<span id="handCounter" class="metric">Hand 0</span>
</div>
</header>
<section class="workspace">
<aside class="panel controls" aria-label="Game controls">
<div class="panel-header">
<h2>Source</h2>
</div>
<label class="field">
<span>Core service</span>
<input id="baseUrlInput" type="url" value="http://127.0.0.1:8000" spellcheck="false">
</label>
<label class="field">
<span>Game ID</span>
<input id="gameIdInput" type="text" value="demo" spellcheck="false">
</label>
<div class="button-row">
<button id="fetchButton" type="button" class="primary">Fetch</button>
<button id="togglePollButton" type="button">Auto</button>
</div>
<div class="divider"></div>
<div class="panel-header compact">
<h2>Run Hands</h2>
</div>
<div class="form-grid">
<label class="field">
<span>Count</span>
<input id="runCountInput" type="number" min="1" max="100" value="1">
</label>
<label class="field">
<span>Poll sec</span>
<input id="pollSecondsInput" type="number" min="1" max="60" value="3">
</label>
</div>
<div class="form-grid">
<label class="field">
<span>Small blind</span>
<input id="smallBlindInput" type="number" min="1" placeholder="keep">
</label>
<label class="field">
<span>Big blind</span>
<input id="bigBlindInput" type="number" min="1" placeholder="keep">
</label>
</div>
<label class="check-field">
<input id="untilOneLeftInput" type="checkbox">
<span>Run until one player remains</span>
</label>
<button id="runButton" type="button" class="wide">Run</button>
<div class="divider"></div>
<div class="panel-header compact">
<h2>Create Game</h2>
</div>
<textarea id="createGameInput" rows="10" spellcheck="false"></textarea>
<button id="createGameButton" type="button" class="wide">Create</button>
<div class="divider"></div>
<div class="panel-header compact">
<h2>Load JSON</h2>
</div>
<input id="fileInput" class="file-input" type="file" accept="application/json,.json">
<textarea id="jsonInput" rows="8" spellcheck="false" placeholder='Paste a GET /games/{id} response'></textarea>
<button id="loadJsonButton" type="button" class="wide">Load Snapshot</button>
</aside>
<section class="table-zone" aria-label="Poker table replay">
<div class="table-toolbar">
<div class="select-wrap">
<label for="handSelect">Hand</label>
<select id="handSelect"></select>
</div>
<div class="select-wrap">
<label for="speedInput">Pace</label>
<input id="speedInput" type="range" min="0.5" max="2" step="0.1" value="1">
</div>
</div>
<div class="felt-stage">
<div class="table-felt" id="tableFelt">
<div id="seatLayer" class="seat-layer"></div>
<div class="board-zone">
<div id="potDisplay" class="pot-display">Pot 0</div>
<div id="boardCards" class="cards board-cards"></div>
<div id="frameCaption" class="frame-caption">Load a game snapshot</div>
</div>
</div>
</div>
<div class="transport" aria-label="Replay transport">
<button id="resetButton" type="button" title="Reset">|&lt;</button>
<button id="prevButton" type="button" title="Previous">&lt;</button>
<button id="playButton" type="button" class="primary">Play</button>
<button id="nextButton" type="button" title="Next">&gt;</button>
<span id="frameCounter" class="metric">0 / 0</span>
</div>
</section>
<aside class="panel log-panel" aria-label="Game details">
<div class="panel-header">
<h2>Table State</h2>
</div>
<div id="tableStats" class="stats-grid"></div>
<div class="panel-header compact">
<h2>Players</h2>
</div>
<div id="playerList" class="player-list"></div>
<div class="panel-header compact">
<h2>Timeline</h2>
</div>
<ol id="eventLog" class="event-log"></ol>
</aside>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>
-609
View File
@@ -1,609 +0,0 @@
:root {
color-scheme: dark;
--bg: #07100e;
--surface: #101b18;
--surface-2: #15231f;
--line: #294038;
--text: #f2f7f4;
--muted: #9fb3aa;
--green: #22c55e;
--green-2: #15803d;
--amber: #f59e0b;
--red: #ef4444;
--blue: #38bdf8;
--felt: #0f6b42;
--felt-dark: #06452c;
--wood: #7c4a1e;
--shadow: 0 16px 40px rgba(0, 0, 0, 0.34);
--radius: 8px;
--font: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px),
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
var(--bg);
background-size: 24px 24px;
color: var(--text);
font-family: var(--font);
}
button,
input,
select,
textarea {
font: inherit;
}
button {
border: 1px solid var(--line);
border-radius: var(--radius);
min-height: 38px;
padding: 8px 12px;
background: #172621;
color: var(--text);
cursor: pointer;
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
}
button:hover {
border-color: var(--green);
background: #1e322b;
}
button:active {
transform: translateY(1px);
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
button.primary {
border-color: #35d772;
background: linear-gradient(180deg, #28c864, #179249);
color: #04100a;
font-weight: 700;
}
button.wide {
width: 100%;
}
input,
select,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #0a1411;
color: var(--text);
min-height: 38px;
padding: 8px 10px;
}
textarea {
resize: vertical;
min-height: 120px;
font-family: var(--mono);
font-size: 12px;
line-height: 1.45;
}
input:focus,
select:focus,
textarea:focus,
button:focus-visible {
outline: 2px solid rgba(34, 197, 94, 0.75);
outline-offset: 2px;
}
.shell {
width: min(1720px, calc(100vw - 32px));
margin: 0 auto;
padding: 18px 0 24px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-bottom: 16px;
}
.eyebrow {
margin: 0 0 4px;
color: var(--green);
font-family: var(--mono);
font-size: 12px;
text-transform: uppercase;
}
h1,
h2 {
margin: 0;
letter-spacing: 0;
}
h1 {
font-size: 26px;
}
h2 {
font-size: 14px;
}
.status-strip {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.pill,
.metric {
display: inline-flex;
align-items: center;
min-height: 30px;
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 10px;
background: rgba(16, 27, 24, 0.9);
color: var(--muted);
font-family: var(--mono);
font-size: 12px;
white-space: nowrap;
}
.pill.ok {
border-color: rgba(34, 197, 94, 0.6);
color: #86efac;
}
.pill.error {
border-color: rgba(239, 68, 68, 0.7);
color: #fecaca;
}
.workspace {
display: grid;
grid-template-columns: 300px minmax(520px, 1fr) 340px;
gap: 14px;
align-items: start;
}
.panel {
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(16, 27, 24, 0.94);
box-shadow: var(--shadow);
padding: 14px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.panel-header.compact {
margin-top: 4px;
}
.field {
display: grid;
gap: 6px;
margin-bottom: 10px;
}
.field span,
.select-wrap label {
color: var(--muted);
font-size: 12px;
}
.form-grid,
.button-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.check-field {
display: flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 13px;
margin: 4px 0 10px;
}
.check-field input {
width: 16px;
min-height: 16px;
}
.divider {
height: 1px;
background: var(--line);
margin: 14px 0;
}
.file-input {
margin-bottom: 10px;
}
.table-zone {
min-width: 0;
}
.table-toolbar,
.transport {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.select-wrap {
display: grid;
grid-template-columns: auto minmax(120px, 1fr);
align-items: center;
gap: 8px;
}
.felt-stage {
min-height: 560px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: radial-gradient(circle at 50% 45%, rgba(56, 189, 248, 0.12), transparent 46%), #08120f;
box-shadow: var(--shadow);
padding: 20px;
}
.table-felt {
position: relative;
height: 520px;
min-height: 520px;
border: 16px solid var(--wood);
border-radius: 48%;
background:
radial-gradient(ellipse at center, rgba(255, 255, 255, 0.1), transparent 58%),
radial-gradient(ellipse at center, var(--felt), var(--felt-dark));
box-shadow: inset 0 0 0 6px rgba(255, 255, 255, 0.08), inset 0 0 32px rgba(0, 0, 0, 0.45);
overflow: visible;
}
.table-felt::after {
content: "";
position: absolute;
inset: 58px 80px;
border: 1px dashed rgba(255, 255, 255, 0.18);
border-radius: 50%;
pointer-events: none;
}
.seat-layer {
position: absolute;
inset: 0;
}
.seat {
position: absolute;
width: 154px;
min-height: 106px;
transform: translate(-50%, -50%);
border: 1px solid rgba(148, 163, 184, 0.34);
border-radius: var(--radius);
background: rgba(6, 16, 12, 0.9);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.36);
padding: 8px;
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
.seat.active {
border-color: var(--green);
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.24), 0 0 24px rgba(34, 197, 94, 0.28);
}
.seat.folded {
opacity: 0.66;
}
.seat .name-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.seat .name {
min-width: 0;
overflow: hidden;
color: var(--text);
font-size: 13px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag {
border-radius: 999px;
padding: 2px 6px;
background: rgba(34, 197, 94, 0.16);
color: #bbf7d0;
font-family: var(--mono);
font-size: 10px;
white-space: nowrap;
}
.seat-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
color: var(--muted);
font-family: var(--mono);
font-size: 11px;
}
.hole-cards {
margin-top: 7px;
}
.cards {
display: flex;
justify-content: center;
gap: 6px;
}
.card {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 58px;
border: 1px solid #d7dee3;
border-radius: 6px;
background: #f8fafc;
color: #111827;
font-family: var(--mono);
font-size: 18px;
font-weight: 800;
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.22);
}
.card.red {
color: #b91c1c;
}
.card.back {
border-color: #38bdf8;
background:
linear-gradient(45deg, rgba(255, 255, 255, 0.16) 25%, transparent 25%),
linear-gradient(-45deg, rgba(255, 255, 255, 0.16) 25%, transparent 25%),
#123a52;
background-size: 10px 10px;
color: transparent;
}
.board-zone {
position: absolute;
left: 50%;
top: 50%;
display: grid;
width: min(420px, 52%);
transform: translate(-50%, -50%);
justify-items: center;
gap: 12px;
z-index: 3;
}
.pot-display {
border: 1px solid rgba(245, 158, 11, 0.55);
border-radius: 999px;
padding: 6px 12px;
background: rgba(31, 20, 5, 0.7);
color: #fde68a;
font-family: var(--mono);
font-size: 13px;
}
.board-cards {
min-height: 58px;
}
.frame-caption {
width: 100%;
min-height: 42px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: var(--radius);
padding: 9px 12px;
background: rgba(3, 7, 18, 0.62);
color: var(--text);
text-align: center;
}
.transport {
justify-content: center;
margin: 10px 0 0;
}
.transport button {
min-width: 48px;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.stat {
border: 1px solid var(--line);
border-radius: var(--radius);
background: #0a1411;
padding: 9px;
}
.stat .label {
color: var(--muted);
font-size: 11px;
}
.stat .value {
margin-top: 4px;
font-family: var(--mono);
font-size: 16px;
}
.player-list {
display: grid;
gap: 8px;
}
.player-row {
display: grid;
grid-template-columns: 28px 1fr auto;
gap: 8px;
align-items: center;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #0a1411;
padding: 8px;
}
.player-row .seat-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(34, 197, 94, 0.14);
color: #bbf7d0;
font-family: var(--mono);
font-size: 12px;
}
.player-row .name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-row .stack {
color: var(--muted);
font-family: var(--mono);
font-size: 12px;
}
.event-log {
display: grid;
max-height: 392px;
margin: 0;
padding: 0 0 0 20px;
gap: 7px;
overflow: auto;
}
.event-log li {
border-left: 2px solid rgba(34, 197, 94, 0.35);
padding: 2px 0 2px 8px;
color: var(--muted);
font-size: 13px;
}
.event-log li.current {
color: var(--text);
border-left-color: var(--green);
}
@media (max-width: 1180px) {
.workspace {
grid-template-columns: 300px minmax(0, 1fr);
}
.log-panel {
grid-column: 1 / -1;
}
}
@media (max-width: 820px) {
.shell {
width: min(100vw - 20px, 760px);
padding-top: 12px;
}
.topbar {
align-items: flex-start;
flex-direction: column;
}
.status-strip {
justify-content: flex-start;
}
.workspace {
grid-template-columns: 1fr;
}
.felt-stage {
min-height: 430px;
padding: 12px;
}
.table-felt {
height: 390px;
min-height: 390px;
border-width: 10px;
}
.seat {
width: 124px;
min-height: 94px;
padding: 7px;
}
.seat-stats {
grid-template-columns: 1fr;
}
.card {
width: 34px;
height: 48px;
font-size: 15px;
}
.board-zone {
width: 62%;
}
.table-toolbar {
align-items: stretch;
flex-direction: column;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}