Files
huajishe-tts/docs/double-up/01-e2e-test.md
e2hang ddcb28472c feat(double-up): 双升游戏 + 单端口 start.sh 部署 + 文档统一更新
- 新增双升(DoubleUp)完整实现:引擎 double-up-core.ts / double-up.ts、牌桌 UI DoubleUpBoard.tsx、单测与 E2E
- 甩牌跟牌修复:validateFollow 新增 mixed 分支,甩牌后其他家可正常跟单张同花色牌
- start.sh:PORT=5173 单端口一键启动(Koa 同时 serve 页面+/api+/socket.io),浏览器直连无需 Vite 代理
- 玩家名 server 权威化(join 时写入 G.playerNames);LobbyRoomList 支持 fixedSeats(双升固定 4 人)
- 文档:新增 dev-log/14、更新 dev-log README 索引与进度、更新根 README 启动/部署说明
- .gitignore 忽略根目录 data/*.db 运行时 SQLite
2026-08-30 02:11:04 +08:00

150 lines
11 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.
# 双升DoubleUp联机 E2E 测试文档
> 配套文件:`apps/web/e2e/double-up-full-game.spec.ts`
> 目标:用 **Playwright 驱动真实 UI + 真实 serverboardgame.io relay+ 真实 engine**,验证 4 人双升「出牌 / 打牌(一墩结算)/ 得分」在全程联机环境下正确。
> 状态:✅ 已通过1 passed~11s
---
## 0. 为什么需要这个测试
双升引擎 `packages/engine/src/games/double-up.ts` 规则多、状态机长waiting → deal → trump → bottom → play → score且依赖 boardgame.io 的 `playerView` 剥密、`activePlayers` 权限、服务端 auth 网关。仅靠 `*.test.ts` 单测能验证**纯逻辑**,但验证不了:
- 联机 4 端是否都能正确收到自己视角的 `myHand`(他家手牌剥空);
- 出牌按钮的「轮到谁」是否与 `G.currentPlayer` 一致UI 不依赖 boardgame.io 默认轮转);
- 25 墩真实跑完后能自然进入 score 阶段并正确结算;
- 计分 UIsidebar渲染的数值与引擎一致。
此 E2E 把这些「端到端」环节一次性跑通。
---
## 1. 测试设计
### 1.1 参与者
4 个独立浏览器上下文context每个模拟一个座位P0/P1/P2/P3各自注册独立账号并写入 `localStorage` 登录态,以便通过 server 的登录网关创建/加入房间。
### 1.2 流程
1. **P0 创建房间**(需登录)→ 进入 online 牌桌。
2. **取 matchID**:从 P1 视角的可加入按钮 `lobby-join-1-<matchID>` 解析出房间 ID房主对自己不渲染 join 按钮)。
3. **P1/P2/P3 依次加入**
4. **P0 点「开始游戏」**waiting → deal引擎进入 `deal` 阶段,由**房主端 UI 自动驱动 `dealOne`** 以 ~150ms 一张把 100 张逐张发给 4 家、每张即时 `sortHand` 排序,发满 100 张(每家 25剩 8 张作底牌 → 自动转 trump
5. **主循环驱动整局**
- `deal` 阶段:亮主(`du-declare-single`/`du-declare-pair`)会把牌移到该玩家座位前的**亮主公开区**`du-reveal-${pid}`,所有人可见),反主时自动归还被反者;本测试**不主动亮主**,发满 100 张由引擎**自动定主**(翻底牌第一张兜底,`resolveTrump`),亮主区牌归还各自主人,并据轮庄默认/最高亮主者定庄家,直接进入 `bottom`
- `bottom` 阶段:仅庄家页面可见「确认扣底」(`du-set-bottom`);庄家手牌此时为 33 张(已收 8 底牌),选前 8 张 → `du-set-bottom`,这 8 张仅庄家可见。
- `play` 阶段:用 `du-seat-${pid}``data-actor="true"`(由 `G.currentPlayer` 决定)判断轮到谁;轮到时**自由多选**手牌UI 实时提示牌型(`du-selection-hint`:单张/对子/拖拉机/甩牌 + 合法性),「出牌」(`du-play`)仅在合法时点亮,点击即出。
- 本测试默认只点**第一张 `data-legal="true"` 的单张**以稳定跑完 25 墩(单张永远合法);对子/拖拉机路径由引擎单测(`parsePlay`/`validateFollow` + 首出对子跟对子集成测试)覆盖。
6. 检测到进入 `score` 阶段即停。
### 1.3 关键选择器
| 选择器 | 含义 |
| --- | --- |
| `du-board` | 牌桌根节点 |
| `du-info` | 顶部信息条(局/级数/主花色/墩数/阶段:发牌中/扣底/出牌/结算/等待) |
| `du-seat-${pid}` `data-actor` | 轮到该玩家出牌时为 `true`**引擎 `G.currentPlayer` 驱动,非 boardgame.io 默认轮转** |
| `du-card-${id}` `data-legal` | 该手牌当前是否可出(跟牌约束由 UI 高亮) |
| `du-play` | 出牌按钮(仅当多选牌型合法时点亮) |
| `du-selection-hint` | 出牌前实时提示:已选 N 张 · 牌型(单张/对子/拖拉机/甩牌)[+ 不合法原因] |
| `du-reveal-${pid}` | 该玩家座位前的「亮主公开区」(亮出的牌,所有人可见,定主后归还) |
| `du-declare-single` / `du-declare-pair` | 亮主按钮(单级牌 / 对级牌),发牌阶段内可随时亮 |
| `du-trick-${pid}` / `du-trick-win-${pid}` | 按座位分区展示的「本墩出牌区」/ 本墩赢家高亮(金色边框 + 「赢」徽标) |
| `du-set-bottom` | 庄家确认扣底(仅庄家在 bottom 阶段可见) |
| `du-seat-${pid}` | 座位框;含真实玩家名(联机来自 `ctx.players.username`,本地回退 `玩家N`+ 本人标记「★你坐这里」(金色边框) |
| `du-sidebar` | 右侧计分板(含 4 家真实名 + 本局分 `+N`、本局扣底牌、本局闲家得分) |
| `lobby-seats-fixed` | 双升创建房间时显示「固定 4 人」,不出现人数下拉 |
---
## 2. 验证点(断言)
| # | 断言 | 说明 |
| --- | --- | --- |
| 1 | 全程无 `console.error` / `pageerror` | 4 个页面各自收集错误,必须为空 |
| 2 | 进入 `score` 阶段 | 25 墩后自然结束并结算 |
| 3 | `墩 25/25` | 本局打满 25 墩 |
| 4 | 结算后手牌区无 `du-card-*` | 牌已全部出完 |
| 5 | sidebar 显示「本局扣底牌」 | 扣底在结算后公开 |
| 6 | `sum(lastRoundScores) + 底牌分值 === 200` | **deck 总分不变量**(见 §3`lastRoundScores``du-lastscore-*` 精确 span避免误匹配队伍标签 |
| 7 | sidebar 显示「本局闲家得分 X」 | 升级信息正确生成 |
| 8 | 出现过 `du-trick-*` 区 | 出牌按座位分区展示Request A |
| 9 | 出现过 `du-trick-win-*` | 每墩赢家高亮(金色边框 + 「赢」) |
---
## 3. 得分不变量(核心正确性判据)
两副牌共 108 张,分值牌 5/10/K 合计 **200 分**。发牌时 100 张分给 4 人、8 张留作底牌。
引擎在 `scoreRound` 中:
- `lastRoundScores[px] = sumScores(G.taken[px])` —— 该家在 tricks 中吃到的分值;
- 底牌 8 张**不进入任何 `taken`**(结算前归庄家/闲家,但不经 tricks其分值在 `idleScore` 里按抠底倍率另算。
因此 **UI 可见的「本局分之和」不等于 200**——它只统计了打进 tricks 的牌。正确不变量是:
```
Σ(lastRoundScores) + 底牌(8张)分值 = 200
```
> 早期版本曾错误地断言 `Σ(lastRoundScores) === 200`,在某一局底牌含 10 分时就得到 190 而误报失败。修正为「taken + 底牌 = 200」后稳定通过。本测试中观察到的真实一局`本局闲家得分 110`taken 合计 190 + 底牌 10 = 200。
`sumBottomPoints` 从 sidebar「本局扣底牌」区域解析每张牌标签`5♠`/`10♥`/`K♦`/`大王`/`小王`)按 `cardScore` 规则5→510/K→10余→0王→0累加与引擎口径一致。
---
## 4. 如何运行
前置server `:8000` 与 web `:5173` 已在运行(`./run.sh` 或分启两个进程。Playwright 配置 `reuseExistingServer`,不自动起服务。
```sh
# 安装浏览器(首次)
npx playwright install
# 仅跑双升整局 E2E
pnpm --filter @tts-like/web exec playwright test e2e/double-up-full-game.spec.ts
# 或仓库根目录等价写法
cd apps/web && ./node_modules/.bin/playwright test e2e/double-up-full-game.spec.ts --reporter=list
```
预期输出:
```
Running 1 test using 1 worker
✓ ...DoubleUp — 4-player full game E2E create → 4 join → deal → confirm trump → declarer setBottom → 25 tricks → score; verify play/follow/score (10.xs)
1 passed (11.xs)
```
---
## 5. 已知结论与遗留点
### 5.1 已验证正确
- 4 端联机、各端 `playerView` 剥密正常(他家手牌为空,本人手牌可见)。
- **双升固定 4 人**:创建房间时不出现人数下拉,显示「固定 4 人」(`lobby-seats-fixed``numPlayers` 强制为 4仅对 double-up 生效,其他游戏仍可选人数)。
- **新发牌/定主/扣底流程**`startGame` 进入 `deal` 阶段,由房主端 UI `useEffect` 自动以 ~150ms 驱动 `dealOne` 逐张发牌(每家即时排序)、发满 100 张剩 8 张底牌自动转 `trump``trump` 阶段确认主花色(翻第四张兜底);`bottom` 阶段庄家收 8 底牌(手牌临时 33 张)后扣 8 张隐藏底牌。E2E 全程跑通。
- **亮主公开区**`declareTrump` 把牌从手牌移到 `revealed[pid]`(座位前 `du-reveal-${pid}` 公开显示),更高优先级反主时先归还被反者手牌;`confirmTrump` 定案后全部归还。四人真实玩家名(`ctx.players.username`)在座位与右侧计分板同时显示,本人座位金色边框 +「★你坐这里」标记。
- **扣底保密**`playerView` 对非庄家返回空的 `unshownBottom`,庄家 `myHand` 含完整 33→25 牌;非庄家在 bottom 阶段看不到庄家手牌与扣底内容。
- 出牌权与 `G.currentPlayer` 一致UI `data-actor` 与引擎一致),不依赖 boardgame.io 默认轮转。
- 25 墩真实跑完、自然进入 score、计分 UI 与引擎数值一致taken + 底牌 = 200
### 5.2 实现过程中已修复/确认的坑(供后续维护参考)
1. **顶层 `turn: { activePlayers: ALL }` 无效**boardgame.io 的 `turn` 必须放在 **phase 对象内**,放顶层会被忽略,导致所有 move 被「player not active」拒绝。
2. **`random is not a function`**boardgame.io 注入的 `random` 是 Random API 对象而非 `() => number`;发牌必须传 `Math.random`
3. **出牌权驱动方式**`playCards``G.currentPlayer`(本引擎自维护)而非 `ctx.currentPlayer` 校验。
4. **自然 25 墩结束未结算**`applyPlay` 在最后一墩分支必须显式调用 `scoreRound(G)` 并置 `G.phaseName='score'`
5. **计分不变量**`lastRoundScores` 只含 tricks 吃分,底牌分需单独计入(见 §3
6. **阶段机拆分**`waiting → deal → trump → bottom → play → score``deal` 阶段由房主端自动 `dealOne` 逐张发牌、`endIf`(每家 25 张)自动转 `trump``trump``confirmTrump``bottom`(此时庄家收底并排序);`bottom``setBottom``play``declareTrump` 允许在 `deal`/`trump` 任意时刻,亮出的牌移到公开亮主区(`revealed[pid]`),定主后归还。
### 5.3 本测试未覆盖(可后续补充)
- **亮主/反主(含 2 张反 1 张 V5**:本测试全程不亮主,主花色走「翻第四张兜底」;庄家按轮庄默认(首局 P0。可加用例验证单级牌/对级牌/对王亮主、反主优先级、以及「最高亮主者坐庄」。
- **甩牌罚分V4**:本测试只出单张,不触发甩牌校验;罚分路径未走。
- **抠底翻番V6封顶 64**:底牌分经 `kouchudiMultiplier` 计入 `idleScore`,但本测试不校验倍率上限。
- **多局/升级/轮庄**:只验证单局结算;`nextDeclarer``level` 变更可加断言(下一局 `startGame``score` 重发,沿用 `level`/`nextDeclarer`)。
- **跟牌约束 UI 高亮**`verifyFollowSuit` 已埋点,但当前为尽力捕获(约束情形偶发),未做硬性断言。