/** * 节拍行者(rhythm)鼓手模式无头试玩测试 —— docs/design/24-rhythm-drummer.md §11(A1-A22) * + 全局通用断言(13 号文档 §7 摘取引擎可测项)。 * * 方法:mock 浏览器/WebAudio 最小接口(window/document/navigator/localStorage/ * fetch/AudioContext),加载**真实** engine(audio.js/settings.js/haptics.js) * 与 game.js;手势带坐标注入 game.onGesture(g, info)(引擎坐标透传契约);键盘 * 数字键直接调 game._onKeyDown;虚拟时钟驱动 setTimeout/setInterval/ * performance.now/ctx.currentTime,voice 链由引擎真实结束定时器推进,零真实等待。 * * 运行(仓库根):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 = 60000) { 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', { value: { maxTouchPoints: 1, vibrate: (p) => { vibLog.push(p); return true; } }, configurable: true, }); // 分区坐标系:root rect 300×600 → 列界 100/200,行界 300(zoneOf 三等分/对半) const RECT = { left: 0, top: 0, width: 300, height: 600 }; const ZC = { TL: { x: 50, y: 100 }, TC: { x: 150, y: 100 }, TR: { x: 250, y: 100 }, BL: { x: 50, y: 500 }, BC: { x: 150, y: 500 }, BR: { x: 250, y: 500 }, }; const mkEl = () => ({ id: '', className: '', textContent: '', src: '', alt: '', offsetWidth: 0, style: { setProperty() {} }, getBoundingClientRect: () => ({ ...RECT, right: RECT.width, bottom: RECT.height }), 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() {} }; } createOscillator() { return { type: 'sine', frequency: new MockParam(), connect() {}, start() {}, stop() {} }; } createStereoPanner() { return { pan: new MockParam(0), 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 putBodies = []; // /api/progress PUT body const blocked = []; // 子串命中即 404(看门狗测试用) globalThis.fetch = async (url, opts) => { fetchLog.push(url); if (typeof url === 'string' && url.startsWith('/api/')) { if (url === '/api/progress' && opts && opts.body) putBodies.push(String(opts.body)); 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.progress.rhythm'; const AUTH_KEY = 'soundgame.auth'; 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; putBodies.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()); gestures = 0; return game; } function G(g, info) { gestures++; game.onGesture(g, info); } const key = (code) => game._onKeyDown({ code, repeat: false, preventDefault() {} }); 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); // 跳 intro 旁白链剩余句(welcome/tut_map 可能已被自然播完或跳过,跳满 3 次 // 后等探针态——多跳的 tap 落在探针态 = 摸 BC 家区,不影响后续断言口径) async function skipIntro() { for (let i = 0; i < 3; i++) { G('tap'); await advance(600); } await waitFor(() => game.phase === 'tut' && game._tutStage === 'probe', '进入摸鼓认区'); } // ================= 场景 A:首局关键路径(摸鼓认区→教学→曲目一 S→解锁→结算导航) ================= console.log('\n[场景 A] 首局:A1 分区映射 / A7 摸鼓认区 / 教学 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 旁白开始播放'); await advance(1400); assert(voicePlayed('voice/tut_map'), 'welcome 播完自然入链 tut_map(双轴模型讲解)'); // —— A1 分区映射:九宫边界逐一断言 —— for (const [zone, pt] of Object.entries(ZC)) { assert(game.zoneOf(pt) === zone, `A1 zoneOf(${pt.x},${pt.y}) = ${zone}`); } assert(game.zoneOf({ x: 99, y: 299 }) === 'TL', 'A1 左上角边界点归 TL'); assert(game.zoneOf({ x: 100, y: 300 }) === 'BC', 'A1 正中心点归 BC(键盘空格语义)'); assert(game.zoneOf(null) === null && game._zoneFrom(null) === 'BC', 'A1 payload 缺失兜底 BC'); // —— A21 旁白跳过:intro 语音播放中单击 = skipVoice —— G('tap'); await advance(600); assert(lastVoice() === 'voice/tut_probe', '单击跳过 tut_map → 链推进到 tut_probe'); await skipIntro(); // —— A7 摸鼓认区:每区音色+区名一次;重复摸不报名;≥3 区自动进阶 —— { const base = game._probeHeard.size; // skipIntro 多跳的 tap 可能已摸过 BC const dmMark = playLog.length; G('tap', ZC.TL); await flush(); assert(playLog.slice(dmMark).some((p) => p.id === 'sfx/dm_hat'), 'A7 摸左上播 dm_hat(区音色)'); assert(voicePlayed('voice/zone_tl'), 'A7 区名播报 zone_tl'); const tlCount = voicePlays().filter((v) => v === 'voice/zone_tl').length; G('tap', ZC.TL); await flush(); assert(voicePlays().filter((v) => v === 'voice/zone_tl').length === tlCount, 'A7 重复摸同一区不重复报名(每区一次)'); assert(game._probeHeard.size === base + 1, 'A7 探针集合只多记 1 区'); G('tap', ZC.TR); await flush(); assert(game._probeHeard.size === base + 2, 'A7 第三区(右上)入集'); G('tap', ZC.BL); await flush(); assert(game._probeHeard.size >= 3, 'A7 摸到 3 个不同区'); await waitFor(() => game._tutStage === 'step' && game._cues.length > 0, 'A7 自动进阶 T1 领奏'); } // —— A25(R1 F2):三连快速摸 → 三个区名全部播完不被掐断,教学句 queue 其后 —— { const mark = voicePlays().length; // 回到探针态(重开世界最干净) await newWorld(); game.start(); await flush(); await skipIntro(); G('tap', ZC.TL); await flush(); G('tap', ZC.TR); await flush(); G('tap', ZC.BL); await flush(); // 第三击触发进阶:区名不得被掐断 const heard = voicePlays().slice(mark >= 0 ? 0 : 0); // 新世界从头计 assert(voicePlays().filter((v) => v === 'voice/zone_tl').length === 1 && voicePlays().filter((v) => v === 'voice/zone_tr').length === 1 && voicePlays().filter((v) => v === 'voice/zone_bl').length === 1, 'A25 F2:三连摸的三个区名全部播完(第三击不被 stopVoice 掐断)'); await waitFor(() => voicePlayed('voice/tut_z_kick'), 'A25 F2:教学句排在区名链后播'); assert(voicePlays().indexOf('voice/tut_z_kick') > voicePlays().indexOf('voice/zone_bl'), 'A25 F2:教学句在区名之后(queue 链序)'); // 教学步跟打通过进正片(后续场景独立 newWorld,此处直接跳场景) } // —— 教学 T1-T3(A8 跟打通过;数拍先于领奏 = A9 教学侧)—— for (let step = 0; step < 3; step++) { await waitFor(() => game._tutIdx === step && game.phase === 'tut' && game._cues.length > 0 && game._tutAttempts.length === 0, `T${step + 1} 领奏排程`); { // A9 教学侧:数拍 4 记木鱼 click 已入 _sched,且全部早于本步最早领奏音 const leadT = Math.min(...game._cues.map((c) => c.expected - 4 * (60 / 90))); const clicks = game._sched.filter((e) => e.sfx === 'sfx/dm_accent' && e.time < leadT); assert(clicks.length === 4, `A9 教学第 ${step + 1} 步:数拍恰 4 记排程且早于领奏(实际 ${clicks.length})`); } for (const cue of game._cues) { if (cue.judged) continue; await runUntil(cue.expected); G('tap', ZC[cue.zone]); await flush(); } assert(game._tutAttempts.every((a) => a.correct && Math.abs(a.dt) <= 0.35), `T${step + 1} 应答全部正确且 Good 窗内`); await runUntil(game._phaseEnd + 0.2); 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:A9 数拍 / A2 Perfect / A14 连击 / A15 mid_song / A11 加花预告 —— 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(无鼓版)按网格启动'); { await runUntil(game._bgmT0 + 4.5 * (60 / 90)); // 数拍播完、句 1 领奏已起 const kicks = srcLog.filter((s) => s.url && s.url.includes('/sfx/inst_kick') && s.when >= game._bgmT0 - 1e-6 && s.when < game._bgmT0 + 4 * (60 / 90) - 1e-6); assert(kicks.length === 4, `A9 数拍小节恰 4 记底鼓(实际 ${kicks.length})`); const firstLead = Math.min(...game._cues.map((c) => c.expected)) - 4 * (60 / 90); assert(kicks.every((k) => k.when < firstLead), 'A9 数拍全部早于句 1 领奏'); } for (const cue of game._cues) { if (cue.judged) continue; await runUntil(cue.expected); G('tap', ZC[cue.zone]); await flush(); } assert(game._stats.perfect === game._cues.length, `A2 曲目一 ${game._cues.length} 音全部 Perfect(perfect=${game._stats.perfect})`); assert(game._stats.miss === 0 && game._stats.combo === game._cues.length, 'A14 无 Miss 连击贯穿全曲'); assert(vibLog.some((p) => Array.isArray(p) && p[0] === 100), 'A14 16 连击 success 震动'); assert(vibLog.some((p) => Array.isArray(p) && p[0] === 20 && p[1] === 40), 'A14 乐句全 Perfect [20,40,20] 震动'); assert(voicePlayed('voice/mid_song'), 'A15 mid_song 曲中播报'); assert(voicePlayed('voice/tut_fill'), 'A11 曲目一终曲加花前 tut_fill 预告(每账号一次)'); assert(game._progress.tutFillDone === true, 'A11 tutFillDone 持久化'); { const fillCues = game._cues.filter((c) => c.phrase === game._song.units.length - 1); const tr = fillCues[fillCues.length - 1]; assert(tr.zone === 'TR' && Math.abs((tr.expected - 4 * (60 / 90)) % (60 / 90) - 0) < 1e-6 || tr.zone === 'TR', 'A11 加花尾音 = TR 吊镲'); } await waitFor(() => game.phase === 'end', '曲目一结算'); assert(game._progress.unlocked === 2, 'A12 unlocked === 2(B 以上解锁下一首)'); await waitFor(() => voicePlayed('voice/grade_s'), '结算链播到 grade_s'); assert(game._grade() === 's', 'A12 全 Perfect 评级 S'); assert(game._progress.grades.song1 === 's', 'A12 grades.song1 === "s"'); await waitFor(() => voicePlayed('voice/new_best_combo'), 'A12 结算链播到 new_best_combo'); // —— A16 进度同步:summary/grades/unlocked/bestCombo —— { store.set(AUTH_KEY, JSON.stringify({ token: 'tk' })); game._putProgress(); await flush(); const body = JSON.parse(putBodies[putBodies.length - 1] || '{}'); assert(body.game === 'rhythm' && body.data && /已解锁2\/3首/.test(body.data.summary), 'A16 PUT summary 含解锁进度'); assert(body.data.grades && body.data.grades.song1 === 's' && body.data.bestCombo > 0, 'A16 PUT 含 grades 与 bestCombo'); } // —— A21 结算跳句:播报中单击 = 跳下一句不重开 —— await waitFor(() => !!audio._voice, '结算语音链开始'); G('tap'); await advance(600); assert(game.phase === 'end', 'A21 结算播报中单击 = 跳句(未误触重开)'); // —— 结算 BGM:链播完淡入 menu pad,不骑链尾 —— await waitFor(() => audio._bgm && audio._bgm.id === 'bgm/menu', '结算 menu pad 淡入'); { 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 在语音链末句之后起播'); } // —— A13 结算导航:单击=同曲重开;右滑=下一首 —— G('tap'); await advance(600); await flush(); assert(game.phase === 'main' && game._songIdx === 0, 'A13 结算单击 = 同曲再来一遍'); game._finish(game._runId); await flush(); assert(game.phase === 'end', '钩子:曲目一二次结算'); G('right'); await flush(); assert(game.phase === 'main' && game._song.id === 'song2', 'A13 结算右滑 = 下一首(song2 进入 main)'); assert(srcLog.some((s) => s.url && s.url.includes('/bgm/track105')), 'song2 BGM track105(无鼓版)启动'); // ================= 场景 B:A10 菜单无条件下滑 + 暂停冻结/恢复平移 + 负偏移保护 ================= console.log('\n[场景 B] A10 菜单:main 下滑无条件呼出(无 down 音符折中)/ 冻结 / 平移 / 断点续播'); await waitFor(() => game._cues.length > 0 && game._bgmT0 > 0, 'song2 排程就绪'); // —— A10 关键差异:有未判定音符临近时下滑照样弹菜单(旧作折中已退役)—— const nearCue = game._cues.find((c) => !c.judged); await runUntil(nearCue.expected - 0.3); // 音符已临近/超窗(未判定) G('down'); await flush(); assert(game.phase === 'menu', 'A10 音符临近时下滑照样呼出菜单(无条件)'); assert(!playLog.some((p) => p.id === 'bgm/menu' && p.t >= nowMs - 100), '暂停菜单保持静默(不叠 menu pad)'); assert(voicePlayed('voice/menu_main'), '菜单语音 menu_main 播报'); const missAtMenu = game._stats.miss; const srcAtMenu = srcLog.length; await advance(2000); assert(game._stats.miss === missAtMenu, 'A10 菜单开启 2s:判定冻结(无新 Miss)'); assert(srcLog.length === srcAtMenu, 'A10 菜单开启 2s:谱面冻结(无新排程)'); // 关菜单(guide off:无 gd_count 即时恢复) const bgmT0Before = game._bgmT0; const expBefore = game._cues.map((c) => c.expected); G('down'); await flush(); assert(!audio.isVoicePlaying(), '关菜单即停菜单语音链'); assert(game.phase === 'main', '关菜单恢复 main'); const pauseDur = (nowMs + 100 - (nowMs - 0)) && (game._bgmT0 - bgmT0Before); assert(Math.abs(pauseDur - (2000 + 100) / 1000) < 0.01, `A10 _bgmT0 平移 ≈ 暂停时长(${pauseDur.toFixed(2)}s)`); assert(game._cues.every((c, i) => Math.abs(c.expected - expBefore[i] - pauseDur) < 1e-6), 'A10 全部 cue.expected 同步平移'); const lastTrack105 = srcLog.filter((s) => s.url && s.url.includes('/bgm/track105')).pop(); assert(lastTrack105 && lastTrack105.offset > 0, `A10 BGM 断点续播(offset=${lastTrack105 && lastTrack105.offset.toFixed(2)}s > 0)`); // —— 负偏移保护:BGM 起点前开菜单,恢复 offset 不得为负 —— game._startSong(game._runId, 0); await flush(); 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} ≥ 0)`); // ================= 场景 C:A2/A3/A4 判定窗 + 错鼓 Miss + 自由演奏 ================= console.log('\n[场景 C] A2 判定窗 / A3 错鼓 Miss / A4 超时 Miss / 自由演奏不判定'); await newWorld({ tutorialDone: true, unlocked: 3, grades: {}, bestCombo: 0 }); game.start(); await flush(); await waitFor(() => game.phase === 'select', '进入选曲'); game._startSong(game._runId, 0); await waitFor(() => game.phase === 'main' && game._cues.length > 0, 'song1 排程就绪'); // —— A2b Good 窗:|dt|=0.25 → Good(0.15<0.25≤0.35)—— const goodCue = game._cues[0]; await runUntil(goodCue.expected + 0.25); G('tap', ZC[goodCue.zone]); await flush(); assert(goodCue.result === 'good', 'A2 |dt|=0.25 → Good'); // —— A2a Perfect 窗:|dt|=0.10 → Perfect + accent —— const perfCue = game._cues.find((c) => !c.judged); { const accMark = playLog.filter((p) => p.id === 'sfx/dm_accent').length; await runUntil(perfCue.expected - 0.10); G('tap', ZC[perfCue.zone]); await flush(); assert(perfCue.result === 'perfect', 'A2 |dt|=0.10 → Perfect'); assert(playLog.filter((p) => p.id === 'sfx/dm_accent').length === accMark + 1, 'A2 Perfect 叠 accent'); } // —— A2c 窗口外同区出手 = 自由演奏:出声不判定、不 Miss —— const freeCue = game._cues.find((c) => !c.judged); { const missMark = game._stats.miss; const sfxMark = playLog.length; await runUntil(freeCue.expected + 0.40); // > GOOD 0.35 G('tap', ZC[freeCue.zone]); await flush(); assert(freeCue.judged === false, 'A2 窗口外同区出手不判定(音符未判)'); assert(game._stats.miss === missMark, 'A2 自由演奏不计 Miss'); assert(playLog.length > sfxMark, 'A2 自由演奏照样出声'); await runUntil(freeCue.expected + 0.50); // 越过超时死线 +0.45 await flush(); assert(freeCue.result === 'miss' && freeCue.missKind === 'timeout', 'A4 无人接 → 超时 Miss(GOOD+0.10 死线)'); } // —— A3 错鼓 Miss:异分区半窗内被敲 → Miss + 区名播报 + combo 清零 —— const wrongCue = game._cues.find((c) => !c.judged); { const wrongZone = wrongCue.zone === 'BC' ? 'BL' : 'BC'; game._stats.combo = 5; const vibMark = vibLog.length; const srcMark = srcLog.length; await runUntil(wrongCue.expected - 0.10); // 半窗内(0.175) G('tap', ZC[wrongZone]); await flush(); assert(wrongCue.result === 'miss' && wrongCue.missKind === 'wrong', 'A3 半窗内敲错区 → Miss(错鼓)'); assert(game._stats.combo === 0, 'A3 错鼓 combo 清零'); assert(vibLog.slice(vibMark).some((p) => Array.isArray(p) && p[0] === 60), 'A3 错鼓 error 震动'); assert(srcLog.slice(srcMark).some((s) => s.url && s.url.includes('dm_hat')), 'A3 错鼓闷镲反馈音(dm_hat 降调)'); assert(!voicePlayed('voice/zone_bl') && !voicePlayed('voice/zone_bc'), 'A3 主片错鼓不播区名(语音配额:区名播报限教学期,错音本身=听觉归因)'); } // —— A6 双击 = 两连击:两下 tap 各按各自拍点出手,doubletap 到达无角色、无撤销 —— { const dblCues = game._cues.filter((c) => !c.judged).slice(0, 2); assert(dblCues.length === 2 && dblCues[0].zone === dblCues[1].zone, 'A6 前置:找到同区相邻双音(句 3 底鼓双击)'); const judgMark = game._tapJudg.length; for (const cue of dblCues) { await runUntil(cue.expected); G('tap', ZC[cue.zone]); await flush(); } await advance(250); const missBeforeDbl = game._stats.miss; G('doubletap'); await flush(); // 双击改判到达:演奏期无角色 assert(dblCues[0].result === 'perfect' && dblCues[1].result === 'perfect', 'A6 两连击各自独立判定(双击不撤销)'); assert(game._tapJudg.length === judgMark + 2, 'A6 账簿记两笔(doubletap 不撤账)'); assert(game._stats.miss === missBeforeDbl, 'A6 无额外 Miss(双击序曲不判死)'); } // ================= 场景 D:A5 序曲 tap 撤销(tapcancel) ================= console.log('\n[场景 D] A5 tapcancel 撤销序曲 tap 判定'); { const cue = game._cues.find((c) => !c.judged); await runUntil(cue.expected); G('tap', ZC[cue.zone]); await flush(); assert(cue.result === 'perfect', 'A5 序曲 tap 先认领命中'); const perfBefore = game._stats.perfect; G('tapcancel'); await flush(); assert(cue.judged === false && cue.result === null, 'A5 tapcancel 撤销认领(音符回未判定)'); assert(game._stats.perfect === perfBefore - 1, `A5 统计回滚(perfect=${game._stats.perfect})`); await runUntil(cue.expected + 0.50); await flush(); assert(cue.result === 'miss', 'A5 撤销后真不接 → 超时 Miss 兜底(撤销不是免死)'); } // —— 长按死输入 —— { const judgedMark = game._cues.filter((c) => c.judged).length; const vibMark = vibLog.length; G('longpress'); await flush(); assert(game._cues.filter((c) => c.judged).length === judgedMark, '长按不判定任何音符'); assert(vibLog.slice(vibMark).some((p) => Array.isArray(p) && p[0] === 60), '长按 error 震动(死输入给定义)'); G('longpressend'); await flush(); } // ================= 场景 E:A18 键盘平权 ================= console.log('\n[场景 E] A18 键盘:Digit1-6 = 六区零延迟;空格中心点 = BC;选曲态数字键无效'); { const kc = game._cues.find((c) => !c.judged && c.zone === 'BC'); assert(!!kc, 'A18 前置:找到未判定的 BC 音符'); await runUntil(kc.expected - 0.05); key('Digit5'); await flush(); // Digit5 = BC assert(kc.result === 'perfect', 'A18 Digit5 命中 BC(零延迟判定)'); const kc2 = game._cues.find((c) => !c.judged); if (kc2 && kc2.zone !== 'BC') { const map = { TL: 'Digit1', TC: 'Digit2', TR: 'Digit3', BL: 'Digit4', BR: 'Digit6' }; await runUntil(kc2.expected - 0.05); key(map[kc2.zone]); await flush(); assert(kc2.result === 'perfect', `A18 ${map[kc2.zone]} 命中 ${kc2.zone}`); } // 空格 tap:元素中心坐标 → zoneOf 判 BC assert(game.zoneOf({ x: 150, y: 300 }) === 'BC', 'A18 元素中心点(键盘空格虚拟指针)→ BC 家区'); const comboMark = game._stats.combo; const n = gestures; key('Digit1'); key('Digit6'); await flush(); // 窗口外连按:自由演奏不判定 assert(game._stats.combo === comboMark, 'A18 键盘自由演奏不判定'); } // —— 选曲态数字键不生效 —— { game._finish(game._runId); await flush(); game._startSong(game._runId, 0); await flush(); game._finish(game._runId); await flush(); game._goHome ? null : null; } await newWorld({ tutorialDone: true, unlocked: 3, grades: {}, bestCombo: 0 }); game.start(); await flush(); await waitFor(() => game.phase === 'select', 'E2 进入选曲'); { const idx = game._songIdx; key('Digit1'); key('Digit4'); await flush(); assert(game.phase === 'select' && game._songIdx === idx, 'A18 选曲态数字键无效(不切曲不进曲)'); } // ================= 场景 F:A8 教学重教(DDA 降速 + 跳步 + 归因真话律) ================= console.log('\n[场景 F] A8 教学:失败 2 次 → 降速 25% 重演(每局 1 次)→ 再败跳过;归因真话律'); await newWorld(); settings.set({ guide: 'full' }); // 归因事件(gd_miss_*)需要 guide 非 off game.start(); await flush(); await skipIntro(); // T0 摸鼓认区:补 3 区(skipIntro 多跳的 tap 可能已摸过 BC) G('tap', ZC.TL); await flush(); G('tap', ZC.TR); await flush(); G('tap', ZC.BL); await flush(); // —— T1 失败①:敲错区(BL 冒充 BC)—— const fEvents = []; game.guide.setEventHook((evt) => fEvents.push(evt)); await waitFor(() => game._tutStage === 'step' && game._cues.length > 0 && game._tutAttempts.length === 0, 'F T1 领奏'); { const cue = game._cues[0]; // BC await runUntil(cue.expected - 0.1); G('tap', ZC.BL); await flush(); // 半窗内错区 await runUntil(game._phaseEnd + 0.2); await flush(); } assert(game._tutFails === 1, 'F 失败 1 次'); assert(voicePlayed('voice/retry_wrong'), 'F 错鼓纠正 retry_wrong'); await waitFor(() => game._tutAttempts.length === 0 && game._cues.length > 0 && game._phaseEnd < 1e8, 'F T1 重教领奏'); assert(fEvents.some((e) => e.type === 'error' && e.voiceId === 'gd_miss_wrong'), 'F 归因 gd_miss_wrong 事件(链尾 idle 点补发)'); // T1 失败②:整拍不出手 → 连 2 败 → G2 DDA(降速重演重新给机会,_tutFails 归零) await runUntil(game._phaseEnd + 0.2); await flush(); assert(game._tutSlowUsed === true && game._tutRate === 0.75, 'F G2 DDA:连 2 败触发降速 25%(每教学局 1 次)'); assert(game._tutFails === 0, 'F 降速重演 = 该步重新计机会(fails 归零)'); // 降速重演:跟着慢速领奏做对(步进间隔 = spb/0.75) await waitFor(() => game._phaseEnd < 1e8 && game._tutAttempts.length === 0, 'F 慢速领奏排程'); for (const cue of game._cues) { if (cue.judged) continue; await runUntil(cue.expected); G('tap', ZC[cue.zone]); await flush(); } await runUntil(game._phaseEnd + 0.2); await flush(); assert(game._tutIdx === 1, 'F 降速重演通过 → 进 T2'); assert(game._tutRate === 1.0, 'F 做对恢复常速'); // T2 连错 2 次(不出手 ×2)→ 封顶已用 → 直接跳过 for (let round = 0; round < 2; round++) { await waitFor(() => game._tutIdx === 1 && game._phaseEnd < 1e8 && (round === 0 ? game._tutAttempts.length === 0 : true), `F T2 第 ${round + 1} 轮`); await runUntil(game._phaseEnd + 0.2); await flush(); } await waitFor(() => game._tutIdx === 2, 'F T2 跳过进 T3(DDA 封顶后走 retry_skip)'); assert(voicePlayed('voice/retry_skip'), 'F 跳步播 retry_skip'); // —— T3 失败①不出手 → 归因必须是 gd_miss_none(真话律)—— { await waitFor(() => game._tutIdx === 2 && game._cues.length > 0 && game._tutAttempts.length === 0, 'F T3 领奏'); const rlMark = voicePlays().filter((v) => v === 'voice/retry_listen').length; await runUntil(game._phaseEnd + 0.2); await flush(); assert(game._tutFails === 1, 'F T3 失败 1 次'); await waitFor(() => game._phaseEnd < 1e8, 'F T3 重教(纠正链播完)'); assert(fEvents.some((e) => e.type === 'error' && e.voiceId === 'gd_miss_none'), 'F 归因真话律:没出手 → gd_miss_none 事件'); assert(voicePlays().filter((v) => v === 'voice/retry_listen').length === rlMark + 1, 'A23 F3:没出手失败的纠正链 retry_listen 只播一次(不二连复读)'); for (const cue of game._cues) { if (cue.judged) continue; await runUntil(cue.expected); G('tap', ZC[cue.zone]); await flush(); } await runUntil(game._phaseEnd + 0.2); await flush(); } await waitFor(() => game.phase === 'main', 'F 教学完成进曲目一'); // ================= 场景 G:A12 评级分档 + A13 末位右滑 ================= console.log('\n[场景 G] A12 评级阈值 / A13 song3 末位右滑 → restart 不播 song_locked'); { const stats = { perfect: 0, good: 0, miss: 0, combo: 0, maxCombo: 0 }; game._stats = stats; // rate = (p*2+g)/(2*total),total = p+g+m(_grade 按 stats 实算)——补 miss 凑满 20 const setPgm = (p, g) => { stats.perfect = p; stats.good = g; stats.miss = 20 - p - g; }; setPgm(19, 1); // (38+1)/40 = 0.975 → S assert(game._grade() === 's', 'A12 rate 0.975 → S'); setPgm(17, 1); // 35/40 = 0.875 → A assert(game._grade() === 'a', 'A12 rate 0.875 → A'); setPgm(14, 2); // 30/40 = 0.75 → B assert(game._grade() === 'b', 'A12 rate 0.75 → B'); setPgm(10, 4); // 24/40 = 0.60 → C assert(game._grade() === 'c', 'A12 rate 0.60 → C'); } 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'), 'A13 末位曲目右滑 → 播 voice/restart'); assert(!newVoices.includes('voice/song_locked'), 'A13 末位曲目右滑 → 不播 song_locked'); } // —— C 级不解锁 / B 级解锁 —— { game._startSong(game._runId, 0); await flush(); game._stats = { perfect: 4, good: 4, miss: 12, combo: 0, maxCombo: 0 }; // rate=(8+4)/40=0.3 → C game._progress.unlocked = 1; game._finish(game._runId); await flush(); assert(game._grade() === 'c' && game._progress.unlocked === 1, 'A12 C 级不解锁下一首'); game._startSong(game._runId, 0); await flush(); game._stats = { perfect: 14, good: 2, miss: 4, combo: 0, maxCombo: 16 }; // 30/40=0.75 → B game._progress.unlocked = 1; game._finish(game._runId); await flush(); assert(game._progress.unlocked === 2, 'A12 B 级解锁下一首'); } // ================= 场景 H0:A24(R1 F1)曲尾 gd_miss 守卫 ================= console.log('\n[场景 H0] A24:曲尾 3s 内连 2 Miss 不插播 gd_miss(防半句被结算顶断)'); await newWorld({ tutorialDone: true, unlocked: 3, grades: {}, bestCombo: 0 }); settings.set({ guide: 'full' }); game.start(); await flush(); await waitFor(() => game.phase === 'select', 'H0 进入选曲'); { const events = []; game.guide.setEventHook((evt) => events.push(evt)); game._startSong(game._runId, 0); await flush(); await waitFor(() => game.phase === 'main' && game._cues.length > 0, 'H0 song1 排程'); // 把全部 cue 压到曲尾:直接改 expected 到 phaseEnd 前 2s(等价曲尾连漏) const lastTwo = game._cues.slice(-2); const t1 = game._phaseEnd - 2.2, t2 = game._phaseEnd - 1.2; lastTwo[0].expected = t1; lastTwo[1].expected = t2; for (const cue of game._cues.slice(0, -2)) { cue.judged = true; cue.result = 'perfect'; } game._stats.perfect = game._cues.length - 2; await runUntil(game._phaseEnd + 0.3); await flush(); assert(game._stats.miss === 2, 'A24 曲尾两音超时 Miss'); assert(!events.some((e) => e.type === 'error' && e.voiceId === 'gd_miss'), 'A24 F1:曲尾 3s 内不插播 gd_miss(结算链不被顶断)'); await waitFor(() => game.phase === 'end', 'A24 结算到达'); } settings.set({ guide: 'off' }); // ================= 场景 H:A19 guide 接入(full 档)+ A17 应答零语音 ================= console.log('\n[场景 H] A19 guide full 档:状态签名 / gd_answer / gd_miss;A17 应答窗零语音'); await newWorld({ tutorialDone: true, unlocked: 3, grades: {}, bestCombo: 0 }); settings.set({ guide: 'full' }); game.start(); await flush(); { const events = []; game.guide.setEventHook((evt) => events.push(evt)); await waitFor(() => game.phase === 'select', 'H 进入选曲'); assert(events.some((e) => e.type === 'state' && e.name === 'menu'), 'A19 选曲 = menu 状态签名'); game._startSong(game._runId, 0); await flush(); await waitFor(() => game.phase === 'main' && game._cues.length > 0, 'H song1 排程'); assert(events.some((e) => e.type === 'state' && e.name === 'battle'), 'A19 演奏 = battle 状态签名'); await waitFor(() => voicePlayed('voice/gd_answer'), 'A19 首个应答窗前 1 小节 gd_answer'); assert(events.some((e) => e.type === 'turn'), 'A19 turn earcon 与 gd_answer 同点'); // A17:应答窗内零 voice——把句 1 应答完,期间(领奏末音后~应答死线)无 voice 调度 const phrase0 = game._cues.filter((c) => c.phrase === 0); for (const cue of phrase0) { if (cue.judged) continue; await runUntil(cue.expected); const mark = playLog.length; G('tap', ZC[cue.zone]); await flush(); for (let i = mark; i < playLog.length; i++) { assert(!playLog[i].id.startsWith('voice/'), 'A17 应答出手不触发任何语音'); } } // A19 结算签名 game._finish(game._runId); await flush(); assert(events.some((e) => e.type === 'state' && e.name === 'settle'), 'A19 结算 = settle 状态签名'); // gd_count 每曲限 1 次:full 档关菜单先播 gd_count game._startSong(game._runId, 0); await flush(); await waitFor(() => game.phase === 'main' && game._cues.length > 0, 'H song1 再起'); G('down'); await flush(); assert(game.phase === 'menu', 'H 开菜单'); await advance(1100); G('down'); await flush(); // 关菜单 assert(voicePlayed('voice/gd_count'), 'A19 full 档恢复先播 gd_count'); await waitFor(() => game.phase === 'main', 'H gd_count 后恢复 main'); assert(game._gdCountUsed === true, 'A19 gd_count 每曲限 1 次(已用)'); } settings.set({ guide: 'off' }); // ================= 场景 I:看门狗(资产缺失不卡死)+ 全局设置边界 ================= console.log('\n[场景 I] 看门狗 / §7 全局:解锁前静默 / voice 性别目录 / speed / bgm=0'); await newWorld(); blocked.push('/welcome.'); const errBefore = consoleErrors; game.start(); await flush(); await waitFor(() => game.phase === 'tut' && game._tutStage === 'probe', 'welcome 缺失看门狗 14s 推进进摸鼓认区'); assert(consoleErrors > errBefore, '缺失资产仅 console.error 不崩'); { const locked = new WebAudioEngine('audio/'); const fetchMark = fetchLog.length; locked.play('voice/rules'); 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(§7.3)'); 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)'); settings.set({ bgm: 1.0 }); await flush(); assert(audio._bgm && audio._bgm.id === 'bgm/menu', 'bgm 调回非 0 自动补播最后状态 BGM'); // ================= 资产引用校验(A20 前半:fs ogg+m4a 双格式零缺失) ================= console.log('\n[资产校验 A20] 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, `A20 资产零缺失${missing.length ? '——缺失: ' + missing.join(', ') : ''}`); } // ================= 汇总 ================= console.log('\n========== 汇总 =========='); assert(uncaught.length === 0, `全程无未捕获异常${uncaught.length ? '——' + uncaught.map(String).join('; ') : ''}`); console.log(`通过 ${passed} 项,失败 ${failed} 项,注入手势/按键 ${gestures} 次`); process.exit(failed > 0 ? 1 : 0);