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

11 KiB
Raw Permalink Blame History

双升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 总分不变量(见 §3lastRoundScoresdu-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」后稳定通过。本测试中观察到的真实一局本局闲家得分 110taken 合计 190 + 底牌 10 = 200。

sumBottomPoints 从 sidebar「本局扣底牌」区域解析每张牌标签5♠/10♥/K♦/大王/小王)按 cardScore 规则5→510/K→10余→0王→0累加与引擎口径一致。


4. 如何运行

前置server :8000 与 web :5173 已在运行(./run.sh 或分启两个进程。Playwright 配置 reuseExistingServer,不自动起服务。

# 安装浏览器(首次)
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-fixednumPlayers 强制为 4仅对 double-up 生效,其他游戏仍可选人数)。
  • 新发牌/定主/扣底流程startGame 进入 deal 阶段,由房主端 UI useEffect 自动以 ~150ms 驱动 dealOne 逐张发牌(每家即时排序)、发满 100 张剩 8 张底牌自动转 trumptrump 阶段确认主花色(翻第四张兜底);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 functionboardgame.io 注入的 random 是 Random API 对象而非 () => number;发牌必须传 Math.random
  3. 出牌权驱动方式playCardsG.currentPlayer(本引擎自维护)而非 ctx.currentPlayer 校验。
  4. 自然 25 墩结束未结算applyPlay 在最后一墩分支必须显式调用 scoreRound(G) 并置 G.phaseName='score'
  5. 计分不变量lastRoundScores 只含 tricks 吃分,底牌分需单独计入(见 §3
  6. 阶段机拆分waiting → deal → trump → bottom → play → scoredeal 阶段由房主端自动 dealOne 逐张发牌、endIf(每家 25 张)自动转 trumptrumpconfirmTrumpbottom(此时庄家收底并排序);bottomsetBottomplaydeclareTrump 允许在 deal/trump 任意时刻,亮出的牌移到公开亮主区(revealed[pid]),定主后归还。

5.3 本测试未覆盖(可后续补充)

  • 亮主/反主(含 2 张反 1 张 V5:本测试全程不亮主,主花色走「翻第四张兜底」;庄家按轮庄默认(首局 P0。可加用例验证单级牌/对级牌/对王亮主、反主优先级、以及「最高亮主者坐庄」。
  • 甩牌罚分V4:本测试只出单张,不触发甩牌校验;罚分路径未走。
  • 抠底翻番V6封顶 64:底牌分经 kouchudiMultiplier 计入 idleScore,但本测试不校验倍率上限。
  • 多局/升级/轮庄:只验证单局结算;nextDeclarerlevel 变更可加断言(下一局 startGamescore 重发,沿用 level/nextDeclarer)。
  • 跟牌约束 UI 高亮verifyFollowSuit 已埋点,但当前为尽力捕获(约束情形偶发),未做硬性断言。