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

292 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 决策记录 · 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` 拆成两个 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'` 时早 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.x`Tailscale
**理由**
- 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. 玩家命名系统
**决定**`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-24Holdem 游戏落地)
### 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和 Holdem2-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 位短码 + 二维码分享
- 资产存储:本地 + 远程 CDNv1 优先 DataURL
- 游戏市场v3 再说