# 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`),用户批准后按计划实施。 ## 遇到什么问题 ### 问题 1:boardgame.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) ``` 直接用 `LobbyClient`(`boardgame.io/client`)即可,不需要写 server 端代码。 **修法**:写一个 `packages/engine/src/lobby-client.ts` re-export `LobbyClient` + `LobbyClientError` + `createLobbyClient(serverUrl)` 工厂(自动 strip 尾斜杠)。 ### 问题 2:CORS '*' 不工作 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 都通不过。 ```ts // api.ts:670 } else if (typeof allowedOrigin === 'string') { return origin === allowedOrigin; // '*' === 'http://localhost:5173' → false } ``` **修法**:把 `parseOrigins()` 默认改成 RegExp 列表(`/^https?:\/\/localhost(:\d+)?$/` 等),让 `isOriginAllowed` 走 RegExp 分支。 ### 问题 3:Mill 单测中 `client.events.setPhase` 把 currentPlayer 重置为 '1' 设当前是 player 0,调 `setPhase('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。 ### 问题 4:War 的 collect 在平局时把牌清空了 老代码: ```ts 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 触发战争"。 **修法**:平局分支提早 `return`,pile 不动: ```ts if (G.lastWinner === 'tie') { G.lastWinner = null; return; } ``` ### 问题 5:服务端测试 `/leaveSlot` 返回 404 期望 200,结果 404。查看 boardgame.io 的 installed 0.50.2 源码: ```bash $ 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,另一个仍在。 ### 问题 6:Docker 镜像里 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 行: ```dockerfile COPY --from=builder /repo/node_modules/boardgame.io ./node_modules/boardgame.io ``` 只复制 symlink,不复制真正的 dist 文件。运行时 Node 解析 symlink 失败。 **修法**:在运行时阶段用 `cp -rL` 从 pnpm 隔离层物化: ```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 deploy --prod` 改为 `--legacy`(pnpm v10 默认 inject,需要 fallback)。 ### 问题 7:Playwright 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"])` 排除容器。 ### 问题 8:Playwright `toBeVisible` 把尺寸 0 的 absolute div 判为 hidden `` 是 absolute 定位 + `width: auto`,含子 `` 也 absolute → 父 div 计算尺寸为 0。Playwright 报 hidden 但元素确实存在。 **修法**:用 `toBeAttached()` 而不是 `toBeVisible()`。 ### 问题 9:Zone 初始状态没渲染 count 徽章 代码 `entities.length > 0 && ...` 让空 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 修复 ```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) ``` ## 验证步骤(用户验收) ```bash # 1. 一键完整 CI pnpm verify # 2. 手动联机验证 pnpm dev:server # 终端 1 pnpm dev # 终端 2 # 浏览器开 2 个 tab:localhost: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 ``` ## 关联 - [08-local-lan-multiplayer.md](./08-local-lan-multiplayer.md) — 上一次的 SocketIO 联机骨架 - [lessons-learned.md §27-30](./lessons-learned.md) — 本轮的踩坑(CORS / setPhase / War 平局 / Dockerfile) - [decisions.md D19-D22](./decisions.md) — 本轮锁定的新决策