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

463 lines
23 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({ 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 期望时刻注入 Perfect 应答;tap 模拟真实回调延迟 +0.30s(补偿判定路径)
async function answerCue(cue) {
await runUntil(cue.expected + (cue.gesture === 'tap' ? 0.30 : 0));
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(链推进到 tut_1,不重头)——
assert(!!audio._voice, 'welcome 播放中(voice token 存在)');
G('tap'); // 单击跳过 welcome
await flush();
assert(lastVoice() === 'voice/tut_1', '单击跳过 welcome → 链推进到 tut_1');
// —— 教学 T1-T3:按 cues.expected 做手势(tap 注入在 expected+0.30 仍应判对)——
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} 领奏排程`);
assert(game._tutIdx === step, `教学步进 T${step + 1}(_tutIdx=${step})`);
const leadNoteCount = srcLog.filter((s) => s.url && s.url.includes('/sfx/inst_')).length;
for (const cue of game._cues) await answerCue(cue);
assert(game._tutAttempts.every((a) => a.correct && Math.abs(a.dt) <= 0.25), `T${step + 1} 应答全部正确且在 Good 窗口内`);
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 按网格启动');
const firstTapCue = game._cues.find((c) => c.gesture === 'tap');
for (const cue of game._cues) await answerCue(cue);
assert(firstTapCue.result === 'perfect', 'tap 补偿:expected+0.30s 注入的 tap 仍判 Perfect(回调时刻 -0.30s 判定)');
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 播报(结算语音链首句)');
// —— 旁白跳过②:结算语音链播放中单击 = 跳下一句,不是"再来一遍" ——
await waitFor(() => !!audio._voice, '结算语音链开始');
const phaseBeforeSkip = game.phase;
G('tap');
await flush();
assert(game.phase === 'end' && phaseBeforeSkip === 'end', '结算播报中单击 = 跳句(phase 保持 end,未误触重开)');
// —— 结算 BGM:语音链播完后淡入 bgm/menu(10 文档 §5)——
await waitFor(() => audio._bgm && audio._bgm.id === 'bgm/menu', '结算语音链播完后 menu pad 淡入');
assert(voicePlayed('voice/unlock_song2'), 'unlock_song2 播报(语音链后段)');
assert(true, '结算语音链播完后 bgm/menu 播放(引擎层)');
// —— 结算单击 = 同曲再来一遍;menu pad 应被 stopBgm(400) 停掉 ——
G('tap');
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 播报');
await advance(3000); // 菜单停留 3s
G('down'); // 关菜单
await flush();
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:窗口内做错手势 → 无乐器音 + error 震动 + combo 清零 ——
const missCue = game._cues.find((c) => !c.judged && c.gesture === 'tap');
await runUntil(missCue.expected + 0.1);
const instPlaysBefore = playLog.filter((p) => p.id.startsWith('sfx/inst_')).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.startsWith('sfx/inst_')).length === instPlaysBefore, 'Miss 不播乐器音(错过=没出声)');
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);
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');
// —— 结算末位: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');
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
await flush();
assert(game.phase === 'select' && lastVoice() === 'voice/song1_name', '选曲播报单击 = 跳下一句(phase 保持 select)');
G('right'); // 切到 song2(未解锁)
await flush();
assert(game._songIdx === 1 && lastVoice() === 'voice/song_locked', '右滑切到未解锁曲目 → song_locked');
G('tap'); // 跳过 song_locked
await flush();
assert(game.phase === 'select', 'song_locked 可跳过且不离开选曲');
G('left'); // 切回 song1
await flush();
assert(game._songIdx === 0, '左滑切回 song1');
await waitFor(() => !audio._voice, '选曲播报播完');
G('tap'); // 无语音时单击 = 确认选曲
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
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');
// ================= 场景 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');
// ================= 资产引用校验(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);