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

233 lines
9.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```
直接用 `LobbyClient``boardgame.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 都通不过。
```ts
// 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 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。
### 问题 4War 的 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另一个仍在。
### 问题 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 行:
```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
### 问题 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 修复
```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 个 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
```
## 关联
- [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) — 本轮锁定的新决策