/** * 《暗室》无头试玩测试 —— 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') === 1 && rec.count('sfx/win_chime') === 1, '逃脱音效 gate_open + win_chime'); assert(rec.count('voice/escape') === 1, 'escape 语音播出'); assert(playSecAtEscape < 120, `本次用时 ${playSecAtEscape}s < 120s`); 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 播放中单击 = 跳过本句 const skipsBefore = rec.skips(); const ambBefore = rec.count('sfx/amb_drip'); await gs(game, 'tap'); assert(rec.skips() === skipsBefore + 1, '检查描述播放中单击 = skipVoice'); assert(rec.count('sfx/amb_drip') === ambBefore, '跳过时不触发检查'); 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(); } // ================================================================ // 场景 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'); // L1 可跳:stuck_ask 播放中单击 → 链进 stuck_key_1 const skips0 = rec.skips(); await gs(game, 'tap'); assert(rec.skips() === skips0 + 1, 'L1 提示可单击跳过'); await flush(); assert(rec.count('voice/stuck_key_1') === 1, '跳过后链播 stuck_key_1'); assert(vibrateCount(WARN_PAT) === warn0 + 1, 'L1 发 warning 震动'); await finishVoices(1); // 120s → L2;180s → L3 带路(stuck_key_3 + nav_left + nav_down) await tick(60000); assert(rec.count('voice/stuck_key_2') === 1, '120s 播 stuck_key_2'); 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'); // L3 带路不可跳:播放中单击 = 忽略 const skips1 = rec.skips(); const ambC = rec.count('sfx/amb_drip'); await gs(game, 'tap'); assert(rec.skips() === skips1, 'L3 带路语音不可单击跳过'); assert(rec.count('sfx/amb_drip') === ambC, 'L3 播放中单击不触发检查'); await finishVoices(1); // nav_down 播完 // L3 之后每 60s 重复带路 await tick(60000); assert(rec.count('voice/stuck_key_3') === 2, 'L3 每 60s 重复带路'); 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(); } // ================================================================ // 场景 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);