/** * 《暗室》无头试玩测试 —— 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);