第二批反馈:引擎改造(限幅/SoundTouch不变调变速/长按快进/跳句/iOS闪光降级) + 6游戏状态BGM与试玩测试全绿

This commit is contained in:
SoundGame Dev
2026-08-30 16:45:42 +08:00
parent cc0f8c008d
commit 9a915d62c0
1163 changed files with 6346 additions and 208 deletions
+462
View File
@@ -0,0 +1,462 @@
/**
* 节拍行者(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);