Files
SoundGame/web/escape/test/playtest.mjs
T
xsl 963dfa1cc8 fix(escape): 暗室状态引导方案落地(25号策划文档 B1-B8 → F1-F9 实施)
策划轮:25-escape-map-flow.md as-built地图/玩家流程/信息账本+盲区清单(S1-S7)+方案B1-B8
实施(玩法零改动,只加状态引导层):
- F1 撞墙方位句「左边是墙」(wall_dir_*×4,同向10s限频)
- F2 目标句并入方位「铁柜,在右面的房间」(gd_*_dir×3)
- F3 无进展150s活跃计时器+gd_prog_1..3进度重述(只重述不泄题,专治活跃迷路玩家——修卡关计时从「无进展」退化为「无操作」的既定偏差)
- F4 双击兜底直通L2降级L1(不再白送答案)
- F5 空房首访描述(4空房可区分)
- F7 busy窗口手势缓存补判(原静默吞手势)
- F8 结算链单击守卫(time_band/again完整到达)
- F9 跳过引导补播目标句
- F6 空房方位特征句按25号裁定留档后续轮
新增语音10句×2性别(flock串行QC无离群,ASR逐字正确)
验证:escape playtest 235/235(+85新断言)+ guide 132/132 + 终检全绿 + 14游戏回归零回退
live复跑10.4分钟通关:零截断,S1-S7盲区逐条核销
档案 research/escape-state/round1-2;01号文档追加修订记录R1
2026-09-15 00:37:41 +08:00

