4 Commits

Author SHA1 Message Date
mamamiyear d5982f15f9 refactor: replay ui to terminal style 2026-05-20 00:38:28 +08:00
mamamiyear 1ee963ce2e chore: add .codex to .gitignore 2026-05-17 11:23:21 +08:00
mamamiyear 351cac7734 docs: add AGENTS.md 2026-05-15 14:58:54 +08:00
mamamiyear 79dccde963 fix: game service api block when a game is running 2026-05-13 21:42:53 +08:00
12 changed files with 1262 additions and 215 deletions
+5
View File
@@ -32,8 +32,13 @@ htmlcov/
# IDE and editor files
.idea/
.vscode/
.codex/
*.swp
*.swo
.codex/
# debug resources
debug/
# Node dependencies for browser automation tooling
node_modules/
+32
View File
@@ -0,0 +1,32 @@
# Repository Guidelines
## Project Structure & Module Organization
Core poker service code lives in `texas_holdem/`. Important modules include `engine.py` for Texas Hold'em rules, `service.py` for game management, `server.py` for the HTTP API, `agents.py` for local/HTTP agents, and `ai_client.py` / `human_client.py` for standalone agents. Prompt templates live in `texas_holdem/prompts/`.
Replay UI code lives in `texas_holdem_replay/`, with static browser assets under `texas_holdem_replay/static/`. Tests are in `tests/`, named by feature area such as `test_engine.py`, `test_service.py`, and `test_replay_server.py`. Design notes belong in `docs/`.
## Build, Test, and Development Commands
- `python -m unittest discover -v` runs the full test suite.
- `python -m compileall texas_holdem texas_holdem_replay tests` checks import and syntax validity.
- `python -m texas_holdem.server --host 127.0.0.1 --port 8000` starts the game service.
- `python -m texas_holdem.human_client --port 9001 --keep-history` starts an interactive human HTTP agent.
- `python -m texas_holdem.ai_client --port 9101 --api-key "$OPENAI_API_KEY" --model gpt-4o-mini` starts an OpenAI-compatible AI agent.
- `python -m texas_holdem_replay.server --port 8088` starts the replay viewer.
## Coding Style & Naming Conventions
Use Python 3.11+ standard-library APIs unless a dependency is intentionally added to `pyproject.toml`. Keep modules focused and prefer explicit dataclasses for wire/state models. Use 4-space indentation, type hints, `snake_case` for functions and variables, `PascalCase` for classes, and concise comments only where logic is non-obvious.
## Testing Guidelines
Use `unittest`. Add tests near the behavior changed: engine rules in `tests/test_engine.py`, HTTP/service behavior in `tests/test_service.py`, agent transport in `tests/test_agents.py`, and replay UI server helpers in `tests/test_replay_server.py`. New bug fixes should include a regression test. Avoid tests that require external network access or real LLM calls.
## Commit & Pull Request Guidelines
History uses short Conventional Commit-style subjects, for example `feat: add replay server and web client` and `fix: game service api block when a game is running`. Keep commits scoped to one behavior change. Pull requests should include a short summary, test commands run, linked issue or motivation, and screenshots only for replay UI or visible terminal-output changes.
## Security & Configuration Tips
Do not commit API keys. Pass LLM credentials through `OPENAI_API_KEY` or CLI flags in local shells only. HTTP Agent endpoints are exclusive per active game; preserve this invariant when changing service concurrency.
+16 -2
View File
@@ -11,6 +11,7 @@
- 支持盲注、四条街下注、弃牌、过牌、跟注、下注、加注、全下、边池和摊牌结算。
- 支持本地 Agent 和 HTTP Agent。
- 支持 Human Agent 和 OpenAI-compatible AI Agent 的终端过程输出。
- 游戏运行中可以并发查询状态;查询返回上一手完成后的稳定快照。
## 运行服务
@@ -51,6 +52,12 @@ curl -X POST http://127.0.0.1:8000/games/demo/hands/run \
curl http://127.0.0.1:8000/games/demo
```
也可以使用单数别名:
```bash
curl http://127.0.0.1:8000/game/demo
```
## HTTP Agent 协议
玩家配置可以使用远程 HTTP Agent:
@@ -62,12 +69,19 @@ curl http://127.0.0.1:8000/games/demo
"agent": {
"type": "http",
"endpoint": "http://127.0.0.1:9101",
"timeout_seconds": 10
"timeout_seconds": 10,
"game_update_timeout_seconds": 3,
"retries": 2,
"retry_backoff_seconds": 0.25
}
}
```
服务会向 `endpoint` 发送当前行动玩家的观察 JSON。Agent 返回:
服务会向 `endpoint + /game` 推送每手开始时的游戏快照,向 `endpoint + /act` 发送当前行动玩家的观察 JSON。`endpoint` 也可以传入历史形式的 `/act``/game` 后缀,服务会归一化为 base URL。
同一个 HTTP Agent endpoint 不能同时被不同游戏占用;后创建的游戏会返回错误。服务会给 HTTP Agent 请求自动重试,`/act` 重试仍失败时,规则引擎会按 `check > call > fold` 选择默认动作,避免整桌卡死。
Agent 返回:
```json
{"action": "call"}
+60
View File
@@ -0,0 +1,60 @@
import json
import unittest
from unittest.mock import patch
from urllib.error import URLError
from texas_holdem.agents import HttpAgent, normalise_http_agent_endpoint
class FakeResponse:
def __init__(self, payload: dict[str, object]) -> None:
self.payload = payload
def read(self) -> bytes:
return json.dumps(self.payload).encode("utf-8")
def __enter__(self) -> "FakeResponse":
return self
def __exit__(self, *args: object) -> None:
return None
class AgentTests(unittest.TestCase):
def test_normalise_http_agent_endpoint_accepts_action_or_game_paths(self) -> None:
self.assertEqual(
normalise_http_agent_endpoint("http://127.0.0.1:9101/act"),
"http://127.0.0.1:9101",
)
self.assertEqual(
normalise_http_agent_endpoint("http://127.0.0.1:9101/game/"),
"http://127.0.0.1:9101",
)
def test_http_agent_post_retries_and_sets_player_header(self) -> None:
calls = []
def fake_urlopen(request, timeout): # type: ignore[no-untyped-def]
calls.append((request, timeout))
if len(calls) == 1:
raise URLError("temporary")
return FakeResponse({"ok": True})
agent = HttpAgent(
"http://agent.test/act",
player_id="p1",
retries=1,
retry_backoff_seconds=0,
)
with patch("texas_holdem.agents.urlopen", fake_urlopen):
payload = agent._post_json("/game", {"game_id": "g1"}, timeout_seconds=2)
self.assertEqual(payload, {"ok": True})
self.assertEqual(len(calls), 2)
self.assertEqual(calls[1][0].headers["X-player-id"], "p1")
self.assertEqual(calls[1][1], 2)
if __name__ == "__main__":
unittest.main()
+74 -1
View File
@@ -1,8 +1,26 @@
import unittest
from threading import Event, Thread
from texas_holdem.agents import PokerAgent
from texas_holdem.models import Observation, PlayerAction
from texas_holdem.service import GameManager
class BlockingAgent(PokerAgent):
def __init__(self, entered: Event, release: Event) -> None:
self.entered = entered
self.release = release
def decide(self, observation: Observation) -> PlayerAction:
self.entered.set()
if not self.release.wait(timeout=5):
raise RuntimeError("test timed out waiting to release blocking agent")
for action in observation.legal_actions:
if action["action"] == "check":
return PlayerAction("check")
return PlayerAction("call")
class ServiceTests(unittest.TestCase):
def test_create_and_run_game(self) -> None:
manager = GameManager()
@@ -23,7 +41,62 @@ class ServiceTests(unittest.TestCase):
hands = manager.run_hands(game.game_id, count=1)
self.assertEqual(len(hands), 1)
self.assertEqual(manager.get_game("demo").to_dict()["hand_number"], 1)
self.assertEqual(manager.get_game_state("demo")["hand_number"], 1)
def test_get_game_state_does_not_block_during_run(self) -> None:
manager = GameManager()
entered = Event()
release = Event()
game = manager.create_game(
{
"game_id": "blocking",
"seed": 13,
"starting_stack": 200,
"small_blind": 5,
"big_blind": 10,
"players": [
{"id": "a", "type": "calling"},
{"id": "b", "type": "calling"},
],
}
)
manager.run_hands("blocking", count=1)
game.agents["a"] = BlockingAgent(entered, release)
thread = Thread(target=lambda: manager.run_hands("blocking", count=1))
thread.start()
self.assertTrue(entered.wait(timeout=2))
state = manager.get_game_state("blocking")
release.set()
thread.join(timeout=2)
self.assertFalse(thread.is_alive())
self.assertEqual(state["hand_number"], 1)
self.assertEqual(len(state["hands"]), 1)
def test_duplicate_http_agent_endpoint_is_rejected_across_active_games(self) -> None:
manager = GameManager()
payload = {
"starting_stack": 200,
"small_blind": 5,
"big_blind": 10,
"players": [
{
"id": "ai",
"agent": {
"type": "http",
"endpoint": "http://127.0.0.1:9101/act",
},
},
{"id": "b", "type": "calling"},
],
}
manager.create_game({"game_id": "g1", **payload})
with self.assertRaisesRegex(ValueError, "already belongs to game g1"):
manager.create_game({"game_id": "g2", **payload})
if __name__ == "__main__":
+106 -31
View File
@@ -2,10 +2,11 @@ from __future__ import annotations
import json
import sys
import time
from abc import ABC, abstractmethod
from random import Random
from typing import IO, Any
from urllib.error import URLError
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from texas_holdem.human_io import clear_screen, prompt_action, render_observation
@@ -54,6 +55,27 @@ class CallingStationAgent(PokerAgent):
return PlayerAction("fold")
def normalise_http_agent_endpoint(raw: str) -> str:
"""Return the canonical base URL for an HTTP agent endpoint."""
url = raw.rstrip("/")
if url.endswith("/act"):
url = url[: -len("/act")]
if url.endswith("/game"):
url = url[: -len("/game")]
return url
def http_agent_endpoint_from_spec(spec: dict[str, Any]) -> str | None:
"""Extract the canonical HTTP endpoint from an agent spec, if present."""
agent_type = str(spec.get("type", "calling")).lower()
if agent_type != "http":
return None
endpoint = spec.get("endpoint")
if not endpoint:
raise ValueError("http agent requires an endpoint")
return normalise_http_agent_endpoint(str(endpoint))
class HttpAgent(PokerAgent):
"""Remote agent that talks to a base URL exposing ``/act`` and ``/game``.
@@ -66,28 +88,36 @@ class HttpAgent(PokerAgent):
ACT_PATH = "/act"
GAME_PATH = "/game"
def __init__(self, endpoint: str, timeout_seconds: float = 10.0) -> None:
self.base_url = self._normalise_base_url(endpoint)
def __init__(
self,
endpoint: str,
timeout_seconds: float = 10.0,
player_id: str | None = None,
game_update_timeout_seconds: float | None = None,
retries: int = 2,
retry_backoff_seconds: float = 0.25,
) -> None:
self.base_url = normalise_http_agent_endpoint(endpoint)
self.timeout_seconds = timeout_seconds
@staticmethod
def _normalise_base_url(raw: str) -> str:
"""Strip a trailing slash so URL joins do not produce double slashes.
Centralising this also tolerates the legacy "endpoint already points
at /act" mistake by chopping off a redundant ``/act`` suffix.
"""
url = raw.rstrip("/")
if url.endswith("/act"):
url = url[: -len("/act")]
return url
self.player_id = player_id
self.game_update_timeout_seconds = (
float(game_update_timeout_seconds)
if game_update_timeout_seconds is not None
else min(timeout_seconds, 3.0)
)
self.retries = max(0, retries)
self.retry_backoff_seconds = max(0.0, retry_backoff_seconds)
def _url(self, path: str) -> str:
"""Compose a full URL by joining the base with a path component."""
return f"{self.base_url}{path}"
def decide(self, observation: Observation) -> PlayerAction:
payload = self._post_json(self.ACT_PATH, observation.to_dict())
payload = self._post_json(
self.ACT_PATH,
observation.to_dict(),
timeout_seconds=self.timeout_seconds,
)
if not isinstance(payload, dict):
raise RuntimeError("agent endpoint must return a JSON object")
return PlayerAction.from_dict(payload)
@@ -100,30 +130,54 @@ class HttpAgent(PokerAgent):
only by way of the raised exception bubbling to the engine guard.
"""
try:
self._post_json(self.GAME_PATH, game_state)
self._post_json(
self.GAME_PATH,
game_state,
timeout_seconds=self.game_update_timeout_seconds,
)
except RuntimeError:
# ``/game`` is informational; treat any HTTP error as a benign
# drop rather than reraising and aborting the hand loop.
return None
def _post_json(self, path: str, payload: dict[str, Any]) -> Any:
def _post_json(
self,
path: str,
payload: dict[str, Any],
timeout_seconds: float,
) -> Any:
"""POST ``payload`` as JSON to ``base_url + path`` and return parsed body.
Extracted as a tiny helper so ``decide`` and ``on_game_update`` share
identical transport semantics (timeout, error wrapping, content-type).
"""
body = json.dumps(payload).encode("utf-8")
request = Request(
self._url(path),
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urlopen(request, timeout=self.timeout_seconds) as response:
raw = response.read().decode("utf-8")
except (OSError, URLError) as exc:
raise RuntimeError(f"agent endpoint failed: {self._url(path)}") from exc
last_error: BaseException | None = None
raw = ""
for attempt in range(self.retries + 1):
request = Request(
self._url(path),
data=body,
headers=self._headers(),
method="POST",
)
try:
with urlopen(request, timeout=timeout_seconds) as response:
raw = response.read().decode("utf-8")
break
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
last_error = RuntimeError(
f"agent endpoint failed with HTTP {exc.code}: "
f"{self._url(path)} {detail}"
)
except (OSError, URLError) as exc:
last_error = exc
if attempt < self.retries and self.retry_backoff_seconds > 0:
time.sleep(self.retry_backoff_seconds * (2**attempt))
else:
raise RuntimeError(f"agent endpoint failed: {self._url(path)}") from last_error
if not raw:
return None
try:
@@ -133,6 +187,12 @@ class HttpAgent(PokerAgent):
f"agent endpoint returned invalid JSON: {self._url(path)}"
) from exc
def _headers(self) -> dict[str, str]:
headers = {"Content-Type": "application/json", "Connection": "close"}
if self.player_id:
headers["X-Player-Id"] = self.player_id
return headers
class HumanAgent(PokerAgent):
"""Interactive CLI agent for debugging and manual play.
@@ -189,7 +249,11 @@ class HumanAgent(PokerAgent):
return line.rstrip("\n")
def build_agent(spec: dict[str, Any], rng: Random | None = None) -> PokerAgent:
def build_agent(
spec: dict[str, Any],
rng: Random | None = None,
player_id: str | None = None,
) -> PokerAgent:
agent_type = str(spec.get("type", "calling")).lower()
if agent_type == "random":
return RandomAgent(rng)
@@ -199,7 +263,18 @@ def build_agent(spec: dict[str, Any], rng: Random | None = None) -> PokerAgent:
endpoint = spec.get("endpoint")
if not endpoint:
raise ValueError("http agent requires an endpoint")
return HttpAgent(str(endpoint), float(spec.get("timeout_seconds", 10.0)))
return HttpAgent(
str(endpoint),
timeout_seconds=float(spec.get("timeout_seconds", 10.0)),
player_id=player_id,
game_update_timeout_seconds=(
float(spec["game_update_timeout_seconds"])
if "game_update_timeout_seconds" in spec
else None
),
retries=int(spec.get("retries", 2)),
retry_backoff_seconds=float(spec.get("retry_backoff_seconds", 0.25)),
)
if agent_type in {"human", "cli", "interactive"}:
return HumanAgent()
raise ValueError(f"unknown agent type: {agent_type}")
+63 -18
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
from copy import deepcopy
from random import Random
from threading import RLock
from time import time
from texas_holdem.agents import PokerAgent
@@ -54,6 +56,7 @@ class TableGame:
self.small_blind = small_blind
self.big_blind = big_blind
self.rng = rng or Random()
self.lock = RLock()
self.hand_number = 0
self.button_index: int | None = None
self.board = []
@@ -64,6 +67,7 @@ class TableGame:
# first hand that played under those stakes, which makes it trivial
# to reconstruct the schedule from the outside.
self.blind_history: list[BlindLevel] = []
self._completed_snapshot: dict[str, object] = self._to_dict_unlocked()
@property
def is_complete(self) -> bool:
@@ -83,6 +87,14 @@ class TableGame:
:attr:`blind_history` whenever it changes (including the very first
hand) so external observers can replay the schedule.
"""
with self.lock:
return self._run_hand_locked(small_blind=small_blind, big_blind=big_blind)
def _run_hand_locked(
self,
small_blind: int | None = None,
big_blind: int | None = None,
) -> HandSummary:
if self.is_complete:
raise GameComplete("game is complete")
@@ -151,6 +163,7 @@ class TableGame:
finished_at=time(),
)
self.hand_summaries.append(summary)
self._completed_snapshot = deepcopy(self._to_dict_unlocked())
return summary
def run_hands(
@@ -166,24 +179,47 @@ class TableGame:
with the first hand of this call; subsequent calls can raise them
again. Leaving them ``None`` keeps the current level unchanged.
"""
if max_hands <= 0:
raise ValueError("max_hands must be positive")
summaries = []
for _ in range(max_hands):
if self.is_complete:
break
# Only the first hand of the batch needs to apply the blind
# override; after that the engine reuses the stored values.
summaries.append(
self.run_hand(small_blind=small_blind, big_blind=big_blind)
)
small_blind = None
big_blind = None
if until_one_left and self.is_complete:
break
return summaries
with self.lock:
if max_hands <= 0:
raise ValueError("max_hands must be positive")
summaries = []
for _ in range(max_hands):
if self.is_complete:
break
# Only the first hand of the batch needs to apply the blind
# override; after that the engine reuses the stored values.
summaries.append(
self._run_hand_locked(
small_blind=small_blind,
big_blind=big_blind,
)
)
small_blind = None
big_blind = None
if until_one_left and self.is_complete:
break
return summaries
def to_dict(self) -> dict[str, object]:
with self.lock:
return self._to_dict_unlocked()
def snapshot_completed(self) -> dict[str, object]:
"""Return a stable snapshot from the latest completed hand boundary.
If a hand is currently running under ``self.lock``, this method does
not block. It returns the most recent completed hand summary and
stacks captured in memory, which is exactly what status endpoints
need while a long-running HTTP-agent decision is in progress.
"""
if self.lock.acquire(blocking=False):
try:
return deepcopy(self._to_dict_unlocked())
finally:
self.lock.release()
return deepcopy(self._completed_snapshot)
def _to_dict_unlocked(self) -> dict[str, object]:
return {
"game_id": self.game_id,
"status": "complete" if self.is_complete else "running",
@@ -449,9 +485,18 @@ class TableGame:
try:
requested = agent.decide(observation)
except Exception:
requested = PlayerAction("fold")
requested = self._default_action(observation.legal_actions)
return self._coerce_action(requested, observation.legal_actions)
def _default_action(self, legal_actions: list[dict[str, object]]) -> PlayerAction:
by_action = {str(action["action"]): action for action in legal_actions}
for action_type in ("check", "call", "fold"):
if action_type in by_action:
legal = by_action[action_type]
return PlayerAction(action_type, int(legal.get("amount") or 0))
legal = legal_actions[0]
return PlayerAction(str(legal["action"]), int(legal.get("amount") or 0))
def _coerce_action(
self,
requested: PlayerAction,
@@ -581,7 +626,7 @@ class TableGame:
swallow individual exceptions so a flaky remote endpoint cannot
break the table flow.
"""
snapshot = self.to_dict()
snapshot = self._to_dict_unlocked()
for agent in self.agents.values():
try:
agent.on_game_update(snapshot)
+8 -8
View File
@@ -25,8 +25,8 @@ class PokerRequestHandler(BaseHTTPRequestHandler):
if path == ["games"]:
self._json({"games": MANAGER.list_games()})
return
if len(path) == 2 and path[0] == "games":
self._json(MANAGER.get_game(path[1]).to_dict())
if len(path) == 2 and path[0] in {"game", "games"}:
self._json(MANAGER.get_game_state(path[1]))
return
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
except KeyError as exc:
@@ -35,11 +35,11 @@ class PokerRequestHandler(BaseHTTPRequestHandler):
def do_POST(self) -> None:
path = self._path_parts()
try:
if path == ["games"]:
if path in (["game"], ["games"]):
game = MANAGER.create_game(self._read_json())
self._json(game.to_dict(), HTTPStatus.CREATED)
self._json(game.snapshot_completed(), HTTPStatus.CREATED)
return
if len(path) == 3 and path[0] == "games" and path[2] == "hands":
if len(path) == 3 and path[0] in {"game", "games"} and path[2] == "hands":
body = self._read_json()
count = int(body.get("count", 1))
until_one_left = bool(body.get("until_one_left", False))
@@ -51,9 +51,9 @@ class PokerRequestHandler(BaseHTTPRequestHandler):
small_blind=small_blind,
big_blind=big_blind,
)
self._json({"hands": summaries, "game": MANAGER.get_game(path[1]).to_dict()})
self._json({"hands": summaries, "game": MANAGER.get_game_state(path[1])})
return
if len(path) == 4 and path[0] == "games" and path[2] == "hands" and path[3] == "run":
if len(path) == 4 and path[0] in {"game", "games"} and path[2] == "hands" and path[3] == "run":
body = self._read_json()
count = int(body.get("count", 1))
until_one_left = bool(body.get("until_one_left", False))
@@ -65,7 +65,7 @@ class PokerRequestHandler(BaseHTTPRequestHandler):
small_blind=small_blind,
big_blind=big_blind,
)
self._json({"hands": summaries, "game": MANAGER.get_game(path[1]).to_dict()})
self._json({"hands": summaries, "game": MANAGER.get_game_state(path[1])})
return
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
except KeyError as exc:
+51 -13
View File
@@ -5,13 +5,14 @@ from threading import RLock
from typing import Any
from uuid import uuid4
from texas_holdem.agents import build_agent
from texas_holdem.agents import build_agent, http_agent_endpoint_from_spec
from texas_holdem.engine import TableGame
class GameManager:
def __init__(self) -> None:
self._games: dict[str, TableGame] = {}
self._http_endpoint_owners: dict[str, str] = {}
self._lock = RLock()
def create_game(self, payload: dict[str, Any]) -> TableGame:
@@ -29,12 +30,19 @@ class GameManager:
big_blind = int(payload.get("big_blind", 10))
specs = []
http_endpoints: set[str] = set()
for seat, raw_spec in enumerate(players):
if not isinstance(raw_spec, dict):
raise ValueError("each player must be an object")
player_id = str(raw_spec.get("id") or raw_spec.get("player_id") or f"p{seat + 1}")
name = str(raw_spec.get("name") or player_id)
agent = build_agent(raw_spec.get("agent", raw_spec), rng)
agent_spec = raw_spec.get("agent", raw_spec)
if not isinstance(agent_spec, dict):
raise ValueError("agent spec must be an object")
endpoint = http_agent_endpoint_from_spec(agent_spec)
if endpoint is not None:
http_endpoints.add(endpoint)
agent = build_agent(agent_spec, rng, player_id=player_id)
specs.append((player_id, name, agent))
game = TableGame(
@@ -46,9 +54,18 @@ class GameManager:
rng=rng,
)
with self._lock:
self._release_completed_http_endpoints_locked()
if game_id in self._games:
raise ValueError(f"game already exists: {game_id}")
for endpoint in http_endpoints:
owner = self._http_endpoint_owners.get(endpoint)
if owner is not None and owner != game_id:
raise ValueError(
f"http agent endpoint already belongs to game {owner}: {endpoint}"
)
self._games[game_id] = game
for endpoint in http_endpoints:
self._http_endpoint_owners[endpoint] = game_id
return game
def get_game(self, game_id: str) -> TableGame:
@@ -58,9 +75,13 @@ class GameManager:
except KeyError as exc:
raise KeyError(f"game not found: {game_id}") from exc
def get_game_state(self, game_id: str) -> dict[str, object]:
return self.get_game(game_id).snapshot_completed()
def list_games(self) -> list[dict[str, object]]:
with self._lock:
return [game.to_dict() for game in self._games.values()]
games = list(self._games.values())
return [game.snapshot_completed() for game in games]
def run_hands(
self,
@@ -78,13 +99,30 @@ class GameManager:
no-argument behaviour.
"""
game = self.get_game(game_id)
with self._lock:
return [
summary.to_dict()
for summary in game.run_hands(
count,
until_one_left=until_one_left,
small_blind=small_blind,
big_blind=big_blind,
)
]
summaries = [
summary.to_dict()
for summary in game.run_hands(
count,
until_one_left=until_one_left,
small_blind=small_blind,
big_blind=big_blind,
)
]
if game.is_complete:
with self._lock:
self._release_http_endpoints_for_game_locked(game_id)
return summaries
def _release_completed_http_endpoints_locked(self) -> None:
for game_id, game in list(self._games.items()):
if game.lock.acquire(blocking=False):
try:
if game.is_complete:
self._release_http_endpoints_for_game_locked(game_id)
finally:
game.lock.release()
def _release_http_endpoints_for_game_locked(self, game_id: str) -> None:
for endpoint, owner in list(self._http_endpoint_owners.items()):
if owner == game_id:
del self._http_endpoint_owners[endpoint]
+143 -39
View File
@@ -9,11 +9,8 @@
// buildFrames(). Each frame carries the full visual state
// (board cards, pot, players, active speaker, action kind),
// making renderFrame() a pure function of state.frameIndex.
// * Avatars — pixel-art sprites styled in CSS via 8x8 box-shadow grids.
// Per-player palette is derived deterministically from
// player_id so the same player always looks identical.
// * Animation — seat sprites get a transient `act-${kind}` class that maps
// to an avatar @keyframes animation in styles.css.
// * Terminal — renderFrame() writes a fixed-width command-line view into
// <pre>, keeping all fetch/upload/replay processing unchanged.
// =============================================================================
const state = {
@@ -28,10 +25,13 @@ const state = {
source: "",
};
const THEME_STORAGE_KEY = "txh-replay-theme";
const el = {
subtitle: document.querySelector("#subtitle"),
sourceBadge: document.querySelector("#sourceBadge"),
pollBadge: document.querySelector("#pollBadge"),
themeMode: document.querySelector("#themeMode"),
serverUrl: document.querySelector("#serverUrl"),
gameId: document.querySelector("#gameId"),
fetchBtn: document.querySelector("#fetchBtn"),
@@ -52,9 +52,8 @@ const el = {
handBadge: document.querySelector("#handBadge"),
streetLabel: document.querySelector("#streetLabel"),
potLabel: document.querySelector("#potLabel"),
boardCards: document.querySelector("#boardCards"),
tableMessage: document.querySelector("#tableMessage"),
seatLayer: document.querySelector("#seatLayer"),
frameLabel: document.querySelector("#frameLabel"),
tableOutput: document.querySelector("#tableOutput"),
eventLog: document.querySelector("#eventLog"),
};
@@ -528,28 +527,103 @@ function renderFrame() {
const game = state.game;
const hasData = Boolean(game && hand && frame);
el.handBadge.textContent = hasData ? `Hand ${hand.hand_number}` : "Hand -";
el.streetLabel.textContent = hasData ? (STREET_LABELS[frame.street] || frame.street) : "未加载";
el.potLabel.textContent = hasData ? `Pot ${money(frame.pot)}` : "Pot 0";
el.tableMessage.textContent = hasData ? frame.message : "上传 JSON 或从游戏服务获取快照";
el.tableMessage.style.display = hasData ? "" : "block";
el.handBadge.textContent = hasData ? `hand ${hand.hand_number}` : "hand -";
el.streetLabel.textContent = hasData ? (STREET_LABELS[frame.street] || frame.street) : "idle";
el.potLabel.textContent = hasData ? `pot ${money(frame.pot)}` : "pot 0";
el.frameLabel.textContent = hasData ? `frame ${state.frameIndex + 1}/${state.frames.length}` : "frame 0/0";
el.progressBar.style.width = hasData ? `${((state.frameIndex + 1) / state.frames.length) * 100}%` : "0";
el.boardCards.replaceChildren();
for (const card of hasData ? frame.board : []) el.boardCards.appendChild(renderCard(card));
el.seatLayer.replaceChildren();
if (hasData) {
frame.players.forEach((player, index) => {
const position = seatPosition(index, frame.players.length);
el.seatLayer.appendChild(renderSeat(player, position, frame, hand));
});
}
el.tableOutput.textContent = hasData
? renderTerminalFrame(game, hand, frame)
: [
"$ txh-replay --status idle",
"",
"source: none",
"event : waiting for game data",
"",
"load: fetch from a game service or upload a replay json snapshot",
].join("\n");
renderLog();
syncControls();
}
function renderTerminalFrame(game, hand, frame) {
const active = frame.activePlayerId || "-";
const command = [
"$ txh-replay",
`--game ${game.game_id || "-"}`,
`--hand ${hand.hand_number}`,
`--frame ${state.frameIndex + 1}/${state.frames.length}`,
].join(" ");
const lines = [
command,
"",
`source: ${state.source || "-"}`,
`status: ${game.status || "-"} | street: ${STREET_LABELS[frame.street] || frame.street} | pot: ${money(frame.pot)}`,
`board : ${formatCards(frame.board, 5)}`,
`event : ${frame.message}`,
`actor : ${active}`,
"",
"players",
`${pad("seat", 4)} ${pad("flag", 4)} ${pad("player", 22)} ${pad("stack", 10, "right")} ${pad("street", 10, "right")} ${pad("total", 10, "right")} cards`,
`${"-".repeat(4)} ${"-".repeat(4)} ${"-".repeat(22)} ${"-".repeat(10)} ${"-".repeat(10)} ${"-".repeat(10)} ${"-".repeat(13)}`,
];
for (const player of frame.players) {
const playerName = player.name && player.name !== player.player_id
? `${player.name}/${player.player_id}`
: player.player_id;
lines.push([
pad(player.seat, 4),
pad(playerFlags(player, hand, frame), 4),
pad(playerName, 22),
pad(money(player.stack), 10, "right"),
pad(money(player.street_bet), 10, "right"),
pad(money(player.total_bet), 10, "right"),
formatCards(knownCardsForPlayer(player, hand, frame), 2),
].join(" "));
}
lines.push(
"",
"flags: > active | D dealer | F folded | A all-in",
);
return lines.join("\n");
}
function playerFlags(player, hand, frame) {
const flags = [];
if (player.player_id === frame.activePlayerId) flags.push(">");
if (player.seat === hand.button_seat) flags.push("D");
if (player.folded) flags.push("F");
if (player.all_in) flags.push("A");
return flags.join("") || ".";
}
function formatCards(cards, minimum = 0) {
const output = (cards || []).map(cardText);
while (output.length < minimum) output.push("[--]");
return output.join(" ");
}
function cardText(raw) {
if (!raw || raw === "back") return "[??]";
const parts = cardParts(raw);
return `[${parts.rank}${parts.suit}]`;
}
function pad(value, width, align = "left") {
const text = truncate(value, width);
const gap = Math.max(0, width - text.length);
return align === "right" ? `${" ".repeat(gap)}${text}` : `${text}${" ".repeat(gap)}`;
}
function truncate(value, width) {
const text = String(value ?? "");
if (text.length <= width) return text;
return `${text.slice(0, Math.max(0, width - 1))}~`;
}
function knownCardsForPlayer(player, hand, frame) {
const futureHoleCards = hand.hole_cards?.[player.player_id] || hand.private_hands?.[player.player_id];
const canRevealShowdown = ["showdown", "awards"].includes(frame.street) || ["showdown", "award"].includes(frame.type);
@@ -566,18 +640,12 @@ function knownCardsForPlayer(player, hand, frame) {
// the `.past` class.
// ---------------------------------------------------------------------------
function renderLog() {
const hand = state.hands[state.currentHandIndex];
el.eventLog.replaceChildren();
if (!hand) return;
const events = [
{ text: `${hand.hand_number} 手牌开始`, kind: "setup" },
...(hand.actions || []).map((action) => ({ text: actionText(action), kind: "action" })),
...(hand.awards || []).map((award) => ({ text: awardText(award), kind: "award" })),
];
if (!state.frames.length) return;
let currentLi = null;
events.forEach((event, index) => {
state.frames.forEach((frame, index) => {
const li = document.createElement("li");
li.textContent = `${index + 1}. ${event.text}`;
li.textContent = `${String(index + 1).padStart(2, "0")} ${frame.type.padEnd(8, " ")} ${frame.message}`;
if (index < state.frameIndex) li.classList.add("past");
if (index === state.frameIndex) {
li.classList.add("current");
@@ -588,7 +656,8 @@ function renderLog() {
// Keep the focused event visible without yanking the page when an event
// is already in view.
if (currentLi) {
currentLi.scrollIntoView({ block: "nearest", behavior: "smooth" });
const behavior = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
currentLi.scrollIntoView({ block: "nearest", behavior });
}
}
@@ -600,7 +669,7 @@ function syncControls() {
el.prevBtn.disabled = !loaded || !hasPreviousFrame;
el.nextBtn.disabled = !loaded || !hasNextFrame;
el.resetBtn.disabled = !loaded;
el.playBtn.textContent = state.playing ? "" : "";
el.playBtn.textContent = state.playing ? "pause" : "play";
}
// ---------------------------------------------------------------------------
@@ -675,11 +744,13 @@ function shouldStepIntoIncrement(wasAtTail, hadNewFramesOnCurrentHand, hasLaterH
function renderSummary() {
const game = state.game;
const hand = state.hands[state.currentHandIndex];
const blinds = hand?.blinds || game;
const blinds = hand?.blinds || game?.blinds?.current || game?.blinds || game || {};
const smallBlind = blinds.small_blind ?? blinds.small ?? blinds.sb;
const bigBlind = blinds.big_blind ?? blinds.big ?? blinds.bb;
el.gameStatus.textContent = game?.status || "-";
el.playerCount.textContent = game?.players?.length ?? "-";
el.handCount.textContent = game?.hands?.length ?? "-";
el.blindLevel.textContent = game ? `${money(blinds.small_blind)} / ${money(blinds.big_blind)}` : "-";
el.blindLevel.textContent = game ? `${money(smallBlind)} / ${money(bigBlind)}` : "-";
}
function populateHands() {
@@ -787,8 +858,13 @@ function setAutoPoll(enabled) {
}
function showMessage(message) {
el.tableMessage.textContent = message;
el.sourceBadge.textContent = "Error";
el.tableOutput.textContent = [
"$ txh-replay --status error",
"",
`error: ${message}`,
].join("\n");
el.sourceBadge.textContent = "ERROR";
el.frameLabel.textContent = "frame 0/0";
}
function escapeHtml(value) {
@@ -800,9 +876,35 @@ function escapeHtml(value) {
.replaceAll("'", "&#039;");
}
function applyTheme(mode) {
const theme = ["auto", "dark", "light"].includes(mode) ? mode : "auto";
document.documentElement.dataset.theme = theme;
if (el.themeMode) el.themeMode.value = theme;
}
function initTheme() {
let stored = "auto";
try {
stored = window.localStorage.getItem(THEME_STORAGE_KEY) || "auto";
} catch {
stored = "auto";
}
applyTheme(stored);
}
// ---------------------------------------------------------------------------
// Wiring.
// ---------------------------------------------------------------------------
el.themeMode.addEventListener("change", () => {
const mode = el.themeMode.value;
applyTheme(mode);
try {
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
} catch {
// Ignore storage failures; the current session still applies the theme.
}
});
el.fetchBtn.addEventListener("click", () => {
fetchFromServer().catch((error) => showMessage(error.message));
});
@@ -829,4 +931,6 @@ el.prevBtn.addEventListener("click", () => prevFrame());
el.resetBtn.addEventListener("click", () => selectHand(state.currentHandIndex));
window.addEventListener("resize", () => renderFrame());
initTheme();
syncControls();
renderFrame();
+93 -103
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<html lang="zh-CN" data-theme="auto">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -8,121 +8,111 @@
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div class="brand-lockup">
<div class="chip-mark" aria-hidden="true">TX</div>
<div class="brand-meta">
<h1>Texas Hold X Replay</h1>
<p id="subtitle">等待加载游戏数据</p>
</div>
<header class="terminal-header">
<div class="window-row" aria-hidden="true">
<span class="window-dot red"></span>
<span class="window-dot yellow"></span>
<span class="window-dot green"></span>
<span class="window-title">txh-replay@web:~</span>
</div>
<div class="status-strip" aria-live="polite">
<span id="sourceBadge" class="badge badge-gold">No Data</span>
<span id="pollBadge" class="badge badge-blue">Auto Off</span>
<div class="title-row">
<div>
<h1>Texas Hold X Replay</h1>
<p id="subtitle">awaiting game snapshot</p>
</div>
<div class="status-strip" aria-live="polite">
<span id="sourceBadge" class="status-pill">NO DATA</span>
<span id="pollBadge" class="status-pill">AUTO OFF</span>
<label class="theme-control">
<span>theme</span>
<select id="themeMode" aria-label="Theme mode">
<option value="auto">auto</option>
<option value="dark">dark</option>
<option value="light">light</option>
</select>
</label>
</div>
</div>
</header>
<main class="layout-grid">
<!-- Stage zone: pure visualization (table, seats, animations).
Placed first in DOM so mobile/tablet layouts keep it on top. -->
<section class="stage-zone" aria-label="牌桌动画回放">
<div class="stage-head">
<div class="stage-head-left">
<span id="handBadge" class="badge badge-gold">Hand -</span>
<strong id="streetLabel">未加载</strong>
</div>
<div class="stage-head-right">
<span id="potLabel" class="badge badge-gold">Pot 0</span>
</div>
<main class="terminal-grid">
<section class="terminal-panel source-panel" aria-label="数据源">
<div class="panel-title"><span>$</span> /source</div>
<label>
<span>game service</span>
<input id="serverUrl" type="url" value="http://127.0.0.1:8000" placeholder="http://127.0.0.1:8000" />
</label>
<label>
<span>game id</span>
<input id="gameId" type="text" value="game1" placeholder="game1" />
</label>
<div class="button-row">
<button id="fetchBtn" class="primary-btn" type="button">fetch</button>
<label class="file-btn">
upload json
<input id="fileInput" type="file" accept="application/json,.json" />
</label>
</div>
<div id="table" class="poker-table">
<!-- felt-shell encapsulates the rounded green felt with overflow:hidden,
so player speech bubbles drawn in seat-layer can overflow freely
above and below the table without being clipped. -->
<div class="felt-shell" aria-hidden="true">
<div class="felt-rail"></div>
<div class="felt-surface">
<div class="felt-grid"></div>
<div class="felt-glow"></div>
<div class="felt-mark">TX</div>
</div>
</div>
<div class="community-area">
<div id="boardCards" class="card-row board"></div>
<div id="tableMessage" class="table-message">上传 JSON 或从游戏服务获取快照</div>
</div>
<div id="seatLayer" class="seat-layer"></div>
<div class="auto-grid">
<label class="toggle-line">
<input id="autoPoll" type="checkbox" />
<span>auto poll</span>
</label>
<label>
<span>seconds</span>
<input id="pollSeconds" type="number" min="5" max="300" value="12" />
</label>
</div>
</section>
<!-- Interaction zone: data source + replay controls + summary. -->
<section class="control-panel" aria-label="数据与播放控制">
<div class="panel-section">
<h2>数据源</h2>
<label>
<span>游戏服务</span>
<input id="serverUrl" type="url" value="http://127.0.0.1:8000" placeholder="http://127.0.0.1:8000" />
</label>
<label>
<span>Game ID</span>
<input id="gameId" type="text" value="game1" placeholder="game1" />
</label>
<div class="button-row">
<button id="fetchBtn" class="primary-btn" type="button">获取</button>
<label class="file-btn">
上传 JSON
<input id="fileInput" type="file" accept="application/json,.json" />
</label>
</div>
<div class="auto-grid">
<label class="toggle-line">
<input id="autoPoll" type="checkbox" />
<span>自动获取</span>
</label>
<label>
<span>间隔秒</span>
<input id="pollSeconds" type="number" min="5" max="300" value="12" />
</label>
</div>
<section class="terminal-panel controls-panel" aria-label="回放控制">
<div class="panel-title"><span>$</span> /replay</div>
<label>
<span>hand</span>
<select id="handSelect"></select>
</label>
<label>
<span>pace</span>
<input id="pace" type="range" min="0.75" max="1.8" step="0.05" value="1" />
</label>
<div class="transport-row">
<button id="prevBtn" type="button">prev</button>
<button id="playBtn" class="primary-btn" type="button">play</button>
<button id="nextBtn" type="button">next</button>
<button id="resetBtn" type="button">reset</button>
</div>
<div class="panel-section">
<h2>回放</h2>
<label>
<span>手牌</span>
<select id="handSelect"></select>
</label>
<label>
<span>节奏</span>
<input id="pace" type="range" min="0.75" max="1.8" step="0.05" value="1" />
</label>
<div class="transport-row">
<button id="prevBtn" type="button" title="上一帧"></button>
<button id="playBtn" class="primary-btn" type="button" title="播放/暂停"></button>
<button id="nextBtn" type="button" title="下一帧"></button>
<button id="resetBtn" type="button" title="重置"></button>
</div>
<div class="progress-wrap">
<div id="progressBar"></div>
</div>
</div>
<div class="panel-section dense">
<h2>牌局摘要</h2>
<dl class="stat-list">
<div><dt>状态</dt><dd id="gameStatus">-</dd></div>
<div><dt>玩家</dt><dd id="playerCount">-</dd></div>
<div><dt>总手数</dt><dd id="handCount">-</dd></div>
<div><dt>盲注</dt><dd id="blindLevel">-</dd></div>
</dl>
<div class="progress-shell" aria-hidden="true">
<div id="progressBar"></div>
</div>
</section>
<aside class="event-panel" aria-label="事件日志">
<div class="panel-section">
<h2>事件</h2>
<ol id="eventLog" class="event-log"></ol>
<section class="terminal-panel summary-panel" aria-label="牌局摘要">
<div class="panel-title"><span>$</span> /state</div>
<dl class="stat-list">
<div><dt>status</dt><dd id="gameStatus">-</dd></div>
<div><dt>players</dt><dd id="playerCount">-</dd></div>
<div><dt>hands</dt><dd id="handCount">-</dd></div>
<div><dt>blinds</dt><dd id="blindLevel">-</dd></div>
</dl>
</section>
<section class="terminal-panel display-panel" aria-label="命令行牌局回放">
<div class="display-head">
<div>
<span id="handBadge" class="prompt-chip">hand -</span>
<span id="streetLabel" class="prompt-chip">idle</span>
<span id="potLabel" class="prompt-chip">pot 0</span>
</div>
<span id="frameLabel" class="frame-label">frame 0/0</span>
</div>
<div class="terminal-screen" tabindex="0">
<pre id="tableOutput" class="table-output">txh-replay&gt; waiting for game data</pre>
</div>
</section>
<aside class="terminal-panel log-panel" aria-label="事件日志">
<div class="panel-title"><span>$</span> /events</div>
<ol id="eventLog" class="event-log"></ol>
</aside>
</main>
</div>
+611
View File
@@ -733,3 +733,614 @@ h2 { margin-bottom: 12px; color: var(--gold); font-size: 15px; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
/* =========================================================================
Terminal replay UI
========================================================================= */
:root {
color-scheme: dark;
--bg: #020617;
--bg-grid: rgba(34, 197, 94, 0.07);
--panel: #07111f;
--panel-strong: #0d1726;
--terminal: #030712;
--terminal-line: #1f2a37;
--text: #f8fafc;
--muted: #94a3b8;
--muted-2: #64748b;
--accent: #22c55e;
--accent-strong: #86efac;
--accent-ink: #04130a;
--amber: #f59e0b;
--red-term: #ef4444;
--blue-term: #38bdf8;
--border: #263445;
--border-strong: #3d5268;
--shadow-term: rgba(0, 0, 0, 0.38);
--radius: 8px;
--mono: "Fira Code", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--bg: #f5f7fb;
--bg-grid: rgba(4, 120, 87, 0.08);
--panel: #ffffff;
--panel-strong: #f1f5f9;
--terminal: #fbfdff;
--terminal-line: #d8e0ea;
--text: #0f172a;
--muted: #475569;
--muted-2: #64748b;
--accent: #047857;
--accent-strong: #065f46;
--accent-ink: #ecfdf5;
--amber: #b45309;
--red-term: #b91c1c;
--blue-term: #0369a1;
--border: #cbd5e1;
--border-strong: #94a3b8;
--shadow-term: rgba(15, 23, 42, 0.12);
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg: #020617;
--bg-grid: rgba(34, 197, 94, 0.07);
--panel: #07111f;
--panel-strong: #0d1726;
--terminal: #030712;
--terminal-line: #1f2a37;
--text: #f8fafc;
--muted: #94a3b8;
--muted-2: #64748b;
--accent: #22c55e;
--accent-strong: #86efac;
--accent-ink: #04130a;
--amber: #f59e0b;
--red-term: #ef4444;
--blue-term: #38bdf8;
--border: #263445;
--border-strong: #3d5268;
--shadow-term: rgba(0, 0, 0, 0.38);
}
:root[data-theme="light"] {
color-scheme: light;
--bg: #f5f7fb;
--bg-grid: rgba(4, 120, 87, 0.08);
--panel: #ffffff;
--panel-strong: #f1f5f9;
--terminal: #fbfdff;
--terminal-line: #d8e0ea;
--text: #0f172a;
--muted: #475569;
--muted-2: #64748b;
--accent: #047857;
--accent-strong: #065f46;
--accent-ink: #ecfdf5;
--amber: #b45309;
--red-term: #b91c1c;
--blue-term: #0369a1;
--border: #cbd5e1;
--border-strong: #94a3b8;
--shadow-term: rgba(15, 23, 42, 0.12);
}
html { min-height: 100%; }
body {
min-height: 100vh;
margin: 0;
color: var(--text);
font-family: var(--mono);
font-size: 14px;
letter-spacing: 0;
background:
linear-gradient(var(--bg-grid) 1px, transparent 1px) 0 0 / 28px 28px,
linear-gradient(90deg, var(--bg-grid) 1px, transparent 1px) 0 0 / 28px 28px,
var(--bg);
image-rendering: auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: auto;
}
button,
input,
select {
font: inherit;
}
button,
.file-btn {
min-height: 38px;
border: 1px solid var(--border-strong);
border-radius: 6px;
color: var(--text);
background: var(--panel-strong);
box-shadow: none;
cursor: pointer;
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
}
button:hover,
.file-btn:hover {
border-color: var(--accent);
color: var(--accent-strong);
filter: none;
}
button:active,
.file-btn:active {
transform: none;
box-shadow: inset 0 0 0 1px var(--accent);
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.primary-btn {
color: var(--accent-ink);
background: var(--accent);
border-color: var(--accent);
font-weight: 700;
}
.primary-btn:hover {
color: var(--accent-ink);
background: var(--accent-strong);
}
input,
select {
width: 100%;
min-height: 38px;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
background: var(--terminal);
padding: 8px 10px;
outline: none;
}
input[type="range"] {
padding: 0;
accent-color: var(--accent);
}
input[type="checkbox"] {
width: 16px;
min-height: 16px;
accent-color: var(--accent);
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
.file-btn:focus-within,
.terminal-screen:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
h1,
p,
dl {
margin: 0;
}
h1 {
color: var(--text);
font-size: clamp(20px, 2vw, 28px);
font-weight: 700;
line-height: 1.1;
}
.app-shell {
width: min(1680px, 100%);
margin: 0 auto;
padding: 18px;
}
.terminal-header,
.terminal-panel {
border: 1px solid var(--border);
border-radius: var(--radius);
background: color-mix(in srgb, var(--panel) 92%, transparent);
box-shadow: 0 18px 48px var(--shadow-term);
}
.terminal-header {
overflow: hidden;
}
.window-row {
display: flex;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 0 14px;
border-bottom: 1px solid var(--border);
color: var(--muted);
background: var(--panel-strong);
}
.window-dot {
width: 11px;
height: 11px;
border-radius: 50%;
}
.window-dot.red { background: var(--red-term); }
.window-dot.yellow { background: var(--amber); }
.window-dot.green { background: var(--accent); }
.window-title {
margin-left: 6px;
white-space: nowrap;
}
.title-row {
display: flex;
justify-content: space-between;
align-items: end;
gap: 18px;
padding: 18px;
}
#subtitle {
margin-top: 6px;
color: var(--muted);
font-size: 13px;
}
.status-strip {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: end;
gap: 10px;
}
.status-pill,
.prompt-chip,
.frame-label {
display: inline-flex;
align-items: center;
min-height: 28px;
border: 1px solid var(--border);
border-radius: 999px;
padding: 4px 10px;
color: var(--accent-strong);
background: color-mix(in srgb, var(--accent) 12%, transparent);
white-space: nowrap;
}
.theme-control {
display: grid;
gap: 4px;
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
}
.theme-control select {
min-height: 28px;
padding: 3px 28px 3px 8px;
}
.terminal-grid {
display: grid;
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr) minmax(300px, 380px);
grid-template-areas:
"source display log"
"controls display log"
"summary display log";
gap: 14px;
margin-top: 14px;
align-items: stretch;
}
.source-panel { grid-area: source; }
.controls-panel { grid-area: controls; }
.summary-panel { grid-area: summary; }
.display-panel { grid-area: display; min-width: 0; }
.log-panel { grid-area: log; min-width: 0; }
.terminal-panel {
padding: 14px;
}
.panel-title {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 14px;
color: var(--text);
font-weight: 700;
}
.panel-title span {
color: var(--accent);
}
.terminal-panel label {
display: grid;
gap: 6px;
margin-bottom: 12px;
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
}
.button-row,
.transport-row,
.auto-grid {
display: grid;
gap: 8px;
}
.button-row {
grid-template-columns: 1fr 1fr;
}
.transport-row {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.auto-grid {
grid-template-columns: minmax(0, 1fr) 92px;
align-items: end;
margin-top: 12px;
}
.toggle-line {
display: flex !important;
flex-direction: row;
align-items: center;
min-height: 38px;
}
.file-btn {
display: grid;
place-items: center;
position: relative;
overflow: hidden;
text-align: center;
}
.file-btn input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.progress-shell {
height: 10px;
margin-top: 14px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--terminal);
}
#progressBar {
width: 0;
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--blue-term), var(--amber));
transition: width 180ms linear;
}
.stat-list {
display: grid;
gap: 9px;
}
.stat-list div {
display: grid;
grid-template-columns: minmax(80px, 1fr) minmax(0, 1.4fr);
gap: 12px;
padding-bottom: 9px;
border-bottom: 1px solid var(--border);
}
.stat-list dt {
color: var(--muted);
}
.stat-list dd {
margin: 0;
overflow: hidden;
color: var(--text);
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.display-panel {
display: flex;
flex-direction: column;
}
.display-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.display-head > div {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.frame-label {
color: var(--muted);
background: transparent;
}
.terminal-screen {
flex: 1 1 auto;
min-height: clamp(460px, 68vh, 780px);
overflow: auto;
border: 1px solid var(--terminal-line);
border-radius: var(--radius);
background:
linear-gradient(180deg, color-mix(in srgb, var(--accent) 7%, transparent), transparent 120px),
var(--terminal);
scrollbar-width: thin;
scrollbar-color: var(--accent) var(--terminal);
}
.table-output {
min-width: 860px;
margin: 0;
padding: 18px;
color: var(--text);
font-family: var(--mono);
font-size: clamp(12px, 0.86vw, 14px);
line-height: 1.55;
letter-spacing: 0;
white-space: pre;
tab-size: 2;
}
.terminal-screen::-webkit-scrollbar,
.event-log::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.terminal-screen::-webkit-scrollbar-track,
.event-log::-webkit-scrollbar-track {
background: var(--terminal);
}
.terminal-screen::-webkit-scrollbar-thumb,
.event-log::-webkit-scrollbar-thumb {
border: 2px solid var(--terminal);
border-radius: 999px;
background: var(--accent);
}
.event-log {
display: grid;
gap: 8px;
max-height: clamp(460px, 78vh, 860px);
margin: 0;
padding: 0 2px 0 0;
overflow: auto;
list-style: none;
scrollbar-width: thin;
scrollbar-color: var(--accent) var(--terminal);
}
.event-log li {
border: 1px solid var(--border);
border-left: 3px solid var(--border-strong);
border-radius: 6px;
padding: 8px 10px;
color: var(--muted);
background: var(--terminal);
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
.event-log li.current {
border-left-color: var(--accent);
color: var(--text);
background: color-mix(in srgb, var(--accent) 10%, var(--terminal));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
}
.event-log li.past {
opacity: 0.72;
}
::selection {
color: var(--accent-ink);
background: var(--accent);
}
@media (max-width: 1180px) {
.terminal-grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
"display display"
"source controls"
"summary log";
}
.event-log {
max-height: 340px;
}
}
@media (max-width: 760px) {
.app-shell {
padding: 10px;
}
.title-row {
display: grid;
align-items: start;
}
.status-strip {
justify-content: flex-start;
}
.terminal-grid {
grid-template-columns: 1fr;
grid-template-areas:
"display"
"source"
"controls"
"summary"
"log";
}
.terminal-panel {
padding: 12px;
}
.button-row,
.auto-grid,
.transport-row {
grid-template-columns: 1fr;
}
.display-head {
align-items: flex-start;
flex-direction: column;
}
.terminal-screen {
min-height: 420px;
}
.table-output {
min-width: 760px;
padding: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition: none !important;
animation: none !important;
}
}