/** * 节拍行者(rhythm)无头试玩测试 —— docs/design/13-playtest-criteria.md §4 + §7。 * * 方法:mock 浏览器/WebAudio 最小接口(window/document/navigator/localStorage/ * fetch/AudioContext),加载**真实** engine(audio.js/settings.js/haptics.js) * 与 game.js;手势直接注入 game.onGesture(任务书允许的更稳路径);虚拟时钟 * 驱动 setTimeout/setInterval/performance.now/ctx.currentTime,voice 链由引擎 * 真实 SoundTouch 结束定时器推进,零真实等待。 * * 运行(仓库根):tools/node/bin/node web/rhythm/test/playtest.mjs */ import { readFileSync, existsSync } from 'node:fs'; // ================= 虚拟时钟 ================= let nowMs = 0; let timerSeq = 1; const timers = new Map(); // id -> { t, fn, iv } globalThis.setTimeout = (fn, ms = 0, ...args) => { const id = timerSeq++; timers.set(id, { t: nowMs + Math.max(0, ms), fn: () => fn(...args), iv: 0 }); return id; }; globalThis.setInterval = (fn, ms = 1) => { const id = timerSeq++; timers.set(id, { t: nowMs + ms, fn, iv: ms }); return id; }; globalThis.clearTimeout = (id) => timers.delete(id); globalThis.clearInterval = (id) => timers.delete(id); Object.defineProperty(globalThis, 'performance', { value: { now: () => nowMs }, configurable: true }); async function flush() { for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r)); } /** 推进虚拟时钟 ms 毫秒,沿途触发到期的 timeout/interval 并冲刷微任务。 */ async function advance(ms) { const target = nowMs + ms; for (;;) { await flush(); let nid = -1; let nt = Infinity; for (const [id, t] of timers) if (t.t < nt) { nt = t.t; nid = id; } if (nid < 0 || nt > target) break; const timer = timers.get(nid); nowMs = nt; if (timer.iv) timer.t = nt + timer.iv; else timers.delete(nid); timer.fn(); } nowMs = target; await flush(); } const nowSec = () => nowMs / 1000; async function runUntil(tSec) { await advance(Math.max(0, tSec * 1000 - nowMs)); } async function waitFor(cond, label, maxMs = 40000) { let t = 0; while (!cond()) { if (t >= maxMs) throw new Error('waitFor 超时: ' + label); await advance(100); t += 100; } } // ================= 浏览器环境 mock ================= const store = new Map(); globalThis.localStorage = { 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(), }; const vibLog = []; // navigator.vibrate 调用记录(震型数组) Object.defineProperty(globalThis, 'navigator', { // maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产 // (键盘变体路由的桌面路径由 harness/engine-input.test.mjs K9 专测) value: { maxTouchPoints: 1, vibrate: (p) => { vibLog.push(p); return true; } }, configurable: true, }); const mkEl = () => ({ id: '', className: '', textContent: '', src: '', alt: '', offsetWidth: 0, style: { setProperty() {} }, appendChild() {}, remove() {}, addEventListener() {}, }); globalThis.document = { createElement: mkEl, getElementById: () => null, body: mkEl(), }; globalThis.window = { addEventListener() {}, location: { href: '' }, AudioContext: null, // 下方填充 }; // ================= Web Audio mock ================= const VOICE_DUR = 1.2; // 假 voice 时长(秒),短时长让虚拟时钟跑得快 const BGM_DUR = 64; const SFX_DUR = 0.3; const durFor = (url) => (url.includes('/voice/') ? VOICE_DUR : url.includes('/bgm/') ? BGM_DUR : SFX_DUR); const srcLog = []; // BufferSource.start 记录 { url, when, offset } class MockParam { constructor(v = 0) { this.value = v; } cancelScheduledValues() {} setValueAtTime(v) { this.value = v; } linearRampToValueAtTime(v) { this.value = v; } } class MockAudioContext { constructor() { this.state = 'suspended'; this.destination = {}; this.sampleRate = 44100; } get currentTime() { return nowSec(); } async resume() { this.state = 'running'; } createGain() { return { gain: new MockParam(1), connect() {}, disconnect() {} }; } createDynamicsCompressor() { return { threshold: new MockParam(), ratio: new MockParam(), attack: new MockParam(), release: new MockParam(), connect() {} }; } createBufferSource() { return { buffer: null, loop: false, onended: null, connect() {}, disconnect() {}, start(when = 0, offset = 0) { srcLog.push({ url: this.buffer?.__url ?? null, when, offset, at: nowMs }); }, stop() {}, }; } createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; } async decodeAudioData(raw) { return { duration: durFor(raw.__url || ''), __url: raw.__url }; } } window.AudioContext = MockAudioContext; // ================= fetch mock(资产存在性由末尾 fs 校验单独保证) ================= const fetchLog = []; const blocked = []; // 子串命中即 404(看门狗测试用) globalThis.fetch = async (url, _opts) => { fetchLog.push(url); if (typeof url === 'string' && url.startsWith('/api/')) return { ok: true, json: async () => ({}) }; if (blocked.some((b) => String(url).includes(b))) return { ok: false, status: 404 }; return { ok: true, arrayBuffer: async () => ({ __url: url }) }; }; // ================= 断言框架 ================= let passed = 0; let failed = 0; function assert(cond, msg) { if (cond) { passed++; console.log(' ✓', msg); } else { failed++; console.error(' ✗ 失败:', msg); } } const uncaught = []; process.on('uncaughtException', (e) => { uncaught.push(e); process.exitCode = 1; origConsoleError('UNCAUGHT', e); }); process.on('unhandledRejection', (e) => { uncaught.push(e); process.exitCode = 1; origConsoleError('UNHANDLED', e); }); const origConsoleError = console.error.bind(console); let consoleErrors = 0; console.error = (...a) => { consoleErrors++; origConsoleError(...a); }; // ================= 加载真实引擎与游戏 ================= const { WebAudioEngine } = await import('../../engine/audio.js'); const { settings } = await import('../../engine/settings.js'); const { RhythmGame } = await import('../game.js'); const PROGRESS_KEY = 'soundgame.rhythm.progress'; let audio = null; let game = null; let playLog = []; let gestures = 0; async function newWorld(progress = null) { store.clear(); if (progress) store.set(PROGRESS_KEY, JSON.stringify(progress)); settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 }); nowMs = 0; timers.clear(); vibLog.length = 0; srcLog.length = 0; fetchLog.length = 0; blocked.length = 0; audio = new WebAudioEngine('audio/'); playLog = []; const origPlay = audio.play.bind(audio); audio.play = (id, opts = {}) => { playLog.push({ id, t: nowMs }); return origPlay(id, opts); }; await audio.unlock(); game = new RhythmGame(audio, mkEl()); if (process.env.DEBUG) { for (const m of ['_beginTutorial', '_tutLead', '_tutEvaluate', '_startSong', '_finish', '_enterSelect', '_openMenu', '_closeMenu']) { const orig = game[m].bind(game); game[m] = (...a) => { console.log(` [trace ${nowMs}ms] ${m} tutIdx=${game._tutIdx} fails=${game._tutFails} phase=${game.phase}`); return orig(...a); }; } } gestures = 0; return game; } function G(g) { gestures++; game.onGesture(g); } // voice 链断言用 playLog(play 调用顺序即播报顺序;fetchLog 会被 prewarm 预取污染) const voicePlays = () => playLog.filter((p) => p.id.startsWith('voice/')).map((p) => p.id); const lastVoice = () => { const v = voicePlays(); return v[v.length - 1] || ''; }; const voicePlayed = (id) => voicePlays().includes(id); // 按 cue 期望时刻注入应答;模拟真实识别延迟(immediateTap:tap 按下即达 +0; // 滑动识别在 touchend +0.15 = SWIPE_DELAY;双击 +0.20 = DOUBLE_DELAY)—— // game.js 判定补偿回到触摸时刻后 dt≈0(§13 替换口径)。 // 双击前先注入序曲 tap——immediateTap 的真实行为(第一下按下即发 tap), // 否则游戏的旧引擎闩锁(双击前 350ms 无 tap = 旧引擎)会把测试误判成旧引擎 async function answerCue(cue) { if (cue.judged) return; const lag = cue.gesture === 'tap' ? 0 : cue.gesture === 'doubletap' ? 0.20 : 0.15; await runUntil(cue.expected + lag); if (cue.gesture === 'doubletap') G('tap'); // 序曲 tap(附近无 0.35s 内未判定 tap 音符,出声不判定) G(cue.gesture); await flush(); } // ================= 场景 A:首局关键路径(教学→曲目一 S→解锁→结算 BGM→重开→下一首) ================= console.log('\n[场景 A] 首局:教学 T1-T3 → 曲目一全 Perfect → S 级解锁 → 结算'); await newWorld(); assert(game.phase === 'idle', '初始 phase idle'); game.start(); await flush(); assert(game.phase === 'intro', 'start 后进 intro'); assert(voicePlayed('voice/welcome'), 'welcome 旁白开始播放'); // —— 旁白跳过①:intro 阶段 voice 播放中单击 = skipVoice(immediateTap 下延迟 // 500ms 裁决——防序曲 tap;advance 后链推进到 tut_1,不重头)—— assert(!!audio._voice, 'welcome 播放中(voice token 存在)'); G('tap'); // 单击跳过 welcome await advance(600); assert(lastVoice() === 'voice/tut_1', '单击跳过 welcome → 链推进到 tut_1'); // —— 教学 5 步(§13:单击 → 左右 → 上滑 → 下滑 → 三音带双击):按 cues.expected // 做手势(识别延迟注入,补偿后 dt≈0)—— for (let step = 0; step < 5; step++) { await waitFor(() => game._tutIdx === step && game.phase === 'tut' && game._cues.length > 0 && game._tutAttempts.length === 0, `T${step + 1} 领奏排程`); assert(game._tutIdx === step, `教学步进 T${step + 1}(_tutIdx=${step})`); const leadNoteCount = srcLog.filter((s) => s.url && s.url.includes('/sfx/inst_')).length; if (step === 2) { // 上滑步:先注入序曲 tap(immediateTap 副作用),不得产生尝试记录 await runUntil(game._cues[0].expected); G('tap'); await flush(); assert(game._tutAttempts.length === 0, 'T3 序曲 tap 不产生尝试记录(tap 只认领 tap 音符)'); } for (const cue of game._cues) await answerCue(cue); assert(game._tutAttempts.every((a) => a.correct && Math.abs(a.dt) <= 0.35), `T${step + 1} 应答全部正确且在 Good 窗口内`); if (step === 3) assert(game.phase === 'tut', 'T4 下滑步:临近 down 音符时下滑 = 演奏应答,不跳教学'); assert(srcLog.filter((s) => s.url && s.url.includes('/sfx/inst_')).length > leadNoteCount, `T${step + 1} 领奏音符已排程播放`); await runUntil(game._phaseEnd + 0.2); // 触发 _tutEvaluate await flush(); } assert(game._progress.tutorialDone === true, 'tutorialDone === true'); assert(JSON.parse(store.get(PROGRESS_KEY)).tutorialDone === true, 'localStorage 进度已持久化 tutorialDone'); assert(voicePlayed('voice/tut_done'), 'tut_done 播报'); // —— 曲目一 main:全部 Perfect —— await waitFor(() => game.phase === 'main' && game._cues.length > 0, '曲目一 main 开始'); assert(game._song.id === 'song1' && game._songIdx === 0, '教学后进入曲目一'); assert(srcLog.some((s) => s.url && s.url.includes('/bgm/track90')), '曲目一 BGM track90 按网格启动'); // —— 旧引擎 tap 补偿(§13 闩锁回退路径):_immediateTap=false 时回调 -0.30s 判定 —— const legacyTapCue = game._cues.find((c) => c.gesture === 'tap'); game._immediateTap = false; await runUntil(legacyTapCue.expected + 0.30); G('tap'); await flush(); assert(legacyTapCue.result === 'perfect', '旧引擎 tap 补偿:闩锁回退后 expected+0.30s 注入的 tap 仍判 Perfect'); game._immediateTap = true; for (const cue of game._cues) await answerCue(cue); assert(game._stats.perfect === game._cues.length, `曲目一 31 个音符全部 Perfect(perfect=${game._stats.perfect})`); assert(game._stats.miss === 0 && game._stats.combo === game._cues.length, '无 Miss 且连击不断'); await waitFor(() => game.phase === 'end', '曲目一结算'); assert(game._grade() === 's', '全 Perfect 评级 S'); assert(game._progress.grades.song1 === 's', 'grades.song1 === "s"'); assert(game._progress.unlocked === 2, 'unlocked === 2(B 以上解锁下一首)'); await flush(); assert(voicePlayed('voice/grade_s'), 'grade_s 播报(结算语音链首句)'); // —— 旁白跳过②:结算语音链播放中单击 = 跳下一句(immediateTap 下延迟 500ms // 裁决生效),不是"再来一遍" —— await waitFor(() => !!audio._voice, '结算语音链开始'); G('tap'); await advance(600); assert(game.phase === 'end', '结算播报中单击 = 跳句(phase 保持 end,未误触重开)'); assert(lastVoice() === 'voice/new_best_combo', '单击跳过 grade_s → 链推进到 new_best_combo'); // —— 结算 BGM:语音链播完后淡入 bgm/menu(10 文档 §5);§13:起播点不早于链末句 —— await waitFor(() => audio._bgm && audio._bgm.id === 'bgm/menu', '结算语音链播完后 menu pad 淡入'); assert(voicePlayed('voice/unlock_song2'), 'unlock_song2 播报(语音链后段)'); { const menuPlayIdx = playLog.findIndex((p) => p.id === 'bgm/menu'); const restartIdx = playLog.map((p) => p.id).lastIndexOf('voice/restart'); assert(menuPlayIdx > restartIdx && restartIdx >= 0, '结算 menu pad 在语音链末句之后起播(不骑链尾)'); } // —— 结算单击 = 同曲再来一遍;menu pad 应被 stopBgm(400) 停掉 —— G('tap'); await advance(600); await flush(); assert(game.phase === 'main' && game._songIdx === 0, '结算单击 = 同曲再来一遍'); assert(audio._bgmWanted === null && audio._bgm === null, '选定曲目后 menu pad 停止(stopBgm(400))'); assert(srcLog.filter((s) => s.url && s.url.includes('/bgm/track90')).length >= 2, '曲目一 BGM 重新按网格启动'); // —— 钩子快进结算 → 右滑 = 下一首(song2 已解锁) —— game._finish(game._runId); await flush(); assert(game.phase === 'end', '钩子:曲目一二次结算'); G('right'); await flush(); assert(game.phase === 'main' && game._song.id === 'song2', '结算右滑 = 下一首(song2 进入 main)'); assert(srcLog.some((s) => s.url && s.url.includes('/bgm/track105')), 'song2 BGM track105 启动'); // —— 菜单暂停恢复:下滑呼出(无临近 down 音符)→ 关菜单后时刻平移 + BGM 断点续播 —— await waitFor(() => game._cues.length > 0 && game._bgmT0 > 0, 'song2 排程就绪'); await runUntil(game._bgmT0 + 1.5); // BGM 已起、首个 cue 尚远(首个 cue ≈ bgmT0+5.6s) const expBefore = game._cues.map((c) => c.expected); const bgmT0Before = game._bgmT0; const phaseEndBefore = game._phaseEnd; const menuOpenAt = nowMs; G('down'); await flush(); assert(game.phase === 'menu', '下滑折中:无临近 down 音符时下滑 = 呼出菜单'); assert(!playLog.some((p) => p.id === 'bgm/menu' && p.t >= menuOpenAt), '暂停菜单保持静默(不叠 menu pad,10 文档 §5)'); assert(voicePlayed('voice/menu_main'), '菜单语音 menu_main 播报'); // —— 暂停真冻结(§13,live round1 P1):菜单期间判定与谱面全停 —— const missAtMenu = game._stats.miss; const srcAtMenu = srcLog.length; await advance(2000); // 菜单停留 2s(menu_main 播完、menu_resume 在播) assert(game._stats.miss === missAtMenu, '菜单开启 2s:无新 Miss(判定冻结)'); assert(srcLog.length === srcAtMenu, '菜单开启 2s:无新排程音符(谱面冻结)'); G('down'); // 关菜单 await flush(); assert(!audio.isVoicePlaying(), '关菜单即停菜单语音链(菜单语音不骑恢复后的领奏)'); const pauseDur = (nowMs + 100 - menuOpenAt) / 1000; // resumeAt = 关菜单时刻 +0.1 assert(game.phase === 'main', '关菜单恢复 main'); const shift = game._bgmT0 - bgmT0Before; assert(Math.abs(shift - pauseDur) < 1e-6, `_bgmT0 平移量 = 暂停时长(${shift.toFixed(3)}s ≈ ${pauseDur.toFixed(3)}s)`); assert(game._cues.every((c, i) => Math.abs(c.expected - expBefore[i] - shift) < 1e-6), '全部 cue.expected 同步平移'); assert(Math.abs(game._phaseEnd - phaseEndBefore - shift) < 1e-6, '_phaseEnd 同步平移'); const lastTrack105 = srcLog.filter((s) => s.url && s.url.includes('/bgm/track105')).pop(); assert(lastTrack105 && lastTrack105.offset > 0, `BGM 断点续播(offset=${lastTrack105 && lastTrack105.offset.toFixed(2)}s > 0)`); // —— Miss:窗口内做错手势 → 不播该音符的乐器音 + 闷镲 Miss 音 + error 震动 + combo 清零 —— const missCue = game._cues.find((c) => !c.judged && c.gesture === 'tap'); await runUntil(missCue.expected + 0.1); const kickPlaysBefore = playLog.filter((p) => p.id === 'sfx/inst_kick').length; const srcBeforeMiss = srcLog.length; const vibBefore = vibLog.length; game._stats.combo = 5; // 制造非零 combo,验证清零 G('left'); // 错手势(该 cue 期望 tap,附近无 left cue) await flush(); assert(missCue.result === 'miss', '窗口内错手势判 Miss'); assert(playLog.filter((p) => p.id === 'sfx/inst_kick').length === kickPlaysBefore, 'Miss 不播该音符的乐器音(错过=没出声)'); assert(srcLog.slice(srcBeforeMiss).some((s) => s.url && s.url.includes('inst_hat_l')), 'Miss 有闷镲反馈音(inst_hat_l 降调复用,§13)'); assert(vibLog.slice(vibBefore).some((p) => Array.isArray(p) && p[0] === 60), 'Miss 触发 error 震动'); assert(game._stats.combo === 0, 'Miss 后 combo 清零'); // —— 下滑折中②:临近 down 应答音符时下滑 = 演奏判定,不开菜单 —— for (const cue of game._cues) { // 把 down cue 之前的未判定音符应答掉 if (cue.judged || cue.gesture === 'down') continue; await answerCue(cue); if (game._cues.some((c) => c.gesture === 'down' && !c.judged && c.expected - nowSec() < 4)) break; } const downCue = game._cues.find((c) => !c.judged && c.gesture === 'down'); assert(!!downCue, '找到 song2 的 down 应答音符'); await runUntil(downCue.expected + 0.15); // 真实滑动识别延迟 +0.15s(补偿后 dt≈0) G('down'); await flush(); assert(downCue.result === 'perfect', '临近 down 音符时下滑 = 演奏判定(Perfect)'); assert(game.phase === 'main', '演奏判定后不开菜单(phase 保持 main)'); assert(playLog.some((p) => p.id === 'sfx/inst_tom_lo'), 'down 命中播放 inst_tom_lo'); // —— 下滑折中③(§13,live round1 P0):慢滑(识别 +0.35s)踩点下滑仍判演奏 —— const downCue2 = game._cues.find((c) => !c.judged && c.gesture === 'down'); assert(!!downCue2, '找到 song2 的第二个 down 应答音符'); await runUntil(downCue2.expected + 0.35); G('down'); await flush(); assert(downCue2.result === 'good', '慢滑(识别 +0.35s)踩点下滑判 Good,不弹菜单'); assert(game.phase === 'main', '慢滑下滑后 phase 保持 main(未开菜单)'); // —— 长按定义(§13,live round1 P0):Miss 反馈音 + error 震动,不判定不归属 —— const judgedBeforeLp = game._cues.filter((c) => c.judged).length; const srcBeforeLp = srcLog.length; const vibBeforeLp = vibLog.length; G('longpress'); await flush(); assert(game._cues.filter((c) => c.judged).length === judgedBeforeLp, '长按不判定任何音符'); assert(srcLog.slice(srcBeforeLp).some((s) => s.url && s.url.includes('inst_hat_l')), '长按播 Miss 反馈音(死输入给定义)'); assert(vibLog.slice(vibBeforeLp).some((p) => Array.isArray(p) && p[0] === 60), '长按触发 error 震动'); G('longpressend'); await flush(); // —— 结算末位:song3 结算页右滑 → 播 restart,不播 song_locked(round3-4 裁定①回归) —— game._startSong(game._runId, 2); game._finish(game._runId); await flush(); assert(game.phase === 'end' && game._songIdx === 2, '钩子:song3 结算页'); const playMark = playLog.length; G('right'); await flush(); const newVoices = playLog.slice(playMark).map((p) => p.id); assert(newVoices.includes('voice/restart'), '末位曲目右滑 → 播 voice/restart'); assert(!newVoices.includes('voice/song_locked'), '末位曲目右滑 → 不播 song_locked'); // —— 回大厅(§13,live round1 P1):确认语音 + leaving 收尾态 + 真实跳转 —— G('down'); await flush(); assert(game.phase === 'leaving', '结算屏下滑进入 leaving(收尾态,不再受理手势)'); assert(voicePlayed('voice/menu_lobby'), '回大厅前播确认语音 menu_lobby'); await waitFor(() => window.location.href === '../', '确认语音后跳转大厅'); assert(gestures <= 200, `手势预算内(${gestures} ≤ 200)`); // ================= 场景 B:二周目选曲界面(menu pad + 选曲播报跳过 + 负偏移保护) ================= console.log('\n[场景 B] 二周目:选曲界面 menu pad / 播报跳过 / 锁定曲 / 暂停负偏移保护'); await newWorld({ tutorialDone: true, unlocked: 1, grades: {}, bestCombo: 0 }); game.start(); await flush(); assert(lastVoice() === 'voice/welcome_back', '二周目播 welcome_back(缩减开场)'); await waitFor(() => game.phase === 'select', '进入选曲界面'); assert(audio._bgm && audio._bgm.id === 'bgm/menu', '选曲界面播放 bgm/menu(10 文档 §5)'); await waitFor(() => !!audio._voice, 'select_hint 播报中'); G('tap'); // 跳过 select_hint → song1_name(immediateTap 下延迟 500ms 裁决) await advance(600); assert(game.phase === 'select' && lastVoice() === 'voice/song1_name', '选曲播报单击 = 跳下一句(phase 保持 select)'); G('right'); // 切到 song2(未解锁) await advance(600); // 序曲 tap 裁决窗:右滑不得误触发"选定曲目" assert(game.phase === 'select', '选曲右滑的序曲 tap 被作废(phase 保持 select,未误选)'); assert(game._songIdx === 1 && lastVoice() === 'voice/song_locked', '右滑切到未解锁曲目 → song_locked'); G('tap'); // 跳过 song_locked await advance(600); assert(game.phase === 'select', 'song_locked 可跳过且不离开选曲'); G('tap'); // 序曲 tap(模拟 immediateTap 副作用:左滑的触摸起点) G('left'); // 随后滑动到达 → 序曲 tap 应被作废 await advance(600); assert(game.phase === 'select' && game._songIdx === 0, '序曲 tap 被随后滑动作废;左滑正常切回 song1'); await waitFor(() => !audio._voice, '选曲播报播完'); G('tap'); // 无语音时单击 = 确认选曲 await advance(600); await flush(); assert(game.phase === 'main' && game._song.id === 'song1', '无语音时单击 = 选定曲目进 main'); assert(audio._bgmWanted === null && audio._bgm === null, '进曲目后 menu pad 停止'); // 负偏移保护:BGM 起点(now+1.0)前开菜单,恢复时 offset 不得为负(真实环境负 offset 会抛异常) await waitFor(() => game._cues.length > 0 && game._bgmT0 > 0, 'song1 排程就绪'); G('down'); await flush(); assert(game.phase === 'menu', 'BGM 起点前开菜单'); await advance(1500); G('down'); await flush(); const restarted = srcLog.filter((s) => s.url && s.url.includes('/bgm/track90')).pop(); assert(game.phase === 'main' && restarted && restarted.offset >= 0, `断点续播 offset 非负(${restarted && restarted.offset})——负偏移保护生效`); // ================= 场景 C:教学失败 2 次 → retry_skip 跳过该步 ================= console.log('\n[场景 C] 教学失败重教 → 2 次失败跳过该步 → 下滑跳教学'); await newWorld(); game.start(); await flush(); G('tap'); // 跳 welcome(immediateTap 下延迟 500ms 裁决) await advance(600); await waitFor(() => game.phase === 'tut' && game._cues.length > 0, 'T1 领奏'); // 失败①:做错手势 await runUntil(game._cues[0].expected + 0.3); G('left'); // T1 期望 tap await runUntil(game._phaseEnd + 0.2); await flush(); assert(game._tutFails === 1 && lastVoice() === 'voice/retry_gesture', '失败 1 次 → retry_gesture 纠正重教'); await waitFor(() => game._tutAttempts.length === 0 && game._cues.length > 0 && game.phase === 'tut', 'T1 重新领奏'); // 失败②:完全不出手 await runUntil(game._phaseEnd + 0.2); await flush(); assert(game._tutFails === 2, '同一步失败 2 次'); assert(voicePlayed('voice/retry_skip'), '播 retry_skip,跳过该步不卡死'); await waitFor(() => game._tutIdx === 1 && game.phase === 'tut' && game._cues.length > 0, '进入 T2'); assert(true, '流程推进到 T2'); // 下滑 = 跳过整个教学 G('down'); await flush(); assert(voicePlayed('voice/tut_skip'), '下滑跳教学播 tut_skip'); await waitFor(() => game.phase === 'main', '跳教学后进入曲目一'); assert(game._progress.tutorialDone === true, '跳教学同样持久化 tutorialDone'); // ================= 场景 C2:教学全程零通过 → 收尾播中性句不夸"很好"(§13) ================= console.log('\n[场景 C2] 教学 5 步全部失败跳过 → retry_skip 收尾,不播 tut_done'); await newWorld(); game.start(); await flush(); G('tap'); // 跳 welcome await advance(600); for (let step = 0; step < 5; step++) { await waitFor(() => game._tutIdx === step && game.phase === 'tut' && game._cues.length > 0, `C2 T${step + 1} 领奏`); const leadMark = game._cues[0].expected; await runUntil(game._phaseEnd + 0.2); // 失败①:完全不出手 await flush(); // 零尝试时 attempts 恒为空——必须等"重教的新领奏"(cues 预期时刻刷新), // 否则条件瞬间为真、下一步 runUntil(Infinity) 直接跑完整个教学 await waitFor(() => game._tutFails === 1 && game._cues.length > 0 && game._cues[0].expected !== leadMark && game.phase === 'tut', `C2 T${step + 1} 重教`); await runUntil(game._phaseEnd + 0.2); // 失败②:完全不出手 → 跳过该步 await flush(); } await waitFor(() => game.phase === 'main', 'C2 零通过后进入曲目一'); assert(game._tutPassedAny === false, 'C2 全程零通过(_tutPassedAny === false)'); assert(voicePlayed('voice/retry_skip'), 'C2 零通过收尾播 retry_skip(中性句)'); assert(!voicePlayed('voice/tut_done'), 'C2 零通过不播 tut_done(不夸"很好")'); // ================= 场景 C3:滞留语音链看门狗作废(§13,live 实测深修回归) ================= console.log('\n[场景 C3] 教学纠正链被跳教学顶掉后,滞留看门狗不得把游戏拖回 tut'); await newWorld(); game.start(); await flush(); G('tap'); // 跳 welcome await advance(600); await waitFor(() => game.phase === 'tut' && game._cues.length > 0, 'C3 T1 领奏'); await runUntil(game._phaseEnd + 0.2); // 失败①:不出手 → 纠正链 [retry_listen, retry_listen] await flush(); assert(game._tutFails === 1 && audio.isVoicePlaying(), 'C3 纠正链在播(fail=1)'); G('down'); // 下滑跳教学:tut_skip 链顶掉纠正链(其看门狗 10s 后到期) await flush(); assert(voicePlayed('voice/tut_skip'), 'C3 下滑跳教学播 tut_skip'); await waitFor(() => game.phase === 'main', 'C3 跳教学后进入曲目一'); await advance(12000); // 越过滞留看门狗的 10s 窗口 assert(game.phase === 'main', 'C3 滞留看门狗已作废:12s 后仍在 main(未被拖回 tut)'); // ================= 场景 D:语音资产缺失 → 看门狗推进,不卡死不崩 ================= console.log('\n[场景 D] 看门狗:屏蔽 welcome 资产'); await newWorld(); blocked.push('/welcome.'); const errBefore = consoleErrors; game.start(); await flush(); await waitFor(() => game.phase === 'tut' && game._cues.length > 0, 'welcome 缺失时看门狗 8s 推进到 T1 领奏'); assert(consoleErrors > errBefore, '缺失资产仅 console.error'); assert(game.phase === 'tut', 'welcome 缺失不卡死(看门狗触发进教学)'); // ================= 场景 E:全局通用断言(§7 摘取引擎可测项) ================= console.log('\n[场景 E] 全局:解锁前静默 / 设置边界'); { const locked = new WebAudioEngine('audio/'); const fetchMark = fetchLog.length; locked.play('voice/tut_1'); await flush(); assert(fetchLog.length === fetchMark, '音频解锁前 play 静默跳过(§7.1)'); } await newWorld(); settings.set({ voice: 'male' }); audio.play('voice/rules'); await flush(); assert(fetchLog.some((u) => String(u).includes('/voice/male/rules.')), 'settings.voice=male 后按男性目录解析(§7.3)'); settings.set({ speed: 1.7 }); audio.play('voice/rules'); await flush(); assert(audio._voice && audio._voice.st.tempo === 1.7, 'settings.speed=1.7 时下一句 tempo=1.7(SoundTouch 不变调)'); settings.set({ voice: 'female', speed: 1.0 }); const bgmFetchMark = fetchLog.length; settings.set({ bgm: 0 }); audio.play('bgm/menu'); await flush(); assert(!fetchLog.slice(bgmFetchMark).some((u) => String(u).includes('/bgm/menu')), 'bgm=0 时 BGM 不加载(§7.3)'); assert(audio._bgm === null && audio._bgmWanted && audio._bgmWanted.id === 'bgm/menu', 'bgm=0 只记"想播"意图'); settings.set({ bgm: 1.0 }); await flush(); assert(audio._bgm && audio._bgm.id === 'bgm/menu', 'bgm 调回非 0 自动补播最后状态 BGM'); // ================= 场景 F:live round2 判定治理(序曲 tap 撤销 / 超时复活 / 下滑不弹菜单 / 菜单去抖) ================= console.log('\n[场景 F] live round2:tapcancel 撤销 / 超时 Miss 复活 / 正片下滑不弹菜单 / 菜单去抖独占'); // —— F3 教学:tapcancel 撤销序曲 tap 尝试记录(转滑动/长按的序曲不算出手)—— await newWorld(); game.start(); await flush(); G('tap'); // 跳 welcome(延迟 500ms 裁决) await advance(600); await waitFor(() => game.phase === 'tut' && game._cues.length > 0, 'F3 T1 领奏'); await runUntil(game._cues[0].expected); G('tap'); // 序曲 tap(T1 是 tap 音符,先记尝试) await flush(); assert(game._tutAttempts.length === 1, 'F3 序曲 tap 先记教学尝试'); G('tapcancel'); // 引擎补发:本次按下转滑动/长按 → 序曲 tap 作废 await flush(); assert(game._tutAttempts.length === 0, 'F3 tapcancel 撤销教学尝试记录(live round2 P0)'); // —— 正片判定治理:钩子直进 song2(105BPM,含 3 个 down 音符)—— await newWorld({ tutorialDone: true, unlocked: 3, grades: {}, bestCombo: 0 }); game.start(); await flush(); await waitFor(() => game.phase === 'select', 'F 进入选曲'); game._startSong(game._runId, 1); await flush(); await waitFor(() => game.phase === 'main' && game._cues.length > 0, 'F song2 排程就绪'); // —— F1 tapcancel 撤销序曲 tap 命中(声音已出收不回,判定必须撤销)—— const fTapCue = game._cues.find((c) => c.gesture === 'tap'); await runUntil(fTapCue.expected); G('tap'); await flush(); assert(fTapCue.result === 'perfect', 'F1 序曲 tap 先按 tap 音符判定(Perfect)'); assert(game._tapJudg.length === 1, 'F1 序曲 tap 判定已记账'); G('tapcancel'); // 引擎补发:转滑动/长按 → 序曲 tap 作废 await flush(); assert(fTapCue.judged === false && fTapCue.result === null, 'F1 tapcancel 撤销序曲 tap 判定(音符回到未判定,不计 Miss)'); assert(game._stats.perfect === 0 && game._stats.combo === 0, 'F1 命中统计回滚(perfect/combo 归零)'); await runUntil(fTapCue.expected + 0.2); G('tap'); // 重新接打(撤销不是免判) await flush(); assert(fTapCue.result === 'good', 'F1 撤销后重新接打 → Good'); // —— F2 双击序曲治理(tap+tap+doubletap 序列的判定归属)—— const fDblCue = game._cues.find((c) => !c.judged && c.gesture === 'doubletap'); // phrase2 beat2 await runUntil(fDblCue.expected - 0.2); G('tap'); // 序曲 tap1(双击第一下按下,自由演奏) await flush(); await advance(200); G('tap'); // 序曲 tap2(双击第二下按下) await flush(); await advance(250); const fMiss2 = game._stats.miss; G('doubletap'); // 双击改判到达(识别在第二次 touchend) await flush(); assert(fDblCue.result === 'perfect', 'F2 双击改判 Perfect(序曲 tap 撤销后判定)'); assert(game._stats.miss === fMiss2, 'F2 序曲 tap 不产生 Miss(爆音不进判定链)'); assert(fTapCue.result === 'good', 'F2 双击撤销窗不误伤更早的真实 tap 命中'); assert(game._tapJudg.length === 1, 'F2 两笔序曲 tap 账已撤销(账簿只剩真实命中)'); // —— F2c 序曲 tap 误认领命中 → 双击到达时撤销;撤销后真不接 → 超时 Miss 兜底 —— const fTapCue2 = game._cues.find((c) => !c.judged && c.gesture === 'tap'); // phrase2 beat3 await runUntil(fTapCue2.expected); G('tap'); // 序曲 tap1 误认领(其实是双击第一下) await flush(); assert(fTapCue2.result === 'perfect', 'F2c 序曲 tap 先认领命中'); const fPerf2c = game._stats.perfect; await advance(200); G('tap'); // 序曲 tap2(自由演奏) await flush(); await advance(250); G('doubletap'); // 双击改判 → 两笔序曲判定一并撤销(本次双击附近无 doubletap 音符,自由演奏) await flush(); assert(fTapCue2.judged === false && fTapCue2.result === null, 'F2c 双击到达撤销序曲 tap 误认领的命中'); assert(game._stats.perfect === fPerf2c - 1, 'F2c Perfect 统计回滚'); await runUntil(fTapCue2.expected + 0.55); // 越过 tap 超时死线(+0.45) await flush(); assert(fTapCue2.result === 'miss', 'F2c 撤销后真不接 → 超时 Miss 兜底(撤销不是免死)'); // —— F4 超时 Miss 复活:识别延迟把音符拖死、手势还在路上(live round2 事件流实证)—— const fUpCue = game._cues.find((c) => !c.judged && c.gesture === 'up'); // phrase3 beat1 await runUntil(fUpCue.expected + 0.64); // 超时死线 +0.60 已过:泵先判 Miss(滑动回调尚未到达) await flush(); assert(fUpCue.result === 'miss' && fUpCue.missKind === 'timeout', 'F4 上滑音符先被超时拖死(事件流同款时序)'); const fMiss4 = game._stats.miss; G('up'); // 慢滑回调此刻才到(触摸 expected+0.49、触程 0.15) await flush(); assert(fUpCue.result === 'good', 'F4 超时误判复活改判 Good(补偿 dt=+0.49 ≤ REVIVE_DT_S)'); assert(game._stats.miss === fMiss4 - 1, 'F4 复活回滚 Miss 统计'); // —— F5 正片踩点下滑不弹菜单(live round2 P1 续修:超时拖死 ≠ 无临近 down 音符)—— const fDownCue = game._cues.find((c) => !c.judged && c.gesture === 'down'); // phrase3 beat3 await runUntil(fDownCue.expected + 0.64); // 死线 +0.60 已过,下滑回调 +0.64 才到(round2 实测差 2~114ms) await flush(); assert(fDownCue.result === 'miss' && fDownCue.missKind === 'timeout', 'F5 down 音符先被超时拖死'); const fTomMark = playLog.length; G('down'); await flush(); assert(game.phase === 'main', 'F5 正片踩点下滑不弹菜单(live round2 P1 续修)'); assert(fDownCue.result === 'good', 'F5 下滑复活改判 Good'); assert(playLog.slice(fTomMark).some((p) => p.id === 'sfx/inst_tom_lo'), 'F5 下滑复活播放 inst_tom_lo'); // —— F5b 窗口内晚下滑(超复活时差上限):不弹菜单、Miss 维持、tom_lo 自由演奏 —— const fDownCue2 = game._cues.find((c) => !c.judged && c.gesture === 'down'); // phrase7 beat3 await runUntil(fDownCue2.expected + 1.1); // 死线过 ~0.5s(账龄仍在 REVIVE_WINDOW_S 内),dt 超 REVIVE_DT_S await flush(); assert(fDownCue2.result === 'miss', 'F5b down 音符先被超时拖死'); const fTomMark2 = playLog.length; G('down'); await flush(); assert(game.phase === 'main', 'F5b 窗口内晚下滑仍不弹菜单(瞄准 down 音符证据明确)'); assert(fDownCue2.result === 'miss', 'F5b 超复活时差上限,Miss 维持不复活'); assert(playLog.slice(fTomMark2).some((p) => p.id === 'sfx/inst_tom_lo'), 'F5b tom_lo 自由演奏出声'); // —— F5c 窗口过后下滑正常呼出菜单(折中不破)—— await advance(300); // downCue2 的 Miss 账龄 > REVIVE_WINDOW_S G('down'); await flush(); assert(game.phase === 'menu', 'F5c 窗口过后下滑正常呼出菜单'); // —— F6 菜单去抖独占期(live round2 P1 瞬穿):开菜单 1s 内手势不穿透 —— assert(audio.isVoicePlaying(), 'F6 菜单语音 menu_main 在播'); G('doubletap'); // 开菜单 <1s:路上手势(双击)不得穿透菜单确认 await flush(); assert(game.phase === 'menu', 'F6 去抖期内双击不穿透菜单(phase 保持 menu)'); assert(audio.isVoicePlaying(), 'F6 去抖期内菜单语音不被掐断'); G('down'); // 去抖期内下滑也不得关菜单 await flush(); assert(game.phase === 'menu', 'F6 去抖期内下滑不穿透菜单'); await advance(1100); // 越过 1s 去抖独占期 G('doubletap'); // 双击确认"继续演奏" await flush(); assert(game.phase === 'main', 'F6 去抖期后双击正常确认(恢复演奏)'); // ================= 资产引用校验(fs:ogg+m4a 双格式零缺失) ================= console.log('\n[资产校验] game.js 引用的 id 对照 audio/ 目录'); { const src = readFileSync(new URL('../game.js', import.meta.url), 'utf8'); const ids = new Set(); for (const m of src.matchAll(/['"`]((?:voice|sfx|bgm)\/[a-z0-9_]+)['"`]/g)) ids.add(m[1]); if (src.includes('voice/grade_${grade}')) for (const g of ['s', 'a', 'b', 'c']) ids.add(`voice/grade_${g}`); const missing = []; const has = (p) => existsSync(new URL(p, import.meta.url)); for (const id of ids) { const [layer, name] = id.split('/'); if (layer === 'voice') { const dual = ['male', 'female'].every((g) => has(`../audio/voice/${g}/${name}.ogg`) && has(`../audio/voice/${g}/${name}.m4a`)); const flat = has(`../audio/voice/${name}.ogg`) && has(`../audio/voice/${name}.m4a`); if (!dual && !flat) missing.push(id + '(voice 需双性别或平铺 ogg+m4a)'); } else if (!has(`../audio/${layer}/${name}.ogg`) || !has(`../audio/${layer}/${name}.m4a`)) { missing.push(id + '(缺 ogg 或 m4a)'); } } console.log(` 共引用 ${ids.size} 个资产 id`); assert(missing.length === 0, `资产零缺失${missing.length ? '——缺失: ' + missing.join(', ') : ''}`); } // ================= 汇总 ================= console.log('\n========== 汇总 =========='); assert(uncaught.length === 0, `全程无未捕获异常(§7.2)${uncaught.length ? '——' + uncaught.map(String).join('; ') : ''}`); console.log(`通过 ${passed} 项,失败 ${failed} 项`); process.exit(failed > 0 ? 1 : 0);