1324 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《暗室》无头试玩测试 —— mock 浏览器环境 + 虚拟时钟,以玩家手势跑通
* docs/design/13-playtest-criteria.md §2 的关键路径与边界断言。
*
* 运行:tools/node/bin/node web/escape/test/playtest.mjs
*
* 设计:
* - 真实引擎(engine/audio.js + settings.js + vendor/soundtouch.js),
* mock 的是浏览器 API 层(AudioContext/fetch/localStorage/navigator/window)。
* - decodeAudioData 返回带 duration 的假 buffer(fetch 把时长编进 ArrayBuffer
* 头 8 字节);voice 自然结束由引擎内部定时器驱动(duration ÷ tempo),
* 虚拟时钟推进即触发 onComplete,语音链随之推进。
* - 手势直接在 game 实例上注入 onGesture(不过 gesture.js 的触摸判定)。
* - 全程虚拟时钟:setTimeout/setInterval/Date.now/performance.now 都走
* 手动推进的逻辑时钟,测试毫秒级跑完、完全可重复(本游戏无随机)。
*/
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const HERE = dirname(fileURLToPath(import.meta.url)); // web/escape/test
const GAME_DIR = join(HERE, '..'); // web/escape
// ---------- 虚拟时钟(必须先于引擎模块加载安装) ----------
let now = 0;
let timerSeq = 1;
const timers = new Map(); // id -> { at, fn, interval|null }
globalThis.setTimeout = (fn, ms = 0) => {
const id = timerSeq++;
timers.set(id, { at: now + ms, fn, interval: null });
return id;
};
globalThis.clearTimeout = (id) => { timers.delete(id); };
globalThis.setInterval = (fn, ms = 0) => {
const id = timerSeq++;
timers.set(id, { at: now + ms, fn, interval: ms });
return id;
};
globalThis.clearInterval = (id) => { timers.delete(id); };
Date.now = () => now;
Object.defineProperty(globalThis, 'performance', { value: { now: () => now }, configurable: true });
/** 推进虚拟时钟 ms,按序触发到期定时器(interval 自动重排)。 */
function advance(ms) {
const end = now + ms;
for (;;) {
let nextId = null;
let nextAt = Infinity;
for (const [id, t] of timers) {
if (t.at <= end && t.at < nextAt) { nextAt = t.at; nextId = id; }
}
if (nextId === null) break;
const t = timers.get(nextId);
now = t.at;
if (t.interval !== null) t.at = now + t.interval;
else timers.delete(nextId);
t.fn();
}
now = end;
}
/** 刷新微任务队列(fetch/decode/语音链接续都是 promise 驱动)。 */
async function flush() {
for (let i = 0; i < 30; i++) await new Promise((r) => setImmediate(r));
}
// ---------- 浏览器环境 mock ----------
const vibrateLog = [];
const fetchLog = [];
let blockedAssets = []; // 命中即 404(模拟资产缺失)
Object.defineProperty(globalThis, 'navigator', {
// maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产(键盘路由由 engine-input K9 专测)
value: { maxTouchPoints: 1, vibrate: (p) => vibrateLog.push(p) }, configurable: true,
});
const store = new Map();
Object.defineProperty(globalThis, 'localStorage', {
value: {
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
removeItem: (k) => store.delete(k),
clear: () => store.clear(),
},
configurable: true,
});
Object.defineProperty(globalThis, 'window', {
value: {
addEventListener: () => {},
removeEventListener: () => {},
AudioContext: null, // 下方填入
location: { href: '' },
},
configurable: true,
});
Object.defineProperty(globalThis, 'document', {
value: {
createElement: () => ({
style: { setProperty: () => {} },
classList: { add: () => {}, remove: () => {} },
appendChild: () => {}, remove: () => {}, addEventListener: () => {},
}),
body: { appendChild: () => {} },
getElementById: () => null,
},
configurable: true,
});
const LAYER_DURATION = { voice: 1.0, sfx: 0.3, bgm: 60 };
Object.defineProperty(globalThis, 'fetch', {
value: async (url) => {
fetchLog.push(String(url));
if (blockedAssets.some((b) => String(url).includes(b))) {
return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
}
const u = String(url);
const layer = u.includes('/voice/') ? 'voice' : u.includes('/bgm/') ? 'bgm' : 'sfx';
const buf = new ArrayBuffer(8);
new Float64Array(buf)[0] = LAYER_DURATION[layer];
return { ok: true, arrayBuffer: async () => buf };
},
configurable: true,
});
class MockAudioParam {
constructor(v = 0) { this.value = v; }
cancelScheduledValues() {}
setValueAtTime() {}
linearRampToValueAtTime() {}
}
class MockNode {
constructor() { this.gain = new MockAudioParam(1); }
connect() {}
disconnect() {}
}
class MockAudioContext {
constructor() {
this.state = 'running';
this.currentTime = 0;
this.destination = {};
this.sampleRate = 44100;
}
createGain() { return new MockNode(); }
createDynamicsCompressor() {
const n = new MockNode();
for (const k of ['threshold', 'ratio', 'attack', 'release', 'knee', 'reduction']) n[k] = new MockAudioParam(0);
return n;
}
createBufferSource() {
const n = new MockNode();
n.buffer = null; n.loop = false;
n.start = () => {}; n.stop = () => {};
return n;
}
createScriptProcessor() { const n = new MockNode(); n.onaudioprocess = null; return n; }
decodeAudioData(raw) {
const duration = new Float64Array(raw)[0] || 1;
return Promise.resolve({
duration,
numberOfChannels: 1,
sampleRate: 44100,
length: Math.ceil(duration * 44100),
getChannelData: () => new Float32Array(Math.ceil(duration * 44100)),
});
}
resume() { this.state = 'running'; return Promise.resolve(); }
}
window.AudioContext = MockAudioContext;
// ---------- 加载引擎与游戏(globals 就绪后动态 import) ----------
const { WebAudioEngine } = await import('../../engine/audio.js');
const { settings } = await import('../../engine/settings.js');
const { EscapeGame } = await import('../game.js');
// 向导层关闭:回归测试只验证纯游戏逻辑(guide='full' 的行为由 harness/guide.test.mjs 覆盖)
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
// ---------- 断言与辅助 ----------
let failures = 0;
let passes = 0;
function assert(cond, msg) {
if (cond) { passes++; console.log(' ✓', msg); }
else { failures++; console.error(' ✗ FAIL:', msg); }
}
function section(title) { console.log('\n== ' + title); }
/** play 调用记录 + skipVoice 计数。 */
function instrument(audio) {
const log = [];
const origPlay = audio.play.bind(audio);
audio.play = (id, opts = {}) => { log.push({ t: now, id, opts }); return origPlay(id, opts); };
const origSkip = audio.skipVoice.bind(audio);
let skips = 0;
audio.skipVoice = () => { skips++; return origSkip(); };
return {
log,
skips: () => skips,
count: (id) => log.filter((e) => e.id === id).length,
last: (id) => [...log].reverse().find((e) => e.id === id),
};
}
async function newGame() {
const audio = new WebAudioEngine('audio/');
await audio.unlock();
const game = new EscapeGame(audio, {});
return { audio, game, rec: instrument(audio) };
}
async function gs(game, g) { game.onGesture(g); await flush(); }
async function tick(ms) { advance(ms); await flush(); }
/** 播完 n 句 voice(每句 1s + 120ms 余量)。 */
async function finishVoices(n = 1) { for (let i = 0; i < n; i++) await tick(1300); }
const vibrateCount = (pattern) => vibrateLog.filter((p) => JSON.stringify(p) === JSON.stringify(pattern)).length;
const ERR_PAT = [60, 40, 60];
const WARN_PAT = [30, 50, 30, 50, 30];
const VOICE_END_SLACK = 1300; // 一句 voice 的虚拟时长
// ================================================================
// 场景 A:关键路径 —— 首局引导 G0-G7 → PLAY 解谜链 → 逃脱结算 → 菜单 → 重开
// ================================================================
section('A. 关键路径:引导 → 解谜 → 逃脱 → 结算 → 菜单 → 重开');
{
store.clear();
const { audio, game, rec } = await newGame();
game.start();
await flush();
assert(game.phase === 'GUIDE', '启动进入 GUIDE');
assert(rec.count('bgm/drone') === 1, '开局播 bgm/drone');
assert(rec.count('voice/g0_intro') === 1, 'g0_intro 播放');
await tick(VOICE_END_SLACK); // g0 播完
// G0:单击 → G1(链播 g1_move + skip_hint 首局插播)
await gs(game, 'tap');
assert(rec.count('voice/g1_move') === 1, 'G0 单击后播 g1_move');
await tick(VOICE_END_SLACK);
assert(rec.count('voice/skip_hint') === 1, 'skip_hint 在 G0/G1 附近插播一次');
await tick(VOICE_END_SLACK);
assert(game.guideStep === 1 && !game._guideBusy, '进入 G1,输入解锁');
// G1:任意滑动 → 脚步声
const stepsBefore = rec.count('sfx/step');
await gs(game, 'up');
assert(rec.count('sfx/step') === stepsBefore + 1, 'G1 滑动播脚步声');
await finishVoices(2); // g1_done + g2_wall
assert(game.guideStep === 2, '进入 G2');
// G2:滑动 ×2 撞墙反馈(避开下滑——引导期任何步骤下滑 = 跳过引导)
const wallsBefore = rec.count('sfx/wall');
const errBefore = vibrateCount(ERR_PAT);
await gs(game, 'up');
await gs(game, 'right');
assert(rec.count('sfx/wall') === wallsBefore + 2, 'G2 两滑各播 sfx/wall');
assert(vibrateCount(ERR_PAT) === errBefore + 2, 'G2 两滑各发 error 震动');
await finishVoices(2); // wall + g3_left
assert(game.guideStep === 3, '进入 G3');
// G3:左滑 → (0,1)
await gs(game, 'left');
assert(game.x === 0 && game.y === 1, 'G3 左滑移到 (0,1)');
await finishVoices(1); // g4_down
assert(game.guideStep === 4, '进入 G4');
// G4:下滑 → (0,2) 储物间
await gs(game, 'down');
assert(game.x === 0 && game.y === 2, 'G4 下滑移到 (0,2)');
assert(rec.count('sfx/amb_storage') >= 1, '储物间环境音');
await finishVoices(1); // g5_inspect
assert(game.guideStep === 5, '进入 G5');
// G5:单击拾钥匙
await gs(game, 'tap');
assert(game.hasKey === true, 'G5 拾得钥匙 hasKey === true');
await finishVoices(2); // g5_key + g6_doubletap
assert(game.guideStep === 6, '进入 G6');
// G6:双击 → G7 → 引导完成进 PLAY
await gs(game, 'doubletap');
await finishVoices(3); // inv_key + g6_done + g7_goal → onDone → _finishGuide
assert(game.phase === 'PLAY', '引导完成进入 PLAY');
assert(store.get('soundgame.escape.guideDone') === '1', "localStorage guideDone === '1'");
// ---- PLAY 解谜链:(0,2) → 铁柜房 (2,1) → 电箱房 (0,0) → 大门 (2,2) ----
await gs(game, 'right');
assert(game.x === 1 && game.y === 2, '右滑到 (1,2)');
await gs(game, 'right');
assert(game.x === 2 && game.y === 2, '右滑到 (2,2)');
assert(rec.count('voice/room_gate') === 1, '首到大门播 room_gate');
await finishVoices(1);
await gs(game, 'up');
assert(game.x === 2 && game.y === 1, '上滑到铁柜房 (2,1)');
assert(rec.count('voice/room_cabinet') === 1, '首到铁柜房播 room_cabinet');
await finishVoices(1);
// 双击开柜 → 保险丝;unlock_cabinet 为关键获得不可跳
await gs(game, 'doubletap');
assert(game.cabinetOpen === true && game.hasFuse === true, '开柜得保险丝 cabinetOpen && hasFuse');
assert(game.hasKey === false, '钥匙被消耗');
await flush();
assert(!!audio._voice, 'unlock_cabinet 播放中');
const skipsAtUnlock = rec.skips();
const ambAtUnlock = rec.count('sfx/amb_cabinet');
await gs(game, 'tap'); // 不可跳句播放中单击 = 忽略
assert(rec.skips() === skipsAtUnlock, 'unlock_cabinet 不可单击跳过');
assert(rec.count('sfx/amb_cabinet') === ambAtUnlock, '不可跳句播放中单击不触发检查');
await finishVoices(1);
// 左滑×2 + 上滑 → 电箱房 (0,0)
await gs(game, 'left');
await gs(game, 'left');
await gs(game, 'up');
assert(game.x === 0 && game.y === 0, '到达电箱房 (0,0)');
assert(rec.count('voice/room_power') === 1, '首到电箱房播 room_power');
await finishVoices(1);
// 双击修电箱 → 通电
await gs(game, 'doubletap');
assert(game.powerOn === true, '修复电箱 powerOn === true');
assert(rec.count('sfx/spark') === 1 && rec.count('sfx/power_on') === 1, '修电箱音效 spark + power_on');
await finishVoices(1); // fix_box
// 右滑×2 + 下滑×2 → 大门 (2,2),通电后环境音切 amb_gate_live
await gs(game, 'right');
await gs(game, 'right');
await gs(game, 'down');
await gs(game, 'down');
assert(game.x === 2 && game.y === 2, '到达大门 (2,2)');
assert(rec.count('sfx/amb_gate_live') >= 1, '通电后大门播 amb_gate_live');
// 双击逃脱 → ENDING
const playSecAtEscape = Math.floor((now - game.startElapsedMs) / 1000);
await gs(game, 'doubletap');
assert(game.phase === 'ENDING', '双击大门逃脱,进入 ENDING');
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
const menuPad = rec.last('bgm/menu');
assert(!!menuPad && menuPad.opts.volume === 0.5 && menuPad.opts.fadeMs === 1500,
'逃脱语音链播完淡入 bgm/menu(volume 0.5 / fadeMs 1500)');
// ENDING 下滑 → 菜单:切 bgm/menu(400ms)
await gs(game, 'down');
assert(game.phase === 'MENU', 'ENDING 下滑打开菜单');
const menuOpen = rec.last('bgm/menu');
assert(menuOpen && menuOpen.opts.fadeMs === 400, '进菜单 crossfade 400ms 播 bgm/menu');
await finishVoices(2); // menu_open + menu_restart
// 菜单约定:单击循环 / 上滑帮助 / 双击确认(选"返回")
await gs(game, 'tap');
assert(rec.count('voice/menu_home') === 1, '菜单单击循环到 menu_home');
await gs(game, 'up');
assert(rec.count('voice/help_ops') === 1, '菜单上滑播操作说明');
await finishVoices(1);
await gs(game, 'tap');
assert(rec.count('voice/menu_back') === 1, '菜单单击循环到 menu_back');
await finishVoices(1);
await gs(game, 'doubletap'); // idx=2 返回
assert(game.phase === 'ENDING', '菜单双击"返回"回到结算');
// 菜单约定:下滑 = 关闭菜单
await gs(game, 'down');
assert(game.phase === 'MENU', '再次下滑打开菜单');
await gs(game, 'down');
assert(game.phase === 'ENDING', '菜单下滑关闭回到结算');
// 结算单击重开 → PLAY + bgm/drone
const droneBefore = rec.count('bgm/drone');
await gs(game, 'tap');
assert(game.phase === 'PLAY', '结算单击重开回 PLAY');
assert(rec.count('bgm/drone') === droneBefore + 1, '重开切回 bgm/drone');
assert(rec.count('voice/replay') === 1, '重开播 replay');
assert(game.x === 1 && game.y === 1 && game.hasKey === false, '重开状态复位');
await finishVoices(1);
// ---- 边界:单击检查 = 环境音先播、描述 800ms 后播 ----
const ambDripBefore = rec.count('sfx/amb_drip');
await gs(game, 'tap');
assert(rec.count('sfx/amb_drip') === ambDripBefore + 1, '检查先播环境音 amb_drip');
assert(rec.count('voice/start_room') === 0, '描述 800ms 内未播');
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 播放中单击 = 跳过本句 + 补发检查
// (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 + 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 震动 + 位置不变 + 无脚步声 ----
await gs(game, 'left'); // (0,1)
await gs(game, 'up'); // (0,0)
const stepCnt = rec.count('sfx/step');
const wallCnt = rec.count('sfx/wall');
const errCnt = vibrateCount(ERR_PAT);
await gs(game, 'left'); // 撞墙
assert(game.x === 0 && game.y === 0, '撞墙位置不变');
assert(rec.count('sfx/wall') === wallCnt + 1, '撞墙播 sfx/wall');
assert(vibrateCount(ERR_PAT) === errCnt + 1, '撞墙发 error 震动');
assert(rec.count('sfx/step') === stepCnt, '撞墙不播脚步声');
assert(rec.count('voice/wall') >= 1, '撞墙播 wall 语音');
await finishVoices(1);
// ---- 边界:双击无目标兜底 = 背包/use_fail + L1 意象暗示,不崩(escape-state
// round2 B4 变更登记:原直通 L2 明示 stuck_*_2 把答案白送给没卡关的玩家,
// 降级为 L1 意象句 stuck_*_1——不点明房间与动作;答案仍锁在计时器 L2/L3) ----
const errCnt2 = vibrateCount(ERR_PAT);
await gs(game, 'doubletap'); // 电箱房无保险丝
assert(vibrateCount(ERR_PAT) === errCnt2 + 1, '双击无目标发 error 震动');
assert(rec.count('voice/use_fail') === 1, '兜底播 use_fail(关键房间)');
await finishVoices(1);
assert(rec.count('voice/stuck_key_1') === 1, 'B4:兜底追加 L1 意象暗示 stuck_key_1(原 L2 已降级)');
assert(rec.count('voice/stuck_key_2') === 0, 'B4:兜底不再直通 L2 明示 stuck_key_2');
await finishVoices(1);
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);
// B8(escape-state round2 变更登记):跳过确认句后、进 PLAY 前补播目标句
// g7_goal——跳过链从 1 句变 2 句,第一句播完时仍在 GUIDE
assert(g2b.rec.count('voice/g7_goal') === 1, 'B8:guide_skip 后补播 g7_goal');
assert(g2b.game.phase === 'GUIDE', 'B8:g7_goal 补播期间尚未进 PLAY');
await tick(VOICE_END_SLACK);
assert(g2b.game.phase === 'PLAY', 'G3 下滑跳过(补播 g7_goal 后)直达 PLAY');
assert(g2b.rec.count('voice/start_room') === 1, 'B8:跳过路径 g7_goal 后播 start_room');
const goalT = g2b.rec.last('voice/g7_goal').t;
const startT = g2b.rec.last('voice/start_room').t;
assert(goalT < startT, 'B8:跳过路径播报顺序 guide_skip → g7_goal → start_room');
g2b.game.stop();
}
// ================================================================
// 场景 B:引导错手势纠错 + 连错 3 次直白重述 + 下滑跳过引导
// ================================================================
section('B. 引导纠错与跳过');
{
store.clear();
vibrateLog.length = 0;
const { game, rec } = await newGame();
game.start();
await flush();
await tick(VOICE_END_SLACK); // g0
await gs(game, 'tap'); // G0 → G1
await finishVoices(2); // g1_move + skip_hint
await gs(game, 'up'); // G1
await finishVoices(2); // g1_done + g2_wall
await gs(game, 'up'); // G2 ×2
await gs(game, 'up');
await finishVoices(2); // wall + g3_left
assert(game.guideStep === 3, '到达 G3');
// G3 右滑(应为左)→ guide_wrong_left;连错 3 次 → guide_blunt_left + warning 震动
const warnBefore = vibrateCount(WARN_PAT);
await gs(game, 'right');
assert(rec.count('voice/guide_wrong_left') === 1, 'G3 右滑播 guide_wrong_left');
await gs(game, 'right');
await gs(game, 'right');
assert(rec.count('voice/guide_blunt_left') === 1, '连错 3 次播 guide_blunt_left');
assert(vibrateCount(WARN_PAT) === warnBefore + 1, '连错 3 次发 warning 震动');
assert(game.guideStep === 3, '纠错不离步(仍 G3)');
await gs(game, 'left');
assert(game.guideStep === 3 && game._guideBusy, '左滑后正常推进(G4 语音中)');
game.stop();
// 下滑跳过引导(B8:确认句后补播 g7_goal 再进 PLAY)
store.clear();
const g2 = await newGame();
g2.game.start();
await flush();
await gs(g2.game, 'down');
assert(g2.rec.count('voice/guide_skip') === 1, '引导期下滑播 guide_skip');
await tick(VOICE_END_SLACK);
assert(g2.rec.count('voice/g7_goal') === 1, 'B8:guide_skip 后补播 g7_goal');
await tick(VOICE_END_SLACK);
assert(g2.game.phase === 'PLAY', '下滑跳过引导(补播 g7_goal 后)直达 PLAY');
assert(store.get('soundgame.escape.guideDone') === '1', '跳过引导也写 guideDone');
assert(g2.rec.count('voice/start_room') === 1, '跳过引导播 start_room');
g2.game.stop();
}
// ================================================================
// 场景 C:卡关三级提示(无操作 60s/120s/180s + nav 带路 + 进展重置 + 跳过补发检查)
// ================================================================
section('C. 卡关提示计时器(无操作时长语义)');
{
store.clear();
store.set('soundgame.escape.guideDone', '1'); // 二周目直达
vibrateLog.length = 0;
const { game, rec } = await newGame();
game.start();
await flush();
assert(game.phase === 'REOPEN', '二周目进 REOPEN');
assert(rec.count('voice/reopen_prompt') === 1, '播 reopen_prompt');
await gs(game, 'tap');
assert(game.phase === 'PLAY', 'REOPEN 单击直接进 PLAY');
await finishVoices(1); // start_room
// 无操作 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');
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 提示链播放中单击 = 跳过本句');
await flush();
assert(rec.count('voice/stuck_key_1') === 1, '跳过后链播 stuck_key_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(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');
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');
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(3); // 该轮带路链播完
// 进展事件重置:移动到关键房间 → _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');
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();
}
// ================================================================
// 场景 D:语音资产缺失 → 20s 看门狗兜底推进,不卡死
// ================================================================
section('D. 资产缺失看门狗(屏蔽 g4_down)');
{
store.clear();
blockedAssets = ['g4_down'];
const { game } = await newGame();
game.start();
await flush();
await tick(VOICE_END_SLACK);
await gs(game, 'tap'); // G0
await finishVoices(2);
await gs(game, 'up'); // G1
await finishVoices(2);
await gs(game, 'up'); // G2 ×2
await gs(game, 'up');
await finishVoices(2);
await gs(game, 'left'); // G3 → _advance(4, ['voice/g4_down']) 资产缺失
await tick(2000);
assert(game.guideStep === 3 && game._guideBusy, 'g4_down 缺失:链不推进,停在 G3 等待');
await tick(20000); // 看门狗
assert(game.guideStep === 4 && !game._guideBusy, '20s 看门狗兜底推进到 G4');
// 继续走完引导,确认不卡死
await gs(game, 'down'); // G4
await finishVoices(1);
await gs(game, 'tap'); // G5
await finishVoices(2);
await gs(game, 'doubletap'); // G6
await finishVoices(3);
assert(game.phase === 'PLAY', '资产缺失下仍走完引导进 PLAY');
blockedAssets = [];
game.stop();
}
// ================================================================
// 场景 E:设置边界(bgm=0 静默/恢复、voice 性别切换、speed 1.7)
// ================================================================
section('E. 设置边界');
{
store.clear();
store.set('soundgame.escape.guideDone', '1');
const { audio, game } = await newGame();
game.start();
await flush();
await gs(game, 'tap'); // REOPEN → PLAY
await finishVoices(1);
assert(audio._bgm && audio._bgm.id === 'bgm/drone', 'PLAY 中 BGM 为 drone');
settings.set({ bgm: 0 });
await tick(300);
assert(audio._bgm === null, 'bgm=0:进行中的 BGM 停止');
assert(audio._bgmWanted && audio._bgmWanted.id === 'bgm/drone', 'bgm=0:保留"想播"意图');
settings.set({ bgm: 1.0 });
await flush();
assert(audio._bgm && audio._bgm.id === 'bgm/drone', 'bgm 调回自动补播 drone');
settings.set({ voice: 'male' });
const fetchBase = fetchLog.length;
await gs(game, 'tap'); // 检查 → 描述用男声目录
await tick(800);
await flush();
assert(fetchLog.slice(fetchBase).some((u) => u.includes('/voice/male/')), 'voice=male 后从 male 目录加载');
settings.set({ speed: 1.7 });
await tick(VOICE_END_SLACK); // 让当前句播完
await gs(game, 'tap'); // 再触发一句检查描述
await tick(800);
await flush();
assert(audio._voice && audio._voice.st.tempo === 1.7, 'speed=1.7 下一句 tempo 生效');
settings.set({ voice: 'female', speed: 1.0, bgm: 1.0 }); // 复位共享单例
game.stop();
}
// ================================================================
// 场景 G:B1 撞墙方位句(wall_dir_* 接播 + 同向 10s 限频 + 打断作废;引导期不播)
// ================================================================
section('G. B1 撞墙方位句(escape-state round2 F1)');
{
// 引导期 G2 伪撞墙不播方位句(B1 仅 PLAY 阶段)
store.clear();
const gd = await newGame();
gd.game.start();
await flush();
await tick(VOICE_END_SLACK);
await gs(gd.game, 'tap'); // G0 → G1
await finishVoices(2);
await gs(gd.game, 'up'); // G1
await finishVoices(2);
await gs(gd.game, 'up'); // G2 ×2(伪撞墙)
await gs(gd.game, 'left');
await finishVoices(2);
assert(gd.rec.count('voice/wall') >= 2, '前提:G2 撞墙播过 wall 语音');
for (const d of ['l', 'r', 'u', 'd']) {
assert(gd.rec.count(`voice/wall_dir_${d}`) === 0, `B1:引导期 G2 撞墙不播 wall_dir_${d}(仅 PLAY)`);
}
gd.game.stop();
store.clear();
store.set('soundgame.escape.guideDone', '1');
const { game, rec } = await newGame();
game.start();
await flush();
await gs(game, 'tap'); // REOPEN → PLAY(start_room → g7_goal)
await finishVoices(2);
await gs(game, 'left'); // (0,1) B5a 首访 empty_2
await gs(game, 'up'); // (0,0) room_power
await finishVoices(2);
// ① 撞墙后 wall 播完接播方位句(顺序:wall → wall_dir_u)
await gs(game, 'up'); // 撞北墙
assert(rec.count('voice/wall_dir_u') === 0, 'B1:wall 播放中方位句未播(接播不抢话)');
await finishVoices(1);
assert(rec.count('voice/wall_dir_u') === 1, 'B1:wall 播完接播 wall_dir_u「上边是墙。」');
assert(rec.last('voice/wall_dir_u').t >= rec.last('voice/wall').t + 1000, 'B1:方位句在 wall 自然播完之后');
await finishVoices(1);
// ② 打断作废:撞左墙 wall 未播完再撞上墙——左墙方位句作废;
// 上墙 10s 限频内(① 刚播过)不再重复
await gs(game, 'left'); // wall(左)
await gs(game, 'up'); // wall(上)顶掉上一句
await finishVoices(2);
assert(rec.count('voice/wall_dir_l') === 0, 'B1:wall 被打断则方位句自然作废(wall_dir_l 未播)');
assert(rec.count('voice/wall_dir_u') === 1, 'B1:同向 10s 限频——上墙方位句不重复(仍 1 次)');
// ③ 同向限频与恢复:左墙首播 → 10s 内再撞不重复 → 窗口过后恢复
await gs(game, 'left'); // 左墙(② 的接播被作废,未播过 → 本次接播)
await finishVoices(2);
assert(rec.count('voice/wall_dir_l') === 1, 'B1:被作废的方向补撞后方位句正常接播');
await gs(game, 'left'); // 10s 窗口内再撞左墙
await finishVoices(2);
assert(rec.count('voice/wall_dir_l') === 1, 'B1:同向 10s 内限频 1 次(wall_dir_l 不重复)');
await tick(10000);
await gs(game, 'left');
await finishVoices(2);
assert(rec.count('voice/wall_dir_l') === 2, 'B1:限频窗口过后同向方位句恢复');
game.stop();
}
// ================================================================
// 场景 H:B2 目标名+方位一体句(gd_*_dir 替换三处接播,异常路径回落 gd_room_*)
// ================================================================
section('H. B2 目标名+方位一体句(escape-state round2 F2)');
{
store.clear();
store.set('soundgame.escape.guideDone', '1');
const { game, rec } = await newGame();
game.start();
await flush();
await gs(game, 'tap'); // REOPEN → PLAY (1,1)
await finishVoices(2); // start_room + g7_goal
// ① found_key 链尾 → gd_cabinet_dir(储物间 (0,2) → 铁柜房 (2,1),固定为右)
game.x = 0; game.y = 2; // 测试钩子:瞬移储物间
await gs(game, 'tap'); // 检查 → 拾钥匙
await tick(800); // found_key 起播
await finishVoices(2); // found_key → gd_cabinet_dir
assert(rec.count('voice/found_key') === 1, '前提:拾钥匙播 found_key');
assert(rec.count('voice/gd_cabinet_dir') === 1, 'B2:found_key 链尾播 gd_cabinet_dir「铁柜,在右面的房间。」');
assert(rec.last('voice/gd_cabinet_dir').t > rec.last('voice/found_key').t, 'B2:一体句在 found_key 之后');
assert(rec.count('voice/gd_room_r') === 0, 'B2:正常路径不再播无目标名的 gd_room_r');
// ② unlock_cabinet/gd_next_fuse 链尾 → gd_box_dir(铁柜房 → 电箱房 (0,0),固定为左)
game.x = 2; game.y = 1; // 钩子:瞬移铁柜房
await gs(game, 'doubletap');
await finishVoices(3); // unlock_cabinet → gd_next_fuse → gd_box_dir
assert(rec.count('voice/gd_box_dir') === 1, 'B2:gd_next_fuse 链尾播 gd_box_dir「电箱,在左面的房间。」');
assert(rec.last('voice/gd_box_dir').t > rec.last('voice/gd_next_fuse').t, 'B2:一体句在 gd_next_fuse 之后');
assert(rec.count('voice/gd_room_l') === 0, 'B2:正常路径不再播 gd_room_l');
// ③ fix_box 链尾 → gd_gate_dir(电箱房 → 大门 (2,2),固定为右)
game.x = 0; game.y = 0; // 钩子:瞬移电箱房
await gs(game, 'doubletap');
await finishVoices(2); // fix_box → gd_gate_dir
assert(rec.count('voice/gd_gate_dir') === 1, 'B2:fix_box 链尾播 gd_gate_dir「大门,在右面的房间。」');
assert(rec.last('voice/gd_gate_dir').t > rec.last('voice/fix_box').t, 'B2:一体句在 fix_box 之后');
// ④ 异常路径回落:计算方位与枚举不符 → 回落原 gd_room_*(归因真话,原句保留接线)
game.x = 1; game.y = 1; // 钩子:异常位置(正常链上不存在)
game._playGoalDir([0, 1], 'voice/gd_cabinet_dir', 'voice/gd_room_r'); // 目标在西 ≠ 枚举右
await flush();
assert(rec.count('voice/gd_room_l') === 1, 'B2:方位与枚举不符时回落 gd_room_*(原句保留接线)');
await finishVoices(1);
game._playGoalDir([1, 1], 'voice/gd_cabinet_dir', 'voice/gd_room_r'); // 同房间不播(H1 原语义)
await flush();
assert(rec.count('voice/gd_room_l') === 1 && rec.count('voice/gd_cabinet_dir') === 1,
'B2:目标在同一房间不播(H1 原语义不变)');
game.stop();
}
// ================================================================
// 场景 I:B3 无进展 150s 活跃计时器(gd_prog 进度重述)
// 边界:key 阶段不走 / ≥5 手势闸门(挂机不触)/ 每段限 1 次 /
// 有进展时复位 / 待播期间操作作废且计入已播
// ================================================================
section('I. B3 无进展活跃计时器(escape-state round2 F3)');
{
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(2);
// 往返手势一律 (1,1)↔(0,1)(空房,避开关键房间的首到进展事件)
const wander = async (n) => { for (let i = 0; i < n; i++) await gs(game, i % 2 ? 'right' : 'left'); };
// ① key 阶段不走本通道:无进展 200s + 活跃 5 手势 → 无 gd_prog
await wander(5);
game._lastProgressAt = now - 200000; // 钩子:模拟 200s 无进展
game._checkStuck();
await flush();
assert(rec.count('voice/gd_prog_1') === 0, 'B3:key 阶段不走本通道(L1/L2/L3 已覆盖)');
await tick(1600); // 消化首访 empty_2
// ② 拾钥匙进 cabinet 阶段(进展事件复位计时)
game.x = 0; game.y = 2; // 钩子:瞬移储物间
await gs(game, 'tap');
await tick(800);
await finishVoices(2); // found_key + gd_cabinet_dir
assert(game._stage() === 'cabinet' && game._gesturesSinceProgress === 0,
'B3 前提:cabinet 阶段,进展事件已复位计数');
// ③ 闸门:无进展 151s 但活跃手势 <5(挂机/低活跃)→ 不触
game._lastProgressAt = now - 151000;
game._checkStuck();
await flush();
assert(rec.count('voice/gd_prog_1') === 0, 'B3:活跃手势 <5 不触(挂机交给 60s L1 体系)');
// ④ 活跃触发:5 手势后无进展 151s → gd_prog_1 到达一次
game.x = 1; game.y = 1;
await wander(5);
await tick(1600);
game._checkStuck(); // _lastProgressAt 仍为 ② 的钩子值(移动不复位无进展计时)
await flush();
assert(rec.count('voice/gd_prog_1') === 1, 'B3:活跃(≥5 手势)无进展 150s 播 gd_prog_1');
// ⑤ 每进展段限 1 次:再到点不重复
await finishVoices(1);
game._lastProgressAt = now - 200000;
game._checkStuck();
await flush();
assert(rec.count('voice/gd_prog_1') === 1, 'B3:gd_prog_1 每进展段限 1 次');
// ⑥ 有进展时复位:开柜(进展事件)→ 计数清零、计时重起,不立即触下一段
game.x = 2; game.y = 1;
await gs(game, 'doubletap'); // 开柜
assert(game._gesturesSinceProgress === 0, 'B3:进展事件后活跃手势计数清零(复位)');
game._checkStuck(); // _lastProgressAt 刚复位,远不足 150s
await flush();
assert(rec.count('voice/gd_prog_2') === 0, 'B3:有进展时计时器复位(不立即触 gd_prog_2)');
await finishVoices(3); // unlock 链
// ⑦ box 段可再触一次
game.x = 1; game.y = 1;
await wander(5);
await tick(1600);
game._lastProgressAt = now - 151000;
game._checkStuck();
await flush();
assert(rec.count('voice/gd_prog_2') === 1, 'B3:box 阶段活跃无进展 150s 播 gd_prog_2');
await finishVoices(1);
// ⑧ 待播期间操作作废且计入已播(每段限 1 次不重发)
game.x = 0; game.y = 0;
await gs(game, 'doubletap'); // 修电 → gate 阶段(进展复位)
await finishVoices(2); // fix_box + gd_gate_dir
game.x = 1; game.y = 1;
await wander(5);
await tick(1600);
await gs(game, 'tap'); // 单击检查
await tick(800); // 检查描述播放中
assert(audio.isVoicePlaying(), 'B3 前提:检查描述播放中');
game._lastProgressAt = now - 151000;
game._checkStuck(); // 到点 → gd_prog_3 只入队
assert(rec.count('voice/gd_prog_3') === 0, 'B3:语音播放中 gd_prog 只入队不开播(不顶句)');
await gs(game, 'left'); // 玩家操作 → 待播 gd_prog_3 作废
await finishVoices(2);
assert(rec.count('voice/gd_prog_3') === 0, 'B3:待播期间操作即作废(不播出)');
assert(game._progShown.has('gate'), 'B3:作废仍计入已播(每进展段限 1 次)');
game._lastProgressAt = now - 200000;
game._checkStuck();
await flush();
assert(rec.count('voice/gd_prog_3') === 0, 'B3:作废的 gd_prog 不重发');
game.stop();
}
// ================================================================
// 场景 J:B4 双击兜底降级(背包 + L1 意象;去重;L2 仍由计时器到达)
// ================================================================
section('J. B4 双击兜底降级(escape-state round2 F4)');
{
store.clear();
store.set('soundgame.escape.guideDone', '1');
const { game, rec } = await newGame();
game.start();
await flush();
await gs(game, 'tap'); // REOPEN → PLAY (1,1) 非关键房
await finishVoices(2);
// 非关键房兜底 = 背包 + L1 意象暗示(不再直通 L2);播了即记账
await gs(game, 'doubletap');
assert(rec.count('voice/inv_empty') === 1, 'B4:非关键房兜底先播背包 inv_empty');
await finishVoices(2);
assert(rec.count('voice/stuck_key_1') === 1, 'B4:兜底接 L1 意象暗示 stuck_key_1');
assert(rec.count('voice/stuck_key_2') === 0, 'B4:兜底不直通 L2 明示 stuck_key_2');
assert(game._stuckShown.has('L1'), 'B4:兜底播 L1 即记账(沿用 _stuckShown 去重)');
// 同进展段再次兜底不重复 L1 意象句(既有去重)
await gs(game, 'doubletap');
await finishVoices(2);
assert(rec.count('voice/inv_empty') === 2, 'B4:再次兜底仍播背包');
assert(rec.count('voice/stuck_key_1') === 1, 'B4:同进展段再次兜底不重复 L1 意象句');
// 降级后 L2 仍可达:L1 已记账 → 60s 不再重复(整级跳过,stuck_ask 亦不播);
// 120s 无操作 L2 明示照常到达(难度不降)
await tick(65000);
assert(rec.count('voice/stuck_ask') === 0, 'B4:L1 已记账,60s 无操作不再重复 L1 级');
await tick(60000);
assert(rec.count('voice/stuck_key_2') === 1, 'B4:降级后 L2 仍由计时器到达(120s 无操作)');
game.stop();
}
// ================================================================
// 场景 K:B5a 空房首访描述(首访到达 / 重访不重复 / 单击检查语义 / 非进展事件)
// ================================================================
section('K. B5a 空房首访描述(escape-state round2 F5)');
{
store.clear();
store.set('soundgame.escape.guideDone', '1');
const { game, rec } = await newGame();
game.start();
await flush();
await gs(game, 'tap'); // REOPEN → PLAY (1,1)
await finishVoices(2);
// 首访空房不算进展事件:不清卡关已播级别
game._stuckShown.add('L1'); // 钩子:模拟本段已播 L1
await gs(game, 'up'); // (1,0) 首访
assert(game._stuckShown.has('L1'), 'B5a:空房首访不算进展事件(不清 _stuckShown)');
game._stuckShown.clear(); // 复位钩子
// 首访 (1,0) → empty_1;本房间内单击不再重复首访句;重访零语音;重访后单击照常
assert(rec.count('voice/empty_1') === 1, 'B5a:空房首访补播 empty_1(一次性听觉锚)');
await finishVoices(1);
await gs(game, 'tap'); // 首访句刚播过的房间内单击
await tick(900);
assert(rec.count('voice/empty_1') === 1, 'B5a:首访句刚播过的房间内单击不重复同一句');
await gs(game, 'down'); // (1,1)
await gs(game, 'up'); // (1,0) 重访
await tick(900);
assert(rec.count('voice/empty_1') === 1, 'B5a:空房重访零语音(不重复首访句)');
await gs(game, 'tap'); // 重访后单击检查
await tick(900);
assert(rec.count('voice/empty_1') === 2, 'B5a:重访后单击检查照常播 empty_1(语义不变)');
await finishVoices(1);
// 四房映射:(0,1)→empty_2、(1,2)→empty_3、(2,0)→empty_3(对称水滴共用合法记忆点)
await gs(game, 'down'); // (1,1)
await gs(game, 'left'); // (0,1) 首访
assert(rec.count('voice/empty_2') === 1, 'B5a:(0,1) 首访播 empty_2');
await finishVoices(1);
await gs(game, 'right'); // (1,1)
await gs(game, 'down'); // (1,2) 首访
assert(rec.count('voice/empty_3') === 1, 'B5a:(1,2) 首访播 empty_3');
await finishVoices(1);
game.x = 2; game.y = 1; // 钩子:瞬移铁柜房(已 visited 与否不影响下行)
game.visited.add('2,1');
await gs(game, 'up'); // (2,0) 首访
assert(rec.count('voice/empty_3') === 2, 'B5a:(2,0) 首访复用 empty_3(两间"远处水滴"对称分布)');
await finishVoices(1);
game.stop();
}
// ================================================================
// 场景 L:B6 引导 busy 窗口手势缓存(补判 / 答错路径 / 不触发跳过 / 仅 1 个)
// ================================================================
section('L. B6 引导 busy 手势缓存(escape-state round2 F7)');
{
store.clear();
const { game, rec } = await newGame();
game.start();
await flush();
await tick(VOICE_END_SLACK); // intro 播完(自动推进 1.2s 未到点)
await gs(game, 'tap'); // G0 → _advance(1) busy
// ① busy 窗口手势缓存:不当场判定,解锁后按新步骤补判(合法 = 作答)
await gs(game, 'up'); // busy 窗口注入滑动 → 缓存
assert(game.guideStep === 0 && game._guideBusy, 'B6 前提:busy 窗口内步骤未解锁');
const stepsAtBusy = rec.count('sfx/step');
assert(rec.count('sfx/step') === stepsAtBusy, 'B6:busy 窗口手势不当场判定(无脚步声)');
await finishVoices(2); // g1_move + skip_hint 播完 → 解锁 G1 → 缓存 up 补判
assert(rec.count('sfx/step') === stepsAtBusy + 1, 'B6:解锁后缓存手势补判(G1 滑动作答,脚步声)');
assert(game.guideStep === 1 && game._guideBusy, 'B6:补判作答后正常推进(G2 语音中)');
await finishVoices(2); // g1_done + g2_wall
assert(game.guideStep === 2, 'B6:进入 G2');
// ② 缓存手势对新步骤合法:G2→G3 busy 缓存左滑(G3 的答案)→ 解锁直接作答
await gs(game, 'up'); // G2 第一滑
await tick(VOICE_END_SLACK); // wall 播完
await gs(game, 'right'); // G2 第二滑 → _advance(3) busy
await gs(game, 'left'); // busy 缓存左滑
assert(game.guideStep === 2, 'B6:busy 窗口仍停 G2');
await finishVoices(2); // wall + g3_left 播完 → 解锁 G3 → 缓存 left 补判
assert(game.x === 0 && game.y === 1, 'B6:缓存左滑按 G3 补判作答(移到 (0,1))');
assert(game.guideStep === 3 && game._guideBusy, 'B6:补判作答后推进 G4');
assert(rec.count('voice/guide_skip') === 0, 'B6:缓存手势不触发下滑跳过(S6 事故修复)');
await finishVoices(1); // g4_down → G4 解锁
// ③ 缓存手势对新步骤不合法:按答错路径处理(有纠正语音,可归因)
await gs(game, 'down'); // G4 作答 → _advance(5) busy
await gs(game, 'up'); // busy 缓存 up
await finishVoices(1); // 解锁 G5 → 补判:G5 期望单击,up = 答错
assert(game.guideStep === 5, 'B6:进入 G5');
assert(rec.count('voice/guide_wrong_gesture') === 1, 'B6:缓存手势不合法按答错路径处理(纠正语音可归因)');
assert(game.guideErrs === 1, 'B6:答错计数照常');
assert(rec.count('voice/guide_skip') === 0, 'B6:答错路径同样不触发跳过');
// ④ 仅缓存 1 个:busy 连注两个手势,解锁后只补判首个
await gs(game, 'tap'); // G5 作答 → _advance(6) busy
await gs(game, 'doubletap'); // 缓存(恰为 G6 答案)
await gs(game, 'left'); // 不再缓存(仅 1 个)
await finishVoices(2); // g5_key + g6_doubletap → 解锁 G6 → 补判 doubletap
assert(game.guideStep === 6 && game._guideBusy, 'B6:仅首个缓存手势补判(doubletap 作答 G6)');
await finishVoices(3); // inv_key + g6_done + g7_goal → PLAY
assert(game.phase === 'PLAY', 'B6:引导链完整走完(缓存不破坏既有推进)');
game.stop();
// ⑤ 跳过分手势不缓存:busy 窗口下滑(可跳过步骤)仍立即跳过
store.clear();
const g2b = await newGame();
g2b.game.start();
await flush();
await gs(g2b.game, 'tap'); // G0 → _advance(1) busy
assert(g2b.game._guideBusy, '前提:G0→G1 推进语音播放中');
await gs(g2b.game, 'down'); // busy 窗口下滑 = 跳过分手势 → 立即跳过,不缓存
assert(g2b.rec.count('voice/guide_skip') === 1, 'B6:busy 窗口下滑跳过语义不变(不缓存)');
assert(g2b.game._guidePending === null, 'B6:跳过分手势不进缓存');
g2b.game.stop();
}
// ================================================================
// 场景 M:B7 结算链单击守卫(播完前不重开 / 链完整到达 / 30s 兜底解除)
// ================================================================
section('M. B7 结算链单击守卫(escape-state round2 F8)');
{
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(2);
// 钩子速通:开柜 → 修电 → 大门
game.hasKey = true; game.x = 2; game.y = 1;
await gs(game, 'doubletap'); // 开柜
await finishVoices(3);
game.x = 0; game.y = 0;
await gs(game, 'doubletap'); // 修电
await finishVoices(2);
game.x = 2; game.y = 2;
await gs(game, 'doubletap'); // 逃脱
assert(game.phase === 'ENDING' && game._settleBusy, 'B7 前提:ENDING + 结算守卫挂载');
// 结算链播放中单击 = 忽略(链不被截断,time_band/again 完整到达)
await gs(game, 'tap');
assert(game.phase === 'ENDING', 'B7:结算链播完前单击不重开(仍 ENDING)');
assert(rec.count('voice/replay') === 0, 'B7:守卫期单击不触发重开(无 replay)');
await finishVoices(1);
assert(rec.count('voice/time_band1') === 1, 'B7:结算链完整——time_band 到达(不再被吞)');
await finishVoices(1);
assert(rec.count('voice/again') === 1, 'B7:结算链完整——again 到达');
await finishVoices(1); // 链完 → 守卫解除
assert(!game._settleBusy, 'B7:链播完守卫解除');
await gs(game, 'tap');
assert(game.phase === 'PLAY' && rec.count('voice/replay') === 1, 'B7:守卫解除后单击重开原语义不变');
game.stop();
// 30s 资产缺失兜底:again 缺失 → 链停摆 → 守卫靠兜底解除(防静默死锁)
blockedAssets = ['again'];
store.clear();
store.set('soundgame.escape.guideDone', '1');
const g2 = await newGame();
g2.game.start();
await flush();
await gs(g2.game, 'tap');
await finishVoices(2);
g2.game.hasKey = true; g2.game.x = 2; g2.game.y = 1;
await gs(g2.game, 'doubletap');
await finishVoices(3);
g2.game.x = 0; g2.game.y = 0;
await gs(g2.game, 'doubletap');
await finishVoices(2);
g2.game.x = 2; g2.game.y = 2;
await gs(g2.game, 'doubletap');
assert(g2.game.phase === 'ENDING', '前提:again 缺失下逃脱进 ENDING');
await finishVoices(2); // escape + time_band 播完,again 缺失链停摆
await gs(g2.game, 'tap');
assert(g2.game.phase === 'ENDING', 'B7:again 缺失链停摆时守卫仍生效(单击不重开)');
await tick(30000); // 兜底
assert(!g2.game._settleBusy, 'B7:30s 资产缺失兜底解除守卫(防静默死锁)');
await gs(g2.game, 'tap');
assert(g2.game.phase === 'PLAY', 'B7:兜底解除后可重开');
blockedAssets = [];
g2.game.stop();
}
// ================================================================
// 场景 N:B8 REOPEN 直进路径补播目标句(start_room → g7_goal,可单击跳过)
// ================================================================
section('N. B8 REOPEN 直进补播 g7_goal(escape-state round2 F9)');
{
store.clear();
store.set('soundgame.escape.guideDone', '1');
const { game, rec } = await newGame();
game.start();
await flush();
assert(game.phase === 'REOPEN', '前提:二周目 REOPEN');
await gs(game, 'tap'); // 直接开始
assert(game.phase === 'PLAY', 'REOPEN 单击直进 PLAY');
assert(rec.count('voice/start_room') === 1, 'B8:直进播 start_room');
await finishVoices(1);
assert(rec.count('voice/g7_goal') === 1, 'B8:start_room 后接播 g7_goal');
assert(rec.last('voice/g7_goal').t > rec.last('voice/start_room').t, 'B8:直进路径顺序 start_room → g7_goal');
// g7_goal 可单击跳过(skippable 默认 true,12 号文档——教学义务句已随首局完成,
// 此处是状态告知不是教学)
const skips0 = rec.skips();
await gs(game, 'tap'); // g7_goal 播放中单击 = 跳过 + 补发检查
assert(rec.skips() === skips0 + 1, 'B8:二周目 g7_goal 可单击跳过');
assert(game.phase === 'PLAY', 'B8:跳过后仍在 PLAY');
await finishVoices(2); // 补发检查描述消化
game.stop();
}
// ================================================================
// 资产引用校验:game.js 引用的 id 在音频目录有 ogg + m4a(voice 双性别),零缺失
// ================================================================
section('F. 资产引用校验');
{
const src = readFileSync(join(GAME_DIR, 'game.js'), 'utf8');
const ids = new Set();
for (const m of src.matchAll(/(voice|sfx|bgm)\/[a-z0-9_]+/g)) {
if (!m[0].endsWith('_')) ids.add(m[0]); // 模板前缀(stuck_ 等)单独展开
}
// 模板字符串族展开
for (const d of ['up', 'down', 'left', 'right']) {
ids.add(`voice/guide_wrong_${d}`);
ids.add(`voice/guide_blunt_${d}`);
}
for (const d of ['l', 'r', 'u', 'd']) ids.add(`voice/wall_dir_${d}`); // B1 撞墙方位句族
for (const st of ['key', 'cabinet', 'box', 'gate']) {
for (const lv of [1, 2, 3]) ids.add(`voice/stuck_${st}_${lv}`);
}
let missing = 0;
for (const id of [...ids].sort()) {
const [layer, name] = id.split('/');
const bases = layer === 'voice'
? [`audio/voice/male/${name}`, `audio/voice/female/${name}`]
: [`audio/${layer}/${name}`];
for (const base of bases) {
for (const ext of ['.ogg', '.m4a']) {
if (!existsSync(join(GAME_DIR, base + ext))) {
missing++;
console.error(' ✗ 缺失资产:', base + ext);
}
}
}
}
assert(missing === 0, `资产引用校验:${ids.size} 个 id 全部存在(ogg+m4a${''},voice 双性别),零缺失`);
}
// ================================================================
console.log(`\n========== 结果:${passes} 通过 / ${failures} 失败 ==========`);
process.exit(failures === 0 ? 0 : 1);