引擎(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
19 KiB
踩坑总结 · 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 必须带通配符后缀
"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 只导出运行时值。
修法:
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 字段。
// ✅
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':
const me = (playerID ?? '0') as PlayerID;
2.5 React 18 + Client + StrictMode 双重挂载
Client(...) 用全局 store;StrictMode 双重挂载让 store 注册两次 → state 翻倍 + React duplicate key 警告。
修法:
const LocalClient = useMemo(() => Client({...}), [game]);
且 main.tsx 暂时移除 <StrictMode>。
3. 拖拽
3.1 SVG 拦截 pointer events
<rect>/<text> 等会"吃掉"事件,dragstart 不会触发。
修法:把 SVG 包在 <div style="pointer-events:none"> 里:
dangerouslySetInnerHTML={{
__html: `<div style="pointer-events:none;width:100%;height:100%;">${svg}</div>`,
}}
3.2 HTML5 拖拽必须 preventDefault
dragover 上必须 e.preventDefault() 才能让 drop 触发。
3.3 自定义 MIME 携带数据
const DRAG_MIME = 'application/x-tts-cards';
e.dataTransfer.setData(DRAG_MIME, JSON.stringify({ cardIds, fromZoneId }));
3.4 多选拖拽必须在 Zone 传 cardIds
Zone 渲染 Card 时:
<Card
cardIds={selected ? Array.from(selectedIds) : undefined}
onDragStart={...}
/>
Card 内部 cardIds ?? [card.id] 兜底。
4. Immer + 棋类状态
4.1 同 key 操作导致重复加
// ❌ 错误: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:
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 行为
<Zone type="pile" faceDown>面朝下<Zone type="discard">强制面朝上
War 里 deck 用 pile,战时 pile 用 discard。
5.4 棋类不适合用通用 Zone/Card
九子棋 24 个交叉点写成 24 个 zone 会有大量 React 组件开销。用一个大 SVG + 24 个 <g> 反而更简单。
5.5 阶段转换用 boardgame.io phases
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://
// ✅ 正确
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。如果游戏不分回合(双方都"现在"行动),必须显式:
import { ActivePlayers } from 'boardgame.io/core';
turn: { activePlayers: ActivePlayers.ALL }
否则 player 1 调 move 报 disallowed move: <name>。
8.3 ActivePlayers 类型 vs 值
ActivePlayers 同时是 type 和 value,入口不同:
// ❌ 拿到 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 默默失败。
// boardgame.io/src/server/api.ts:670
} else if (typeof allowedOrigin === 'string') {
return origin === allowedOrigin; // 'http://localhost:5173' !== '*'
}
修法:dev 默认用 RegExp 列表:
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:
// 实际安装版本 src/server/api.ts
router.post('/games/:name/:id/leave', koaBody(), async (ctx) => {...});
主入口默认 checkout 可能是更高版本;要确认特定版本的能力,grep -r '<endpoint>' /…/.pnpm/boardgame.io@<version>/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:
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@<v>/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:
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"]) 排除容器:
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
<Zone> 是 position: absolute + width: auto,子 <Card> 也 absolute → 父 div 计算尺寸 0。Playwright 报 hidden 但元素确实在 DOM 里、确实渲染了。
修法:用 toBeAttached() 验证存在性;用 toHaveText() 验证内容。
项目影响:dev-log 09 — War E2E zone check 失败,已改 toBeAttached。
9.8 测试 ID 要随组件生命周期稳定
Zone 组件的计数徽章原本是 entities.length > 0 && <span>...</span>——空 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 网段:
/^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):
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:
// 之前
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 留的房间会污染第二个。两种修法:
- 每个 spec 之前 HTTP DELETE 清空(server 没暴露这个端点,要加)
- 测试容忍旧房间(用
[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:
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<HoldemState,'deck'|'hands'> & { myHand } 无法传给签名是 G: HoldemState 的 HOLDEM_HELPERS.host(G)(缺 deck/hands)。
修法:引擎层加 HoldemView = Omit<HoldemState,'deck'|'hands'>,只读 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 的特殊行动顺序。