Files
SoundGame/web/rhythm/test/playtest.mjs
T
xsl ae850a4ce0 feat(rhythm): 鼓手模式重构——屏幕六区鼓组 + 无鼓伴奏合拍(用户裁定重构)
- 屏幕3列×2行六区鼓组(列=声像左中右/行=音高上亮下沉),六件程序合成打击乐,三重冗余蒙眼可辨
- 无鼓伴奏BGM上跟随领奏 call&response + 加花句;3曲90/105/120 BPM解锁制,BPM实测89.99/105.00/120.01
- 引擎后向兼容扩展:onGesture补{x,y}坐标(单参回调零感知,15游戏+3引擎测试全量回归零失败)
- 判定简化:全部音符=分区点按零延迟,旧双击补偿/下滑折中退役;键盘Digit1-6+空格=家区
- 教学摸鼓认区(摸哪报哪,8s无触摸自动进阶)+重教≤2+降速DDA+归因真话律
- 保留旧作好评:跟随领奏核心/S~C评级/解锁制/判定口径/guide工程遗产
- 语音49id双性别+8key;R1+R2连续2轮4/4愿意玩P0=0裁定可交付;live直证33 Perfect dt≈0.00s
- 03号文档仅文末加取代指针;新设计24号;档案research/rhythm-drummer/;Mureka累计$1.35
2026-09-14 20:17:10 +08:00

755 lines
39 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/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);