Files
huajishe-tts/docs/dev-log/09-room-list-ui-and-complete-tests.md
e2hang ecb5bf0e53 docs(dev-log): 09-room-list-ui-and-complete-tests + lessons §9 + decisions D19-D23
Captures the work in d46dc54:
- 09-…: 房间列表 UI 完整化、E2E/unit 覆盖、Dockerfile 修复
- lessons §9.1-9.8: CORS '*' 不工作、0.50.2 没有 /leaveSlot、setPhase
  重置 currentPlayer、War 平局清 pile bug、Dockerfile pnpm symlink、
  Playwright selector 匹配容器、hidden vs attached、测试 ID 稳定性
- decisions D19-D23: 复用 Lobby REST、RegExp origins、不再硬编码
  credentials、shuffle 注入 random、修平局 bug
2026-08-23 17:57:24 +08:00

9.3 KiB
Raw Permalink Blame History

09 · 联机房间列表 UI + 完整测试覆盖

做什么

用户要求把 tts-like 做成"多平台共同游玩的 boardgame 平台",但首要任务是把联机功能打磨到可验收的程度:

  1. 联机 UI 完整化:加"创建房间"按钮 + "列出活跃房间"下拉credentials 不再硬编码,由 server 颁发。
  2. 完整测试E2E + 单元 + Docker 修复)。
  3. 验收

明确不在范围Tauri 桌面壳P4、游戏热重载P3、房间短码 + 二维码分享、撤销/回放。

用户对话过程(上下文)

用户:"现在我需要你首先做出联机的功能并且完整测试,然后我需要验收"

