/** * 《暗室》无头试玩测试 —— 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', { value: { 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 + L2 明示,不崩 ---- 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_2') === 1, '兜底追加 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); assert(g2b.game.phase === 'PLAY', 'G3 下滑跳过直达 PLAY'); 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(); // 下滑跳过引导 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.game.phase === 'PLAY', '下滑跳过引导直达 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(); } // ================================================================ // 资产引用校验: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 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);