引擎(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
10 KiB
决策记录 · 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 字符串(inline),fallback 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 拆 move:flip + collect
决定:把原 flip 拆成两个 move:flip(翻到 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 调 move(
disallowed 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
LobbyClient(boardgame.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.credentials 由 LobbyClient.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)
决定:collect 在 lastWinner === 'tie' 时早 return,pile 不动(等下一轮 flip 触发"战争")。
理由:
- 老代码无条件清 pile,但平局时牌没分配给任何玩家——2 张牌消失
- dev-log 07 D13 描述的就是"平局 → pile 留着",但实现漏了
- 单测
p0Deck.length + p1Deck.length期望 50 实际 52 时抓到的 bug
2026-08-23(LAN 部署后)
D24. dev 默认 origins 覆盖 LAN 网段
决定:parseOrigins() 默认 RegExp 列表显式覆盖 192.168.x.x / 10.x.x.x / 172.16-31.x.x / 100.x.x.x(Tailscale)。
理由:
- dev-log 09 的 RegExp
^https?://localhost(:\d+)?$只匹配 hostnamelocalhost,不匹配 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()偶发 tie,E2E 用确定性断言会 flake - 容忍 tie + 不改生产 server 是最简单做法
2026-08-24
D27. 统一端口(/api 前缀)
决定:页面和 API 都走单一端口。dev 用 vite proxy,prod 用 server static serve + /api mount。
理由:
- 不再需要两个端口,LAN 只需放行一个
- CORS 完全消失(同源请求)
- 生产只需一个
node dist/server.js进程 - 见 dev-log 12
D28. 玩家命名系统
决定:joinMatch 传 playerName,大厅显示名字,游戏内用 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-24(Holdem 游戏落地)
D30. 引擎层引入 HoldemView 类型供 UI 复用 helper
决定:新增 export type HoldemView = Omit<HoldemState, 'deck' | 'hands'>,把只读 helper(pids/host/seatedWithChips/nextSeat/canAct 等)签名从 HoldemState 放宽到 HoldemView。
理由:
- UI 拿到的是 playerView 剥过 secret 的
ViewG,缺deck/hands,无法直接传给HOLDEM_HELPERS.host(G) - 强转
as HoldemState绕过了类型安全,且掩盖了"该函数不该读 secret"的意图 - 结构类型让
HoldemState和ViewG都自动满足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)和 Holdem(2-9 人)共用大厅
boardgame.io的createMatch已支持numPlayers+setupData,LobbyClient 只需透传OnlineConfig加numPlayers让Client({ numPlayers })在切房间时正确重建
D32. Holdem 本地模式做 9 座切换而非固定 P0
决定:HoldemLocalView 用下拉框切 P0..P8,key={seat} 重建 Client 触发 playerView 重算,而非像 CardLocalView 固定 playerID="0"。
理由:
- 德州扑克的 playerView 保密性是核心,单机调试要能切座位验证"别人看不到我的底牌"
key变化触发 React 重建,boardgame.io Client 重新订阅 playerID 的 view- 比依赖 debug 面板切 playerID 更直观
待定(暂未锁定)
- 联机协议:暂定复用 boardgame.io master socket.io 协议(不加层)
- 房间管理:6 位短码 + 二维码分享
- 资产存储:本地 + 远程 CDN(v1 优先 DataURL)
- 游戏市场:v3 再说