live试玩round1+round2修复: 引擎gesture新增tapcancel(immediateTap误判定回滚,默认关闭零行为变化)+ rhythm判定账簿/超时Miss复活/菜单1s去抖/正片下滑永不弹菜单 + space判定冷静期800ms/暂停恢复3颗缓冲/回大厅leaving收尾/hit_last在播标记 + storyline intro满播不掐/抉择25s兜底 + simon免费重听两层账 + escape教学链自动接播/90s高频零提示 + rpg擦伤零误报/tap不丢 + guide.test适配去抖期(语义不变)+ 文档同步(13号§9-§19/web-engine-api/README)。回归:storyline82/escape140/simon132/rhythm141/space155/rpg188/guide122共921断言全绿

This commit is contained in:
xsl
2026-09-01 22:00:00 +08:00
parent 624fec8a23
commit 4e9970e227
22 changed files with 3161 additions and 401 deletions
+225 -24
View File
@@ -334,9 +334,13 @@ section('A. 关键路径:引导 → 解谜 → 逃脱 → 结算 → 菜单
const playSecAtEscape = Math.floor((now - game.startElapsedMs) / 1000);
await gs(game, 'doubletap');
assert(game.phase === 'ENDING', '双击大门逃脱,进入 ENDING');
assert(rec.count('sfx/gate_open') === 1 && rec.count('sfx/win_chime') === 1, '逃脱音效 gate_open + win_chime');
assert(rec.count('sfx/gate_open') === 0 && rec.count('sfx/win_chime') === 0,
'逃脱音效后移:人声头部 2.6s 内无 gate_open/win_chime 对撞(round2-live P2)');
assert(rec.count('voice/escape') === 1, 'escape 语音播出');
assert(playSecAtEscape < 120, `本次用时 ${playSecAtEscape}s < 120s`);
await tick(2600);
assert(rec.count('sfx/gate_open') === 1 && rec.count('sfx/win_chime') === 1,
'逃脱音效 2.6s 后播出(人声头部避让,轰鸣落在「电机轰鸣」附近)');
await finishVoices(1);
assert(rec.count('voice/time_band1') === 1, 'time_band1 语音播出(<120s 档)');
await finishVoices(2); // time_band1 + again → 链完 → 结算 pad
@@ -386,12 +390,17 @@ section('A. 关键路径:引导 → 解谜 → 逃脱 → 结算 → 菜单
await tick(800);
assert(rec.last('voice/start_room') && rec.last('voice/start_room').t - rec.last('sfx/amb_drip').t === 800,
'描述在环境音 800ms 后播');
// start_room 是检查描述(可跳):voice 播放中单击 = 跳过本句
// start_room 是检查描述(可跳):voice 播放中单击 = 跳过本句 + 补发检查
// (round1-live:跳过后检查意图不丢——环境音即时重播,描述 800ms 后补播)
const skipsBefore = rec.skips();
const ambBefore = rec.count('sfx/amb_drip');
const startRoomBefore = rec.count('voice/start_room');
await gs(game, 'tap');
assert(rec.skips() === skipsBefore + 1, '检查描述播放中单击 = skipVoice');
assert(rec.count('sfx/amb_drip') === ambBefore, '跳过时不触发检查');
assert(rec.count('sfx/amb_drip') === ambBefore + 1, '跳过后补发检查:环境音即时重播');
assert(rec.count('voice/start_room') === startRoomBefore, '补发的检查描述 800ms 内未播');
await tick(800);
assert(rec.count('voice/start_room') === startRoomBefore + 1, '跳过后补发检查:描述 800ms 后补播');
await tick(200);
// ---- 边界:撞墙 (0,0) 左滑 → wall + error 震动 + 位置不变 + 无脚步声 ----
@@ -420,6 +429,81 @@ section('A. 关键路径:引导 → 解谜 → 逃脱 → 结算 → 菜单
game.stop();
}
// ================================================================
// 场景 A2:冷启动引导链回归(round2-live P1)——intro 后不点不滑,
// g1_move 必须自动播出;全程零单击(G0 不点)也能逐步走完 8 步引导
// ================================================================
section('A2. 冷启动引导链:intro 后 g1_move 自动播出,零单击走通(round2-live 回归)');
{
store.clear();
const { game, rec } = await newGame();
game.start();
await flush();
assert(rec.count('voice/g0_intro') === 1, 'g0_intro 播放');
await tick(VOICE_END_SLACK); // intro 播完(虚拟 1s 句)
assert(rec.count('voice/g1_move') === 0, 'intro 刚播完:g1_move 尚未播(1.2s 自动推进未到点)');
await tick(1200); // 自动推进到点
assert(rec.count('voice/g1_move') === 1, '无任何手势:intro 后 1.2s 自动播 g1_move(P1 回归断言)');
await finishVoices(2); // g1_move + skip_hint
assert(game.guideStep === 1 && !game._guideBusy, '自动进入 G1,输入解锁');
// intro 播放中滑动不顶开场白(对照:原"先跟我走"会 kill intro 且不再自动推进)
store.clear();
const g1b = await newGame();
g1b.game.start();
await flush();
await tick(400); // intro 播放中
await gs(g1b.game, 'right'); // 抢跑滑动:应被忽略,不顶 intro
assert(g1b.rec.count('voice/guide_offpath') === 0, 'intro 播放中滑动:不播 guide_offpath 顶开场白');
await tick(VOICE_END_SLACK + 1200);
assert(g1b.rec.count('voice/g1_move') === 1, 'intro 播放中滑动后:g1_move 仍自动播出');
g1b.game.stop();
// G1 下滑 = 作答(g1_move 明说"任意方向"),不再是跳过引导
await gs(game, 'down');
assert(game.phase === 'GUIDE' && game.guideStep !== 0, 'G1 下滑不跳过引导(仍在 GUIDE)');
assert(rec.count('voice/guide_skip') === 0, 'G1 下滑不播 guide_skip');
await finishVoices(2); // g1_done + g2_wall
assert(game.guideStep === 2, 'G1 下滑作答进入 G2');
// G2 下滑也计入撞墙滑动(再补一滑凑满 2 次推进)
await gs(game, 'down');
await gs(game, 'up');
await finishVoices(2); // wall + g3_left
assert(game.guideStep === 3, 'G2 下滑作答计入,两滑后进入 G3');
// 零单击走完全链:G3 左 → G4 下 → G5 单击(答题单击,非 G0 继续单击)
await gs(game, 'left');
await finishVoices(1); // g4_down
await gs(game, 'down'); // G4 下滑 = 作答
await finishVoices(1); // g5_inspect
assert(game.guideStep === 5, 'G4 下滑作答进入 G5');
await gs(game, 'tap'); // G5 检查拾钥匙
await finishVoices(2); // g5_key + g6_doubletap
await gs(game, 'doubletap'); // G6
await finishVoices(3); // inv_key + g6_done + g7_goal
assert(game.phase === 'PLAY', '冷启动零 G0 单击:教学链逐步走通进 PLAY');
game.stop();
// 下滑跳过在答案不含滑动的步骤仍生效(G3)
store.clear();
const g2b = await newGame();
g2b.game.start();
await flush();
await gs(g2b.game, 'tap'); // G0 → G1
await finishVoices(2);
await gs(g2b.game, 'up'); // G1
await finishVoices(2);
await gs(g2b.game, 'up'); // G2 ×2
await gs(g2b.game, 'up');
await finishVoices(2); // → G3
await gs(g2b.game, 'down'); // G3 答案 = 左滑,下滑 = 跳过
assert(g2b.rec.count('voice/guide_skip') === 1, 'G3 下滑仍跳过引导');
await tick(VOICE_END_SLACK);
assert(g2b.game.phase === 'PLAY', 'G3 下滑跳过直达 PLAY');
g2b.game.stop();
}
// ================================================================
// 场景 B:引导错手势纠错 + 连错 3 次直白重述 + 下滑跳过引导
// ================================================================
@@ -468,9 +552,9 @@ section('B. 引导纠错与跳过');
}
// ================================================================
// 场景 C:卡关三级提示(60s/120s/180s + nav 带路 + 进展重置 + 跳过裁定)
// 场景 C:卡关三级提示(无操作 60s/120s/180s + nav 带路 + 进展重置 + 跳过补发检查)
// ================================================================
section('C. 卡关提示计时器');
section('C. 卡关提示计时器(无操作时长语义)');
{
store.clear();
store.set('soundgame.escape.guideDone', '1'); // 二周目直达
@@ -484,49 +568,166 @@ section('C. 卡关提示计时器');
assert(game.phase === 'PLAY', 'REOPEN 单击直接进 PLAY');
await finishVoices(1); // start_room
// 60s 无进展 → L1(stuck_ask + stuck_key_1)
// 无操作 60s → L1(stuck_ask + stuck_key_1)
const warn0 = vibrateCount(WARN_PAT);
await tick(60000);
assert(rec.count('voice/stuck_ask') === 1, '60s 播 stuck_ask');
// L1 可跳:stuck_ask 播放中单击 → 链进 stuck_key_1
assert(rec.count('voice/stuck_ask') === 1, '无操作 60s 播 stuck_ask');
assert(vibrateCount(WARN_PAT) === warn0 + 1, 'L1 发 warning 震动');
// L1 链可跳:播放中单击 = 跳过本句(链播 stuck_key_1)+ 补发检查(round1-live)
const skips0 = rec.skips();
const ambDrip0 = rec.count('sfx/amb_drip');
await gs(game, 'tap');
assert(rec.skips() === skips0 + 1, 'L1 提示可单击跳过');
assert(rec.skips() === skips0 + 1, 'L1 提示链播放中单击 = 跳过本句');
await flush();
assert(rec.count('voice/stuck_key_1') === 1, '跳过后链播 stuck_key_1');
assert(vibrateCount(WARN_PAT) === warn0 + 1, 'L1 发 warning 震动');
await finishVoices(1);
assert(rec.count('sfx/amb_drip') === ambDrip0 + 1, '跳过后补发检查:环境音即时重播');
await tick(800); // 补发描述到点:stuck_key_1 仍在播 → 进待播队列
assert(rec.count('voice/start_room') === 1, '链播未完,补发描述排队等待(不顶句)');
await finishVoices(1); // stuck_key_1 播完 → 补发描述开播
assert(rec.count('voice/start_room') === 2, '链播播完后补发检查描述');
await finishVoices(1); // 补发描述播完
// 120s → L2;180s → L3 带路(stuck_key_3 + nav_left + nav_down)
await tick(60000);
assert(rec.count('voice/stuck_key_2') === 1, '120s 播 stuck_key_2');
// 自上次操作起连续无操作 120s → L2 明示;180s → L3 带路(stuck_key_3 + nav_left + nav_down)
await tick(125000);
assert(rec.count('voice/stuck_key_2') === 1, '无操作 120s 播 stuck_key_2');
await finishVoices(1);
await tick(60000);
assert(rec.count('voice/stuck_key_3') === 1, '180s 播 stuck_key_3');
assert(rec.count('voice/stuck_key_3') === 1, '无操作 180s 播 stuck_key_3');
await finishVoices(1); // stuck_key_3 播完 → nav_left 起
assert(rec.count('voice/nav_left') === 1, 'L3 带路算路:(1,1)→(0,2) 先播 nav_left');
await finishVoices(1); // nav_left 播完 → nav_down 起
assert(rec.count('voice/nav_down') === 1, 'L3 带路算路:再播 nav_down');
// L3 带路不可跳:播放中单击 = 忽略
await finishVoices(1); // nav_down 播完
// L3 之后每 60s 无操作重复带路
await tick(60000);
assert(rec.count('voice/stuck_key_3') === 2, 'L3 每 60s 无操作重复带路');
await finishVoices(3);
// L3 带路不可跳:播放中单击 = 忽略(新一轮带路起播后验证)
await tick(60000);
assert(rec.count('voice/stuck_key_3') === 3, '持续无操作 L3 再重复');
const skips1 = rec.skips();
const ambC = rec.count('sfx/amb_drip');
await gs(game, 'tap');
assert(rec.skips() === skips1, 'L3 带路语音不可单击跳过');
assert(rec.count('sfx/amb_drip') === ambC, 'L3 播放中单击不触发检查');
await finishVoices(1); // nav_down 播完
await finishVoices(3); // 该轮带路链播完
// L3 之后每 60s 重复带路
await tick(60000);
assert(rec.count('voice/stuck_key_3') === 2, 'L3 每 60s 重复带路');
await finishVoices(3);
// 进展事件重置:移动到关键房间 → _stuckShown 清空 → 再无进展约 60s 重新从 L1 起
// 进展事件重置:移动到关键房间 → _stuckShown 清空 → 再无操作约 60s 重新从 L1 起
// (卡关检查每 5s 一次,相位对齐需略多于 60s)
await gs(game, 'right'); // (2,1) 铁柜房,首到 = 进展
assert(game._stuckShown.size === 0, '进展事件后 _stuckShown 清空');
await finishVoices(1); // room_cabinet
await tick(66000);
assert(rec.count('voice/stuck_ask') === 2, '进展重置后 60s 重新播 L1');
assert(rec.count('voice/stuck_ask') === 2, '进展重置后无操作 60s 重新播 L1');
game.stop();
}
// ================================================================
// 场景 C2:活跃操作不触发卡关提示(round1-live:按"无操作时长"计时)
// ================================================================
section('C2. 活跃操作时不触发卡关提示');
{
store.clear();
store.set('soundgame.escape.guideDone', '1');
const { game, rec } = await newGame();
game.start();
await flush();
await gs(game, 'tap'); // REOPEN → PLAY
await finishVoices(1); // start_room
// 每 ~25s 一个普通移动手势(无进展事件),持续 ~250s:任何卡关提示都不应出现
for (let i = 0; i < 10; i++) {
await tick(25000);
await gs(game, i % 2 === 0 ? 'left' : 'right');
}
assert(game.x === 1 && game.y === 1, '往返移动 10 次回到 (1,1)');
assert(rec.count('voice/stuck_ask') === 0, '活跃操作 250s:无 L1(stuck_ask)');
assert(
rec.count('voice/stuck_key_1') === 0 && rec.count('voice/stuck_key_2') === 0 && rec.count('voice/stuck_key_3') === 0,
'活跃操作 250s:无 L2/L3 提示(机关枪回归断言)',
);
// 停止操作 60s 后 L1 如期而至:计时器仍武装
await tick(65000);
assert(rec.count('voice/stuck_ask') === 1, '停止操作 60s 后播 L1');
game.stop();
}
// ================================================================
// 场景 C3:提示排队礼仪——不顶掉进行中的语音;待播期间玩家操作即作废
// ================================================================
section('C3. 卡关提示排队:不顶语音、操作作废');
{
store.clear();
store.set('soundgame.escape.guideDone', '1');
const { audio, game, rec } = await newGame();
game.start();
await flush();
await gs(game, 'tap'); // REOPEN → PLAY
await finishVoices(1); // start_room
// —— 语音播放中到点:提示入队,等当前句自然播完 ——
await gs(game, 'tap'); // 单击检查(无语音 → 直接检查)
await tick(800); // 检查描述开播(start_room,虚拟时长 1s)
assert(audio.isVoicePlaying(), '前提:检查描述播放中');
game._lastActiveAt = now - 61000; // 测试钩子:回拨无操作计时(模拟已 61s 无操作)
game._checkStuck(); // 直调:等效卡关轮询到点
assert(rec.count('voice/stuck_ask') === 0, '语音播放中 L1 只入队不开播');
assert(audio.isVoicePlaying(), '进行中的语音未被顶掉');
await finishVoices(1); // 描述自然播完
assert(rec.count('voice/stuck_ask') === 1, '语音播完后 L1 补播 stuck_ask');
const desc = rec.last('voice/start_room');
const hint = rec.last('voice/stuck_ask');
assert(hint.t >= desc.t + 1000, 'stuck_ask 在描述自然播完之后才开播(未 kill)');
await finishVoices(2); // stuck_ask + stuck_key_1 播完
// —— 待播期间玩家操作:提示作废,且不按已播计入 ——
await gs(game, 'tap'); // 再次检查(无语音 → 直接检查)
await tick(800); // 描述开播
game._lastActiveAt = now - 121000; // 钩子:121s 无操作 → L2 到点
game._checkStuck();
assert(rec.count('voice/stuck_key_2') === 0, '语音播放中 L2 只入队不开播');
await gs(game, 'tap'); // 玩家操作(跳过描述 + 补发检查)→ 待播 L2 作废
await finishVoices(2);
assert(rec.count('voice/stuck_key_2') === 0, '玩家操作后待播 L2 不再播出');
assert(!game._stuckShown.has('L2'), '作废的 L2 不计入已播级别');
game.stop();
}
// ================================================================
// 场景 C4:重开后高频操作零卡关提示(round2-live:复测 stuck_key_2 实为
// 双击兜底主动求提示,非计时器——本场景冻结计时器语义:重开重置无操作计时)
// ================================================================
section('C4. 重开后高频操作 90s 零卡关提示(round2-live 回归)');
{
store.clear();
store.set('soundgame.escape.guideDone', '1');
const { game, rec } = await newGame();
game.start();
await flush();
await gs(game, 'tap'); // REOPEN → PLAY
await finishVoices(1);
game._restart(); // 结算/菜单重开路径:计时必须重置
await flush();
assert(rec.count('voice/replay') === 1, '重开播 replay');
await finishVoices(1);
// 每秒一个操作(滑动/单击交替,不用双击——双击无目标是设计内主动求提示),
// 持续 90s:任何卡关提示都不应出现
for (let i = 0; i < 90; i++) {
await tick(1000);
await gs(game, i % 2 === 0 ? 'left' : 'tap');
if (i % 2 === 1) await tick(900); // 单击补发描述到点播出,下一句前消化
}
assert(rec.count('voice/stuck_ask') === 0, '重开后高频操作 90s:无 L1(stuck_ask)');
assert(
rec.count('voice/stuck_key_1') === 0 && rec.count('voice/stuck_key_2') === 0 && rec.count('voice/stuck_key_3') === 0,
'重开后高频操作 90s:无 L2/L3(复测 stuck_key_2 = 双击兜底,非计时器)',
);
// 停手 60s 后 L1 如期而至:重开后计时器仍武装
await tick(66000);
assert(rec.count('voice/stuck_ask') === 1, '重开后停手 60s 播 L1(计时器仍武装)');
game.stop();
}