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

19 KiB
Raw Permalink Blame History

踩坑总结 · 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.iolegacy 入口)里有;子包 /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 来自 FnContextDefaultPluginAPIs),不是 Ctx 字段。

// ✅
moves: { foo: ({ G, ctx, events, playerID }, ...args) => { events.endTurn(); } }
// ❌
moves: { foo: ({ G, ctx }, ...args) => { ctx.events.endTurn(); } }

2.4 playerID 总是 string | undefined

Ctx.currentPlayerstring,自己强转 PlayerID = '0' | '1'

const me = (playerID ?? '0') as PlayerID;

2.5 React 18 + Client + StrictMode 双重挂载

Client(...) 用全局 storeStrictMode 双重挂载让 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 两个 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

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:nonePlaywright 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(主入口)导出 typeboardgame.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 分支源码看到 /leaveSlotapi.ts:426-430),但 installed boardgame.io@0.50.2api.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/Xnode_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=truedeploy 会要求 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.x10.x.x.x172.16-31.x.x100.x.x.xTailscale/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 注入成确定性 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

pnpm dev --host 0.0.0.0

或者在 vite.config.tsserver: { 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/promisesreadFile + stat 手写(约 40 行),带 MIME 表 + SPA fallback不需要第三方包。

11.2 boardgame.io LobbyClient.request 是 TS private无法继承

LobbyClientrequest 方法在 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 在错误目录执行

cdpackages/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 字节 0x00wc -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: HoldemStateHOLDEM_HELPERS.host(G)(缺 deck/hands)。

修法:引擎层加 HoldemView = Omit<HoldemState,'deck'|'hands'>,只读 helper 放宽签名。结构类型让 HoldemStateViewG 都满足,无需 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 的特殊行动顺序。