Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5982f15f9 | |||
| 1ee963ce2e | |||
| 351cac7734 | |||
| 79dccde963 |
@@ -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/
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
Core poker service code lives in `texas_holdem/`. Important modules include `engine.py` for Texas Hold'em rules, `service.py` for game management, `server.py` for the HTTP API, `agents.py` for local/HTTP agents, and `ai_client.py` / `human_client.py` for standalone agents. Prompt templates live in `texas_holdem/prompts/`.
|
||||
|
||||
Replay UI code lives in `texas_holdem_replay/`, with static browser assets under `texas_holdem_replay/static/`. Tests are in `tests/`, named by feature area such as `test_engine.py`, `test_service.py`, and `test_replay_server.py`. Design notes belong in `docs/`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
- `python -m unittest discover -v` runs the full test suite.
|
||||
- `python -m compileall texas_holdem texas_holdem_replay tests` checks import and syntax validity.
|
||||
- `python -m texas_holdem.server --host 127.0.0.1 --port 8000` starts the game service.
|
||||
- `python -m texas_holdem.human_client --port 9001 --keep-history` starts an interactive human HTTP agent.
|
||||
- `python -m texas_holdem.ai_client --port 9101 --api-key "$OPENAI_API_KEY" --model gpt-4o-mini` starts an OpenAI-compatible AI agent.
|
||||
- `python -m texas_holdem_replay.server --port 8088` starts the replay viewer.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Use Python 3.11+ standard-library APIs unless a dependency is intentionally added to `pyproject.toml`. Keep modules focused and prefer explicit dataclasses for wire/state models. Use 4-space indentation, type hints, `snake_case` for functions and variables, `PascalCase` for classes, and concise comments only where logic is non-obvious.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Use `unittest`. Add tests near the behavior changed: engine rules in `tests/test_engine.py`, HTTP/service behavior in `tests/test_service.py`, agent transport in `tests/test_agents.py`, and replay UI server helpers in `tests/test_replay_server.py`. New bug fixes should include a regression test. Avoid tests that require external network access or real LLM calls.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
History uses short Conventional Commit-style subjects, for example `feat: add replay server and web client` and `fix: game service api block when a game is running`. Keep commits scoped to one behavior change. Pull requests should include a short summary, test commands run, linked issue or motivation, and screenshots only for replay UI or visible terminal-output changes.
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
Do not commit API keys. Pass LLM credentials through `OPENAI_API_KEY` or CLI flags in local shells only. HTTP Agent endpoints are exclusive per active game; preserve this invariant when changing service concurrency.
|
||||
@@ -11,6 +11,7 @@
|
||||
- 支持盲注、四条街下注、弃牌、过牌、跟注、下注、加注、全下、边池和摊牌结算。
|
||||
- 支持本地 Agent 和 HTTP Agent。
|
||||
- 支持 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"}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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("'", "'");
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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> 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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user