# 双升(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-` 解析出房间 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` 已埋点,但当前为尽力捕获(约束情形偶发),未做硬性断言。