# 决策记录 · 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` 空时 `` 显示"请先创建或加入房间"提示,不挂载 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`,把只读 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 再说