# 踩坑总结 · Lessons Learned 按现象分类。所有 26 条都已验证有效。 --- ## 1. 环境 / 工具 ### 1.1 pnpm 不在 PATH `which pnpm` 报 NOT FOUND,但 corepack 路径下能找到 shim。 **修法**:export PATH 或调用 `/home/e2hang/.nvm/versions/node/v22.22.2/lib/node_modules/corepack/shims/pnpm`。 **项目影响**:Phase 1.1 搭骨架。 ### 1.2 pnpm workspace exports 必须带通配符后缀 ```json "exports": { "./*": "./src/*.ts" // ✅ "./*": "./src/*" // ❌ 解析出来没扩展名 } ``` **项目影响**:所有跨包 import @tts-like/engine/games/war。 ### 1.3 Vite 项目需要 `vite-env.d.ts` 否则 `import.meta.env` 报错。 **项目影响**:apps/web/。 --- ## 2. boardgame.io 0.50.2 类型导出 ### 2.1 类型只在主 entry `Game` / `Ctx` / `State` 等类型只在 `boardgame.io`(legacy 入口)里有;子包 `/react` / `/server` / `/multiplayer` 只导出运行时值。 **修法**: ```ts import type { Game, Ctx } from 'boardgame.io'; // 类型从主 entry import { Client } from 'boardgame.io/react'; // 运行时从子包 ``` ### 2.2 move 是 Immer 风格 形参是 `({ G, ctx, events, playerID }, ...args)`,**直接 mutate G**,不要 return。 ### 2.3 `events` 不在 ctx 上 `events` 来自 `FnContext`(`DefaultPluginAPIs`),不是 `Ctx` 字段。 ```ts // ✅ moves: { foo: ({ G, ctx, events, playerID }, ...args) => { events.endTurn(); } } // ❌ moves: { foo: ({ G, ctx }, ...args) => { ctx.events.endTurn(); } } ``` ### 2.4 `playerID` 总是 `string | undefined` `Ctx.currentPlayer` 是 `string`,自己强转 `PlayerID = '0' | '1'`: ```ts const me = (playerID ?? '0') as PlayerID; ``` ### 2.5 React 18 + Client + StrictMode 双重挂载 `Client(...)` 用全局 store;StrictMode 双重挂载让 store 注册两次 → state 翻倍 + React duplicate key 警告。 **修法**: ```ts const LocalClient = useMemo(() => Client({...}), [game]); ``` 且 `main.tsx` 暂时移除 ``。 --- ## 3. 拖拽 ### 3.1 SVG 拦截 pointer events ``/`` 等会"吃掉"事件,dragstart 不会触发。 **修法**:把 SVG 包在 `
` 里: ```tsx dangerouslySetInnerHTML={{ __html: `
${svg}
`, }} ``` ### 3.2 HTML5 拖拽必须 preventDefault `dragover` 上必须 `e.preventDefault()` 才能让 `drop` 触发。 ### 3.3 自定义 MIME 携带数据 ```ts const DRAG_MIME = 'application/x-tts-cards'; e.dataTransfer.setData(DRAG_MIME, JSON.stringify({ cardIds, fromZoneId })); ``` ### 3.4 多选拖拽必须在 Zone 传 cardIds Zone 渲染 Card 时: ```tsx ``` Card 内部 `cardIds ?? [card.id]` 兜底。 --- ## 4. Immer + 棋类状态 ### 4.1 同 key 操作导致重复加 ```ts // ❌ 错误:fromKey === toZoneId 时会重复加 const fromArr = G[fromKey]; const toArr = G[toKey]; G[fromKey] = fromArr.filter(...); // G[fromKey] = G[toKey] G[toKey] = [...toArr, ...moved]; // toArr 是旧引用! // ✅ 修法 if (fromZoneId === toZoneId) return; ``` ### 4.2 noUncheckedIndexedAccess 兜底 TS 严格模式下 `arr[i]` 是 `T | undefined`: ```ts const cell: Cell = G.board[i] ?? null; ``` --- ## 5. 棋类 / 卡牌游戏 UX ### 5.1 卡牌游戏单 move 自动 collect 的 UX 问题 原本 `flip` 一次完成翻+收,pile 永远只显示 0 张。**修法**:拆成 `flip` + `collect` 两个 move,UI 两个按钮。 ### 5.2 卡牌游戏 visibility:双人都应该看见 zone 的 `owner` 默认隐藏其他玩家的私有 zone。2 人卡牌游戏所有 zone 应该是公开的(不设 owner)。 ### 5.3 discard vs pile 的 face-up 行为 - `` 面朝下 - `` 强制面朝上 War 里 deck 用 `pile`,战时 pile 用 `discard`。 ### 5.4 棋类不适合用通用 Zone/Card 九子棋 24 个交叉点写成 24 个 zone 会有大量 React 组件开销。**用一个大 SVG + 24 个 `` 反而更简单**。 ### 5.5 阶段转换用 boardgame.io phases ```ts phases: { placing: { start: true, next: 'moving', endIf: (G) => /* 条件 */ }, moving: { next: 'flying', endIf: (G, ctx) => /* 条件 */ }, flying: {}, } ``` phase 通过 `ctx.phase` 访问,move 内先检查防止跨阶段调用。 --- ## 6. Playwright 测试 ### 6.1 SVG 元素坐标 `circle.cx` / `circle.cy` 是 SVG 坐标系,可用来定位目标。`dispatchEvent('click')` 在 SVG 元素上工作正常。 ### 6.2 拖拽 timeout 多半是 hit test 如果 Card 内部 SVG 没包 `pointer-events:none`,Playwright `dragTo()` 会 timeout:"subtree intercepts pointer events"。 ### 6.3 切换玩家 boardgame.io debug 面板的 `.player` 按钮可点击切换当前玩家(用于绕过单 player 限制测试多人游戏)。 --- ## 7. 验证清单 每章都有一条: - `pnpm -r ts` — 6 个包全过 - `pnpm --filter @tts-like/web build` — vite build 成功 - `pnpm --filter @tts-like/web dev` — HTTP 200 - Playwright 端到端:点击 + 拖拽 + 状态验证 不通过其中任一不算完成。 --- ## 8. 联机 (SocketIO) ### 8.1 SocketIO URL 用 http:// 不是 ws:// ```ts // ✅ 正确 SocketIO({ server: 'http://localhost:8000' }) // ❌ 错误 SocketIO({ server: 'ws://localhost:8000' }) // 浏览器报 ERR_NAME_NOT_RESOLVED ``` SocketIO 内部自动升级到 WebSocket,不需要 `ws://` 前缀。 ### 8.2 boardgame.io 默认 turn 限制 boardgame.io 默认 `currentPlayer='0'`,SocketIO 传输层只允许 currentPlayer 调 move。如果游戏不分回合(双方都"现在"行动),必须显式: ```ts import { ActivePlayers } from 'boardgame.io/core'; turn: { activePlayers: ActivePlayers.ALL } ``` 否则 player 1 调 move 报 `disallowed move: `。 ### 8.3 ActivePlayers 类型 vs 值 `ActivePlayers` 同时是 type 和 value,**入口不同**: ```ts // ❌ 拿到 type alias import { ActivePlayers } from 'boardgame.io'; // ✅ 拿到 const 对象 import { ActivePlayers } from 'boardgame.io/core'; ``` `boardgame.io`(主入口)导出 type;`boardgame.io/core` 导出 const。 ### 8.4 Docker 镜像里 boardgame.io/server 路径导入报错 ESM 模式 Node 拒绝目录导入(即便 `package.json` 里有 `main` 字段)。`ERR_UNSUPPORTED_DIR_IMPORT: Directory import '...boardgame.io/server'`。 **v2 部署建议**:服务端在 Docker 里改 CJS(`"module": "CommonJS"`, `moduleResolution: "Node16"`),避免从 ESM 调 CJS 的目录导入问题。本地开发用 tsx 跑即可,不影响。 --- ## 9. 联机功能完善(dev-log 09) ### 9.1 boardgame.io 字符串 `'*'` 当 origin 不是通配 `Server({origins: ['*']})` 看着像允许所有 origin,实际上 boardgame.io 的 `isOriginAllowed` 把字符串 `'*'` 当**字面量**匹配(`origin === '*'`),任何 HTTP origin 都通不过,CORS 默默失败。 ```ts // boardgame.io/src/server/api.ts:670 } else if (typeof allowedOrigin === 'string') { return origin === allowedOrigin; // 'http://localhost:5173' !== '*' } ``` **修法**:dev 默认用 RegExp 列表: ```ts parseOrigins(): (string | RegExp)[] { if (!process.env.ALLOWED_ORIGINS) { return [ /^https?:\/\/localhost(:\d+)?$/, /^https?:\/\/127\.0\.0\.1(:\d+)?$/, 'tauri://localhost', 'http://tauri.localhost', ]; } return process.env.ALLOWED_ORIGINS.split(',').map(s => s.trim()).filter(Boolean); } ``` 生产环境必须用具体 origin 列表或 RegExp,不要写 `'*'`。 **项目影响**:dev-log 09 — E2E 一开始全 404,浏览器报 `No 'Access-Control-Allow-Origin' header`。 ### 9.2 boardgame.io 0.50.2 没有 `/leaveSlot` 端点 我读 main 分支源码看到 `/leaveSlot`(`api.ts:426-430`),但 installed `boardgame.io@0.50.2` 的 `api.ts` 只有 deprecated 的 `/leave`: ```ts // 实际安装版本 src/server/api.ts router.post('/games/:name/:id/leave', koaBody(), async (ctx) => {...}); ``` 主入口默认 checkout 可能是更高版本;要确认特定版本的能力,`grep -r '' /…/.pnpm/boardgame.io@/node_modules/boardgame.io/src/server/`。 **修法**:测试用 `/leave`。注意:`clearPlayerSlot` 在所有玩家都走完后会 `db.wipe(matchID)`,所以 leave 测试必须至少 join 两个玩家。 **项目影响**:dev-log 09 — 服务端 `/leaveSlot` 测试一开就 404。 ### 9.3 显式 `client.events.setPhase` 会重置 `ctx.currentPlayer` 调试脚本确认: ``` initial phase: moving ← 构造时 piecesLeft 全 0 → 自动进入 moving initial currentPlayer: 0 after setPhase moving: currentPlayer= 1 ← 但显式 setPhase 会改 currentPlayer ``` **后果**:单测里 setPhase('moving') 之后 `c.moves.selectFrom(0)` 被 boardgame.io 判为 `disallowed move: selectFrom`,因为 `me ('0') !== ctx.currentPlayer ('1')`。 **修法**:测试用 setup() 直接构造目标 phase 的初始状态(让 `phases.endIf` 自动触发),**不**显式调 setPhase。 **项目影响**:dev-log 09 — Mill 单测一开始 2 个失败就是这个。 ### 9.4 War 平局时 collect 把牌凭空清空 老代码在平局分支没 return,无条件 `G.p0Pile = []; G.p1Pile = []`,但 `won` 又没分配给任何玩家——2 张牌消失。dev-log 07 D13 描述的"平局 → pile 留着"没实现。 **修法**:平局分支提早 return: ```ts if (G.lastWinner === 'tie') { G.lastWinner = null; return; // ← 让 pile 留着 } ``` **项目影响**:dev-log 09 — War 单测 `p0Deck.length + p1Deck.length` 期望 50 实际 52(已修)。 ### 9.5 Dockerfile 用 pnpm 软链拿不到 boardgame.io dist `pnpm deploy --filter X --prod /prod/X` 把 `node_modules/boardgame.io` 留成 symlink 指向 `.pnpm/boardgame.io@/node_modules/boardgame.io`。Dockerfile 第 51 行 `COPY --from=builder /repo/node_modules/boardgame.io ./node_modules/boardgame.io` 只复制 symlink,运行时 Node 解析失败: ``` ERR_UNSUPPORTED_DIR_IMPORT: Directory import '...boardgame.io/server' ``` **修法**:运行时阶段用 `cp -rL` 物化 symlink: ```dockerfile RUN BGIO_SRC=$(find /prod/server/node_modules/.pnpm -maxdepth 4 \ -name 'boardgame.io' -type d | head -n1) && \ mkdir -p /app/node_modules/boardgame.io && \ cp -rL "$BGIO_SRC/." /app/node_modules/boardgame.io/ ``` 附带:pnpm v10 默认 `inject-workspace-packages=true`,deploy 会要求 inject;用 `--legacy` flag 退回 v9 行为。 **项目影响**:dev-log 09 — Dockerfile 修复。 ### 9.6 Playwright `[data-testid^="..."]` 选择器会匹配容器 `[data-testid^="lobby-room-"]` 同时匹配 `lobby-room-list`(容器)和真正的房间 `lobby-room-{matchID}`。`.first()` 拿到容器,提取的 matchID 是 `"list"`,点 `lobby-join-1-list` 自然失败。 **修法**:用 `:not([data-testid="lobby-room-list"])` 排除容器: ```ts page.locator('[data-testid^="lobby-room-"]:not([data-testid="lobby-room-list"])') ``` **项目影响**:dev-log 09 — DragTest / Mill E2E 一开始都因这个失败。 ### 9.7 Playwright `toBeVisible` 把尺寸 0 的 absolute div 判为 hidden `` 是 `position: absolute` + `width: auto`,子 `` 也 absolute → 父 div 计算尺寸 0。Playwright 报 hidden 但元素确实在 DOM 里、确实渲染了。 **修法**:用 `toBeAttached()` 验证存在性;用 `toHaveText()` 验证内容。 **项目影响**:dev-log 09 — War E2E zone check 失败,已改 `toBeAttached`。 ### 9.8 测试 ID 要随组件生命周期稳定 `Zone` 组件的计数徽章原本是 `entities.length > 0 && ...`——空 pile 时元素不存在。E2E 想 `expect(getByTestId('zone-count-p0-pile')).toHaveText('0')` 就失败(元素不存在)。 **修法**:让组件**总是**渲染测试钩子(计数徽章),空时设 opacity 0.3 或文本 0,方便 e2e 选择器稳定。 **项目影响**:dev-log 09 — 已改 Zone.tsx。 --- ## 10. LAN 部署调试(dev-log 10) ### 10.1 CORS RegExp 不会自动覆盖 LAN IP 09 那轮我把 `parseOrigins` 改成 RegExp,但只写了 `^https?://localhost(:\d+)?$` —— 只匹配 hostname `localhost`,不匹配 `192.168.x.x`、`10.x.x.x`、`172.16-31.x.x`、`100.x.x.x`(Tailscale/CGNAT)。 **修法**:在 dev 默认列表里显式覆盖常见 LAN 网段: ```ts /^http:\/\/192\.168\.\d{1,3}\.\d{1,3}(:\d+)?$/, /^http:\/\/10\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?$/, /^http:\/\/172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}(:\d+)?$/, /^http:\/\/100\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?$/, ``` **生产环境**必须用 `ALLOWED_ORIGINS` 环境变量设具体列表,不要用 dev 默认。 **项目影响**:dev-log 10 — 用户实测 LAN 访问时 fetch 失败、socket.io 一直 connecting。 ### 10.2 `joinRoom` 成功后必须 refresh,本地 state 残留导致 409 09 那轮 `createRoom` 调了 `await refresh()`,但 `joinRoom` 漏了。结果本地 `rooms` 状态里的 `players[].name` 还是旧值,"加入 P1" 按钮还显示着,重复点击 → server 409。 **修法**:join 成功 + 失败都 refresh(失败也刷,可能其他人刚占了 slot): ```ts try { const joinRes = await lobby.joinMatch(...); onSelect(matchID, joinRes.playerID, joinRes.playerCredentials); await refresh(); } catch (e) { setError(...); await refresh(); } ``` **项目影响**:dev-log 10 — 用户实测重复点加入 → 409。 ### 10.3 测试 server-side Math.random() 偶发 tie dev-log 09 修了 War 平局时 collect 保留 pile 的 bug(正确行为),但测试没考虑这个。服务端洗牌用 `Math.random()`,偶发平局 → 测试 `expect(...).toHaveText('0')` 失败。 **修法**:测试断言容忍 tie: ```ts // 之前 await expect(...).toHaveText('0'); // 现在 await expect(...).toHaveText(/^[01]$/); ``` 或者更彻底:在 server 测试里把 `Math.random` 注入成确定性 PRNG(dev-log 09 §问题 4 已经做了类似工作),但生产 server 不应改。E2E 层容忍 tie 是最简单做法。 **项目影响**:dev-log 10 — War E2E 偶发 flake。 ### 10.4 server 端状态用 InMemory DB,重启会丢所有房间 Playwright 跑多个 spec 时,第一个 spec 留的房间会污染第二个。两种修法: 1. 每个 spec 之前 HTTP DELETE 清空(server 没暴露这个端点,要加) 2. 测试容忍旧房间(用 `[data-testid^="lobby-join-1-"]` 而不是 `.first()`) 10.2 的修法顺便覆盖了这个:选"含加入按钮"的房间,自然跳过满员房间。 **项目影响**:dev-log 10 — E2E 多 spec 顺序运行更稳。 ### 10.5 vite dev 默认只监听 localhost,LAN 不可达 启动 vite 要加 `--host 0.0.0.0`: ```bash pnpm dev --host 0.0.0.0 ``` 或者在 `vite.config.ts` 里 `server: { host: '0.0.0.0', port: 5173 }`。 **项目影响**:dev-log 10 — LAN 机器访问 `http://192.168.5.11:5173/` 前必须确认 vite 监听 0.0.0.0。 --- ## 11. 统一端口 + 命名系统(dev-log 12) ### 11.1 koa-send 装包被拒 → 用原生 fs 实现静态 serve 想用 `koa-send` serve 静态文件,但 `pnpm add` 被权限拦截。**修法**:用 `node:fs/promises` 的 `readFile` + `stat` 手写(约 40 行),带 MIME 表 + SPA fallback,不需要第三方包。 ### 11.2 boardgame.io `LobbyClient.request` 是 TS private,无法继承 `LobbyClient` 的 `request` 方法在 TS 里标 `private`,子类无法 override 加前缀。**修法**:放弃继承,直接写 fetch 包装类,行为兼容但请求路径自动加 `/api` 前缀。 ### 11.3 Koa `ctx.URL` 是只读 getter 临时改 path 时报 `Cannot set property URL ... only a getter`。**修法**:只改 `ctx.path`(可写),改完 `finally` 恢复。 ### 11.4 服务端测试 import index.ts 会触发 `server.run(8000)` `import { mountApiPrefix } from './index.js'` 导致 index.ts 整个执行,底部 `server.run(8000)` 与已运行的 server 冲突(EADDRINUSE)。**修法**:把 `mountApiPrefix` 抽到独立 `mount-api-prefix.ts`。 ### 11.5 vite proxy rewrite 和 server static serve 冲突 vite proxy 把 `/api` rewrite 成 `/games` 转发,但 server 端 static serve 拦截了 `/games`(返回 index.html),导致 `/api/games` 返回 HTML。**修法**:vite proxy 不 rewrite,保留 `/api` 转发到 server,由 server 的 `mountApiPrefix` 剥前缀。 ### 11.6 `pnpm dev` 在错误目录执行 `cd` 到 `packages/server` 后跑 `pnpm dev` 启动的是 server(tsx watch),不是 web 的 vite。**教训**:跑命令前明确 `cd apps/web`,或加 `--filter`。 ### 11.7 统一端口后 CORS 完全消失 同源请求(`fetch('/api/games')`)不需要 CORS,之前 dev-log 10 的 CORS 配置在 dev 模式下变成冗余。生产环境如果前后端不同源仍需要。 --- ## 12. Holdem 游戏落地(dev-log 13) ### 12.1 机器重启导致写入文件末尾出现 NULL 字节 写 `HoldemBoard.tsx` 时机器重启,文件末尾被填充 128 字节 `0x00`(`wc -c` = 16161,末 128 字节全 NULL)。TypeScript 报 `TS1127: Invalid character` 在第 427 行 128 个字符上。 **判定**:`xxd` 看到末尾全是 `0000 0000 ...`;`data.rstrip(b'\x00')` 后实际内容到第 426 行 `};` 完整结束。 **教训**:跨会话恢复时,TypeScript 报 "Invalid character" 而该行看着是空行 / 空格 → 先 `xxd` 查 NULL 字节,不要怀疑编码。 ### 12.2 playerView 剥 secret 后 UI 无法复用 helper UI 的 `ViewG = Omit & { myHand }` 无法传给签名是 `G: HoldemState` 的 `HOLDEM_HELPERS.host(G)`(缺 `deck`/`hands`)。 **修法**:引擎层加 `HoldemView = Omit`,只读 helper 放宽签名。结构类型让 `HoldemState` 和 `ViewG` 都满足,无需 `as` 强转。读 secret 的函数(`buildPots`/`resolveHand`)保持 `HoldemState` 不变。 **教训**:boardgame.io 的 playerView 把 secret 字段剥掉后,UI 的 G 类型是 engine state 的子集。设计 helper 时按"只读哪些字段"给签名,而不是全量 `HoldemState`,否则 UI 要么强转要么复制函数。 ### 12.3 新 shell 没加载 nvm → pnpm 找不到 重启后新 shell `which pnpm` 报 NOT FOUND(nvm 没加载,corepack shim 没启用)。 **修法**:`export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"; corepack enable`。之后 pnpm 在 PATH 里。见 §1.1。 ### 12.4 heads-up 先行动者是 SB 而非按钮位 实现 `firstToAct = nextSeat(BB)` 后,2 人房里 button=P0 → SB=P1 → BB=P0 → firstToAct=nextSeat(P0)=P1。所以 preflop P1(SB)先动。符合标准 heads-up 规则(SB 先动 preflop,BB 先动 flop 及以后)。 **教训**:boardgame.io 的 `turn.order` 默认从 `ctx.currentPlayer` 推进;自己在 `startHandSetup` 里设 `firstToAct` 并配合 `turn.order.first` 才能精确控制 heads-up 的特殊行动顺序。