Files
huajishe-tts/docs/dev-log/lessons-learned.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

516 lines
19 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.
# 踩坑总结 · 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(...)` 用全局 storeStrictMode 双重挂载让 store 注册两次 → state 翻倍 + React duplicate key 警告。
**修法**
```ts
const LocalClient = useMemo(() => Client({...}), [game]);
```
`main.tsx` 暂时移除 `<StrictMode>`
---
## 3. 拖拽
### 3.1 SVG 拦截 pointer events
`<rect>`/`<text>` 等会"吃掉"事件dragstart 不会触发。
**修法**:把 SVG 包在 `<div style="pointer-events:none">` 里:
```tsx
dangerouslySetInnerHTML={{
__html: `<div style="pointer-events:none;width:100%;height:100%;">${svg}</div>`,
}}
```
### 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={selected ? Array.from(selectedIds) : undefined}
onDragStart={...}
/>
```
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` 两个 moveUI 两个按钮。
### 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
```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: <name>`
### 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 '<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
```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@<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
```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
`<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 网段:
```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` 注入成确定性 PRNGdev-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 默认只监听 localhostLAN 不可达
启动 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` 启动的是 servertsx 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 FOUNDnvm 没加载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 P1SB先动。符合标准 heads-up 规则SB 先动 preflopBB 先动 flop 及以后)。
**教训**boardgame.io 的 `turn.order` 默认从 `ctx.currentPlayer` 推进;自己在 `startHandSetup` 里设 `firstToAct` 并配合 `turn.order.first` 才能精确控制 heads-up 的特殊行动顺序。