/** * playtest.mjs — 《音序回响》无头试玩仿真测试(docs/design/13-playtest-criteria.md §3 + §7)。 * * 思路:mock 浏览器环境(window/document/navigator/localStorage/fetch/AudioContext), * 加载**真实** engine/audio.js(拿到真实 skipVoice/onComplete/设置语义)与 game.js, * 用虚拟时钟推进所有 setTimeout(语音链结束定时器也在其列),手势直接注入 * game.onGesture(即引擎回调路径)。Math.random 注入种子,全流程可重复。 * * 运行:tools/node/bin/node web/simon/test/playtest.mjs */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); // web/simon // ---------------------------------------------------------------- 虚拟时钟 let nowMs = 0; const timers = new Map(); let nextTimerId = 1; function vSetTimeout(fn, ms) { const id = nextTimerId++; timers.set(id, { at: nowMs + (ms || 0), fn }); return id; } function vClearTimeout(id) { timers.delete(id); } async function flush(rounds = 50) { for (let i = 0; i < rounds; i++) await Promise.resolve(); } /** 推进虚拟时钟 ms,沿途触发到期定时器(含级联)。 */ async function advance(ms) { const target = nowMs + ms; for (;;) { let bestId = null; let bestAt = Infinity; for (const [id, t] of timers) { if (t.at <= target && t.at < bestAt) { bestAt = t.at; bestId = id; } } if (bestId === null) break; const t = timers.get(bestId); timers.delete(bestId); nowMs = t.at; t.fn(); await flush(); } nowMs = target; await flush(); } async function advanceUntil(cond, { step = 25, max = 60000, desc = '' } = {}) { let t = 0; while (!cond()) { if (t >= max) { (globalThis.__origError || console.error)(`[debug] phase=${game?.phase} teachIdx=${game?.teachIdx} teachAwait=${game?.teachAwait} teachErrs=${game?.teachErrs} round=${game?.round} voice=${lastVoice?.() ?? '?'} voicePlaying=${!!audio?._voice} timers=${timers.size}`); throw new Error(`advanceUntil 超时(${max}ms):${desc}`); } await advance(step); t += step; } } // ---------------------------------------------------------------- 浏览器 mock const vibLog = []; const fetchLog = []; const warnLog = []; const errorLog = []; const blockedAssets = new Set(); // 子串匹配,命中的资产 fetch 返回 !ok const bufDuration = new Map(); // ArrayBuffer -> 秒 function durFor(url) { if (url.includes('voice/')) return 1.0; if (url.includes('sfx/gameover')) return 0.95; if (url.includes('sfx/tone')) return 0.3; if (url.includes('bgm/')) return 90; return 1.0; } function fakeEl() { return { id: '', className: '', textContent: '', src: '', alt: '', offsetWidth: 0, style: { setProperty() {} }, children: [], appendChild(c) { this.children.push(c); }, remove() {}, addEventListener() {}, }; } const winListeners = new Map(); const windowMock = { addEventListener(type, fn) { if (!winListeners.has(type)) winListeners.set(type, []); winListeners.get(type).push(fn); }, removeEventListener() {}, dispatch(type, event = {}) { for (const fn of winListeners.get(type) || []) fn(event); }, location: { href: '' }, }; const lsStore = new Map(); const localStorageMock = { getItem: (k) => (lsStore.has(k) ? lsStore.get(k) : null), setItem: (k, v) => lsStore.set(k, String(v)), removeItem: (k) => lsStore.delete(k), }; class FakeAudioParam { constructor(v = 0) { this.value = v; } setValueAtTime() {} cancelScheduledValues() {} linearRampToValueAtTime() {} } class FakeAudioContext { constructor() { this.state = 'suspended'; this.currentTime = 0; this.sampleRate = 44100; this.destination = {}; } async resume() { this.state = 'running'; } createGain() { return { gain: new FakeAudioParam(1), connect() {}, disconnect() {} }; } createDynamicsCompressor() { return { threshold: new FakeAudioParam(), ratio: new FakeAudioParam(), attack: new FakeAudioParam(), release: new FakeAudioParam(), connect() {}, disconnect() {}, }; } createBufferSource() { return { buffer: null, loop: false, onended: null, connect() {}, disconnect() {}, start() {}, stop() {} }; } createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; } async decodeAudioData(ab) { const duration = bufDuration.get(ab) ?? 1; return { duration, numberOfChannels: 1, sampleRate: 44100, getChannelData: () => new Float32Array(8), }; } } async function fetchMock(url) { fetchLog.push(url); const blocked = [...blockedAssets].some((b) => url.includes(b)); const fsPath = path.join(ROOT, decodeURIComponent(url)); const ok = !blocked && fs.existsSync(fsPath); return { ok, async arrayBuffer() { const ab = new ArrayBuffer(8); bufDuration.set(ab, durFor(url)); return ab; }, }; } // 种子随机(LCG),保证序列可重复 let seed = 20260830; function seededRandom() { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; } // 全局安装(defineProperty:Node 24 自带 fetch/performance/navigator 只读 getter) for (const [k, v] of Object.entries({ window: windowMock, document: { body: fakeEl(), createElement: () => fakeEl(), getElementById: () => null, }, navigator: { vibrate: (p) => vibLog.push(p) }, localStorage: localStorageMock, fetch: fetchMock, performance: { now: () => nowMs }, setTimeout: vSetTimeout, clearTimeout: vClearTimeout, })) { Object.defineProperty(globalThis, k, { value: v, configurable: true, writable: true }); } windowMock.AudioContext = FakeAudioContext; Math.random = seededRandom; const origWarn = console.warn; const origError = console.error; globalThis.__origError = origError; console.warn = (...a) => { warnLog.push(a.join(' ')); }; console.error = (...a) => { errorLog.push(a.join(' ')); }; // ---------------------------------------------------------------- 断言框架 let passed = 0; let failed = 0; function assert(cond, msg) { if (cond) { passed++; console.log(` ✓ ${msg}`); } else { failed++; origError(` ✗ ${msg}`); } // 失败行走原始 console(console.error 被 mock 捕获) } function section(name) { console.log(`\n== ${name}`); } process.on('unhandledRejection', (e) => { failed++; origError(' ✗ 未处理 Promise 拒绝:', e); }); // ---------------------------------------------------------------- 加载引擎与游戏 const { WebAudioEngine } = await import('../../engine/audio.js'); const { settings } = await import('../../engine/settings.js'); const { SimonGame } = await import('../game.js'); // 向导层关闭:回归测试只验证纯游戏逻辑(guide='full' 的行为由 harness/guide.test.mjs 覆盖) settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 }); const audio = new WebAudioEngine('audio/'); const game = new SimonGame(audio, fakeEl()); let gameRef = game; // 手势注入目标(第二场景换新实例时切换) // 间谍:记录播放/跳过/停 BGM const playLog = []; const skipLog = []; const stopBgmLog = []; const origPlay = audio.play.bind(audio); audio.play = (id, opts) => { playLog.push({ id, opts: opts || {}, t: nowMs }); return origPlay(id, opts); }; const origSkip = audio.skipVoice.bind(audio); audio.skipVoice = () => { skipLog.push(nowMs); return origSkip(); }; const origStopBgm = audio.stopBgm.bind(audio); audio.stopBgm = (ms) => { stopBgmLog.push(ms); return origStopBgm(ms); }; const played = (id) => playLog.some((e) => e.id === id); const playCount = (id) => playLog.filter((e) => e.id === id).length; const lastVoice = () => { for (let i = playLog.length - 1; i >= 0; i--) { if (playLog[i].id.startsWith('voice/')) return playLog[i].id; } return null; }; let gestureCount = 0; // 真实手势回调是独立宏任务;每次注入后 flush 微任务,模拟事件循环间隔 //(否则同一 tick 内连续两个 voice play 会撞上引擎 _playVoice 的异步加载窗口) async function gesture(g) { gestureCount++; gameRef.onGesture(g); await flush(); } const GESTURES = ['up', 'down', 'left', 'right', 'tap', 'doubletap']; // ---------------------------------------------------------------- 全局断言 1:解锁前无音频 section('启动遮罩前(引擎解锁语义)'); audio.play('voice/t0_welcome'); await flush(); assert(warnLog.some((w) => w.includes('尚未解锁')), '解锁前 play 仅 console.warn'); assert(fetchLog.length === 0, '解锁前无任何资产加载'); await audio.unlock(); // ---------------------------------------------------------------- 启动 + 设置边界(全局断言 3) section('启动 / 设置生效(voice 性别、语速)'); settings.set({ voice: 'male', speed: 1.7 }); game.start(); await advanceUntil(() => !!audio._voice, { desc: 't0_welcome 开播' }); assert(game.phase === 'tutorial', '0.5s 后进入教学(T0)'); assert(lastVoice() === 'voice/t0_welcome', 't0_welcome 播放'); assert(fetchLog.some((u) => u.includes('audio/voice/male/t0_welcome.ogg')), 'voice=male 时从 male 目录取材'); assert(audio._voice.st.tempo === 1.7, 'speed=1.7 时播放实例 tempo=1.7(不变调变速)'); settings.set({ voice: 'female', speed: 1.0 }); // skip_hint 插播 + 长按加速(引擎内置行为过一遍真实代码路径) await advanceUntil(() => lastVoice() === 'voice/skip_hint' && !!audio._voice, { desc: 'skip_hint 开播' }); assert(fetchLog.some((u) => u.includes('audio/voice/female/skip_hint.ogg')), '切回 female 后下一句从 female 目录取材'); assert(playCount('voice/skip_hint') === 1, 'skip_hint 在 t0 附近插播一次'); windowMock.dispatch('soundgame:longpress'); assert(audio._voice && audio._voice.st.tempo === 1.7, '长按 → 旁白临时 1.7 倍速'); windowMock.dispatch('soundgame:longpressend'); assert(audio._voice && audio._voice.st.tempo === 1.0, '松开 → 恢复 settings.speed'); // ---------------------------------------------------------------- 教学 T1-T6 section('交互式教学(T0-T7)'); // T1 讲解句播放中单击 = 跳过本句(12 号文档 + 本轮裁定) // 注意:teachIdx 构造即 0,必须以"当前在播 t1_up"为准 await advanceUntil(() => lastVoice() === 'voice/t1_up' && !!audio._voice, { desc: 't1_up 开播' }); assert(game.phase === 'tutorial' && game.teachIdx === 0, 'T1 教学句播放'); { const skips = skipLog.length; await gesture('tap'); assert(skipLog.length === skips + 1, '教学讲解播放中单击 = skipVoice()'); assert(game.phase === 'tutorial' && game.teachIdx === 0, '跳过后教学状态机不破坏'); } await advanceUntil(() => game.teachAwait, { desc: 'T1 等待输入' }); for (let i = 0; i < GESTURES.length; i++) { await advanceUntil(() => game.teachAwait, { desc: `T${i + 1} 等待输入` }); assert(game.teachIdx === i, `teachIdx 递进到 ${i}`); const expected = GESTURES[i]; if (i === 1) { // 错手势:播 t_wrong_* + error 震动,不推进 const wrong = GESTURES.find((g) => g !== expected && g !== 'down'); // down=跳教学,避开 await gesture(wrong); assert(played('voice/t_wrong_down'), '教学错手势播 t_wrong_down'); assert(vibLog.some((p) => Array.isArray(p) && p[0] === 60), '教学错手势发 error 震动'); assert(game.teachIdx === 1 && game.teachAwait, '错手势后仍在原步骤等待'); } await gesture(expected); // i=4 时 expected='tap':等待输入阶段单击=答题(不跳过) if (i < GESTURES.length - 1) { await advanceUntil(() => game.teachIdx === i + 1, { desc: `teachIdx → ${i + 1}` }); } } await advanceUntil(() => game.phase === 'announce', { desc: 'T7 收尾后进第 1 轮' }); assert(game.round === 1 && played('voice/round_01'), '教学完成直达第 1 轮播报'); // ---------------------------------------------------------------- 对局辅助 function checkSeqConstraints() { const s = game.sequence; if (game.round <= 5) { for (let i = 2; i < s.length; i++) { assert(!(s[i] === s[i - 1] && s[i - 1] === s[i - 2]), `第 ${game.round} 轮序列前三连同手势(位置 ${i})`); } } for (let i = 3; i < s.length; i++) { assert(!(s[i] === s[i - 1] && s[i - 1] === s[i - 2] && s[i - 2] === s[i - 3]), `全程无四连同手势(第 ${game.round} 轮位置 ${i})`); } } /** announce →(单击跳过轮次播报)→ playback(手势静默忽略)→ input。 */ async function toInputViaSkip(round) { await advanceUntil(() => game.phase === 'announce' && !!audio._voice, { desc: `第 ${round} 轮播报` }); const skips = skipLog.length; await gesture('tap'); assert(skipLog.length === skips + 1, `第 ${round} 轮 round_* 播报单击可跳`); assert(game.phase === 'playback', '跳过后直接进入序列播放'); const seqSnapshot = game.sequence.join(','); await gesture('left'); // playback 阶段任意手势静默忽略(含 voice 也不跳) assert(game.phase === 'playback' && game.sequence.join(',') === seqSnapshot, 'playback 阶段手势静默忽略、序列不受影响'); await advanceUntil(() => game.phase === 'input', { desc: `第 ${round} 轮进入输入` }); assert(game.inputIdx === 0, `第 ${round} 轮 inputIdx 归零`); checkSeqConstraints(); } /** 语音缺失(round_03 被屏蔽)→ 2.5s 看门狗直接进 playback。 */ async function toInputViaWatchdog(round) { await advanceUntil(() => game.phase === 'announce', { desc: `第 ${round} 轮 announce` }); await advance(300); assert(!audio._voice, '被屏蔽的语音确实没有播起来'); await advanceUntil(() => game.phase === 'playback', { desc: '看门狗兜底进 playback', max: 10000 }); assert(errorLog.some((e) => e.includes('资产缺失')), '资产缺失仅 console.error'); const seqSnapshot = game.sequence.join(','); await gesture('left'); assert(game.phase === 'playback' && game.sequence.join(',') === seqSnapshot, '看门狗路径 playback 手势同样忽略'); await advanceUntil(() => game.phase === 'input', { desc: `第 ${round} 轮进入输入` }); } let dtAnswerTested = false; // 裁定①:期望恰为 doubletap 时双击=答题 /** 输入阶段按序列原样复现;途中遇到期望 doubletap 时验证"双击=答题不触发重听"。 */ async function answerAll() { while (game.phase === 'input' && game.inputIdx < game.sequence.length) { const exp = game.sequence[game.inputIdx]; if (exp === 'doubletap' && !dtAnswerTested) { const ch = game.chances; const idx = game.inputIdx; const relistenCount = playCount('voice/relisten'); await gesture('doubletap'); assert(game.inputIdx === idx + 1 && game.chances === ch && playCount('voice/relisten') === relistenCount, '裁定①:期望输入恰为 doubletap 时双击=答题(inputIdx 前进、不触发重听)'); dtAnswerTested = true; } else { await gesture(exp); } } } /** 把前缀里连续的 doubletap 答掉,直到期望输入非 doubletap。 */ async function answerPastDoubletaps() { while (game.phase === 'input' && game.sequence[game.inputIdx] === 'doubletap') { await gesture('doubletap'); // 期望即 doubletap → 答题 } } // ---------------------------------------------------------------- 第 1-3 轮 section('第 1-3 轮(含单击跳播报、playback 忽略、看门狗)'); assert(game._interval() === 700, '第 1 轮播放间隔 700ms'); await toInputViaSkip(1); await answerAll(); await advanceUntil(() => game.phase === 'announce' && game.round === 2, { desc: '进第 2 轮' }); await toInputViaSkip(2); await answerAll(); blockedAssets.add('round_03'); // 看门狗被测项:在第 3 轮播报发起前屏蔽该语音资产 await advanceUntil(() => game.phase === 'announce' && game.round === 3, { desc: '进第 3 轮' }); await toInputViaWatchdog(3); await answerAll(); await advanceUntil(() => game.phase === 'announce' && game.round === 4, { desc: '进第 4 轮' }); // ---------------------------------------------------------------- 第 4 轮:答错触发复活 section('第 4 轮:答错消耗复活、序列不变重播'); await toInputViaSkip(4); assert(game.chances === 1, '初始 1 次重听机会'); { const prevSeq = game.sequence; const exp = game.sequence[0]; const wrong = GESTURES.find((g) => g !== exp && g !== 'doubletap'); await gesture(wrong); assert(game.chances === 0, '答错自动消耗复活:chances 1→0'); assert(game.phase === 'roundend' && played('voice/retry_use'), '播 retry_use'); await advanceUntil(() => played('voice/chances_out'), { desc: 'chances_out 链播' }); assert(played('voice/chances_out'), '机会归 0 追加播 chances_out'); assert(vibLog.some((p) => Array.isArray(p) && p[0] === 60), '答错发 error 震动'); await advanceUntil(() => game.phase === 'input', { desc: '重播后回输入' }); assert(game.sequence === prevSeq, '复活重播序列不变(引用相等)'); assert(game.inputIdx === 0, '复活后输入进度清零'); } await answerAll(); await advanceUntil(() => game.phase === 'announce' && game.round === 5, { desc: '进第 5 轮' }); assert(game.chances === 1, '每完成 4 轮 chances +1(回到 1)'); assert(played('voice/chance_plus'), '播 chance_plus'); // ---------------------------------------------------------------- 第 5 轮:主动重听 + 边界 section('第 5 轮:主动重听 / chances=0 双击 / 双击冲突裁定'); await toInputViaSkip(5); { await answerPastDoubletaps(); const relistenCount = playCount('voice/relisten'); await gesture('doubletap'); // 期望非 doubletap → 主动重听 assert(game.chances === 0, '主动重听耗机会:chances 1→0'); assert(playCount('voice/relisten') === relistenCount + 1 && game.phase === 'roundend', '播 relisten 并重播'); await advanceUntil(() => game.phase === 'input', { desc: '重听重播后回输入' }); assert(game.inputIdx === 0, '重听后输入进度清零'); } { // chances=0 时双击非期望手势:不判错、播 chances_out await answerPastDoubletaps(); const round = game.round; const idx = game.inputIdx; const coCount = playCount('voice/chances_out'); await gesture('doubletap'); assert(game.phase === 'input' && game.round === round && game.inputIdx === idx, 'chances=0 双击非期望:不判错(round/inputIdx 不变)'); assert(playCount('voice/chances_out') === coCount + 1, 'chances=0 双击播 chances_out'); assert(game.chances === 0, 'chances 不变(不为负)'); } // 答完第 5 轮(途中验证双击=答题);若本轮没遇到 doubletap 则继续往下轮找 await answerAll(); while (!dtAnswerTested && game.round < 10) { await advanceUntil(() => game.phase === 'announce', { desc: '继续找 doubletap 回合' }); const r = game.round; await advanceUntil(() => !!audio._voice, { desc: `第 ${r} 轮播报` }); await gesture('tap'); // 跳播报 await advanceUntil(() => game.phase === 'input', { desc: `第 ${r} 轮输入` }); await answerAll(); } assert(dtAnswerTested, '双击冲突裁定①已覆盖(序列中出现 doubletap 并验证)'); // ---------------------------------------------------------------- 耗尽后答错 → gameover section('复活耗尽 → gameover → 结算'); await advanceUntil(() => game.phase === 'input' || game.phase === 'roundend', { desc: '最后一轮输入' }); if (game.phase === 'roundend') { await advanceUntil(() => game.phase === 'input', { desc: '进入输入阶段' }); } assert(game.chances === 0, '复活已耗尽'); const finalRound = game.round; { const exp = game.sequence[game.inputIdx]; const wrong = GESTURES.find((g) => g !== exp && g !== 'doubletap'); await gesture(wrong); } assert(game.phase === 'gameover', 'chances=0 答错 → gameover'); const gameoverT = nowMs; const score = finalRound - 1; const expectedBand = score <= 0 ? 'voice/score_band1' : score <= 3 ? 'voice/score_band2' : score <= 6 ? 'voice/score_band3' : score <= 9 ? 'voice/score_band4' : score <= 14 ? 'voice/score_band5' : 'voice/score_band6'; assert(!played('bgm/menu'), 'gameover 瞬间尚无 BGM'); // 结算语音链播放中单击 = 跳过本句(不是重开) await advanceUntil(() => !!audio._voice, { desc: '结算语音链开播' }); for (let i = 0; i < 8 && audio._voice; i++) { const ph = game.phase; await gesture('tap'); assert(game.phase === ph && game.phase === 'gameover', '结算语音播放中单击=跳过本句(不重开)'); await advance(50); } await advanceUntil(() => !audio._voice, { desc: '结算语音链播完' }); assert(played('voice/gameover') && played(expectedBand), `score_band 语音与 round-1=${score} 匹配(${expectedBand})`); assert(played('voice/new_record'), '破纪录播 new_record'); assert(lsStore.get('soundgame.simon.best') === String(score), 'best 写入 localStorage'); assert(played('sfx/gameover'), 'sfx/gameover 播出'); // 状态 BGM:sfx/gameover 播完 1s 后淡入 menu pad(gameover 起 +2650ms,volume 0.3) await advanceUntil(() => played('bgm/menu'), { desc: '结算 menu pad 淡入' }); { const e = playLog.find((x) => x.id === 'bgm/menu'); assert(e.opts.volume === 0.3, '结算 bgm/menu 以 volume 0.3 播放'); assert(e.t - gameoverT >= 2500 && e.t - gameoverT <= 2800, `menu pad 在 sfx/gameover 播完 1s 后淡入(实际 +${e.t - gameoverT}ms)`); } // 全程无 BGM(教学/对局):gameover 之前无任何 bgm 层播放调用 assert(playLog.filter((e) => e.id.startsWith('bgm/') && e.t < gameoverT).length === 0, '教学/对局/序列播放全程无 BGM(用户硬性要求)'); // ---------------------------------------------------------------- 速度下限(单测 _interval) { const r = game.round; game.round = 1; assert(game._interval() === 700, '第 1 轮 interval=700ms'); game.round = 11; assert(game._interval() === 440, '第 11 轮 interval=440ms(未到下限)'); game.round = 12; assert(game._interval() === 420, '第 12 轮起 interval=420ms 下限'); game.round = 30; assert(game._interval() === 420, '第 30 轮仍为 420ms(不穿了下限)'); game.round = r; } // ---------------------------------------------------------------- 菜单约定 + 重开 section('菜单(下滑呼出)与单击重开'); await gesture('down'); assert(game.phase === 'menu' && played('voice/menu_open'), '下滑呼出菜单'); assert(playCount('bgm/menu') >= 1, '菜单状态挂 bgm/menu(与结算同一首)'); await gesture('tap'); assert(game._menuIdx === 1 && played('voice/menu_home'), '菜单单击循环到"回大厅"'); await gesture('tap'); assert(game._menuIdx === 2 && played('voice/menu_back'), '菜单单击循环到"返回"'); await gesture('up'); assert(played('voice/rules'), '菜单上滑=帮助(规则播报)'); await gesture('doubletap'); // 选中"返回" assert(game.phase === 'gameover', '菜单双击确认"返回"→ 回结算页'); await advanceUntil(() => !audio._voice, { desc: 'rules 链播完' }); const stopsBefore = stopBgmLog.length; await gesture('tap'); // 结算页无语音时单击 = 重开 assert(stopBgmLog.length === stopsBefore + 1 && stopBgmLog[stopBgmLog.length - 1] === 300, '单击重开 stopBgm(300) 回静默'); await advanceUntil(() => game.phase === 'announce' && game.round === 1, { desc: '重开进第 1 轮' }); assert(game.phase !== 'tutorial' && playCount('voice/skip_hint') === 1, '单击重开跳过教学直达第 1 轮(skip_hint 不重播)'); await advanceUntil(() => game.phase === 'input', { desc: '重开后第 1 轮输入' }); await answerAll(); assert(game.round === 1 && (game.phase === 'roundend' || game.phase === 'announce'), '重开后对局功能正常'); // ---------------------------------------------------------------- settings.bgm 边界(全局断言 3) section('settings.bgm 边界'); settings.set({ bgm: 0 }); await flush(); assert(audio._bgm === null, 'bgm=0 立即停播进行中 BGM'); { const fetches = fetchLog.filter((u) => u.includes('bgm/menu')).length; audio.play('bgm/menu', { volume: 0.3 }); await flush(); assert(fetchLog.filter((u) => u.includes('bgm/menu')).length === fetches && audio._bgm === null, 'bgm=0 时 play(bgm) 只记意图不加载不播放'); } settings.set({ bgm: 1.0 }); await advanceUntil(() => !!audio._bgm, { desc: 'bgm 恢复自动补播' }); assert(audio._bgm.id === 'bgm/menu', 'bgm 调回非零自动补播最后的状态 BGM'); audio.stopBgm(); settings.set({ bgm: 1.0 }); // ---------------------------------------------------------------- 场景二:教学期下滑=跳教学(现有裁定回归) section('场景二:教学期下滑=跳过教学'); game.stop(); // 主场景实例收尾(作废旧会话计时器/语音链) await advance(500); { const game2 = new SimonGame(audio, fakeEl()); gameRef = game2; game2.start(); await advanceUntil(() => game2.phase === 'tutorial' && lastVoice() === 'voice/t1_up' && !!audio._voice, { desc: '场景二 T1 讲解开播' }); await gesture('down'); // 非"等待练下滑"时刻:下滑=跳整个教学 assert(played('voice/t_skip'), '教学讲解期下滑播 t_skip 确认句'); await advanceUntil(() => game2.phase === 'announce' && game2.round === 1, { desc: '跳教学直达第 1 轮' }); assert(game2.phase !== 'tutorial', '下滑跳过教学直达第 1 轮'); game2.stop(); gameRef = game; await advance(500); } // ---------------------------------------------------------------- 资产引用校验 section('资产引用校验(ogg+m4a 双格式,voice 双性别)'); { const src = fs.readFileSync(path.join(ROOT, 'game.js'), 'utf8'); const ids = new Set(); for (const m of src.matchAll(/'(voice|sfx|bgm)\/[a-z0-9_]+'/gi)) ids.add(m[0].slice(1, -1)); for (let i = 1; i <= 20; i++) ids.add(`voice/round_${String(i).padStart(2, '0')}`); // 动态 id ids.add('voice/round_more'); const missing = []; for (const id of ids) { const [layer, name] = id.split('/'); if (layer === 'voice') { for (const gender of ['male', 'female']) { for (const ext of ['ogg', 'm4a']) { if (!fs.existsSync(path.join(ROOT, 'audio/voice', gender, `${name}.${ext}`))) { missing.push(`audio/voice/${gender}/${name}.${ext}`); } } } } else { for (const ext of ['ogg', 'm4a']) { if (!fs.existsSync(path.join(ROOT, 'audio', layer, `${name}.${ext}`))) { missing.push(`audio/${layer}/${name}.${ext}`); } } } } assert(missing.length === 0, `game.js 引用 ${ids.size} 个资产 id,双格式双性别零缺失${missing.length ? ':缺 ' + missing.join(', ') : ''}`); } // ---------------------------------------------------------------- 汇总 section('汇总'); assert(gestureCount <= 120, `手势预算达标(${gestureCount} ≤ 120)`); console.warn = origWarn; console.error = origError; console.log(`\n通过 ${passed} 条断言,失败 ${failed} 条,注入手势 ${gestureCount} 次,虚拟时长 ${(nowMs / 1000).toFixed(1)}s`); if (failed > 0) { console.error('PLAYTEST FAILED'); process.exit(1); } console.log('PLAYTEST PASSED');