feat(web): add web control client

This commit is contained in:
2026-05-17 18:04:33 +08:00
parent 01c7176b1c
commit 2062f917b0
8 changed files with 1800 additions and 2 deletions
+19
View File
@@ -126,3 +126,22 @@ AI Agent 会在终端输出:
```bash ```bash
python -m unittest discover -v 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 快照进行离线回放。
- 自动轮询正在运行的游戏,保留当前历史查看位置。
+1
View File
@@ -9,6 +9,7 @@ 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] [tool.setuptools.package-data]
texas_holdem_replay = ["static/*"] texas_holdem_replay = ["static/*"]
+7 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import unittest import unittest
from texas_holdem_replay.server import build_game_url from texas_holdem_replay.server import build_core_url, build_game_url
class ReplayServerTests(unittest.TestCase): class ReplayServerTests(unittest.TestCase):
@@ -22,7 +22,12 @@ class ReplayServerTests(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
build_game_url({"url": ["file:///tmp/game.json"]}) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+2
View File
@@ -0,0 +1,2 @@
"""Standalone web replay viewer for Texas Hold X."""
+222
View File
@@ -0,0 +1,222 @@
from __future__ import annotations
import argparse
import json
import mimetypes
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import parse_qs, quote, urlencode, urlparse
from urllib.request import ProxyHandler, Request, build_opener
STATIC_DIR = Path(__file__).resolve().parent / "static"
DEFAULT_CORE_BASE_URL = "http://127.0.0.1:8000"
NO_PROXY_OPENER = build_opener(ProxyHandler({}))
def build_game_url(params: dict[str, list[str]]) -> str:
"""Build a core-service game URL from query parameters.
Supported forms:
- ``url=https://host/games/demo`` for callers that already have a full URL.
- ``base_url=http://host&game_id=demo`` for the common local service case.
"""
raw_url = _first(params, "url")
if raw_url:
return _validate_http_url(raw_url)
base_url = _first(params, "base_url") or DEFAULT_CORE_BASE_URL
game_id = _first(params, "game_id")
if not game_id:
raise ValueError("game_id is required when url is not provided")
parsed = urlparse(_validate_http_url(base_url.rstrip("/")))
base_path = parsed.path.rstrip("/")
game_path = f"{base_path}/games/{quote(game_id, safe='')}"
return parsed._replace(path=game_path, query="", fragment="").geturl()
def build_core_url(base_url: str, path: str) -> str:
parsed = urlparse(_validate_http_url(base_url.rstrip("/")))
base_path = parsed.path.rstrip("/")
target_path = f"{base_path}/{path.lstrip('/')}"
return parsed._replace(path=target_path, query="", fragment="").geturl()
def _first(params: dict[str, list[str]], key: str) -> str | None:
values = params.get(key)
if not values:
return None
return values[0]
def _validate_http_url(value: str) -> str:
parsed = urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("url must be an absolute http(s) URL")
return value
class ReplayRequestHandler(BaseHTTPRequestHandler):
server_version = "TexasHoldemReplay/0.1"
def do_GET(self) -> None:
parsed = urlparse(self.path)
if parsed.path == "/health":
self._json({"ok": True})
return
if parsed.path == "/api/fetch-game":
self._handle_fetch_game(parse_qs(parsed.query))
return
self._serve_static(parsed.path)
def do_POST(self) -> None:
parsed = urlparse(self.path)
if parsed.path == "/api/create-game":
self._proxy_json_request("POST", "/games")
return
if parsed.path == "/api/run-hands":
try:
payload = self._read_json()
base_url = str(payload.pop("base_url", DEFAULT_CORE_BASE_URL))
game_id = str(payload.pop("game_id", "")).strip()
if not game_id:
raise ValueError("game_id is required")
path = f"/games/{quote(game_id, safe='')}/hands/run"
self._proxy_json_request("POST", path, base_url=base_url, payload=payload)
except ValueError as exc:
self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
def log_message(self, format: str, *args: Any) -> None:
return
def _handle_fetch_game(self, params: dict[str, list[str]]) -> None:
try:
target_url = build_game_url(params)
payload, status = self._request_json("GET", target_url)
self._json(payload, HTTPStatus(status))
except ValueError as exc:
self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except RuntimeError as exc:
self._json({"error": str(exc)}, HTTPStatus.BAD_GATEWAY)
def _proxy_json_request(
self,
method: str,
path: str,
base_url: str | None = None,
payload: dict[str, Any] | None = None,
) -> None:
try:
if payload is None:
payload = self._read_json()
base_url = str(payload.pop("base_url", DEFAULT_CORE_BASE_URL))
target_url = build_core_url(base_url or DEFAULT_CORE_BASE_URL, path)
response_payload, status = self._request_json(method, target_url, payload)
self._json(response_payload, HTTPStatus(status))
except ValueError as exc:
self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except RuntimeError as exc:
self._json({"error": str(exc)}, HTTPStatus.BAD_GATEWAY)
def _request_json(
self,
method: str,
url: str,
payload: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], int]:
data = None
headers = {"Accept": "application/json"}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(url, data=data, headers=headers, method=method)
try:
with NO_PROXY_OPENER.open(request, timeout=20) as response:
raw = response.read().decode("utf-8")
status = response.status
except HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
status = exc.code
except (OSError, URLError) as exc:
raise RuntimeError(f"core service request failed: {url}") from exc
try:
parsed = json.loads(raw) if raw else {}
except json.JSONDecodeError as exc:
raise RuntimeError(f"core service returned invalid JSON: {url}") from exc
if not isinstance(parsed, dict):
raise RuntimeError("core service response must be a JSON object")
return parsed, status
def _serve_static(self, path: str) -> None:
relative = "index.html" if path in {"", "/"} else path.lstrip("/")
if relative.startswith("static/"):
relative = relative[len("static/") :]
if "/" in relative:
safe_parts = [part for part in relative.split("/") if part not in {"", ".", ".."}]
relative = "/".join(safe_parts)
target = STATIC_DIR / relative
try:
target.relative_to(STATIC_DIR)
except ValueError:
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
return
if not target.is_file():
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
return
content_type = mimetypes.guess_type(str(target))[0] or "application/octet-stream"
body = target.read_bytes()
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _read_json(self) -> dict[str, Any]:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0:
return {}
try:
payload = json.loads(self.rfile.read(length).decode("utf-8"))
except json.JSONDecodeError as exc:
raise ValueError("request body must be valid JSON") from exc
if not isinstance(payload, dict):
raise ValueError("request body must be a JSON object")
return payload
def _json(self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def create_server(host: str, port: int) -> ThreadingHTTPServer:
return ThreadingHTTPServer((host, port), ReplayRequestHandler)
def main() -> None:
parser = argparse.ArgumentParser(description="Run the Texas Hold X web replay viewer.")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", default=8088, type=int)
args = parser.parse_args()
server = create_server(args.host, args.port)
query = urlencode({"base_url": DEFAULT_CORE_BASE_URL})
print(f"Texas Hold X replay viewer listening on http://{args.host}:{args.port}/?{query}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
if __name__ == "__main__":
main()
+796
View File
@@ -0,0 +1,796 @@
const STREET_ORDER = ["preflop", "flop", "turn", "river"];
const STREET_BOARD_COUNTS = { preflop: 0, flop: 3, turn: 4, river: 5 };
const SUITS = { c: "♣", d: "♦", h: "♥", s: "♠" };
const state = {
rawGame: null,
replay: null,
handIndex: 0,
frameIndex: 0,
playing: false,
timer: null,
polling: false,
pollTimer: null,
};
const els = {
connectionStatus: document.getElementById("connectionStatus"),
gameStatus: document.getElementById("gameStatus"),
handCounter: document.getElementById("handCounter"),
baseUrlInput: document.getElementById("baseUrlInput"),
gameIdInput: document.getElementById("gameIdInput"),
fetchButton: document.getElementById("fetchButton"),
togglePollButton: document.getElementById("togglePollButton"),
runCountInput: document.getElementById("runCountInput"),
pollSecondsInput: document.getElementById("pollSecondsInput"),
smallBlindInput: document.getElementById("smallBlindInput"),
bigBlindInput: document.getElementById("bigBlindInput"),
untilOneLeftInput: document.getElementById("untilOneLeftInput"),
runButton: document.getElementById("runButton"),
createGameInput: document.getElementById("createGameInput"),
createGameButton: document.getElementById("createGameButton"),
fileInput: document.getElementById("fileInput"),
jsonInput: document.getElementById("jsonInput"),
loadJsonButton: document.getElementById("loadJsonButton"),
handSelect: document.getElementById("handSelect"),
speedInput: document.getElementById("speedInput"),
tableFelt: document.getElementById("tableFelt"),
seatLayer: document.getElementById("seatLayer"),
potDisplay: document.getElementById("potDisplay"),
boardCards: document.getElementById("boardCards"),
frameCaption: document.getElementById("frameCaption"),
resetButton: document.getElementById("resetButton"),
prevButton: document.getElementById("prevButton"),
playButton: document.getElementById("playButton"),
nextButton: document.getElementById("nextButton"),
frameCounter: document.getElementById("frameCounter"),
tableStats: document.getElementById("tableStats"),
playerList: document.getElementById("playerList"),
eventLog: document.getElementById("eventLog"),
};
function init() {
const params = new URLSearchParams(window.location.search);
if (params.get("base_url")) els.baseUrlInput.value = params.get("base_url");
if (params.get("game_id")) els.gameIdInput.value = params.get("game_id");
els.fetchButton.addEventListener("click", () => fetchGame());
els.togglePollButton.addEventListener("click", togglePolling);
els.runButton.addEventListener("click", runHands);
els.createGameButton.addEventListener("click", createGame);
els.loadJsonButton.addEventListener("click", loadJsonText);
els.fileInput.addEventListener("change", loadJsonFile);
els.handSelect.addEventListener("change", () => {
state.handIndex = Number(els.handSelect.value || 0);
state.frameIndex = 0;
stopPlayback();
render();
});
els.resetButton.addEventListener("click", () => setFrame(0));
els.prevButton.addEventListener("click", () => setFrame(state.frameIndex - 1));
els.nextButton.addEventListener("click", () => setFrame(state.frameIndex + 1));
els.playButton.addEventListener("click", togglePlayback);
els.createGameInput.value = JSON.stringify(defaultGameSpec(), null, 2);
renderEmpty();
}
async function fetchGame(options = {}) {
const baseUrl = els.baseUrlInput.value.trim();
const gameId = els.gameIdInput.value.trim();
if (!gameId) {
setStatus("error", "Missing game id");
return;
}
setStatus("neutral", "Fetching");
try {
const params = new URLSearchParams({ base_url: baseUrl, game_id: gameId });
const response = await fetch(`/api/fetch-game?${params.toString()}`);
const payload = await readJsonResponse(response);
loadGame(payload, { preserveTail: Boolean(options.preserveTail) });
setStatus("ok", "Connected");
} catch (error) {
setStatus("error", error.message);
}
}
async function runHands() {
const gameId = els.gameIdInput.value.trim();
if (!gameId) {
setStatus("error", "Missing game id");
return;
}
const payload = {
base_url: els.baseUrlInput.value.trim(),
game_id: gameId,
count: Number(els.runCountInput.value || 1),
until_one_left: els.untilOneLeftInput.checked,
};
const smallBlind = els.smallBlindInput.value.trim();
const bigBlind = els.bigBlindInput.value.trim();
if (smallBlind || bigBlind) {
payload.small_blind = Number(smallBlind);
payload.big_blind = Number(bigBlind);
}
setStatus("neutral", "Running");
els.runButton.disabled = true;
try {
const response = await fetch("/api/run-hands", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await readJsonResponse(response);
loadGame(result.game || result, { preserveTail: true });
setStatus("ok", "Updated");
} catch (error) {
setStatus("error", error.message);
} finally {
els.runButton.disabled = false;
}
}
async function createGame() {
let gameSpec;
try {
gameSpec = JSON.parse(els.createGameInput.value);
} catch (error) {
setStatus("error", error.message);
return;
}
if (!gameSpec || typeof gameSpec !== "object" || Array.isArray(gameSpec)) {
setStatus("error", "Game spec must be a JSON object");
return;
}
const payload = { ...gameSpec, base_url: els.baseUrlInput.value.trim() };
setStatus("neutral", "Creating");
els.createGameButton.disabled = true;
try {
const response = await fetch("/api/create-game", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const created = await readJsonResponse(response);
loadGame(created);
setStatus("ok", "Created");
} catch (error) {
setStatus("error", error.message);
} finally {
els.createGameButton.disabled = false;
}
}
function togglePolling() {
state.polling = !state.polling;
els.togglePollButton.classList.toggle("primary", state.polling);
els.togglePollButton.textContent = state.polling ? "Stop" : "Auto";
if (state.polling) {
fetchGame({ preserveTail: true });
schedulePoll();
} else if (state.pollTimer) {
clearTimeout(state.pollTimer);
state.pollTimer = null;
}
}
function schedulePoll() {
if (!state.polling) return;
const delay = Math.max(1, Number(els.pollSecondsInput.value || 3)) * 1000;
state.pollTimer = setTimeout(async () => {
await fetchGame({ preserveTail: true });
schedulePoll();
}, delay);
}
async function readJsonResponse(response) {
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
return payload;
}
function loadJsonText() {
try {
const payload = JSON.parse(els.jsonInput.value);
loadGame(payload);
setStatus("ok", "Loaded JSON");
} catch (error) {
setStatus("error", error.message);
}
}
function loadJsonFile() {
const file = els.fileInput.files && els.fileInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const payload = JSON.parse(String(reader.result || "{}"));
els.jsonInput.value = JSON.stringify(payload, null, 2);
loadGame(payload);
setStatus("ok", "Loaded file");
} catch (error) {
setStatus("error", error.message);
}
};
reader.readAsText(file);
}
function loadGame(rawGame, options = {}) {
const wasAtTail = isAtReplayTail();
const currentHandNumber = currentHand()?.hand_number;
const currentFrameKey = currentFrame()?.key;
state.rawGame = rawGame;
state.replay = buildReplay(rawGame);
syncInputsFromGame(rawGame);
populateHandSelect();
if (options.preserveTail && wasAtTail && state.replay.hands.length) {
state.handIndex = state.replay.hands.length - 1;
state.frameIndex = Math.max(0, currentHand().frames.length - 1);
} else if (currentHandNumber) {
const handIndex = state.replay.hands.findIndex((hand) => hand.hand_number === currentHandNumber);
state.handIndex = handIndex >= 0 ? handIndex : Math.max(0, state.replay.hands.length - 1);
const frameIndex = currentFrameKey
? currentHand().frames.findIndex((frame) => frame.key === currentFrameKey)
: 0;
state.frameIndex = frameIndex >= 0 ? frameIndex : 0;
} else {
state.handIndex = Math.max(0, state.replay.hands.length - 1);
state.frameIndex = 0;
}
render();
}
function buildReplay(game) {
const players = normalizePlayers(game.players || []);
const hands = [];
let stacks = Object.fromEntries(players.map((player) => [player.player_id, numberOr(player.stack, game.starting_stack || 0)]));
const historicalHands = Array.isArray(game.hands) ? game.hands : [];
if (historicalHands.length > 0) {
stacks = Object.fromEntries(players.map((player) => [player.player_id, numberOr(game.starting_stack, player.stack)]));
}
for (const hand of historicalHands) {
const startStacks = { ...stacks };
const replayHand = buildHandReplay(game, players, hand, startStacks);
hands.push(replayHand);
stacks = { ...replayHand.endingStacks };
}
const currentFrame = buildCurrentTableFrame(game, players);
return { game, players, hands, currentFrame };
}
function normalizePlayers(players) {
return players
.map((player, index) => ({
player_id: String(player.player_id || player.id || `p${index + 1}`),
name: String(player.name || player.player_id || player.id || `Player ${index + 1}`),
seat: numberOr(player.seat, index),
stack: numberOr(player.stack, 0),
street_bet: numberOr(player.street_bet, 0),
total_bet: numberOr(player.total_bet, 0),
folded: Boolean(player.folded),
all_in: Boolean(player.all_in),
in_hand: Boolean(player.in_hand),
}))
.sort((a, b) => a.seat - b.seat);
}
function buildHandReplay(game, players, hand, startStacks) {
const handPlayers = players.map((player) => ({
...player,
stack: numberOr(startStacks[player.player_id], player.stack),
street_bet: 0,
total_bet: 0,
folded: false,
all_in: false,
in_hand: numberOr(startStacks[player.player_id], player.stack) > 0,
hole_cards: holeCardsFor(hand, player.player_id),
}));
const playerState = new Map(handPlayers.map((player) => [player.player_id, player]));
const frames = [];
const actions = Array.isArray(hand.actions) ? hand.actions : [];
const blinds = hand.blinds || {};
frames.push(snapshotFrame({
key: `h${hand.hand_number}:start`,
type: "start",
hand,
players: handPlayers,
board: [],
pot: 0,
activePlayerId: null,
caption: `Hand ${hand.hand_number} starts. Blinds ${blinds.small_blind || game.small_blind}/${blinds.big_blind || game.big_blind}.`,
event: `Hand ${hand.hand_number} started`,
}));
let currentStreet = "preflop";
let boardCount = 0;
actions.forEach((action, index) => {
const street = String(action.street || currentStreet);
if (street !== currentStreet) {
currentStreet = street;
boardCount = STREET_BOARD_COUNTS[street] || boardCount;
frames.push(snapshotFrame({
key: `h${hand.hand_number}:${street}:deal`,
type: "street",
hand,
players: handPlayers,
board: (hand.board || []).slice(0, boardCount),
pot: totalPot(handPlayers),
activePlayerId: null,
caption: `${streetLabel(street)} dealt.`,
event: `${streetLabel(street)} board`,
}));
resetStreetBets(handPlayers);
}
const player = playerState.get(String(action.player_id));
if (player) {
const amount = numberOr(action.amount, 0);
player.stack = numberOr(action.stack, player.stack - amount);
player.street_bet = numberOr(action.street_bet, player.street_bet + amount);
player.total_bet += amount;
if (action.action === "fold") player.folded = true;
if (player.stack <= 0 && !player.folded) player.all_in = true;
}
frames.push(snapshotFrame({
key: `h${hand.hand_number}:a${index}`,
type: "action",
hand,
players: handPlayers,
board: (hand.board || []).slice(0, STREET_BOARD_COUNTS[street] || boardCount),
pot: totalPot(handPlayers),
activePlayerId: String(action.player_id),
caption: formatAction(action, player),
event: formatEvent(action, player),
action,
}));
});
const finalBoard = hand.board || [];
if (finalBoard.length && boardCount < finalBoard.length) {
frames.push(snapshotFrame({
key: `h${hand.hand_number}:board-final`,
type: "street",
hand,
players: handPlayers,
board: finalBoard,
pot: totalPot(handPlayers),
activePlayerId: null,
caption: "Final board is visible.",
event: "Final board",
}));
}
const showdown = hand.showdown_hands || {};
if (Object.keys(showdown).length > 0) {
for (const [playerId, cards] of Object.entries(showdown)) {
const player = playerState.get(playerId);
if (player) player.hole_cards = Array.isArray(cards) ? cards : [];
}
frames.push(snapshotFrame({
key: `h${hand.hand_number}:showdown`,
type: "showdown",
hand,
players: handPlayers,
board: finalBoard,
pot: totalPot(handPlayers),
activePlayerId: null,
caption: "Showdown.",
event: "Showdown",
}));
}
const awards = Array.isArray(hand.awards) ? hand.awards : [];
for (const award of awards) {
const amount = numberOr(award.amount, 0);
const winners = Array.isArray(award.winners) ? award.winners : [];
const share = winners.length ? Math.floor(amount / winners.length) : 0;
let remainder = winners.length ? amount % winners.length : 0;
winners.forEach((winnerId) => {
const winner = playerState.get(String(winnerId));
if (!winner) return;
winner.stack += share + (remainder > 0 ? 1 : 0);
remainder -= 1;
});
frames.push(snapshotFrame({
key: `h${hand.hand_number}:award:${frames.length}`,
type: "award",
hand,
players: handPlayers,
board: finalBoard,
pot: 0,
activePlayerId: winners[0] ? String(winners[0]) : null,
caption: formatAward(award),
event: formatAward(award),
}));
}
const endingStacks = Object.fromEntries(handPlayers.map((player) => [player.player_id, player.stack]));
if (frames.length === 1) {
frames.push(snapshotFrame({
key: `h${hand.hand_number}:empty`,
type: "empty",
hand,
players: handPlayers,
board: finalBoard,
pot: 0,
activePlayerId: null,
caption: "No actions recorded for this hand.",
event: "No actions",
}));
}
return { hand_number: hand.hand_number, hand, frames, endingStacks };
}
function buildCurrentTableFrame(game, players) {
return {
key: "current-table",
type: "current",
hand_number: game.hand_number || 0,
button_seat: game.button_seat,
players: players.map((player) => ({
...player,
street_bet: numberOr(player.street_bet, 0),
total_bet: numberOr(player.total_bet, 0),
hole_cards: [],
})),
board: [],
pot: players.reduce((sum, player) => sum + numberOr(player.total_bet, 0), 0),
activePlayerId: null,
caption: "Current table snapshot. Run hands or load history to replay actions.",
event: "Current table snapshot",
action: null,
};
}
function snapshotFrame({ key, type, hand, players, board, pot, activePlayerId, caption, event, action }) {
return {
key,
type,
hand_number: hand.hand_number,
button_seat: hand.button_seat,
players: players.map((player) => ({ ...player, hole_cards: [...(player.hole_cards || [])] })),
board: [...(board || [])],
pot,
activePlayerId,
caption,
event,
action: action ? { ...action } : null,
};
}
function holeCardsFor(hand, playerId) {
const explicit = hand.hole_cards || hand.private_hands || {};
const showdown = hand.showdown_hands || {};
const cards = explicit[playerId] || showdown[playerId] || [];
return Array.isArray(cards) ? cards : [];
}
function totalPot(players) {
return players.reduce((sum, player) => sum + numberOr(player.total_bet, 0), 0);
}
function resetStreetBets(players) {
players.forEach((player) => {
player.street_bet = 0;
});
}
function syncInputsFromGame(game) {
if (game.game_id) els.gameIdInput.value = game.game_id;
}
function populateHandSelect() {
els.handSelect.innerHTML = "";
const hands = state.replay ? state.replay.hands : [];
if (!hands.length) {
const option = document.createElement("option");
option.value = "0";
option.textContent = "No hands";
els.handSelect.append(option);
return;
}
hands.forEach((hand, index) => {
const option = document.createElement("option");
option.value = String(index);
option.textContent = `Hand ${hand.hand_number}`;
els.handSelect.append(option);
});
}
function render() {
if (!state.replay) {
renderEmpty();
return;
}
const hand = currentHand();
const frame = currentFrame();
els.handSelect.value = state.replay.hands.length ? String(state.handIndex) : "0";
renderHeader();
renderStats();
renderPlayers(frame);
renderSeats(frame);
renderBoard(frame);
renderEvents(hand, frame);
renderTransport(hand, frame);
}
function renderEmpty() {
els.gameStatus.textContent = "No game";
els.handCounter.textContent = "Hand 0";
els.tableStats.innerHTML = "";
els.playerList.innerHTML = "";
els.seatLayer.innerHTML = "";
els.boardCards.innerHTML = "";
els.potDisplay.textContent = "Pot 0";
els.frameCaption.textContent = "Load a game snapshot";
els.frameCounter.textContent = "0 / 0";
els.eventLog.innerHTML = "";
}
function renderHeader() {
const game = state.replay.game;
els.gameStatus.textContent = `${game.game_id || "game"} · ${game.status || "unknown"}`;
els.gameStatus.className = `pill ${game.status === "complete" ? "ok" : ""}`;
els.handCounter.textContent = `Hand ${game.hand_number || 0}`;
}
function renderStats() {
const game = state.replay.game;
const hands = state.replay.hands.length;
const current = currentHand();
const stats = [
["Players", state.replay.players.length],
["Hands", hands],
["Blinds", `${game.small_blind || 0}/${game.big_blind || 0}`],
["Button", current && current.hand ? `Seat ${current.hand.button_seat}` : game.button_seat == null ? "-" : `Seat ${game.button_seat}`],
];
els.tableStats.innerHTML = stats
.map(([label, value]) => `<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
@@ -0,0 +1,144 @@
<!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
@@ -0,0 +1,609 @@
:root {
color-scheme: dark;
--bg: #07100e;
--surface: #101b18;
--surface-2: #15231f;
--line: #294038;
--text: #f2f7f4;
--muted: #9fb3aa;
--green: #22c55e;
--green-2: #15803d;
--amber: #f59e0b;
--red: #ef4444;
--blue: #38bdf8;
--felt: #0f6b42;
--felt-dark: #06452c;
--wood: #7c4a1e;
--shadow: 0 16px 40px rgba(0, 0, 0, 0.34);
--radius: 8px;
--font: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px),
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
var(--bg);
background-size: 24px 24px;
color: var(--text);
font-family: var(--font);
}
button,
input,
select,
textarea {
font: inherit;
}
button {
border: 1px solid var(--line);
border-radius: var(--radius);
min-height: 38px;
padding: 8px 12px;
background: #172621;
color: var(--text);
cursor: pointer;
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
}
button:hover {
border-color: var(--green);
background: #1e322b;
}
button:active {
transform: translateY(1px);
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
button.primary {
border-color: #35d772;
background: linear-gradient(180deg, #28c864, #179249);
color: #04100a;
font-weight: 700;
}
button.wide {
width: 100%;
}
input,
select,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #0a1411;
color: var(--text);
min-height: 38px;
padding: 8px 10px;
}
textarea {
resize: vertical;
min-height: 120px;
font-family: var(--mono);
font-size: 12px;
line-height: 1.45;
}
input:focus,
select:focus,
textarea:focus,
button:focus-visible {
outline: 2px solid rgba(34, 197, 94, 0.75);
outline-offset: 2px;
}
.shell {
width: min(1720px, calc(100vw - 32px));
margin: 0 auto;
padding: 18px 0 24px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-bottom: 16px;
}
.eyebrow {
margin: 0 0 4px;
color: var(--green);
font-family: var(--mono);
font-size: 12px;
text-transform: uppercase;
}
h1,
h2 {
margin: 0;
letter-spacing: 0;
}
h1 {
font-size: 26px;
}
h2 {
font-size: 14px;
}
.status-strip {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.pill,
.metric {
display: inline-flex;
align-items: center;
min-height: 30px;
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 10px;
background: rgba(16, 27, 24, 0.9);
color: var(--muted);
font-family: var(--mono);
font-size: 12px;
white-space: nowrap;
}
.pill.ok {
border-color: rgba(34, 197, 94, 0.6);
color: #86efac;
}
.pill.error {
border-color: rgba(239, 68, 68, 0.7);
color: #fecaca;
}
.workspace {
display: grid;
grid-template-columns: 300px minmax(520px, 1fr) 340px;
gap: 14px;
align-items: start;
}
.panel {
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(16, 27, 24, 0.94);
box-shadow: var(--shadow);
padding: 14px;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.panel-header.compact {
margin-top: 4px;
}
.field {
display: grid;
gap: 6px;
margin-bottom: 10px;
}
.field span,
.select-wrap label {
color: var(--muted);
font-size: 12px;
}
.form-grid,
.button-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.check-field {
display: flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 13px;
margin: 4px 0 10px;
}
.check-field input {
width: 16px;
min-height: 16px;
}
.divider {
height: 1px;
background: var(--line);
margin: 14px 0;
}
.file-input {
margin-bottom: 10px;
}
.table-zone {
min-width: 0;
}
.table-toolbar,
.transport {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.select-wrap {
display: grid;
grid-template-columns: auto minmax(120px, 1fr);
align-items: center;
gap: 8px;
}
.felt-stage {
min-height: 560px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: radial-gradient(circle at 50% 45%, rgba(56, 189, 248, 0.12), transparent 46%), #08120f;
box-shadow: var(--shadow);
padding: 20px;
}
.table-felt {
position: relative;
height: 520px;
min-height: 520px;
border: 16px solid var(--wood);
border-radius: 48%;
background:
radial-gradient(ellipse at center, rgba(255, 255, 255, 0.1), transparent 58%),
radial-gradient(ellipse at center, var(--felt), var(--felt-dark));
box-shadow: inset 0 0 0 6px rgba(255, 255, 255, 0.08), inset 0 0 32px rgba(0, 0, 0, 0.45);
overflow: visible;
}
.table-felt::after {
content: "";
position: absolute;
inset: 58px 80px;
border: 1px dashed rgba(255, 255, 255, 0.18);
border-radius: 50%;
pointer-events: none;
}
.seat-layer {
position: absolute;
inset: 0;
}
.seat {
position: absolute;
width: 154px;
min-height: 106px;
transform: translate(-50%, -50%);
border: 1px solid rgba(148, 163, 184, 0.34);
border-radius: var(--radius);
background: rgba(6, 16, 12, 0.9);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.36);
padding: 8px;
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
.seat.active {
border-color: var(--green);
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.24), 0 0 24px rgba(34, 197, 94, 0.28);
}
.seat.folded {
opacity: 0.66;
}
.seat .name-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.seat .name {
min-width: 0;
overflow: hidden;
color: var(--text);
font-size: 13px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag {
border-radius: 999px;
padding: 2px 6px;
background: rgba(34, 197, 94, 0.16);
color: #bbf7d0;
font-family: var(--mono);
font-size: 10px;
white-space: nowrap;
}
.seat-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
color: var(--muted);
font-family: var(--mono);
font-size: 11px;
}
.hole-cards {
margin-top: 7px;
}
.cards {
display: flex;
justify-content: center;
gap: 6px;
}
.card {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 58px;
border: 1px solid #d7dee3;
border-radius: 6px;
background: #f8fafc;
color: #111827;
font-family: var(--mono);
font-size: 18px;
font-weight: 800;
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.22);
}
.card.red {
color: #b91c1c;
}
.card.back {
border-color: #38bdf8;
background:
linear-gradient(45deg, rgba(255, 255, 255, 0.16) 25%, transparent 25%),
linear-gradient(-45deg, rgba(255, 255, 255, 0.16) 25%, transparent 25%),
#123a52;
background-size: 10px 10px;
color: transparent;
}
.board-zone {
position: absolute;
left: 50%;
top: 50%;
display: grid;
width: min(420px, 52%);
transform: translate(-50%, -50%);
justify-items: center;
gap: 12px;
z-index: 3;
}
.pot-display {
border: 1px solid rgba(245, 158, 11, 0.55);
border-radius: 999px;
padding: 6px 12px;
background: rgba(31, 20, 5, 0.7);
color: #fde68a;
font-family: var(--mono);
font-size: 13px;
}
.board-cards {
min-height: 58px;
}
.frame-caption {
width: 100%;
min-height: 42px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: var(--radius);
padding: 9px 12px;
background: rgba(3, 7, 18, 0.62);
color: var(--text);
text-align: center;
}
.transport {
justify-content: center;
margin: 10px 0 0;
}
.transport button {
min-width: 48px;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.stat {
border: 1px solid var(--line);
border-radius: var(--radius);
background: #0a1411;
padding: 9px;
}
.stat .label {
color: var(--muted);
font-size: 11px;
}
.stat .value {
margin-top: 4px;
font-family: var(--mono);
font-size: 16px;
}
.player-list {
display: grid;
gap: 8px;
}
.player-row {
display: grid;
grid-template-columns: 28px 1fr auto;
gap: 8px;
align-items: center;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #0a1411;
padding: 8px;
}
.player-row .seat-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(34, 197, 94, 0.14);
color: #bbf7d0;
font-family: var(--mono);
font-size: 12px;
}
.player-row .name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-row .stack {
color: var(--muted);
font-family: var(--mono);
font-size: 12px;
}
.event-log {
display: grid;
max-height: 392px;
margin: 0;
padding: 0 0 0 20px;
gap: 7px;
overflow: auto;
}
.event-log li {
border-left: 2px solid rgba(34, 197, 94, 0.35);
padding: 2px 0 2px 8px;
color: var(--muted);
font-size: 13px;
}
.event-log li.current {
color: var(--text);
border-left-color: var(--green);
}
@media (max-width: 1180px) {
.workspace {
grid-template-columns: 300px minmax(0, 1fr);
}
.log-panel {
grid-column: 1 / -1;
}
}
@media (max-width: 820px) {
.shell {
width: min(100vw - 20px, 760px);
padding-top: 12px;
}
.topbar {
align-items: flex-start;
flex-direction: column;
}
.status-strip {
justify-content: flex-start;
}
.workspace {
grid-template-columns: 1fr;
}
.felt-stage {
min-height: 430px;
padding: 12px;
}
.table-felt {
height: 390px;
min-height: 390px;
border-width: 10px;
}
.seat {
width: 124px;
min-height: 94px;
padding: 7px;
}
.seat-stats {
grid-template-columns: 1fr;
}
.card {
width: 34px;
height: 48px;
font-size: 15px;
}
.board-zone {
width: 62%;
}
.table-toolbar {
align-items: stretch;
flex-direction: column;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}