Files
SoundGame/web/rhythm/test/playtest.mjs
T

706 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 节拍行者(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', {
value: { 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);