Files
huajishe-tts/docs/dev-log/decisions.md
e2hang 25452ce045 feat(holdem): Texas Hold'em game (engine + UI + variable-seat lobby + e2e + docs)
引擎(packages/engine/src/games/):
- holdem.ts: HoldemState + 5 phase (waiting→preflop→flop→turn→river→showdown)
  + 8 moves (sitDown/startHand/fold/check/call/raise/allIn) + playerView 保密
  + 边池结算 + 盲注/按钮轮转 + validateSetupData
  + HoldemView 类型: 只读 helper 放宽签名, UI 复用无需强转
- holdem-eval.ts: 7 张牌评估器 (evaluate5/7/compareHands/makeDeck/cardLabel, 中文牌型名)
- holdem.test.ts (25) + holdem-eval.test.ts (16)

UI:
- apps/web/HoldemBoard.tsx: 椭圆桌 + N 座位环形布局 + 行动条 + 结果面板 + 日志
- packages/ui/LobbyRoomList.tsx: 可变人数 (maxSeats 2..9) + setupData 表单
  + 动态 P0..P(n-1) 加入按钮 + onSelect 携带 numPlayers
- apps/web/App.tsx: 注册 holdem + HoldemLocalView (9 座切换) + HoldemOnlineView

测试:
- apps/web/e2e/holdem-multiplayer.spec.ts: 双浏览器联机 heads-up 完整一手
- 全量验证: ts 5/5 + unit 84 + e2e 5/5

文档:
- docs/dev-log/13-holdem-game.md: 设计要点 + 坑 (NULL 损坏 / HoldemView / pnpm)
- decisions D30-D32 + lessons-learned §12.1-12.4
- README.md: 系统启动 + 迁移到其它机器步骤

server games 数组已加 Holdem (上轮完成)。

Co-authored-by: GLM-5.2
2026-08-24 14:05:14 +08:00

10 KiB
Raw Permalink Blame History

决策记录 · Decisions

按时间倒序排列。所有决策已锁定,相关讨论不再重复。


2026-08-16

D1. 规则引擎:用 boardgame.io

决定:使用本仓库(boardgame.io)作为规则引擎,不自写 reducer。

理由

  • 已实现 reducer / phases / stages / plugins / MCTS / server
  • 自写要 1-2 个月boardgame.io 直接用
  • 测试覆盖率高、文档完善

D2. 桌面壳Tauri

决定:选择 Tauri不选 Electron 或仅 Web。

