/** * playtest.mjs — 《音序回响》无头试玩仿真测试(docs/design/13-playtest-criteria.md §3 + §7 + §9 round1-live 变更)。 * * 思路: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 OCC_IDS = ['voice/gd_occ_up', 'voice/gd_occ_dt', 'voice/gd_occ_both']; const occTotal = () => OCC_IDS.reduce((n, id) => n + playCount(id), 0); 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 阶段手势静默忽略、序列不受影响'); const pl0 = playLog.length; await advanceUntil(() => game.phase === 'input', { desc: `第 ${round} 轮进入输入` }); assert(game.inputIdx === 0, `第 ${round} 轮 inputIdx 归零`); checkOccAnnouncedOnce(pl0); checkSeqConstraints(); } /** 语音缺失(round_03 被屏蔽)→ 2.5s 看门狗直接进 playback。 */ async function toInputViaWatchdog(round) { await advanceUntil(() => game.phase === 'announce', { desc: `第 ${round} 轮 announce` }); await advance(300); assert(!audio._voice, '被屏蔽的语音确实没有播起来'); const pl0 = playLog.length; 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} 轮进入输入` }); checkOccAnnouncedOnce(pl0); } // P2(round1 live,13 号文档 §9):组合占用提示 gd_occ_* 每局每种只在首次出现 // 轮播一次——在轮次开场窗口(playback→input)跨轮跟踪,重复播报即失败。 const occAtRoundStart = new Set(); function checkOccAnnouncedOnce(fromIdx) { for (const e of playLog.slice(fromIdx)) { if (!e.id.startsWith('voice/gd_occ_')) continue; assert(!occAtRoundStart.has(e.id), `P2:${e.id} 只在首次出现轮播(第 ${game.round} 轮开场重复播报)`); occAtRoundStart.add(e.id); } } 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 → 答题 } } /** 把前缀里连续的 up 答掉,直到期望输入非 up(round3 G5:期望恰为 up 时上滑=作答)。 */ async function answerPastUps() { while (game.phase === 'input' && game.sequence[game.inputIdx] === 'up') { await gesture('up'); // 期望即 up → 答题 } } // ---------------------------------------------------------------- 第 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 轮' }); assert(played('voice/correct'), 'P1:每轮答对都播表扬(第 1 轮 correct,池轮播)'); await toInputViaSkip(2); await answerAll(); assert(played('voice/gd_clear'), 'P1:每轮答对都播表扬(第 2 轮 gd_clear,池轮播)'); blockedAssets.add('round_03'); // 看门狗被测项:在第 3 轮播报发起前屏蔽该语音资产 await advanceUntil(() => game.phase === 'announce' && game.round === 3, { desc: '进第 3 轮' }); await toInputViaWatchdog(3); await answerAll(); assert(playCount('voice/correct') === 2, 'P1:每轮答对都播表扬(第 3 轮 correct 轮播)'); await advanceUntil(() => game.phase === 'announce' && game.round === 4, { desc: '进第 4 轮' }); // ---------------------------------------------------------------- 第 4 轮:答错触发复活 section('第 4 轮:答错消耗复活、序列不变重播'); await toInputViaSkip(4); assert(game.chances === 1, '初始 1 次重听机会'); assert(game.freeRelistens === 2, 'P1(round2 live):免费重听每局 2 次开局即给足(教学承诺「免费机会有两次」)'); { const prevSeq = game.sequence; const exp = game.sequence[0]; // round3 G5:up=重听键,注入错手势时避开(否则触发重听而非判错) const wrong = GESTURES.find((g) => g !== exp && g !== 'doubletap' && g !== 'up'); const occBefore = occTotal(); 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, '复活后输入进度清零'); // P2(round1 live,13 号文档 §9):答错重听链尾重播本轮组合占用提示 const seqHasOcc = game.sequence.includes('up') || game.sequence.includes('doubletap'); assert(occTotal() === occBefore + (seqHasOcc ? 1 : 0), 'P2:答错重听时重播本轮组合提示(唯一重播途径)'); } 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'); assert(playCount('voice/gd_clear') === 2, 'P1:每轮答对都播表扬(第 4 轮 gd_clear 轮播)'); // ---------------------------------------------------------------- 第 5 轮:主动重听 + 边界 section('第 5 轮:主动重听(上滑)两层账 / 重听拒绝 / 双击=作答(round3 G5 §8 + round2 live P1 §15)'); await toInputViaSkip(5); { // 未支持手势兜底(round1 live P1:答题阶段长按 9 秒零反馈,玩家以为卡死) const idx = game.inputIdx; const ch = game.chances; const vib0 = vibLog.length; await gesture('longpress'); assert(game.phase === 'input' && game.inputIdx === idx && game.chances === ch, 'P1:input 阶段长按不判错、不耗机会、不进重听'); assert(vibLog.slice(vib0).some((p) => Array.isArray(p) && p[0] === 60), 'P1:input 阶段长按发 error 震动兜底(不再零反馈,也不再抛 TypeError)'); const vib1 = vibLog.length; await gesture('longpressend'); assert(vibLog.length === vib1 && game.inputIdx === idx, 'P1:longpressend(同一动作收尾)静默不重复反馈'); } { // round3 G5:input 阶段双击永远=作答,主动重听改绑上滑(期望恰为 up 时上滑=作答) // round2 live P1(13 号文档 §15):重听分两层账——免费 2 次/局先用(不碰真机会), // 第 3 次起才扣 chances。教学 gd_economy 承诺「免费机会有两次」,承诺几次给几次。 await answerPastDoubletaps(); await answerPastUps(); // 先把期望恰为 up 的音答掉 const relistenCount = playCount('voice/relisten'); assert(game.freeRelistens === 2 && game.chances === 1, 'P1:第 5 轮账目起点 = 免费 2 次 + 真机会 1 次'); await gesture('up'); // 期望非 up → 第 1 次主动重听(免费) assert(game.freeRelistens === 1 && game.chances === 1, 'P1:第 1 次重听用免费额度(2→1),真机会不动'); assert(playCount('voice/relisten') === relistenCount + 1 && game.phase === 'roundend', '播 relisten 并重播'); await advanceUntil(() => game.phase === 'input', { desc: '重听重播后回输入' }); assert(played('voice/gd_chances_left1'), 'P1:第 1 次后如实报余量(gd_chances_left1「还剩一次」,链尾句)'); assert(game.inputIdx === 0, '重听后输入进度清零'); } { // 第 2 次主动重听:仍走免费额度(兑现教学承诺的第二次) await answerPastDoubletaps(); await answerPastUps(); await gesture('up'); assert(game.freeRelistens === 0 && game.chances === 1, 'P1:第 2 次重听仍免费(1→0),真机会仍不动'); await advanceUntil(() => game.phase === 'input', { desc: '第 2 次重听重播后回输入' }); assert(played('voice/gd_chances_left0'), 'P1:第 2 次后播 gd_chances_left0(免费用完、之后扣机会,链尾句)'); assert(game.inputIdx === 0, '第 2 次重听后输入进度清零'); } { // 第 3 次主动重听:免费已尽,扣真机会(chances 1→0);真机会扣空如实报 chances_out await answerPastDoubletaps(); await answerPastUps(); const coCount = playCount('voice/chances_out'); await gesture('up'); assert(game.freeRelistens === 0 && game.chances === 0, 'P1:第 3 次重听才扣真机会(chances 1→0)'); await advanceUntil(() => game.phase === 'input', { desc: '第 3 次重听重播后回输入' }); assert(playCount('voice/chances_out') === coCount + 1, 'P1:真机会扣空追加播 chances_out(链尾句)'); assert(game.inputIdx === 0, '第 3 次重听后输入进度清零'); } { // 免费+真机会皆空时上滑非期望手势:拒绝重听——不判错、不耗账、播 chances_out await answerPastDoubletaps(); await answerPastUps(); const round = game.round; const idx = game.inputIdx; const coCount = playCount('voice/chances_out'); await gesture('up'); assert(game.phase === 'input' && game.round === round && game.inputIdx === idx, '两账皆空上滑非期望:拒绝重听不判错(round/inputIdx 不变)'); assert(playCount('voice/chances_out') === coCount + 1, '拒绝重听播 chances_out'); assert(game.chances === 0 && game.freeRelistens === 0, '两账不变(不为负)'); } // 答完第 5 轮(途中验证双击=答题);若本轮没遇到 doubletap 则继续往下轮找 await answerAll(); assert(playCount('voice/correct') === 3, 'P1:每轮答对都播表扬(第 5 轮 correct 轮播)'); 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]; // round3 G5:up=重听键,注入错手势时避开 const wrong = GESTURES.find((g) => g !== exp && g !== 'doubletap' && g !== 'up'); 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'); // 结算语音链自然播完,不在此插手——播放中单击=直接重开(P0 言行一致), // 该断言需要一次专门的 gameover,见后文「结算屏言行一致」专项场景。 await advanceUntil(() => !!audio._voice, { desc: '结算语音链开播' }); 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(与结算同一首)'); // P1(round1 live,13 号文档 §9):菜单项切换逐项播报——右滑=下一项、左滑=上一项 await gesture('right'); assert(game._menuIdx === 1 && played('voice/menu_home'), 'P1:菜单右滑=下一项并播报("回大厅")'); const mr = playCount('voice/menu_restart'); // _openMenu 链上的 menu_restart 可能已被右滑顶掉,用增量断言 await gesture('left'); assert(game._menuIdx === 0 && playCount('voice/menu_restart') === mr + 1, 'P1:菜单左滑=上一项并播报("重新开始")'); await gesture('left'); assert(game._menuIdx === 2 && played('voice/menu_back'), 'P1:菜单首项再左滑回绕末项并播报("返回游戏")'); await gesture('right'); assert(game._menuIdx === 0, 'P1:菜单右滑回到首项'); 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 链播完' }); // 下滑=关闭菜单(13 号文档 §7.5 菜单约定的 simon 落地补全) await gesture('down'); assert(game.phase === 'menu', '再次下滑呼出菜单'); await gesture('down'); assert(game.phase === 'gameover', '菜单内下滑=关闭菜单回结算页'); const stopsBefore = stopBgmLog.length; await gesture('tap'); // 结算页单击 = 直接重开(P0 言行一致,无论语音是否在播) 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'), '重开后对局功能正常'); // ---------------------------------------------------------------- 结算屏言行一致(round1 live P0) section('结算屏言行一致:结算语音链播放中单击=直接重开'); // 新局第 2 轮故意连错两次耗尽复活,再造一次 gameover await advanceUntil(() => game.phase === 'input' && game.round === 2, { desc: '新局第 2 轮输入' }); { const wrong = GESTURES.find((g) => g !== game.sequence[0] && g !== 'doubletap' && g !== 'up'); await gesture(wrong); assert(game.phase === 'roundend' && game.chances === 0, '新局答错消耗唯一复活'); await advanceUntil(() => game.phase === 'input', { desc: '新局重播后回输入' }); const wrong2 = GESTURES.find((g) => g !== game.sequence[0] && g !== 'doubletap' && g !== 'up'); await gesture(wrong2); assert(game.phase === 'gameover', '复活耗尽再答错 → 第二次 gameover'); } await advanceUntil(() => !!audio._voice, { desc: '第二次结算语音链开播' }); { const r1 = playCount('voice/round_01'); await gesture('tap'); // 语音链播放中单击:不再跳句,直接开新局(言行一致是铁律) assert(game.phase === 'announce' && game.round === 1, 'P0:结算语音播放中单击=直接开新局(不再跳句)'); assert(playCount('voice/round_01') === r1 + 1, 'P0:新局立即开播 round_01(结算语音链被打断)'); } await advanceUntil(() => game.phase === 'input', { desc: '第三次开局进入第 1 轮输入' }); // ---------------------------------------------------------------- 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');