Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5982f15f9 |
@@ -35,6 +35,7 @@ htmlcov/
|
||||
.codex/
|
||||
*.swp
|
||||
*.swo
|
||||
.codex/
|
||||
|
||||
# debug resources
|
||||
debug/
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Texas Hold X 回放视图设计方案
|
||||
|
||||
## 目标
|
||||
|
||||
构建一个与核心游戏服务和 Agent 解耦的独立 Web 服务,用于读取游戏详情 JSON 并以动画方式回放 Texas Hold'em 对局。它可以部署在任意能运行 Python 标准库 HTTP 服务的环境中,不要求核心服务增加前端路由,也不改变 Human HTTP Agent / AI HTTP Agent 协议。
|
||||
|
||||
## 架构
|
||||
|
||||
新增 `texas_holdem_replay` 包:
|
||||
|
||||
- `texas_holdem_replay.server`:标准库 HTTP 服务,负责托管静态前端文件,并提供 `/api/fetch-game` 抓取代理。
|
||||
- `texas_holdem_replay/static/index.html`:独立页面入口。
|
||||
- `texas_holdem_replay/static/styles.css`:像素风牌桌、卡牌、座位和响应式布局。
|
||||
- `texas_holdem_replay/static/app.js`:数据归一化、手牌时间轴生成、动画播放、上传 JSON、手动抓取和自动轮询。
|
||||
|
||||
运行方式:
|
||||
|
||||
```bash
|
||||
python -m texas_holdem_replay.server --host 127.0.0.1 --port 8088
|
||||
```
|
||||
|
||||
也可以通过安装后的脚本启动:
|
||||
|
||||
```bash
|
||||
texas-holdem-replay --host 127.0.0.1 --port 8088
|
||||
```
|
||||
|
||||
## 数据输入
|
||||
|
||||
视图支持三种输入方式:
|
||||
|
||||
1. 填写核心游戏服务地址和 `game_id`,点击“获取”。
|
||||
前端请求自身的 `/api/fetch-game?base_url=...&game_id=...`,由回放服务去访问核心服务的 `/games/{game_id}`,避免浏览器跨域限制。
|
||||
2. 上传静态 JSON 文件。
|
||||
文件在浏览器本地解析,不依赖核心服务。
|
||||
3. 开启自动获取。
|
||||
按指定秒数轮询同一个核心服务和 `game_id`,用于观察正在运行的游戏快照。新快照会先尝试和当前回放位置合并;如果当前手牌追加了 action、showdown 或 award,回放会接续到新增 frame,而不是重头播放。
|
||||
|
||||
`/api/fetch-game` 也支持传入完整 `url`,便于未来接入网关或静态 JSON 服务。
|
||||
|
||||
## 数据模型与归一化
|
||||
|
||||
当前游戏详情返回结构包含:
|
||||
|
||||
- `players`:玩家最终状态。
|
||||
- `hands`:历史手牌列表。
|
||||
- 每手 `actions`:行动记录,包含 `street`、`player_id`、`action`、`amount`、行动后 `street_bet` 和 `stack`。
|
||||
- `awards`:底池分配。
|
||||
- `showdown_hands`:摊牌玩家手牌。
|
||||
|
||||
前端不会依赖核心服务内部对象,只读取 JSON 字段并做归一化:
|
||||
|
||||
- 根据 `players` 和 `actions` 得到座位顺序。
|
||||
- 从 `starting_stack` 开始,按历史 `actions.stack` 和 `awards` 推演每手牌开始时的筹码。
|
||||
- 每手牌生成一组离散 frame:开局、跨街发公共牌、玩家行动、摊牌、结算。
|
||||
- 每个 frame 都有稳定 key。上传 JSON、手动获取和自动获取都会复用同一套合并逻辑:同一 `game_id` 且能找到当前手牌时,保留当前 frame;如果用户停在最新进度末尾,且当前手牌或后续手牌出现新增 frame,则从当前位置接续播放增量。用户正在查看历史手牌时,不会被新轮询强制跳走。
|
||||
- 非 showdown 玩家手牌显示卡背。
|
||||
- 已预留 `hand.hole_cards[player_id]` 和 `hand.private_hands[player_id]` 兼容点,后续核心服务返回非 showdown 手牌时可直接展示。
|
||||
|
||||
## 动画与交互
|
||||
|
||||
牌桌采用卡通像素风格:
|
||||
|
||||
- 椭圆绿色牌桌、木质像素边框、像素筹码状态条。
|
||||
- 玩家围绕牌桌分布,当前行动玩家高亮并显示冒泡文字。
|
||||
- 公共牌按 flop / turn / river 分阶段发出。
|
||||
- 动作之间默认保留约 1.1-1.5 秒间隔,用户可用“节奏”滑杆调慢或调快。
|
||||
- 支持上一帧、下一帧、播放/暂停、重置、选择指定手牌。
|
||||
|
||||
## 响应式设计
|
||||
|
||||
桌面端为三栏布局:数据控制、牌桌、事件日志。中等屏幕下事件日志下移,手机和平板窄屏下改为单列,牌桌高度固定到可观看的移动端比例,座位尺寸通过 CSS clamp 控制,避免文字和控件溢出。
|
||||
|
||||
## 解耦边界
|
||||
|
||||
回放服务只消费核心服务的公开 HTTP JSON,不导入 `texas_holdem.engine`、`service` 或 Agent 代码,也不要求游戏服务开放 CORS。未来可以单独部署在 CDN + 轻量代理、容器或任意 Python 运行环境中。
|
||||
|
||||
## 后续适配
|
||||
|
||||
- 核心服务返回非 showdown 玩家手牌后,只需要让每手 JSON 包含 `hole_cards` 或 `private_hands` 映射,前端现有归一化会优先读取。
|
||||
- 如果服务端未来提供增量事件流,可以在 `app.js` 增加一个 source adapter,把事件流转成同样的 frame 列表,动画层无需重写。
|
||||
@@ -9,6 +9,10 @@ dependencies = []
|
||||
texas-holdem-server = "texas_holdem.server:main"
|
||||
texas-holdem-human = "texas_holdem.human_client:main"
|
||||
texas-holdem-ai = "texas_holdem.ai_client:main"
|
||||
texas-holdem-replay = "texas_holdem_replay.server:main"
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
texas_holdem_replay = ["static/*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
@@ -100,50 +100,10 @@ class EngineTests(unittest.TestCase):
|
||||
awards = game._award_pots()
|
||||
|
||||
self.assertEqual([award.amount for award in awards], [150, 100])
|
||||
self.assertEqual(
|
||||
[contribution["amount"] for contribution in game._last_pot_contributions],
|
||||
[150, 100],
|
||||
)
|
||||
self.assertEqual(
|
||||
game._last_pot_contributions[0]["contributors"],
|
||||
{"p1": 50, "p2": 50, "p3": 50},
|
||||
)
|
||||
self.assertEqual(
|
||||
game._last_pot_contributions[1]["contributors"],
|
||||
{"p2": 50, "p3": 50},
|
||||
)
|
||||
self.assertEqual(game.players[0].stack, 150)
|
||||
self.assertEqual(game.players[1].stack, 100)
|
||||
self.assertEqual(game.players[2].stack, 0)
|
||||
|
||||
def test_hand_summary_includes_full_hand_snapshots(self) -> None:
|
||||
players = [
|
||||
("p1", "Player 1", CallingStationAgent()),
|
||||
("p2", "Player 2", CallingStationAgent()),
|
||||
("p3", "Player 3", CallingStationAgent()),
|
||||
]
|
||||
game = TableGame("g5", players, starting_stack=100, small_blind=5, big_blind=10, rng=Random(23))
|
||||
|
||||
summary = game.run_hand()
|
||||
payload = summary.to_dict()
|
||||
|
||||
self.assertEqual(set(summary.hole_cards), {"p1", "p2", "p3"})
|
||||
self.assertTrue(all(len(cards) == 2 for cards in summary.hole_cards.values()))
|
||||
self.assertEqual(summary.starting_stacks, {"p1": 100, "p2": 100, "p3": 100})
|
||||
self.assertEqual(set(summary.ending_stacks), {"p1", "p2", "p3"})
|
||||
self.assertEqual(sum(summary.starting_stacks.values()), sum(summary.ending_stacks.values()))
|
||||
self.assertGreaterEqual(len(summary.pot_contributions), 1)
|
||||
self.assertTrue(
|
||||
all(
|
||||
contribution["amount"] == sum(contribution["contributors"].values())
|
||||
for contribution in summary.pot_contributions
|
||||
)
|
||||
)
|
||||
self.assertEqual(set(payload["hole_cards"]), {"p1", "p2", "p3"})
|
||||
self.assertEqual(payload["starting_stacks"], {"p1": 100, "p2": 100, "p3": 100})
|
||||
self.assertIn("ending_stacks", payload)
|
||||
self.assertIn("pot_contributions", payload)
|
||||
|
||||
def test_short_all_in_does_not_reopen_raising_to_prior_actor(self) -> None:
|
||||
seen: list[tuple[str, str, list[str]]] = []
|
||||
players = [
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from texas_holdem_replay.server import build_game_url
|
||||
|
||||
|
||||
class ReplayServerTests(unittest.TestCase):
|
||||
def test_build_game_url_from_base_and_game_id(self) -> None:
|
||||
self.assertEqual(
|
||||
build_game_url({"base_url": ["http://127.0.0.1:8000/"], "game_id": ["game 1"]}),
|
||||
"http://127.0.0.1:8000/games/game%201",
|
||||
)
|
||||
|
||||
def test_build_game_url_accepts_full_url(self) -> None:
|
||||
self.assertEqual(
|
||||
build_game_url({"url": ["https://example.test/games/demo"]}),
|
||||
"https://example.test/games/demo",
|
||||
)
|
||||
|
||||
def test_build_game_url_rejects_non_http_url(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
build_game_url({"url": ["file:///tmp/game.json"]})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import json
|
||||
import unittest
|
||||
from threading import Thread
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from texas_holdem import server as poker_server
|
||||
from texas_holdem.service import GameManager
|
||||
|
||||
|
||||
class ServerTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.previous_manager = poker_server.MANAGER
|
||||
poker_server.MANAGER = GameManager()
|
||||
self.server = poker_server.create_server("127.0.0.1", 0)
|
||||
self.thread = Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
host, port = self.server.server_address
|
||||
self.base_url = f"http://{host}:{port}"
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=2)
|
||||
poker_server.MANAGER = self.previous_manager
|
||||
|
||||
def request_json(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
payload: dict[str, object] | None = None,
|
||||
) -> dict[str, object]:
|
||||
data = None
|
||||
headers = {}
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = Request(
|
||||
f"{self.base_url}{path}",
|
||||
data=data,
|
||||
headers=headers,
|
||||
method=method,
|
||||
)
|
||||
with urlopen(request, timeout=5) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
def test_get_hand_route_returns_expanded_hand_summary(self) -> None:
|
||||
self.request_json(
|
||||
"POST",
|
||||
"/game",
|
||||
{
|
||||
"game_id": "route-demo",
|
||||
"seed": 17,
|
||||
"starting_stack": 200,
|
||||
"small_blind": 5,
|
||||
"big_blind": 10,
|
||||
"players": [
|
||||
{"id": "a", "type": "calling"},
|
||||
{"id": "b", "type": "calling"},
|
||||
],
|
||||
},
|
||||
)
|
||||
self.request_json("POST", "/game/route-demo/hands", {"count": 1})
|
||||
|
||||
hand = self.request_json("GET", "/game/route-demo/hands/1")
|
||||
game = self.request_json("GET", "/game/route-demo")
|
||||
|
||||
self.assertEqual(hand["hand_number"], 1)
|
||||
self.assertEqual(set(hand["hole_cards"]), {"a", "b"})
|
||||
self.assertEqual(hand["starting_stacks"], {"a": 200, "b": 200})
|
||||
self.assertIn("ending_stacks", hand)
|
||||
self.assertIn("pot_contributions", hand)
|
||||
self.assertEqual(game["hands"][0], hand)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -40,16 +40,8 @@ class ServiceTests(unittest.TestCase):
|
||||
|
||||
hands = manager.run_hands(game.game_id, count=1)
|
||||
|
||||
state = manager.get_game_state("demo")
|
||||
hand = manager.get_hand_state("demo", 1)
|
||||
|
||||
self.assertEqual(len(hands), 1)
|
||||
self.assertEqual(state["hand_number"], 1)
|
||||
self.assertEqual(hand, state["hands"][0])
|
||||
self.assertIn("hole_cards", hand)
|
||||
self.assertIn("starting_stacks", hand)
|
||||
self.assertIn("ending_stacks", hand)
|
||||
self.assertIn("pot_contributions", hand)
|
||||
self.assertEqual(manager.get_game_state("demo")["hand_number"], 1)
|
||||
|
||||
def test_get_game_state_does_not_block_during_run(self) -> None:
|
||||
manager = GameManager()
|
||||
|
||||
+4
-55
@@ -62,7 +62,6 @@ class TableGame:
|
||||
self.board = []
|
||||
self.action_history: list[ActionRecord] = []
|
||||
self.hand_summaries: list[HandSummary] = []
|
||||
self._last_pot_contributions: list[dict[str, object]] = []
|
||||
# ``blind_history`` is an append-only log of every blind level change
|
||||
# (including the initial one). Each entry's ``hand_number`` is the
|
||||
# first hand that played under those stakes, which makes it trivial
|
||||
@@ -117,11 +116,6 @@ class TableGame:
|
||||
|
||||
for player in self.players:
|
||||
player.reset_for_hand()
|
||||
starting_stacks = {
|
||||
player.player_id: player.stack
|
||||
for player in self.players
|
||||
if player.in_hand
|
||||
}
|
||||
|
||||
self._advance_button()
|
||||
assert self.button_index is not None
|
||||
@@ -133,11 +127,6 @@ class TableGame:
|
||||
self._broadcast_game_update()
|
||||
|
||||
self._deal_hole_cards(deck)
|
||||
hole_cards = {
|
||||
player.player_id: list(player.hole_cards)
|
||||
for player in self.players
|
||||
if player.in_hand
|
||||
}
|
||||
small_blind_index, big_blind_index = self._blind_indexes()
|
||||
self._post_blind(small_blind_index, "small_blind", self.small_blind)
|
||||
self._post_blind(big_blind_index, "big_blind", self.big_blind)
|
||||
@@ -161,11 +150,6 @@ class TableGame:
|
||||
self._betting_round(street, start_index, self.big_blind)
|
||||
|
||||
awards = self._award_pots()
|
||||
ending_stacks = {
|
||||
player.player_id: player.stack
|
||||
for player in self.players
|
||||
if player.player_id in starting_stacks
|
||||
}
|
||||
summary = HandSummary(
|
||||
game_id=self.game_id,
|
||||
hand_number=self.hand_number,
|
||||
@@ -174,10 +158,6 @@ class TableGame:
|
||||
actions=list(self.action_history),
|
||||
awards=awards,
|
||||
blinds=active_blinds,
|
||||
hole_cards=hole_cards,
|
||||
starting_stacks=starting_stacks,
|
||||
ending_stacks=ending_stacks,
|
||||
pot_contributions=deepcopy(self._last_pot_contributions),
|
||||
showdown_hands=self._collect_showdown_hands(),
|
||||
started_at=started_at,
|
||||
finished_at=time(),
|
||||
@@ -577,43 +557,21 @@ class TableGame:
|
||||
return current_bet, min_raise, full_raise
|
||||
|
||||
def _award_pots(self) -> list[PotAward]:
|
||||
self._last_pot_contributions = []
|
||||
total_pot = sum(player.total_bet for player in self.players)
|
||||
live_players = [player for player in self.players if self._is_live(player)]
|
||||
if not live_players or total_pot <= 0:
|
||||
return []
|
||||
|
||||
levels = sorted({player.total_bet for player in self.players if player.total_bet > 0})
|
||||
if len(live_players) == 1:
|
||||
winner = live_players[0]
|
||||
winner.stack += total_pot
|
||||
previous_level = 0
|
||||
for level in levels:
|
||||
contributors = [player for player in self.players if player.total_bet >= level]
|
||||
pot_amount = (level - previous_level) * len(contributors)
|
||||
self._last_pot_contributions.append(
|
||||
{
|
||||
"amount": pot_amount,
|
||||
"contributors": {
|
||||
player.player_id: level - previous_level
|
||||
for player in contributors
|
||||
},
|
||||
"winners": [winner.player_id],
|
||||
"hand_value": None,
|
||||
}
|
||||
)
|
||||
previous_level = level
|
||||
return [PotAward(total_pot, [winner.player_id], None)]
|
||||
live_players[0].stack += total_pot
|
||||
return [PotAward(total_pot, [live_players[0].player_id], None)]
|
||||
|
||||
levels = sorted({player.total_bet for player in self.players if player.total_bet > 0})
|
||||
previous_level = 0
|
||||
awards: list[PotAward] = []
|
||||
for level in levels:
|
||||
contributors = [player for player in self.players if player.total_bet >= level]
|
||||
pot_amount = (level - previous_level) * len(contributors)
|
||||
level_contributions = {
|
||||
player.player_id: level - previous_level
|
||||
for player in contributors
|
||||
}
|
||||
previous_level = level
|
||||
contenders = [player for player in contributors if self._is_live(player)]
|
||||
if not contenders or pot_amount <= 0:
|
||||
@@ -635,22 +593,13 @@ class TableGame:
|
||||
winner.stack += share
|
||||
for winner in ordered_winners[:remainder]:
|
||||
winner.stack += 1
|
||||
winner_ids = [winner.player_id for winner in ordered_winners]
|
||||
awards.append(
|
||||
PotAward(
|
||||
amount=pot_amount,
|
||||
winners=winner_ids,
|
||||
winners=[winner.player_id for winner in ordered_winners],
|
||||
hand_value=best_value,
|
||||
)
|
||||
)
|
||||
self._last_pot_contributions.append(
|
||||
{
|
||||
"amount": pot_amount,
|
||||
"contributors": level_contributions,
|
||||
"winners": winner_ids,
|
||||
"hand_value": best_value,
|
||||
}
|
||||
)
|
||||
return awards
|
||||
|
||||
def _collect_showdown_hands(self) -> dict[str, list]:
|
||||
|
||||
@@ -178,10 +178,6 @@ class HandSummary:
|
||||
# on the summary (rather than only on the game) guarantees historical
|
||||
# hands remain self-describing even after the blinds are raised later.
|
||||
blinds: BlindLevel | None = None
|
||||
hole_cards: dict[str, list[Card]] = field(default_factory=dict)
|
||||
starting_stacks: dict[str, int] = field(default_factory=dict)
|
||||
ending_stacks: dict[str, int] = field(default_factory=dict)
|
||||
pot_contributions: list[dict[str, Any]] = field(default_factory=list)
|
||||
showdown_hands: dict[str, list[Card]] = field(default_factory=dict)
|
||||
started_at: float = field(default_factory=time)
|
||||
finished_at: float = field(default_factory=time)
|
||||
@@ -195,16 +191,6 @@ class HandSummary:
|
||||
"board": [str(card) for card in self.board],
|
||||
"actions": [record.to_dict() for record in self.actions],
|
||||
"awards": [award.to_dict() for award in self.awards],
|
||||
"hole_cards": {
|
||||
player_id: [str(card) for card in cards]
|
||||
for player_id, cards in self.hole_cards.items()
|
||||
},
|
||||
"starting_stacks": dict(self.starting_stacks),
|
||||
"ending_stacks": dict(self.ending_stacks),
|
||||
"pot_contributions": [
|
||||
self._pot_contribution_to_dict(contribution)
|
||||
for contribution in self.pot_contributions
|
||||
],
|
||||
# ``showdown_hands`` is only populated when more than one player
|
||||
# remained eligible for a pot; empty dict means the hand ended
|
||||
# without a showdown (e.g. everyone folded but the winner).
|
||||
@@ -215,26 +201,3 @@ class HandSummary:
|
||||
"started_at": self.started_at,
|
||||
"finished_at": self.finished_at,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _pot_contribution_to_dict(contribution: dict[str, Any]) -> dict[str, object]:
|
||||
hand_value = contribution.get("hand_value")
|
||||
if isinstance(hand_value, HandValue):
|
||||
hand_value = hand_value.to_dict()
|
||||
elif isinstance(hand_value, dict):
|
||||
hand_value = dict(hand_value)
|
||||
|
||||
raw_contributors = contribution.get("contributors") or {}
|
||||
contributors = {
|
||||
str(player_id): int(amount)
|
||||
for player_id, amount in dict(raw_contributors).items()
|
||||
}
|
||||
return {
|
||||
"amount": int(contribution.get("amount") or 0),
|
||||
"contributors": contributors,
|
||||
"winners": [
|
||||
str(player_id)
|
||||
for player_id in contribution.get("winners", [])
|
||||
],
|
||||
"hand_value": hand_value,
|
||||
}
|
||||
|
||||
@@ -28,14 +28,6 @@ class PokerRequestHandler(BaseHTTPRequestHandler):
|
||||
if len(path) == 2 and path[0] in {"game", "games"}:
|
||||
self._json(MANAGER.get_game_state(path[1]))
|
||||
return
|
||||
if len(path) == 4 and path[0] in {"game", "games"} and path[2] == "hands":
|
||||
try:
|
||||
hand_number = int(path[3])
|
||||
except ValueError:
|
||||
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
self._json(MANAGER.get_hand_state(path[1], hand_number))
|
||||
return
|
||||
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
||||
except KeyError as exc:
|
||||
self._json({"error": str(exc)}, HTTPStatus.NOT_FOUND)
|
||||
|
||||
@@ -78,13 +78,6 @@ class GameManager:
|
||||
def get_game_state(self, game_id: str) -> dict[str, object]:
|
||||
return self.get_game(game_id).snapshot_completed()
|
||||
|
||||
def get_hand_state(self, game_id: str, hand_number: int) -> dict[str, object]:
|
||||
state = self.get_game_state(game_id)
|
||||
for hand in state.get("hands", []):
|
||||
if hand.get("hand_number") == hand_number:
|
||||
return hand
|
||||
raise KeyError(f"hand not found: {game_id} #{hand_number}")
|
||||
|
||||
def list_games(self) -> list[dict[str, object]]:
|
||||
with self._lock:
|
||||
games = list(self._games.values())
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Standalone web replay viewer for Texas Hold X game snapshots."""
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import mimetypes
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote, unquote, urlparse, parse_qs
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
STATIC_DIR = Path(__file__).with_name("static")
|
||||
|
||||
|
||||
def build_game_url(query: dict[str, list[str]]) -> str:
|
||||
raw_url = _first(query, "url")
|
||||
if raw_url:
|
||||
return _validate_http_url(raw_url)
|
||||
|
||||
base_url = _first(query, "base_url")
|
||||
game_id = _first(query, "game_id")
|
||||
if not base_url or not game_id:
|
||||
raise ValueError("provide either url or both base_url and game_id")
|
||||
|
||||
base = _validate_http_url(base_url).rstrip("/")
|
||||
safe_game_id = quote(game_id.strip("/"), safe="")
|
||||
return f"{base}/games/{safe_game_id}"
|
||||
|
||||
|
||||
class ReplayRequestHandler(BaseHTTPRequestHandler):
|
||||
server_version = "TexasHoldemReplay/0.1"
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/api/health":
|
||||
self._json({"ok": True, "service": "texas-holdem-replay"})
|
||||
return
|
||||
if parsed.path == "/api/fetch-game":
|
||||
self._handle_fetch_game(parsed.query)
|
||||
return
|
||||
self._serve_static(parsed.path)
|
||||
|
||||
def do_OPTIONS(self) -> None:
|
||||
self.send_response(HTTPStatus.NO_CONTENT)
|
||||
self._cors_headers()
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
return
|
||||
|
||||
def _handle_fetch_game(self, raw_query: str) -> None:
|
||||
query = parse_qs(raw_query)
|
||||
try:
|
||||
target = build_game_url(query)
|
||||
timeout = float(_first(query, "timeout") or 8)
|
||||
request = Request(target, headers={"Accept": "application/json"})
|
||||
with urlopen(request, timeout=max(1, min(timeout, 30))) as response:
|
||||
payload = response.read()
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
try:
|
||||
data = json.loads(payload.decode("utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("target did not return valid JSON") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("target JSON must be an object")
|
||||
self._json({"source": target, "content_type": content_type, "game": data})
|
||||
except HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")[:600]
|
||||
self._json(
|
||||
{"error": f"upstream returned HTTP {exc.code}", "detail": body},
|
||||
HTTPStatus.BAD_GATEWAY,
|
||||
)
|
||||
except (URLError, TimeoutError) as exc:
|
||||
self._json({"error": "failed to reach upstream", "detail": str(exc)}, HTTPStatus.BAD_GATEWAY)
|
||||
except ValueError as exc:
|
||||
self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def _serve_static(self, raw_path: str) -> None:
|
||||
relative = unquote(raw_path.lstrip("/")) or "index.html"
|
||||
if relative.endswith("/"):
|
||||
relative += "index.html"
|
||||
candidate = (STATIC_DIR / relative).resolve()
|
||||
root = STATIC_DIR.resolve()
|
||||
if root not in candidate.parents and candidate != root:
|
||||
self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
if not candidate.is_file():
|
||||
candidate = STATIC_DIR / "index.html"
|
||||
body = candidate.read_bytes()
|
||||
content_type = mimetypes.guess_type(candidate.name)[0] or "application/octet-stream"
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self._cors_headers()
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Cache-Control", "no-store" if candidate.name == "index.html" else "public, max-age=60")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _json(self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK) -> None:
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self._cors_headers()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _cors_headers(self) -> None:
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
|
||||
def create_server(host: str, port: int) -> ThreadingHTTPServer:
|
||||
return ThreadingHTTPServer((host, port), ReplayRequestHandler)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Run the standalone Texas Hold X replay web viewer.")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", default=8088, type=int)
|
||||
args = parser.parse_args()
|
||||
|
||||
server = create_server(args.host, args.port)
|
||||
print(f"Texas Hold X replay viewer listening on http://{args.host}:{args.port}")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
def _first(query: dict[str, list[str]], key: str) -> str | None:
|
||||
values = query.get(key)
|
||||
if not values:
|
||||
return None
|
||||
value = values[0].strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _validate_http_url(value: str) -> str:
|
||||
parsed = urlparse(value.strip())
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise ValueError("url must be an absolute http(s) URL")
|
||||
return value.strip()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,936 @@
|
||||
// =============================================================================
|
||||
// Texas Hold X Replay — viewer logic
|
||||
// -----------------------------------------------------------------------------
|
||||
// Architecture overview:
|
||||
// * `state` — single mutable runtime store; never read directly by render
|
||||
// helpers, instead passed via the active frame snapshot.
|
||||
// * `el` — cached DOM references (set once on load, not per render).
|
||||
// * Frames — pre-computed, immutable per-hand snapshots produced by
|
||||
// buildFrames(). Each frame carries the full visual state
|
||||
// (board cards, pot, players, active speaker, action kind),
|
||||
// making renderFrame() a pure function of state.frameIndex.
|
||||
// * Terminal — renderFrame() writes a fixed-width command-line view into
|
||||
// <pre>, keeping all fetch/upload/replay processing unchanged.
|
||||
// =============================================================================
|
||||
|
||||
const state = {
|
||||
game: null,
|
||||
hands: [],
|
||||
currentHandIndex: 0,
|
||||
frames: [],
|
||||
frameIndex: 0,
|
||||
playing: false,
|
||||
timer: null,
|
||||
pollTimer: null,
|
||||
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"),
|
||||
fileInput: document.querySelector("#fileInput"),
|
||||
autoPoll: document.querySelector("#autoPoll"),
|
||||
pollSeconds: document.querySelector("#pollSeconds"),
|
||||
handSelect: document.querySelector("#handSelect"),
|
||||
pace: document.querySelector("#pace"),
|
||||
prevBtn: document.querySelector("#prevBtn"),
|
||||
playBtn: document.querySelector("#playBtn"),
|
||||
nextBtn: document.querySelector("#nextBtn"),
|
||||
resetBtn: document.querySelector("#resetBtn"),
|
||||
progressBar: document.querySelector("#progressBar"),
|
||||
gameStatus: document.querySelector("#gameStatus"),
|
||||
playerCount: document.querySelector("#playerCount"),
|
||||
handCount: document.querySelector("#handCount"),
|
||||
blindLevel: document.querySelector("#blindLevel"),
|
||||
handBadge: document.querySelector("#handBadge"),
|
||||
streetLabel: document.querySelector("#streetLabel"),
|
||||
potLabel: document.querySelector("#potLabel"),
|
||||
frameLabel: document.querySelector("#frameLabel"),
|
||||
tableOutput: document.querySelector("#tableOutput"),
|
||||
eventLog: document.querySelector("#eventLog"),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static label tables. Kept module-scope so render functions are fully pure.
|
||||
// ---------------------------------------------------------------------------
|
||||
const STREET_LABELS = {
|
||||
setup: "准备",
|
||||
preflop: "翻牌前",
|
||||
flop: "翻牌",
|
||||
turn: "转牌",
|
||||
river: "河牌",
|
||||
showdown: "摊牌",
|
||||
awards: "结算",
|
||||
};
|
||||
|
||||
const ACTION_LABELS = {
|
||||
small_blind: "小盲",
|
||||
big_blind: "大盲",
|
||||
fold: "弃牌",
|
||||
check: "过牌",
|
||||
call: "跟注",
|
||||
bet: "下注",
|
||||
raise: "加注",
|
||||
all_in: "全下",
|
||||
award: "赢得",
|
||||
showdown: "亮牌",
|
||||
deal: "发牌",
|
||||
};
|
||||
|
||||
// Bubble color category — bucket many actions into a few visual classes.
|
||||
// (The .speech.kind-* CSS classes paint distinct backgrounds for fold/
|
||||
// call/check/bet/raise/all_in/award. Blinds are rendered as bet-style.)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pixel avatar palette — picked from a small 8-bit-friendly set so every
|
||||
// player is visually distinct. Derivation is deterministic via FNV-1a over
|
||||
// player_id, so a given player always renders with the same colors across
|
||||
// frames and hands.
|
||||
// ---------------------------------------------------------------------------
|
||||
const AVATAR_PALETTE = {
|
||||
skin: ["#f7c98a", "#e2a96b", "#c98c5a", "#8b5a3c", "#f2d4ad"],
|
||||
hair: ["#2a1e16", "#5b3a23", "#a85d2a", "#d8a13a", "#7a3b8e", "#3a4d8a", "#b9362f"],
|
||||
shirt: ["#c44c4c", "#3a7fbf", "#3a9b62", "#b87a1f", "#7a3b8e", "#2a3a5a", "#d8a13a"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Hash a string into a non-negative 32-bit integer using FNV-1a.
|
||||
* Used to deterministically pick avatar palette entries from player_id.
|
||||
*/
|
||||
function fnv1a(value) {
|
||||
let hash = 0x811c9dc5;
|
||||
const text = String(value);
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a deterministic avatar palette for the given player_id.
|
||||
* Returns an object with { skin, hair, shirt } CSS colors.
|
||||
*/
|
||||
function avatarPalette(playerId) {
|
||||
const hash = fnv1a(playerId);
|
||||
const pickFrom = (list, salt) => list[(hash >>> salt) % list.length];
|
||||
return {
|
||||
skin: pickFrom(AVATAR_PALETTE.skin, 0),
|
||||
hair: pickFrom(AVATAR_PALETTE.hair, 5),
|
||||
shirt: pickFrom(AVATAR_PALETTE.shirt, 11),
|
||||
};
|
||||
}
|
||||
|
||||
// 8x8 sprite map for the player portrait.
|
||||
// . = transparent / dark frame backdrop
|
||||
// H = hair S = skin E = eye M = mouth (red) T = shirt body
|
||||
// Two side hair pixels on rows 2-3 give a cohesive helmet shape; row 5 mouth
|
||||
// adds personality. Row 7 is full shirt body so the avatar reads as a bust.
|
||||
const AVATAR_SPRITE = [
|
||||
".HHHHHH.",
|
||||
"HHHHHHHH",
|
||||
"HSSSSSSH",
|
||||
".SESSESS",
|
||||
".SSSSSS.",
|
||||
".SSMMSS.",
|
||||
".TTTTTT.",
|
||||
"TTTTTTTT",
|
||||
];
|
||||
|
||||
/**
|
||||
* Build an inline SVG string for a pixel-art avatar using the player's
|
||||
* deterministic palette. Each filled cell becomes a 1x1 <rect> in an 8x8
|
||||
* viewBox; SVG `shape-rendering="crispEdges"` keeps the squares sharp.
|
||||
*/
|
||||
function avatarSvg(playerId) {
|
||||
const palette = avatarPalette(playerId);
|
||||
const colors = {
|
||||
H: palette.hair,
|
||||
S: palette.skin,
|
||||
E: "#1c0e08",
|
||||
M: "#b9362f",
|
||||
T: palette.shirt,
|
||||
};
|
||||
let rects = "";
|
||||
for (let y = 0; y < AVATAR_SPRITE.length; y += 1) {
|
||||
const row = AVATAR_SPRITE[y];
|
||||
for (let x = 0; x < row.length; x += 1) {
|
||||
const ch = row[x];
|
||||
const fill = colors[ch];
|
||||
if (!fill) continue;
|
||||
rects += `<rect x="${x}" y="${y}" width="1" height="1" fill="${fill}"/>`;
|
||||
}
|
||||
}
|
||||
return `<svg viewBox="0 0 8 8" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" shape-rendering="crispEdges">${rects}</svg>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card rendering helpers.
|
||||
// ---------------------------------------------------------------------------
|
||||
function cardParts(raw) {
|
||||
if (!raw || raw === "back") return { rank: "", suit: "", red: false, back: true };
|
||||
const suitCode = raw.slice(-1).toLowerCase();
|
||||
const rank = raw.slice(0, -1).toUpperCase();
|
||||
const suits = { h: "♥", d: "♦", c: "♣", s: "♠" };
|
||||
return {
|
||||
rank,
|
||||
suit: suits[suitCode] || suitCode,
|
||||
red: suitCode === "h" || suitCode === "d",
|
||||
back: false,
|
||||
};
|
||||
}
|
||||
|
||||
function renderCard(raw) {
|
||||
const parts = cardParts(raw);
|
||||
const card = document.createElement("div");
|
||||
card.className = `card${parts.red ? " red" : ""}${parts.back ? " back" : ""}`;
|
||||
if (!parts.back) {
|
||||
card.innerHTML = `<span class="rank">${parts.rank}</span><span class="suit">${parts.suit}</span><span class="rank bottom">${parts.rank}</span>`;
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
function money(value) {
|
||||
return Number(value || 0).toLocaleString("en-US");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Game normalization: hydrate the raw JSON into a uniform shape with
|
||||
// player list and per-hand starting stacks.
|
||||
// ---------------------------------------------------------------------------
|
||||
function uniquePlayers(game) {
|
||||
const byId = new Map();
|
||||
for (const player of game.players || []) {
|
||||
byId.set(player.player_id, {
|
||||
player_id: player.player_id,
|
||||
name: player.name || player.player_id,
|
||||
seat: Number(player.seat || 0),
|
||||
stack: Number(player.stack || 0),
|
||||
});
|
||||
}
|
||||
for (const hand of game.hands || []) {
|
||||
for (const action of hand.actions || []) {
|
||||
if (!byId.has(action.player_id)) {
|
||||
byId.set(action.player_id, {
|
||||
player_id: action.player_id,
|
||||
name: action.player_id,
|
||||
seat: byId.size,
|
||||
stack: Number(game.starting_stack || 0),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(byId.values()).sort((a, b) => a.seat - b.seat);
|
||||
}
|
||||
|
||||
function inferHandStarts(game) {
|
||||
const players = uniquePlayers(game);
|
||||
const stacks = new Map(players.map((player) => [player.player_id, Number(game.starting_stack || player.stack || 0)]));
|
||||
const starts = new Map();
|
||||
for (const hand of game.hands || []) {
|
||||
starts.set(hand.hand_number, new Map(stacks));
|
||||
for (const action of hand.actions || []) {
|
||||
if (stacks.has(action.player_id)) stacks.set(action.player_id, Number(action.stack || 0));
|
||||
}
|
||||
for (const award of hand.awards || []) {
|
||||
const winners = award.winners || [];
|
||||
const share = winners.length ? Math.floor(Number(award.amount || 0) / winners.length) : 0;
|
||||
for (const winner of winners) stacks.set(winner, Number(stacks.get(winner) || 0) + share);
|
||||
}
|
||||
}
|
||||
return starts;
|
||||
}
|
||||
|
||||
function normalizeGame(raw) {
|
||||
const game = raw.game && raw.game.hands ? raw.game : raw;
|
||||
if (!game || !Array.isArray(game.hands)) throw new Error("JSON 中未找到 hands 数组");
|
||||
const players = uniquePlayers(game);
|
||||
const starts = inferHandStarts(game);
|
||||
const hands = game.hands.map((hand) => ({ ...hand, startingStacks: starts.get(hand.hand_number) || new Map() }));
|
||||
return { ...game, players, hands };
|
||||
}
|
||||
|
||||
function clonePlayersForHand(game, hand) {
|
||||
return game.players.map((player) => ({
|
||||
...player,
|
||||
stack: Number(hand.startingStacks.get(player.player_id) ?? game.starting_stack ?? player.stack ?? 0),
|
||||
folded: false,
|
||||
all_in: false,
|
||||
in_hand: Number(hand.startingStacks.get(player.player_id) ?? game.starting_stack ?? player.stack ?? 0) > 0,
|
||||
street_bet: 0,
|
||||
total_bet: 0,
|
||||
hole_cards: [],
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame builder — converts a hand's chronological action list into a list of
|
||||
// fully-resolved frame snapshots. Each frame is independent and renderable.
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildFrames(game, hand) {
|
||||
const players = clonePlayersForHand(game, hand);
|
||||
const byId = new Map(players.map((player) => [player.player_id, player]));
|
||||
const frames = [];
|
||||
const pot = { value: 0 };
|
||||
let street = "preflop";
|
||||
let boardCount = 0;
|
||||
|
||||
// `actionKind` is what drives the seat sprite animation classes. It maps
|
||||
// the raw action verb (fold/call/raise/...) onto a CSS animation token.
|
||||
const snapshot = (type, message, activePlayerId = null, key = `${type}:${frames.length}`, extras = {}) => ({
|
||||
key: `hand:${hand.hand_number}:${key}`,
|
||||
type,
|
||||
message,
|
||||
activePlayerId,
|
||||
street,
|
||||
pot: pot.value,
|
||||
board: (hand.board || []).slice(0, boardCount),
|
||||
players: players.map((player) => ({ ...player, hole_cards: [...player.hole_cards] })),
|
||||
...extras,
|
||||
});
|
||||
|
||||
frames.push(snapshot("setup", `第 ${hand.hand_number} 手牌开始`, null, "setup"));
|
||||
|
||||
const revealStreet = (nextStreet) => {
|
||||
const counts = { flop: 3, turn: 4, river: 5 };
|
||||
const nextCount = counts[nextStreet] || boardCount;
|
||||
for (const player of players) player.street_bet = 0;
|
||||
if (nextCount > boardCount) {
|
||||
street = nextStreet;
|
||||
boardCount = Math.min(nextCount, (hand.board || []).length);
|
||||
frames.push(snapshot("deal", `${STREET_LABELS[nextStreet]}发出`, null, `deal:${nextStreet}:${boardCount}`));
|
||||
} else {
|
||||
street = nextStreet;
|
||||
}
|
||||
};
|
||||
|
||||
for (const [actionIndex, action] of (hand.actions || []).entries()) {
|
||||
if (action.street !== street && action.street !== "preflop") revealStreet(action.street);
|
||||
const player = byId.get(action.player_id);
|
||||
if (!player) continue;
|
||||
const previousTotal = Number(player.total_bet || 0);
|
||||
player.street_bet = Number(action.street_bet || 0);
|
||||
player.stack = Number(action.stack || 0);
|
||||
if (action.action === "fold") player.folded = true;
|
||||
if (action.action === "all_in" || player.stack === 0) player.all_in = true;
|
||||
player.total_bet = previousTotal + Math.max(0, Number(action.amount || 0));
|
||||
pot.value += Math.max(0, Number(action.amount || 0));
|
||||
frames.push(snapshot(
|
||||
"action",
|
||||
actionText(action),
|
||||
action.player_id,
|
||||
actionKey(action, actionIndex),
|
||||
{ actionKind: action.action, bubbleText: bubbleTextFor(action) },
|
||||
));
|
||||
}
|
||||
|
||||
if ((hand.board || []).length > boardCount) {
|
||||
for (const nextStreet of ["flop", "turn", "river"]) {
|
||||
if (({ flop: 3, turn: 4, river: 5 }[nextStreet] || 0) > boardCount) revealStreet(nextStreet);
|
||||
}
|
||||
}
|
||||
|
||||
const shown = hand.showdown_hands || {};
|
||||
for (const [playerId, cards] of Object.entries(shown)) {
|
||||
const player = byId.get(playerId);
|
||||
if (player) player.hole_cards = cards;
|
||||
}
|
||||
if (Object.keys(shown).length) {
|
||||
street = "showdown";
|
||||
frames.push(snapshot("showdown", "摊牌亮牌", null, `showdown:${Object.keys(shown).sort().join(",")}`));
|
||||
}
|
||||
|
||||
street = "awards";
|
||||
for (const [awardIndex, award] of (hand.awards || []).entries()) {
|
||||
const winners = award.winners || [];
|
||||
const share = winners.length ? Math.floor(Number(award.amount || 0) / winners.length) : 0;
|
||||
const remainder = winners.length ? Number(award.amount || 0) % winners.length : 0;
|
||||
winners.forEach((winnerId, index) => {
|
||||
const player = byId.get(winnerId);
|
||||
if (player) player.stack += share + (index < remainder ? 1 : 0);
|
||||
});
|
||||
pot.value = Math.max(0, pot.value - Number(award.amount || 0));
|
||||
frames.push(snapshot(
|
||||
"award",
|
||||
awardText(award),
|
||||
winners[0] || null,
|
||||
awardKey(award, awardIndex),
|
||||
{ actionKind: "award", bubbleText: `赢得 ${money(award.amount)}` },
|
||||
));
|
||||
}
|
||||
|
||||
if (frames.length === 1) frames.push(snapshot("empty", "这手牌没有可回放动作", null, "empty"));
|
||||
return frames;
|
||||
}
|
||||
|
||||
function actionKey(action, index) {
|
||||
return [
|
||||
"action", index, action.street, action.player_id, action.action,
|
||||
Number(action.amount || 0), Number(action.street_bet || 0), Number(action.stack || 0),
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function awardKey(award, index) {
|
||||
return [
|
||||
"award", index, Number(award.amount || 0),
|
||||
(award.winners || []).join(","),
|
||||
award.hand_value?.name || "",
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function actionText(action) {
|
||||
const label = ACTION_LABELS[action.action] || action.action;
|
||||
if (["check", "fold"].includes(action.action)) return `${action.player_id} ${label}`;
|
||||
return `${action.player_id} ${label} ${money(action.amount)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a SHORT speech-bubble label (player_id is implicit since the bubble
|
||||
* already points at the seat). Keeps text readable inside narrow bubbles.
|
||||
*/
|
||||
function bubbleTextFor(action) {
|
||||
const label = ACTION_LABELS[action.action] || action.action;
|
||||
if (["check", "fold"].includes(action.action)) return label;
|
||||
return `${label} ${money(action.amount)}`;
|
||||
}
|
||||
|
||||
function awardText(award) {
|
||||
const winners = (award.winners || []).join(", ");
|
||||
const handName = award.hand_value?.name ? ` · ${award.hand_value.name}` : "";
|
||||
return `${winners} 赢得 ${money(award.amount)}${handName}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seat layout — six radial presets, with mobile fallback.
|
||||
// Returned (x, y) values are percentages relative to the poker-table box
|
||||
// (NOT the felt-shell). The poker-table reserves vertical padding so the
|
||||
// top/bottom seats and their bubbles never overlap the table headers.
|
||||
// ---------------------------------------------------------------------------
|
||||
function seatPosition(index, count) {
|
||||
const mobile = window.matchMedia("(max-width: 760px)").matches;
|
||||
const presets = mobile ? mobileSeatPreset(count) : desktopSeatPreset(count);
|
||||
if (presets[index]) return presets[index];
|
||||
const radiusX = mobile ? 36 : 39;
|
||||
const radiusY = mobile ? 41 : 39;
|
||||
const start = -90;
|
||||
const angle = (start + index * (360 / Math.max(count, 1))) * Math.PI / 180;
|
||||
return {
|
||||
x: 50 + Math.cos(angle) * radiusX,
|
||||
y: 50 + Math.sin(angle) * radiusY,
|
||||
};
|
||||
}
|
||||
|
||||
// Coordinates expressed in percent of the poker-table box (which already
|
||||
// includes vertical padding around the felt). `y < ~25` lands above the
|
||||
// felt (top rail), `y > ~75` lands below the felt (bottom rail).
|
||||
function desktopSeatPreset(count) {
|
||||
const presets = {
|
||||
2: [{ x: 50, y: 18 }, { x: 50, y: 82 }],
|
||||
3: [{ x: 50, y: 18 }, { x: 80, y: 72 }, { x: 20, y: 72 }],
|
||||
4: [{ x: 50, y: 18 }, { x: 84, y: 60 }, { x: 50, y: 84 }, { x: 16, y: 60 }],
|
||||
5: [{ x: 50, y: 18 }, { x: 84, y: 44 }, { x: 72, y: 84 }, { x: 28, y: 84 }, { x: 16, y: 44 }],
|
||||
6: [{ x: 50, y: 18 }, { x: 82, y: 33 }, { x: 82, y: 70 }, { x: 50, y: 86 }, { x: 18, y: 70 }, { x: 18, y: 33 }],
|
||||
};
|
||||
return presets[count] || [];
|
||||
}
|
||||
|
||||
function mobileSeatPreset(count) {
|
||||
const presets = {
|
||||
2: [{ x: 50, y: 16 }, { x: 50, y: 84 }],
|
||||
3: [{ x: 50, y: 15 }, { x: 80, y: 78 }, { x: 20, y: 78 }],
|
||||
4: [{ x: 50, y: 14 }, { x: 83, y: 60 }, { x: 50, y: 86 }, { x: 17, y: 60 }],
|
||||
};
|
||||
return presets[count] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a seat coordinate to a `seat-top|bottom|left|right|mid` zone class.
|
||||
* The CSS uses this to flip the speech bubble below the seat for top-rail
|
||||
* seats so it never extends outside the visible table area.
|
||||
*/
|
||||
function seatZone(pos) {
|
||||
if (pos.y < 32) return "seat-top";
|
||||
if (pos.y > 70) return "seat-bottom";
|
||||
if (pos.x < 34) return "seat-left";
|
||||
if (pos.x > 66) return "seat-right";
|
||||
return "seat-mid";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM rendering: assemble a single seat element from a frame's player snapshot.
|
||||
// Extracted into its own function so renderFrame stays declarative.
|
||||
// ---------------------------------------------------------------------------
|
||||
function renderSeat(player, position, frame, hand) {
|
||||
const zone = seatZone(position);
|
||||
const isActive = player.player_id === frame.activePlayerId;
|
||||
const isDealer = player.seat === hand.button_seat;
|
||||
const isCurrentActor = isActive && (frame.type === "action" || frame.type === "award");
|
||||
|
||||
const seat = document.createElement("div");
|
||||
seat.className = [
|
||||
"seat",
|
||||
zone,
|
||||
isActive ? "active" : "",
|
||||
player.folded ? "folded" : "",
|
||||
isDealer ? "dealer-seat" : "",
|
||||
// The transient act-* class is what triggers the avatar's reaction
|
||||
// animation (shake/nod/fold/cheer). Only apply on the actor's frame.
|
||||
isCurrentActor && frame.actionKind ? `act-${frame.actionKind}` : "",
|
||||
].filter(Boolean).join(" ");
|
||||
seat.style.setProperty("--x", `${position.x}%`);
|
||||
seat.style.setProperty("--y", `${position.y}%`);
|
||||
|
||||
// Speech bubble — shown on the active player's action/award frames using
|
||||
// a SHORT label (e.g. "加注 50") so it fits the small bubble width.
|
||||
const bubbleHtml = (isCurrentActor && frame.bubbleText)
|
||||
? `<div class="speech kind-${frame.actionKind || "info"}">${escapeHtml(frame.bubbleText)}</div>`
|
||||
: "";
|
||||
|
||||
// Avatar — inline SVG pixel-art sprite, deterministic per player_id so
|
||||
// the same player keeps a stable look across hands.
|
||||
const avatarMarkup = `<div class="avatar" aria-hidden="true">${avatarSvg(player.player_id)}</div>`;
|
||||
|
||||
seat.innerHTML = `
|
||||
${bubbleHtml}
|
||||
<div class="player-box">
|
||||
<div class="player-head">
|
||||
${avatarMarkup}
|
||||
<span class="player-name">${escapeHtml(player.name || player.player_id)}</span>
|
||||
<span class="dealer">D</span>
|
||||
</div>
|
||||
<div class="player-meta">
|
||||
<span class="stack">Stack ${money(player.stack)}</span>
|
||||
<span class="bet">${player.folded ? "Folded" : player.all_in ? "All In" : player.street_bet ? `Bet ${money(player.street_bet)}` : ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Hole cards live inside the player-box so they share its layout flow.
|
||||
const hole = document.createElement("div");
|
||||
hole.className = "card-row hole-cards";
|
||||
for (const card of knownCardsForPlayer(player, hand, frame)) hole.appendChild(renderCard(card));
|
||||
seat.querySelector(".player-box").appendChild(hole);
|
||||
|
||||
return seat;
|
||||
}
|
||||
|
||||
function renderFrame() {
|
||||
const hand = state.hands[state.currentHandIndex];
|
||||
const frame = state.frames[state.frameIndex];
|
||||
const game = state.game;
|
||||
const hasData = Boolean(game && hand && frame);
|
||||
|
||||
el.handBadge.textContent = hasData ? `hand ${hand.hand_number}` : "hand -";
|
||||
el.streetLabel.textContent = hasData ? (STREET_LABELS[frame.street] || frame.street) : "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.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);
|
||||
const showdownCards = canRevealShowdown ? hand.showdown_hands?.[player.player_id] : null;
|
||||
const shown = futureHoleCards || showdownCards || player.hole_cards;
|
||||
if (shown && shown.length) return shown;
|
||||
if (!player.in_hand && !player.folded) return [];
|
||||
return ["back", "back"];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event log — one line per visible event, with auto-scroll keeping the
|
||||
// current item in view. Items past the current frame index are dimmed via
|
||||
// the `.past` class.
|
||||
// ---------------------------------------------------------------------------
|
||||
function renderLog() {
|
||||
el.eventLog.replaceChildren();
|
||||
if (!state.frames.length) return;
|
||||
let currentLi = null;
|
||||
state.frames.forEach((frame, index) => {
|
||||
const li = document.createElement("li");
|
||||
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");
|
||||
currentLi = li;
|
||||
}
|
||||
el.eventLog.appendChild(li);
|
||||
});
|
||||
// Keep the focused event visible without yanking the page when an event
|
||||
// is already in view.
|
||||
if (currentLi) {
|
||||
const behavior = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
|
||||
currentLi.scrollIntoView({ block: "nearest", behavior });
|
||||
}
|
||||
}
|
||||
|
||||
function syncControls() {
|
||||
const loaded = Boolean(state.game);
|
||||
const hasPreviousFrame = state.frameIndex > 0 || state.currentHandIndex > 0;
|
||||
const hasNextFrame = state.frameIndex < state.frames.length - 1 || state.currentHandIndex < state.hands.length - 1;
|
||||
el.playBtn.disabled = !loaded;
|
||||
el.prevBtn.disabled = !loaded || !hasPreviousFrame;
|
||||
el.nextBtn.disabled = !loaded || !hasNextFrame;
|
||||
el.resetBtn.disabled = !loaded;
|
||||
el.playBtn.textContent = state.playing ? "pause" : "play";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Game lifecycle: load / merge / select hand / step / play.
|
||||
// ---------------------------------------------------------------------------
|
||||
function loadGame(raw, source, options = {}) {
|
||||
const nextGame = normalizeGame(raw);
|
||||
const wasPlaying = state.playing;
|
||||
const mergeResult = options.allowMerge !== false ? mergeGame(nextGame) : { merged: false, advanced: false };
|
||||
if (!mergeResult.merged) {
|
||||
pause();
|
||||
state.game = nextGame;
|
||||
state.hands = state.game.hands;
|
||||
state.currentHandIndex = Math.max(0, state.hands.length - 1);
|
||||
state.frames = buildFrames(state.game, state.hands[state.currentHandIndex]);
|
||||
state.frameIndex = 0;
|
||||
}
|
||||
state.source = source;
|
||||
el.sourceBadge.textContent = source;
|
||||
el.subtitle.textContent = `${state.game.game_id || "未命名游戏"} · ${state.hands.length} hands`;
|
||||
renderSummary();
|
||||
populateHands();
|
||||
el.handSelect.value = String(state.currentHandIndex);
|
||||
renderFrame();
|
||||
if (mergeResult.merged && (wasPlaying || mergeResult.advanced)) play();
|
||||
}
|
||||
|
||||
function mergeGame(nextGame) {
|
||||
if (!state.game || state.game.game_id !== nextGame.game_id) return { merged: false, advanced: false };
|
||||
const currentHand = state.hands[state.currentHandIndex];
|
||||
const nextHandIndex = nextGame.hands.findIndex((hand) => hand.hand_number === currentHand?.hand_number);
|
||||
if (nextHandIndex < 0) return { merged: false, advanced: false };
|
||||
|
||||
const oldFrame = state.frames[state.frameIndex];
|
||||
const nextFrames = buildFrames(nextGame, nextGame.hands[nextHandIndex]);
|
||||
const oldKeyIndex = oldFrame ? nextFrames.findIndex((frame) => frame.key === oldFrame.key) : -1;
|
||||
const atKnownFrame = oldKeyIndex >= 0;
|
||||
const wasAtTail = state.frameIndex >= state.frames.length - 1;
|
||||
const hadNewFramesOnCurrentHand = nextFrames.length > state.frames.length;
|
||||
const currentWasLatestHand = state.currentHandIndex >= state.hands.length - 1;
|
||||
const hasNewLaterHandFromCurrent = currentWasLatestHand && nextGame.hands.length > state.hands.length;
|
||||
let advanced = false;
|
||||
|
||||
state.game = nextGame;
|
||||
state.hands = nextGame.hands;
|
||||
state.currentHandIndex = nextHandIndex;
|
||||
state.frames = nextFrames;
|
||||
|
||||
if (atKnownFrame) {
|
||||
advanced = shouldStepIntoIncrement(wasAtTail, hadNewFramesOnCurrentHand, hasNewLaterHandFromCurrent);
|
||||
state.frameIndex = advanced
|
||||
? Math.min(oldKeyIndex + 1, nextFrames.length - 1)
|
||||
: oldKeyIndex;
|
||||
} else {
|
||||
state.frameIndex = Math.min(state.frameIndex, nextFrames.length - 1);
|
||||
}
|
||||
|
||||
if (state.frameIndex >= state.frames.length - 1 && hasNewLaterHandFromCurrent) {
|
||||
state.currentHandIndex += 1;
|
||||
state.frames = buildFrames(state.game, state.hands[state.currentHandIndex]);
|
||||
state.frameIndex = 0;
|
||||
advanced = true;
|
||||
}
|
||||
|
||||
return { merged: true, advanced };
|
||||
}
|
||||
|
||||
function shouldStepIntoIncrement(wasAtTail, hadNewFramesOnCurrentHand, hasLaterHands) {
|
||||
return wasAtTail && (hadNewFramesOnCurrentHand || hasLaterHands);
|
||||
}
|
||||
|
||||
function renderSummary() {
|
||||
const game = state.game;
|
||||
const hand = state.hands[state.currentHandIndex];
|
||||
const blinds = hand?.blinds || game?.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(smallBlind)} / ${money(bigBlind)}` : "-";
|
||||
}
|
||||
|
||||
function populateHands() {
|
||||
el.handSelect.replaceChildren();
|
||||
state.hands.forEach((hand, index) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(index);
|
||||
option.textContent = `Hand ${hand.hand_number} · ${hand.actions?.length || 0} actions`;
|
||||
el.handSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function selectHand(index) {
|
||||
pause();
|
||||
state.currentHandIndex = Math.max(0, Math.min(index, state.hands.length - 1));
|
||||
state.frames = buildFrames(state.game, state.hands[state.currentHandIndex]);
|
||||
state.frameIndex = 0;
|
||||
el.handSelect.value = String(state.currentHandIndex);
|
||||
renderSummary();
|
||||
renderFrame();
|
||||
}
|
||||
|
||||
function nextFrame() {
|
||||
if (!state.frames.length) return;
|
||||
if (state.frameIndex < state.frames.length - 1) {
|
||||
state.frameIndex += 1;
|
||||
renderFrame();
|
||||
return;
|
||||
}
|
||||
if (state.currentHandIndex < state.hands.length - 1) {
|
||||
state.currentHandIndex += 1;
|
||||
state.frames = buildFrames(state.game, state.hands[state.currentHandIndex]);
|
||||
state.frameIndex = 0;
|
||||
el.handSelect.value = String(state.currentHandIndex);
|
||||
renderSummary();
|
||||
renderFrame();
|
||||
return;
|
||||
}
|
||||
pause();
|
||||
}
|
||||
|
||||
function prevFrame() {
|
||||
pause();
|
||||
if (state.frameIndex === 0 && state.currentHandIndex > 0) {
|
||||
state.currentHandIndex -= 1;
|
||||
state.frames = buildFrames(state.game, state.hands[state.currentHandIndex]);
|
||||
state.frameIndex = state.frames.length - 1;
|
||||
el.handSelect.value = String(state.currentHandIndex);
|
||||
renderSummary();
|
||||
renderFrame();
|
||||
return;
|
||||
}
|
||||
state.frameIndex = Math.max(0, state.frameIndex - 1);
|
||||
renderFrame();
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (!state.game || state.playing) return;
|
||||
state.playing = true;
|
||||
tick();
|
||||
renderFrame();
|
||||
}
|
||||
|
||||
function pause() {
|
||||
state.playing = false;
|
||||
if (state.timer) window.clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
syncControls();
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!state.playing) return;
|
||||
const frame = state.frames[state.frameIndex];
|
||||
const baseDelay = frame?.type === "deal" || frame?.type === "award" ? 1500 : 1150;
|
||||
const delay = baseDelay * Number(el.pace.value || 1);
|
||||
state.timer = window.setTimeout(() => {
|
||||
nextFrame();
|
||||
tick();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Network / file I/O.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function fetchFromServer() {
|
||||
const base = el.serverUrl.value.trim();
|
||||
const gameId = el.gameId.value.trim();
|
||||
if (!base || !gameId) throw new Error("请填写游戏服务地址和 Game ID");
|
||||
const url = `/api/fetch-game?${new URLSearchParams({ base_url: base, game_id: gameId })}`;
|
||||
const response = await fetch(url);
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || "获取失败");
|
||||
loadGame(payload.game, `Server · ${gameId}`);
|
||||
}
|
||||
|
||||
function setAutoPoll(enabled) {
|
||||
if (state.pollTimer) window.clearInterval(state.pollTimer);
|
||||
state.pollTimer = null;
|
||||
el.pollBadge.textContent = enabled ? `Auto ${el.pollSeconds.value}s` : "Auto Off";
|
||||
if (!enabled) return;
|
||||
const interval = Math.max(5, Number(el.pollSeconds.value || 12)) * 1000;
|
||||
state.pollTimer = window.setInterval(() => {
|
||||
fetchFromServer().catch((error) => showMessage(error.message));
|
||||
}, interval);
|
||||
}
|
||||
|
||||
function showMessage(message) {
|
||||
el.tableOutput.textContent = [
|
||||
"$ txh-replay --status error",
|
||||
"",
|
||||
`error: ${message}`,
|
||||
].join("\n");
|
||||
el.sourceBadge.textContent = "ERROR";
|
||||
el.frameLabel.textContent = "frame 0/0";
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.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));
|
||||
});
|
||||
|
||||
el.fileInput.addEventListener("change", async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const raw = JSON.parse(await file.text());
|
||||
loadGame(raw, `File · ${file.name}`);
|
||||
} catch (error) {
|
||||
showMessage(error.message);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
el.autoPoll.addEventListener("change", () => setAutoPoll(el.autoPoll.checked));
|
||||
el.pollSeconds.addEventListener("change", () => setAutoPoll(el.autoPoll.checked));
|
||||
el.handSelect.addEventListener("change", () => selectHand(Number(el.handSelect.value)));
|
||||
el.playBtn.addEventListener("click", () => state.playing ? pause() : play());
|
||||
el.nextBtn.addEventListener("click", () => nextFrame());
|
||||
el.prevBtn.addEventListener("click", () => prevFrame());
|
||||
el.resetBtn.addEventListener("click", () => selectHand(state.currentHandIndex));
|
||||
window.addEventListener("resize", () => renderFrame());
|
||||
|
||||
initTheme();
|
||||
syncControls();
|
||||
renderFrame();
|
||||
@@ -0,0 +1,122 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-theme="auto">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Texas Hold X Replay</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="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="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="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 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>
|
||||
|
||||
<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="progress-shell" aria-hidden="true">
|
||||
<div id="progressBar"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
|
||||
<script src="/app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user