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
233 lines
9.3 KiB
Markdown
233 lines
9.3 KiB
Markdown
# 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
|
||
|
||
`<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 修复
|
||
|
||
```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) — 本轮锁定的新决策 |