我先问 3 个关键问题把范围锁住:

  • 测试范围 → 选 E2E + 单元 + Docker 修复(不是单一 E2E也不是只 E2E
  • 联机 UI 加什么 → 加房间列表 / 创建按钮(不是短码分享)
  • 多平台指什么 → Web 多设备 + 即将做的 Tauri 桌面端(不加 PWA 移动端)

然后用 plan mode 给出实施方案(写入了 ~/.claude/plans/dynamic-booping-wave.md),用户批准后按计划实施。

遇到什么问题

问题 1boardgame.io Lobby REST API 是内置的,不要重新造轮子

最初考虑自己写 server.app.get('/rooms')。调研 boardgame.io 源码后才发现 src/server/api.ts 已经实现了完整 Lobby REST

GET    /games                              ← 列游戏
POST   /games/:name/create                 ← 创建房间
GET    /games/:name?isGameover=false       ← 列活跃房间
GET    /games/:name/:id                    ← 单房间详情
POST   /games/:name/:id/join               ← 加入(自动分配 credentials
POST   /games/:name/:id/leave              ← 离开0.50.2 没有 /leaveSlot

直接用 LobbyClientboardgame.io/client)即可,不需要写 server 端代码。

修法:写一个 packages/engine/src/lobby-client.ts re-export LobbyClient + LobbyClientError + createLobbyClient(serverUrl) 工厂(自动 strip 尾斜杠)。

问题 2CORS '*' 不工作

E2E 一开始全部 404 → 抓到浏览器 console

Access to fetch at 'http://localhost:8000/games/War?isGameover=false'
from origin 'http://localhost:5173' has been blocked by CORS policy

原因boardgame.io 的 isOriginAllowed 函数把字符串 '*'字面量匹配(不是 wildcard所以任何 origin 都通不过。

// api.ts:670
} else if (typeof allowedOrigin === 'string') {
  return origin === allowedOrigin;  // '*' === 'http://localhost:5173' → false
}

修法:把 parseOrigins() 默认改成 RegExp 列表(/^https?:\/\/localhost(:\d+)?$/ 等),让 isOriginAllowed 走 RegExp 分支。

问题 3Mill 单测中 client.events.setPhase 把 currentPlayer 重置为 '1'

设当前是 player 0setPhase('moving')ctx.currentPlayer 变成 '1'。再调 c.moves.selectFrom(0) 被拒("disallowed move")。

调试脚本确认:

initial phase: moving
initial currentPlayer: 0
after setPhase moving: phase= moving currentPlayer= 1

修法:用 setup() 直接构造 moving 阶段的初始状态(piecesLeft: { '0': 0, '1': 0 } 会自动触发 placing.endIf 进入 moving且保留 currentPlayer='0'显式调 setPhase。

问题 4War 的 collect 在平局时把牌清空了

老代码:

collect: ({ G }) => {
  ...
  if (G.lastWinner === '0') G.p0Deck = [...G.p0Deck, ...won];
  else if (G.lastWinner === '1') G.p1Deck = [...G.p1Deck, ...won];
  // 平局pile 留着(注释说的)

  G.p0Pile = [];   // ← 实际无条件清空
  G.p1Pile = [];
  G.lastWinner = null;
};

平局时 pile 被清空但牌没分配给任何玩家——2 张牌凭空消失。dev-log 07 D13 描述的是"平局 → 留着,下一轮 flip 触发战争"。

修法:平局分支提早 returnpile 不动:

if (G.lastWinner === 'tie') {
  G.lastWinner = null;
  return;
}

问题 5服务端测试 /leaveSlot 返回 404

期望 200结果 404。查看 boardgame.io 的 installed 0.50.2 源码:

$ grep leave /…/boardgame.io/src/server/api.ts | head -3
POST /games/:name/:id/leave, koaBody()

0.50.2 没有 /leaveSlot——只有 deprecated 的 /leave。我之前读的是 main 分支(新版本)源码。

修法:测试用 /leave。但 clearPlayerSlot 的实现是:所有玩家都走了 → 整个 match 被 db.wipe(matchID)。所以 leaveSlot 测试需要先 join 两个玩家,让一个 leave另一个仍在。

问题 6Docker 镜像里 boardgame.io 找不到 dist

错误:ERR_UNSUPPORTED_DIR_IMPORT: Directory import '...boardgame.io/server'

原因pnpm 把 boardgame.io 装在 node_modules/.pnpm/boardgame.io@0.50.2/...,外层 node_modules/boardgame.io 是 symlink。Dockerfile 第 51-52 行:

COPY --from=builder /repo/node_modules/boardgame.io ./node_modules/boardgame.io

只复制 symlink不复制真正的 dist 文件。运行时 Node 解析 symlink 失败。

修法:在运行时阶段用 cp -rL 从 pnpm 隔离层物化:

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 deploy --prod 改为 --legacypnpm v10 默认 inject需要 fallback

问题 7Playwright E2E selector 误匹配 lobby-room-list 容器

[data-testid^="lobby-room-"] 会同时匹配 lobby-room-list(容器)和真正的房间 lobby-room-{matchID}。取 .first() 拿到的是容器,从容器属性提取 matchID 得到 "list",再去点 lobby-join-1-list 自然失败。

修法:用 :not([data-testid="lobby-room-list"]) 排除容器。

问题 8Playwright toBeVisible 把尺寸 0 的 absolute div 判为 hidden

<Zone> 是 absolute 定位 + width: auto,含子 <Card> 也 absolute → 父 div 计算尺寸为 0。Playwright 报 hidden 但元素确实存在。

修法:用 toBeAttached() 而不是 toBeVisible()

问题 9Zone 初始状态没渲染 count 徽章

代码 entities.length > 0 && <span>...</span> 让空 pile 不渲染徽章。E2E 测试 expect(getByTestId('zone-count-p0-pile')).toHaveText('0') 失败(元素不存在)。

修法:总是渲染徽章,空时 opacity 0.3。

怎么解决

1. 联机 UI 重构

App.tsx
├── mode: local | online
├── OnlineConfigBar          ← Server URL / Room / Player / Secret 输入(保留作 fallback
├── LobbyRoomList            ← 新组件:列出活跃房间 + 创建 + 加入
│   ├── lobby-create-room    ← 点 → POST /games/:name/create → POST /games/:name/:id/join
│   ├── lobby-refresh        ← 点 → GET /games/:name?isGameover=false
│   └── lobby-room-{matchID} ← 每个房间一行,含 lobby-join-0/1-{matchID}
└── OnlineGameView           ← config.matchID + config.credentials 非空才挂载 Client

credentials 不再硬编码 p0/p1,由 LobbyClient.joinMatch 返回的 playerCredentials 写入。

2. 测试分层

工具 文件 数量
引擎单测 vitest + Client({multiplayer: Local()}) packages/engine/src/games/*.test.ts 34
服务端单测 vitest + supertest + Server({...}).run(0) packages/server/src/server.test.ts 9
E2E Playwright + 真 server + 2 浏览器 context apps/web/e2e/*.spec.ts 3

3. Dockerfile 修复

# 旧
COPY --from=builder /prod/server ./
COPY --from=builder /repo/packages/server/dist ./dist
COPY --from=builder /repo/node_modules/boardgame.io ./node_modules/boardgame.io

# 新
COPY --from=builder /prod/server ./
COPY --from=builder /repo/packages/server/dist ./dist
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/

成果

  • Commit d46dc54 feat(multiplayer): room list UI + E2E/unit/Docker fixes
  • 23 文件改动,+1857/-123 行
  • 验证:
    pnpm ts           → 6/6 packages pass
    pnpm -r test      → engine 34/34 + server 9/9 pass
    pnpm test:e2e     → 3/3 specs pass (War + DragTest + Mill)
    

验证步骤(用户验收)

# 1. 一键完整 CI
pnpm verify

# 2. 手动联机验证
pnpm dev:server    # 终端 1
pnpm dev           # 终端 2
# 浏览器开 2 个 tablocalhost:5173 → 选 War → 联机
# 一边点"创建房间",另一边刷新列表后点"加入 P1"
# 一边翻牌 → 另一边看到牌 → 收牌 → 同步

# 3. Docker 镜像(需要本机有 Docker
docker compose build
docker compose up -d
curl http://localhost:8000/healthz   # → {"status":"ok",...}
curl http://localhost:8000/games     # → ["DragTest","NineMensMorris","War"]
docker compose down

关联