- 新增双升(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
11 KiB
双升(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 流程
- P0 创建房间(需登录)→ 进入 online 牌桌。
- 取 matchID:从 P1 视角的可加入按钮
lobby-join-1-<matchID>解析出房间 ID(房主对自己不渲染 join 按钮)。 - P1/P2/P3 依次加入。
- P0 点「开始游戏」(waiting → deal:引擎进入
deal阶段,由房主端 UI 自动驱动dealOne以 ~150ms 一张把 100 张逐张发给 4 家、每张即时sortHand排序,发满 100 张(每家 25)剩 8 张作底牌 → 自动转 trump)。 - 主循环驱动整局:
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+ 首出对子跟对子集成测试)覆盖。
- 检测到进入
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,不自动起服务。
# 安装浏览器(首次)
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阶段,由房主端 UIuseEffect自动以 ~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一致(UIdata-actor与引擎一致),不依赖 boardgame.io 默认轮转。 - 25 墩真实跑完、自然进入 score、计分 UI 与引擎数值一致(taken + 底牌 = 200)。
5.2 实现过程中已修复/确认的坑(供后续维护参考)
- 顶层
turn: { activePlayers: ALL }无效:boardgame.io 的turn必须放在 phase 对象内,放顶层会被忽略,导致所有 move 被「player not active」拒绝。 random is not a function:boardgame.io 注入的random是 Random API 对象而非() => number;发牌必须传Math.random。- 出牌权驱动方式:
playCards用G.currentPlayer(本引擎自维护)而非ctx.currentPlayer校验。 - 自然 25 墩结束未结算:
applyPlay在最后一墩分支必须显式调用scoreRound(G)并置G.phaseName='score'。 - 计分不变量:
lastRoundScores只含 tricks 吃分,底牌分需单独计入(见 §3)。 - 阶段机拆分:
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已埋点,但当前为尽力捕获(约束情形偶发),未做硬性断言。