- 新增双升(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
150 lines
11 KiB
Markdown
150 lines
11 KiB
Markdown
# 双升(DoubleUp)联机 E2E 测试文档
|
||
|
||
> 配套文件:`apps/web/e2e/double-up-full-game.spec.ts`
|
||
> 目标:用 **Playwright 驱动真实 UI + 真实 server(boardgame.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 阶段并正确结算;
|
||
- 计分 UI(sidebar)渲染的数值与引擎一致。
|
||
|
||
此 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→5,10/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` 已埋点,但当前为尽力捕获(约束情形偶发),未做硬性断言。
|