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
9.3 KiB
09 · 联机房间列表 UI + 完整测试覆盖
做什么
用户要求把 tts-like 做成"多平台共同游玩的 boardgame 平台",但首要任务是把联机功能打磨到可验收的程度:
- 联机 UI 完整化:加"创建房间"按钮 + "列出活跃房间"下拉;credentials 不再硬编码,由 server 颁发。
- 完整测试(E2E + 单元 + Docker 修复)。
- 验收。
明确不在范围: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 都通不过。
// 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 在平局时把牌清空了
老代码:
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 不动:
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,另一个仍在。
问题 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 行:
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 改为 --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
<Zone> 是 absolute 定位 + width: auto,含子 <Card> 也 absolute → 父 div 计算尺寸为 0。Playwright 报 hidden 但元素确实存在。
修法:用 toBeAttached() 而不是 toBeVisible()。
问题 9:Zone 初始状态没渲染 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 个 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 — 上一次的 SocketIO 联机骨架
- lessons-learned.md §27-30 — 本轮的踩坑(CORS / setPhase / War 平局 / Dockerfile)
- decisions.md D19-D22 — 本轮锁定的新决策