理由

  • 包小(~5MB vs Electron ~100MB
  • Rust 内核跨平台自编译友好macOS / Windows / Linux
  • 符合用户"reVC 式自编译分发"诉求

D3. 游戏定义格式:沿用 Game + 自定义 ui

决定:沿用 boardgame.io Game 定义 + 自定义 ui 字段,不另创 DSL。

理由

  • 50+ 示例游戏可直接复用
  • 降低学习成本
  • ui 字段是纯附加,不影响 reducer

D4. 项目位置:新建 sibling 仓库

决定:在 /home/e2hang/code/Projects/boardgame/tts-like/ 新建boardgame.io 作为 npm 依赖。

理由

  • 职责清晰
  • 可以独立发版
  • 不会污染上游

D5. 第一个游戏做 War

决定Phase 1.4 第一个游戏是 War规则极简、单一按钮

理由

  • 规则简单,专注验证引擎 + 按钮 + UI 链路
  • 与卡牌游戏惯例接近

D6. 资产格式SVG 内嵌优先

决定:卡牌图主要以 SVG 字符串inlinefallback DataURL/URL。

理由

  • 文件小(一张卡 1-5 KB
  • 可访问、可在 React 里直接渲染
  • 可版本控制

D7. zone 坐标系统:固定像素 + Board 视口缩放

决定zone 用绝对像素坐标Board 缩放由 viewport 缩放控制。

D8. 动画策略CSS transition 默认

决定:默认 CSS transition游戏可关闭或覆盖。

D9. 可访问性v1 仅鼠标

决定v1 不实现键盘/屏幕阅读器支持。v2 加键盘v3 加 a11y。

D10. 触屏/移动端v1 仅桌面

决定v1 假设桌面浏览器,触屏在 v2 单独评估。

D11. 多选 UI仅 Shift+click

决定v1 仅 Shift+click 多选,框选 v2。

D12. 牌堆数量显示:角标

决定pile 上显示数量徽章(角标),来源 G.zones[zoneId].length


2026-08-17

D13. War 拆 moveflip + collect

决定:把原 flip 拆成两个 moveflip(翻到 pile+ collect(收给赢家)。

理由

  • 单 move 让 pile 永远空,玩家看不到"对比"
  • 两 move 让 UX 清晰:先翻牌看,再确认收牌

D14. DragTest / War 都不设 zone owner

决定2 人游戏中所有 zone 公开owner 留空),双方都能看到。

理由

  • 卡牌游戏惯例:能看到对手牌堆的大小(虽然看不到牌面)
  • 之前设 owner 导致对方 zone 完全隐藏UX 不通

D15. War 加 turn: ActivePlayers.ALL

决定:在 War 加 turn: { activePlayers: ActivePlayers.ALL },让两个玩家能随时调 flip/collect。

理由

  • War 本身不分回合(双方都"现在"行动)
  • 不加 → SocketIO 拒绝 player 1 调 movedisallowed move: collect

D16. 联机优先走本地 LAN不上云

决定Phase 2 在本地局域网联机,不部署到云。

理由

  • 用户没给云服务器访问权限
  • 本地 LAN 验证机制本身已经足够
  • Docker 镜像保留为 v2 部署素材

D17. App.tsx 加 Mode 切换(本地 / 联机)

决定:在 web App 顶部加 "本地" / "联机" 切换按钮,联机模式下显示可配置 server URL/room/player/secret。

理由

  • 测试时本地模式最方便
  • 联机配置要持久化localStorage避免每次重输

D18. SocketIO URL 用 http:// 不是 ws://

决定SocketIO 客户端配置 server: 'http://localhost:8000',不是 ws://。 (已在 lessons-learned §23 记录)


2026-08-23

D19. 联机房间管理用 boardgame.io 内置 Lobby REST

决定:不做自定义 REST 端点,直接复用 boardgame.io Server 自带的 Lobby API/games/games/:name/create/games/:name/:id/join 等)。

理由

  • Server 已经在 server.run() 时自动 mount 这些路由
  • 客户端 SDK LobbyClientboardgame.io/client)已经封装好 fetch
  • 自加路由要管 CORS / 鉴权 / 协议,重复造轮子
  • 改一个 game 列表要改的地方最少

D20. dev 默认 origins 用 RegExp 列表,不用 '*'

决定parseOrigins() 默认返回 RegExp 列表(/^https?:\/\/localhost(:\d+)?$/ 等),不返回字符串 '*'

理由

  • boardgame.io 的 isOriginAllowed 把字符串 '*' 当字面量匹配lessons-learned §9.1
  • RegExp 才能表达"localhost 任意端口"
  • 显式 RegExp 列表比 '*' 安全

生产环境:必须用具体 origin 列表或精确 RegExp不能 '*'

D21. credentials 不再硬编码 p0/p1

决定:联机模式下 OnlineConfig.credentialsLobbyClient.joinMatch 返回的 playerCredentials 写入,不再硬编码。

理由

  • dev-log 08 的 OnlineConfigBar 还能让用户手动输入 p0/p1但那是 workaround
  • boardgame.io 的 credentials 是 server 颁发的随机 token默认 nanoid(11)),不能用固定值
  • 自动填充降低出错率
  • OnlineConfig.credentials 空时 <OnlineGameView> 显示"请先创建或加入房间"提示,不挂载 Client

D22. War shuffle 接受注入的 random 参数(测试用)

决定shuffle(arr) 改为 shuffle(arr, random = Math.random),调用方仍是 Math.random 默认。

理由

  • 单元测试要确定性(lastWinner 不要随机)
  • 注入 PRNG 比 monkey-patch Math.random 干净
  • boardgame.io 的 seed 参数只影响 random.D6() 等内置 PRNG不影响外部 Math.random

D23. War 平局时 collect 不清空 pile修 bug

决定collectlastWinner === 'tie' 时早 returnpile 不动(等下一轮 flip 触发"战争")。

理由

  • 老代码无条件清 pile但平局时牌没分配给任何玩家——2 张牌消失
  • dev-log 07 D13 描述的就是"平局 → pile 留着",但实现漏了
  • 单测 p0Deck.length + p1Deck.length 期望 50 实际 52 时抓到的 bug

2026-08-23LAN 部署后)

D24. dev 默认 origins 覆盖 LAN 网段

决定parseOrigins() 默认 RegExp 列表显式覆盖 192.168.x.x / 10.x.x.x / 172.16-31.x.x / 100.x.x.xTailscale

