引擎(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
292 lines
10 KiB
Markdown
292 lines
10 KiB
Markdown
# 决策记录 · 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+)?$` 只匹配 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()` 偶发 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 再说
|