round3: G1 gd_answer提前一小节(根因=排程压窗起点) + G2教学降难DDA + G3 count-in修复(根因=未重排调度队列) + G4 escape目标链 + G5 simon双击根治(重听改上滑,重录3句) + G6轮成功/破纪录 + G7 space方向呼喊 + 22语音文件 + guide93断言全绿
This commit is contained in:
+238
-3
@@ -23,6 +23,8 @@ const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..'
|
||||
const GAME_DIRS = {
|
||||
StorylineGame: path.join(ROOT, 'web', 'storyline'),
|
||||
SimonGame: path.join(ROOT, 'web', 'simon'),
|
||||
RhythmGame: path.join(ROOT, 'web', 'rhythm'),
|
||||
SpaceGame: path.join(ROOT, 'web', 'space'),
|
||||
};
|
||||
let currentGameDir = GAME_DIRS.StorylineGame;
|
||||
|
||||
@@ -53,6 +55,20 @@ globalThis.setTimeout = (cb, ms = 0) => {
|
||||
return id;
|
||||
};
|
||||
globalThis.clearTimeout = (id) => { timers.delete(id); };
|
||||
// rhythm 的 40ms 调度泵 / space 的局内定时链依赖 interval(round3 G1/G7 用例需要)
|
||||
const intervals = new Set();
|
||||
globalThis.setInterval = (cb, ms = 0) => {
|
||||
const id = timerSeq++;
|
||||
const step = () => {
|
||||
if (!intervals.has(id)) return; // 已被 clearInterval
|
||||
cb();
|
||||
if (intervals.has(id)) timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step });
|
||||
};
|
||||
intervals.add(id);
|
||||
timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step });
|
||||
return id;
|
||||
};
|
||||
globalThis.clearInterval = (id) => { intervals.delete(id); timers.delete(id); };
|
||||
Object.defineProperty(globalThis, 'performance', {
|
||||
value: { now: () => now }, configurable: true,
|
||||
});
|
||||
@@ -113,7 +129,9 @@ class MockOscillator {
|
||||
}
|
||||
}
|
||||
class MockAudioContext {
|
||||
constructor() { this.state = 'running'; this.currentTime = 0; this.destination = {}; }
|
||||
constructor() { this.state = 'running'; this.destination = {}; this.sampleRate = 44100; }
|
||||
// 跟随虚拟时钟:rhythm 用 ctx.currentTime 绝对排程(round3 G1 时间窗断言的前提)
|
||||
get currentTime() { return now / 1000; }
|
||||
createGain() { return new MockGainNode(); }
|
||||
createOscillator() { return new MockOscillator(); }
|
||||
createDynamicsCompressor() {
|
||||
@@ -123,7 +141,7 @@ class MockAudioContext {
|
||||
return n;
|
||||
}
|
||||
createBufferSource() {
|
||||
return { buffer: null, loop: false, connect() {}, start() {}, stop() {} };
|
||||
return { buffer: null, loop: false, playbackRate: new MockParam(), connect() {}, start() {}, stop() {} };
|
||||
}
|
||||
createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; }
|
||||
decodeAudioData() { return Promise.resolve({ duration: 2.0, numberOfChannels: 1, sampleRate: 44100, getChannelData: () => new Float32Array(16) }); }
|
||||
@@ -149,6 +167,8 @@ const { settings } = await import('../engine/settings.js');
|
||||
const { Guide } = await import('../engine/guide.js');
|
||||
const { StorylineGame } = await import('../storyline/game.js');
|
||||
const { SimonGame } = await import('../simon/game.js');
|
||||
const { RhythmGame } = await import('../rhythm/game.js');
|
||||
const { SpaceGame } = await import('../space/game.js');
|
||||
|
||||
// guide='full':本文件验证向导层行为(既有 playtest 以 guide='off' 验证零干扰)
|
||||
settings.set({ guide: 'full', voice: 'female', speed: 1.0, bgm: 1.0 });
|
||||
@@ -156,10 +176,11 @@ settings.set({ guide: 'full', voice: 'female', speed: 1.0, bgm: 1.0 });
|
||||
/** 组装一局:playLog + 震动/事件捕获(每个用例全新 audio+game)。 */
|
||||
function makeRig(GameClass) {
|
||||
currentGameDir = GAME_DIRS[GameClass.name];
|
||||
storageData.clear(); // 用例间进度隔离(rhythm tutorialDone / simon best 等不串场)
|
||||
const audio = new WebAudioEngine('audio/');
|
||||
const playLog = [];
|
||||
const origPlay = audio.play.bind(audio);
|
||||
audio.play = (id, opts) => { playLog.push({ id, t: now }); return origPlay(id, opts); };
|
||||
audio.play = (id, opts) => { playLog.push({ id, t: now, opts: opts || {} }); return origPlay(id, opts); };
|
||||
const game = new GameClass(audio, {});
|
||||
const events = [];
|
||||
game.guide.setEventHook((evt) => events.push({ ...evt, t: now }));
|
||||
@@ -167,6 +188,7 @@ function makeRig(GameClass) {
|
||||
return {
|
||||
audio, playLog, game, events, vibBase,
|
||||
vibrated: (pattern) => vibrateLog.slice(vibBase).some((p) => JSON.stringify(p) === JSON.stringify(pattern)),
|
||||
vibratedSince: (mark, pattern) => vibrateLog.slice(mark).some((p) => JSON.stringify(p) === JSON.stringify(pattern)),
|
||||
};
|
||||
}
|
||||
const voicePlays = (playLog, id) => playLog.filter((e) => e.id === id).length;
|
||||
@@ -364,6 +386,219 @@ realLog('== ⑤ guide=off:全部 API 静默 no-op(无事件、无 gd_* 播
|
||||
settings.set({ guide: 'full' });
|
||||
}
|
||||
|
||||
// ================================================================ ⑥ G1/G3 rhythm
|
||||
|
||||
realLog('== ⑥ G1/G3 rhythm:gd_answer 落在首个应答窗口前整整 1 小节;菜单恢复 count-in 可感知 ==');
|
||||
{
|
||||
const { audio, playLog, game, events } = makeRig(RhythmGame);
|
||||
const tSec = () => now / 1000;
|
||||
const SPB = 60 / 90; // 曲目一 90BPM
|
||||
await audio.unlock();
|
||||
game.start();
|
||||
await advance(() => audio.isVoicePlaying()); // welcome 开播
|
||||
await gesture(game, 'tap'); // 播放中单击 = 跳过 → 教学链
|
||||
await advance(() => game.phase === 'tut');
|
||||
await gesture(game, 'down'); // 跳过教学 → 曲目一 main
|
||||
await advance(() => game.phase === 'main' && game._cues.length > 0);
|
||||
assert(game._song && game._song.id === 'song1', '前提:进入曲目一 main(90BPM)');
|
||||
|
||||
// G1:gd_answer(曲谱定时队列直达)在首个应答窗口起点之前整整 1 小节
|
||||
await advance(() => playLog.some((e) => e.id === 'voice/gd_answer'));
|
||||
const ans = playLog.find((e) => e.id === 'voice/gd_answer');
|
||||
const win0 = game._cues[0].expected; // 首个应答窗口起点(秒)
|
||||
assert(ans.t / 1000 < win0, `G1:gd_answer(${(ans.t / 1000).toFixed(2)}s) < 首窗口起点(${win0.toFixed(2)}s)`);
|
||||
assert(Math.abs(win0 - ans.t / 1000 - 4 * SPB) < 0.06,
|
||||
`G1:窗口前整整 1 小节(提前 ${(win0 - ans.t / 1000).toFixed(2)}s ≈ ${(4 * SPB).toFixed(2)}s)`);
|
||||
assert(events.some((e) => e.type === 'turn'), 'G1:turn earcon 同点发出(standard 档也发,off 静默)');
|
||||
|
||||
// G3:曲中开菜单 → 关菜单恢复:full 档先播 gd_count,链播完才开始 4 拍 count-in
|
||||
await advance(() => tSec() >= game._bgmT0 + 1.5); // BGM 已起、首窗口未到
|
||||
await gesture(game, 'down');
|
||||
assert(game.phase === 'menu', 'G3:曲中下滑呼出菜单(无悬置 down 音符)');
|
||||
const kicksBefore = game._sched.filter((e) => e.sfx === 'sfx/inst_kick').length;
|
||||
await gesture(game, 'down');
|
||||
assert(voicePlays(playLog, 'voice/gd_count') === 1, 'G3:恢复先播 gd_count(full 档)');
|
||||
assert(audio.isVoicePlaying(), 'G3:count-in 在 gd_count 链播完之后才开始(语音串行)');
|
||||
await advance(() => !audio.isVoicePlaying() && game._timer !== null);
|
||||
const allKicks = game._sched.filter((e) => e.sfx === 'sfx/inst_kick').map((e) => e.time).sort((a, b) => a - b);
|
||||
assert(allKicks.length === kicksBefore + 4, 'G3:count-in 4 记底鼓排入 _sched(sfx 层音量 1.0)');
|
||||
const schedTimes = game._sched.map((e) => e.time);
|
||||
assert(schedTimes.every((t, i) => i === 0 || t >= schedTimes[i - 1]), 'G3:_sched 已按时间重排(count-in 位于队头,round2 版未重排被队头阻塞的根因)');
|
||||
const ciKicks = allKicks.slice(0, 4);
|
||||
assert(ciKicks.every((t, i) => i === 0 || Math.abs(t - ciKicks[i - 1] - SPB) < 0.05), 'G3:底鼓按一拍间距排布');
|
||||
const vibMark = vibrateLog.length;
|
||||
await advance(() => tSec() >= ciKicks[3] + 0.3);
|
||||
assert(vibrateLog.slice(vibMark).filter((p) => JSON.stringify(p) === '[8]').length >= 4,
|
||||
'G3:4 记 count-in 底鼓各带 beat 震(按拍点准时落)');
|
||||
assert(game.phase === 'main', 'G3:恢复后回 main');
|
||||
|
||||
// G1:恢复 = 一次「进 main」:暂停前旧标记已消费 → 对恢复后的首个未判定窗口重排
|
||||
const gdCountBefore = voicePlays(playLog, 'voice/gd_answer');
|
||||
await advance(() => voicePlays(playLog, 'voice/gd_answer') > gdCountBefore);
|
||||
const ans2 = playLog.filter((e) => e.id === 'voice/gd_answer').pop();
|
||||
assert(ans2.t / 1000 < game._cues[0].expected, 'G1:恢复后的 gd_answer 仍在首个未判定窗口起点之前');
|
||||
|
||||
// G3:gd_count 每局恢复限 1 次(第二次恢复直接 count-in)
|
||||
await gesture(game, 'down');
|
||||
const cntBefore = voicePlays(playLog, 'voice/gd_count');
|
||||
await gesture(game, 'down');
|
||||
assert(voicePlays(playLog, 'voice/gd_count') === cntBefore, 'G3:gd_count 每局恢复限 1 次');
|
||||
assert(game.phase === 'main', 'G3:第二次恢复不经语音直接 count-in');
|
||||
game.stop();
|
||||
resetTimers();
|
||||
}
|
||||
|
||||
// ================================================================ ⑦ G2 rhythm 教学 DDA
|
||||
|
||||
realLog('== ⑦ G2 rhythm:教学同一步连 2 失败 → 领奏降速 25% 重演 + gd_slow;做对恢复、每局封顶 ==');
|
||||
{
|
||||
const { audio, playLog, game } = makeRig(RhythmGame);
|
||||
const tSec = () => now / 1000;
|
||||
const SPB = 60 / 90;
|
||||
await audio.unlock();
|
||||
game.start();
|
||||
await advance(() => audio.isVoicePlaying());
|
||||
await gesture(game, 'tap'); // 跳过 welcome
|
||||
await advance(() => game.phase === 'tut' && game._cues.length > 0);
|
||||
let lead = Math.min(...game._sched.filter((e) => e.sfx === 'sfx/inst_kick').map((e) => e.time));
|
||||
assert(Math.abs(game._cues[0].expected - lead - 4 * SPB) < 0.05, '基准:T1 应答窗 = 领奏后 4 拍(正常速率)');
|
||||
|
||||
// 失败①:整句不出手(F2.1 归因 + 纠正重教)。用持久里程碑停表:
|
||||
//(_tutEvaluate 会先把 _phaseEnd 置 Infinity 防重入,等「阶段末」类条件会跑飞)
|
||||
await advance(() => game._tutFails === 1);
|
||||
assert(game._tutFails === 1, '失败 1 次:纠正重教(原路径不变)');
|
||||
await advance(() => game._phaseEnd < 1e9 && tSec() < game._phaseEnd - 0.5); // 纠正链播完、重新领奏
|
||||
|
||||
// 失败②:再整句不出手 → G2 降速重演
|
||||
await advance(() => game._tutSlowUsed === true);
|
||||
assert(game._tutSlowUsed === true, '同一步连续 2 次失败 → 进入降速路径');
|
||||
await advance(() => voicePlays(playLog, 'voice/gd_slow') === 1);
|
||||
assert(voicePlays(playLog, 'voice/gd_slow') === 1, 'G2:连 2 失败播 gd_slow(full 档)');
|
||||
assert(game._tutRate === 0.75, 'G2:该步领奏降速 25%(_tutRate=0.75)');
|
||||
await advance(() => game._cues.length > 0 && game._sched.some((e) => e.sfx === 'sfx/inst_kick' && e.time > tSec()));
|
||||
lead = Math.min(...game._sched.filter((e) => e.sfx === 'sfx/inst_kick').map((e) => e.time));
|
||||
assert(Math.abs(game._cues[0].expected - lead - 4 * SPB / 0.75) < 0.06,
|
||||
`G2:降速后应答窗 = 领奏后 4 拍×1/0.75(实际 ${(game._cues[0].expected - lead).toFixed(2)}s)`);
|
||||
|
||||
// 降速步做对 → 恢复正常速度
|
||||
await advance(() => tSec() >= game._cues[0].expected + 0.29);
|
||||
await gesture(game, 'tap'); // tap 补偿 -0.30s 后 dt≈0,判对
|
||||
await advance(() => game._tutIdx === 1);
|
||||
assert(game._tutRate === 1.0, 'G2:该步做对恢复正常速度');
|
||||
|
||||
// 封顶:T2 再连 2 失败 → 不再降速,维持 retry_skip 跳步
|
||||
await advance(() => game._tutIdx === 1 && game._tutFails === 1); // T2 失败 1
|
||||
await advance(() => game._phaseEnd < 1e9 && tSec() < game._phaseEnd - 0.5 && game._tutIdx === 1);
|
||||
await advance(() => voicePlays(playLog, 'voice/retry_skip') === 1); // T2 失败 2 → 跳步
|
||||
assert(voicePlays(playLog, 'voice/gd_slow') === 1, 'G2:gd_slow 每教学局 1 次封顶');
|
||||
assert(voicePlays(playLog, 'voice/retry_skip') === 1, 'G2:封顶后维持 retry_skip 跳步不卡流程');
|
||||
await advance(() => game._tutIdx === 2);
|
||||
assert(game._tutIdx === 2, 'G2:跳步进 T3');
|
||||
game.stop();
|
||||
resetTimers();
|
||||
}
|
||||
|
||||
// ================================================================ ⑧ G5 simon 双击/上滑
|
||||
|
||||
realLog('== ⑧ G5 simon:input 阶段双击永远=作答、上滑=主动重听(full 与 off 双档)==');
|
||||
function driveSimonToInput(rig) {
|
||||
const { audio, game } = rig;
|
||||
return (async () => {
|
||||
await audio.unlock();
|
||||
rig.game.start();
|
||||
await advance(() => game.phase === 'tutorial' && game.teachAwait);
|
||||
for (const g of ['up', 'down', 'left', 'right', 'tap', 'doubletap']) {
|
||||
await advance(() => game.teachAwait && game.phase === 'tutorial');
|
||||
await gesture(game, g);
|
||||
}
|
||||
await advance(() => game.phase === 'input');
|
||||
return game;
|
||||
})();
|
||||
}
|
||||
{
|
||||
const rig = makeRig(SimonGame);
|
||||
const { playLog, game } = rig;
|
||||
await driveSimonToInput(rig);
|
||||
assert(game.round === 1 && game.phase === 'input', '前提:教学完进第 1 轮 input');
|
||||
assert(voicePlays(playLog, 'voice/gd_economy') === 1, 'G5:教学收尾播 gd_economy(重录新文案)');
|
||||
// 固定期望序列(测试钩子):idx0=doubletap、idx1=left
|
||||
game.sequence[0] = 'doubletap';
|
||||
game.sequence[1] = 'left';
|
||||
await gesture(game, 'doubletap');
|
||||
assert(game.inputIdx === 1 && game.phase === 'input', 'G5:期望恰为 doubletap 时双击=作答(inputIdx 前进)');
|
||||
const ch = game.chances;
|
||||
const relistenN = voicePlays(playLog, 'voice/relisten');
|
||||
await gesture(game, 'up'); // 期望非 up → 主动重听
|
||||
assert(game.chances === ch - 1 && voicePlays(playLog, 'voice/relisten') === relistenN + 1,
|
||||
'G5:input 上滑=主动重听(耗机会、播 relisten)');
|
||||
await advance(() => game.phase === 'input');
|
||||
assert(game.inputIdx === 0, 'G5:重听后输入进度清零');
|
||||
|
||||
// G6:第 1 轮答完 → 轮结算链尾 gd_clear(每局 1 次)
|
||||
await gesture(game, 'doubletap'); // 期望 doubletap → 答对
|
||||
await gesture(game, 'left');
|
||||
await advance(() => game.phase === 'roundend');
|
||||
assert(voicePlays(playLog, 'voice/gd_clear') === 1, 'G6:轮成功链尾播 gd_clear(漂亮,过了)');
|
||||
await advance(() => game.phase === 'input' && game.round === 2);
|
||||
|
||||
// G6:第 2 轮答完且超过预设历史纪录(钩子 best=1)→ 追加 gd_record(每局 1 次)
|
||||
game.best = 1; // 模拟上局留下纪录(测试钩子)
|
||||
game.sequence[0] = 'up'; // 顺带验证期望恰为 up 时上滑=作答(00 §5.3 豁免同款)
|
||||
await gesture(game, 'up');
|
||||
assert(game.inputIdx === 1 && game.phase === 'input', 'G5:期望恰为 up 时上滑=作答');
|
||||
for (let i = 0; i < 8 && game.phase === 'input'; i++) await gesture(game, game.sequence[game.inputIdx]);
|
||||
await advance(() => game.phase === 'roundend' && game.round === 2);
|
||||
assert(voicePlays(playLog, 'voice/gd_record') === 1, 'G6:局中破个人纪录播 gd_record(破纪录了)');
|
||||
assert(voicePlays(playLog, 'voice/gd_clear') === 1, 'G6:gd_clear 每局仅第 1 次轮成功播');
|
||||
await advance(() => game.phase === 'input' && game.round === 3);
|
||||
|
||||
// 双击永远=作答的判错面:chances=0(重听已耗)时双击期望非双击 → 判错到底(gameover),不是 chances_out
|
||||
game.sequence[0] = 'left'; // 钩子固定期望,注入 doubletap 必判错
|
||||
await gesture(game, 'doubletap');
|
||||
assert(game.phase === 'gameover', 'G5:input 双击永远=作答(期望非双击时判错到底,不再触发重听)');
|
||||
game.stop();
|
||||
resetTimers();
|
||||
}
|
||||
{
|
||||
settings.set({ guide: 'off' });
|
||||
const rig = makeRig(SimonGame);
|
||||
const { game } = rig;
|
||||
await driveSimonToInput(rig);
|
||||
game.sequence[0] = 'doubletap';
|
||||
game.sequence[1] = 'left';
|
||||
await gesture(game, 'doubletap');
|
||||
assert(game.inputIdx === 1, 'off:双击=作答同样成立(玩法映射与向导档无关)');
|
||||
await gesture(game, 'up');
|
||||
assert(game.chances === 0, 'off:上滑=主动重听同样成立');
|
||||
await advance(() => game.phase === 'input');
|
||||
await gesture(game, 'doubletap');
|
||||
await gesture(game, 'doubletap');
|
||||
assert(game.phase === 'gameover', 'off:双击期望非双击时判错(无重听兜底)');
|
||||
settings.set({ guide: 'full' });
|
||||
game.stop();
|
||||
resetTimers();
|
||||
}
|
||||
|
||||
// ================================================================ ⑨ G7 space 方向呼喊
|
||||
|
||||
realLog('== ⑨ G7 space:loud 警报档(撞击前 500ms)同窗 dir_* 超短方向呼喊 ==');
|
||||
{
|
||||
const { audio, playLog, game } = makeRig(SpaceGame);
|
||||
await audio.unlock();
|
||||
game.start();
|
||||
await advance(() => game.phase === 'drill');
|
||||
await gesture(game, 'down'); // 无悬置演练陨石:下滑跳过演练
|
||||
await advance(() => game.phase === 'playing');
|
||||
assert(game.phase === 'playing', '前提:跳过演练进对局');
|
||||
await advance(() => playLog.some((e) => e.id.startsWith('voice/dir_')));
|
||||
const shout = playLog.find((e) => e.id.startsWith('voice/dir_'));
|
||||
const louds = playLog.filter((e) => e.id === 'sfx/alarm' && e.opts.volume === 1).map((e) => e.t);
|
||||
assert(['left', 'right', 'up', 'down'].includes(shout.id.slice('voice/dir_'.length)), 'G7:呼喊方向合法');
|
||||
assert(louds.some((t) => Math.abs(t - shout.t) < 60), `G7:dir_* 与 loud 警报同窗(≤50ms,实际最小 ${Math.min(...louds.map((t) => Math.abs(t - shout.t)))}ms)`);
|
||||
game.stop();
|
||||
resetTimers();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 汇总
|
||||
|
||||
realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`);
|
||||
|
||||
Reference in New Issue
Block a user