理由

  • dev-log 09 的 RegExp ^https?://localhost(:\d+)?$ 只匹配 hostname localhost,不匹配 LAN IP → 用户从其他机器访问时 fetch 全 404
  • boardgame.io 的 isOriginAllowed 函数把 RegExp 走 RegExp.test() 分支

生产:必须用 ALLOWED_ORIGINS 环境变量设具体 origin 列表(不要用 dev 默认)。

D25. LobbyRoomList joinRoom 成功后必须 refresh

决定joinRoom 在成功(+失败)后调 await refresh(),让本地 rooms state 与 server 同步。

理由

  • 不刷 → 本地 list 残留旧 players[].name → "加入 P1" 按钮还显示着 → 重复点击 → 409
  • createRoom 保持行为一致createRoom 早就 refresh

D26. War E2E 容忍 tie不强制 pile === 0

决定E2E 断言 expect(...).toHaveText(/^[01]$/) 而不是 '0'

理由

  • dev-log 09 §问题 4 修了 tie 行为collect 在 tie 时保留 pile 等下一轮
  • 服务端 Math.random() 偶发 tieE2E 用确定性断言会 flake
  • 容忍 tie + 不改生产 server 是最简单做法

2026-08-24

D27. 统一端口(/api 前缀)

决定:页面和 API 都走单一端口。dev 用 vite proxyprod 用 server static serve + /api mount。

理由

  • 不再需要两个端口LAN 只需放行一个
  • CORS 完全消失(同源请求)
  • 生产只需一个 node dist/server.js 进程
  • 见 dev-log 12

D28. 玩家命名系统

决定joinMatchplayerName,大厅显示名字,游戏内用 POST /api/games/:name/:id/update 改名,名字持久化到 localStorage。

理由

  • "P0/P1" 对玩家不友好
  • boardgame.io 自带 update 端点,改名成本低
  • 为未来需要显示名字的游戏(德州扑克)打基础

D29. 静态 serve 用原生 fs 不用 koa-send

决定:静态文件 serve 用 node:fs/promises 手写,不引入 koa-send

理由

  • pnpm add 被权限拦截
  • 需求简单SPA fallback + MIME 表40 行内搞定
  • 少一个依赖

2026-08-24Holdem 游戏落地)

D30. 引擎层引入 HoldemView 类型供 UI 复用 helper

决定:新增 export type HoldemView = Omit<HoldemState, 'deck' | 'hands'>,把只读 helperpids/host/seatedWithChips/nextSeat/canAct 等)签名从 HoldemState 放宽到 HoldemView

理由

  • UI 拿到的是 playerView 剥过 secret 的 ViewG,缺 deck/hands,无法直接传给 HOLDEM_HELPERS.host(G)
  • 强转 as HoldemState 绕过了类型安全,且掩盖了"该函数不该读 secret"的意图
  • 结构类型让 HoldemStateViewG 都自动满足 HoldemView,零运行时成本
  • 保密约束在编译期可见:读 hands/deck 的函数(buildPots/resolveHand)保持 HoldemState 不变

D31. 大厅支持可变人数2..9+ setupData 表单

决定LobbyRoomList 新增 maxSeats + setupOptions props创建表单按 maxSeats > 2 显示座位选择器,加入按钮从硬编码 P0/P1 改为遍历 m.players 动态渲染。onSelect 回调加 numPlayers 参数。

理由

  • 既有 2 人游戏War/Mill/DragTest和 Holdem2-9 人)共用大厅
  • boardgame.iocreateMatch 已支持 numPlayers + setupDataLobbyClient 只需透传
  • OnlineConfignumPlayersClient({ numPlayers }) 在切房间时正确重建

D32. Holdem 本地模式做 9 座切换而非固定 P0

决定HoldemLocalView 用下拉框切 P0..P8key={seat} 重建 Client 触发 playerView 重算,而非像 CardLocalView 固定 playerID="0"

理由

  • 德州扑克的 playerView 保密性是核心,单机调试要能切座位验证"别人看不到我的底牌"
  • key 变化触发 React 重建boardgame.io Client 重新订阅 playerID 的 view
  • 比依赖 debug 面板切 playerID 更直观

待定(暂未锁定)

  • 联机协议:暂定复用 boardgame.io master socket.io 协议(不加层)
  • 房间管理6 位短码 + 二维码分享
  • 资产存储:本地 + 远程 CDNv1 优先 DataURL
  • 游戏市场v3 再说