Files
SoundGame/web/harness/guide.test.mjs
T

798 lines
42 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.
/**
* 向导层无头测试(web/harness/guide.test.mjs)。
*
* settings.guide='full' 下,用 storyline + simon 两个代表游戏 + Guide 直连断言:
* ① 看门狗礼仪(round2 F1):storyline 进入等待态 25s → nudge + gd_continue,
* 50s 升级只重申手势(gd_again),**不再重播 help/进度/当前节点**,两级用尽
* 彻底静默;simon input 10s → gd_relisten,25s 升级 = confirm 震 + turn
* earcon 纯标记(**取消自动重播序列**);
* ② turn cue:simon 首轮序列播完 → turn earcon 事件 + confirm 震 + your_turn;
* ③ error 纠错:storyline 无效手势 → error 震 + gd_invalid(onceKey 同局只播一次);
* ④ F1.4 idle-only:对白在播时 error/watchdog 语音跳过(事件带 skipped:true、
* 不入队),空闲时正常播;turn 语音保持 queue 不受影响;
* ⑤ 等级衰减:guide='off' 时以上全部无事件、无 gd_* 播放、无定时器注册。
* 事件经 guide.setEventHook 收集;mock 层与各 playtest 同款(虚拟时钟)。
*
* 运行:tools/node/bin/node web/harness/guide.test.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
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'),
EscapeGame: path.join(ROOT, 'web', 'escape'),
};
let currentGameDir = GAME_DIRS.StorylineGame;
// ---------------------------------------------------------------- 断言收集
const realLog = console.log.bind(console);
let passCount = 0;
const failures = [];
function assert(cond, msg) {
if (cond) { passCount++; realLog(` ✓ ${msg}`); }
else { failures.push(msg); realLog(` ✗ ${msg}`); }
}
console.error = () => {};
console.warn = () => {};
console.log = () => {};
// ---------------------------------------------------------------- 虚拟时钟
let now = 0; // 虚拟毫秒
let timerSeq = 1;
const timers = new Map(); // id -> { fireAt, cb }
const realSetImmediate = setImmediate;
globalThis.setTimeout = (cb, ms = 0) => {
const id = timerSeq++;
timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb });
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,
});
async function flush(rounds = 8) {
for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r));
}
async function advance(cond = null) {
for (let n = 0; n < 50000; n++) {
await flush(2);
if (cond && cond()) return 'cond';
let nextId = -1; let nextAt = Infinity;
for (const [id, t] of timers) if (t.fireAt < nextAt) { nextAt = t.fireAt; nextId = id; }
if (nextId < 0) return 'idle';
now = nextAt;
const t = timers.get(nextId);
timers.delete(nextId);
t.cb();
}
throw new Error('虚拟时钟定时器超上限(疑似死循环)');
}
const idle = () => advance(null);
/** 清空全局定时器(用例边界卫生:上一局的看门狗不串场)。 */
const resetTimers = () => timers.clear();
// ---------------------------------------------------------------- 浏览器 mock
globalThis.window = { addEventListener: () => {}, AudioContext: null };
const storageData = new Map();
globalThis.localStorage = {
getItem: (k) => (storageData.has(k) ? storageData.get(k) : null),
setItem: (k, v) => { storageData.set(k, String(v)); },
removeItem: (k) => { storageData.delete(k); },
};
const vibrateLog = [];
Object.defineProperty(globalThis, 'navigator', {
value: { vibrate: (...a) => { vibrateLog.push(a[0]); return true; } }, configurable: true,
});
class MockParam {
constructor(v = 1) { this.value = v; }
setValueAtTime() {}
linearRampToValueAtTime() {}
cancelScheduledValues() {}
}
class MockGainNode {
constructor() { this.gain = new MockParam(); }
connect() {}
disconnect() {}
}
class MockOscillator {
constructor() {
this.type = 'sine'; this.frequency = new MockParam();
this.connect = () => {}; this.start = () => {}; this.stop = () => {};
}
}
class MockAudioContext {
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() {
const n = new MockGainNode();
n.threshold = new MockParam(); n.ratio = new MockParam();
n.attack = new MockParam(); n.release = new MockParam();
return n;
}
createBufferSource() {
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) }); }
resume() { this.state = 'running'; return Promise.resolve(); }
}
globalThis.window.AudioContext = MockAudioContext;
// fetch 读"当前测试游戏"的真实资产目录(语音链靠引擎 endTimer 推进,需要真文件)
globalThis.fetch = async (u) => {
const p = path.join(currentGameDir, decodeURIComponent(String(u)));
if (!fs.existsSync(p)) return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
const data = fs.readFileSync(p);
return {
ok: true, status: 200,
arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
};
};
// ---------------------------------------------------------------- 装载引擎与游戏
const { WebAudioEngine } = await import('../engine/audio.js');
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');
const { EscapeGame } = await import('../escape/game.js');
// guide='full':本文件验证向导层行为(既有 playtest 以 guide='off' 验证零干扰)
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, opts: opts || {} }); return origPlay(id, opts); };
const game = new GameClass(audio, {});
const events = [];
game.guide.setEventHook((evt) => events.push({ ...evt, t: now }));
const vibBase = vibrateLog.length;
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;
async function gesture(game, g) { game.onGesture(g); await flush(); }
/** 等到当前语音链播完(精确停在链尾,不越过其后的看门狗定时器)。 */
const waitChainDone = (game) => advance(() => !game.voicePlaying);
// ================================================================ ① storyline 看门狗
realLog('== ① storyline:等待态看门狗(25s nudge+gd_continue,50s 升级只发 gd_again,无重播)==');
{
const { audio, playLog, game, events, vibrated } = makeRig(StorylineGame);
await audio.unlock();
game.start();
// F8(round2):开场链最前插 gd_brief(brief→intro)
assert(voicePlays(playLog, 'voice/gd_brief') === 1, 'F8:开场链最前播 gd_brief');
assert(playLog.find((e) => e.id.startsWith('voice/'))?.id === 'voice/gd_brief',
'F8:gd_brief 是本局第一句语音');
await waitChainDone(game); // gd_brief + n00_intro + skip_hint 播完 → W1 静默等待(看门狗已挂)
assert(!game.voicePlaying && game.nodeId === 'n00', 'n00 链播完,进入等待态');
assert(events.some((e) => e.type === 'state' && e.name === 'explore'), 'start 播 explore 状态签名事件');
const tArm = now;
await advance(() => events.some((e) => e.type === 'nudge'));
assert(now - tArm === 25000, `25s 后 nudge 触发(实际 ${now - tArm}ms)`);
assert(events.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_continue'),
'看门狗第 1 级发 gd_continue 语音事件');
assert(voicePlays(playLog, 'voice/gd_continue') === 1, 'gd_continue 语音进入播放');
assert(!vibrated([8]), 'nudge 级不额外震动(earcon-only 轻提醒)');
await advance(() => events.filter((e) => e.type === 'watchdog').length >= 2);
assert(now - tArm === 50000, `50s 升级触发(实际 ${now - tArm}ms)`);
assert(events.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_again'),
'升级只发 gd_again(重申手势)语音事件');
assert(voicePlays(playLog, 'voice/gd_again') === 1, 'gd_again 语音播放');
// F1.1:升级不再重播 help + 进度 + 当前节点
assert(!playLog.some((e) => e.id === 'voice/help' || e.id.startsWith('voice/status')),
'F1:升级不重播 help/进度');
assert(voicePlays(playLog, 'voice/n00_intro') === 1, 'F1:升级不重播当前节点(n00_intro 仅开场一次)');
// 两级用尽彻底静默:链走完后无任何向导定时器 → idle
await idle();
const nudges = events.filter((e) => e.type === 'nudge').length;
await idle();
assert(events.filter((e) => e.type === 'nudge').length === nudges, '两级用尽彻底静默(无后续提醒)');
// 玩家手势后重新挂载:n01 链播完重新挂上,且 25s 节奏到点触发一次
await gesture(game, 'tap'); // → n01,链播
await waitChainDone(game);
const nudgeBefore = events.filter((e) => e.type === 'nudge').length;
const tWait = now;
await advance(() => events.filter((e) => e.type === 'nudge').length > nudgeBefore);
assert(now - tWait === 25000, `重新挂载后仍按 25s 节奏触发(实际 ${now - tWait}ms)`);
resetTimers();
}
// ================================================================ ③ storyline error onceKey
realLog('== ③ storyline:无效手势纠错 gd_invalid(onceKey 同局只播一次)==');
{
const { audio, playLog, game, events, vibrated } = makeRig(StorylineGame);
await audio.unlock();
game.start();
await waitChainDone(game); // W1 等待态(n00 非选择节点)
await gesture(game, 'left'); // 选择节点外左右滑 = 无效手势
assert(vibrated([60, 40, 60]), '无效手势给 error 震');
assert(events.some((e) => e.type === 'error' && e.voiceId === 'gd_invalid'), '发 gd_invalid 语音事件');
assert(voicePlays(playLog, 'voice/gd_invalid') === 1, 'gd_invalid 播放一次');
await gesture(game, 'right');
assert(voicePlays(playLog, 'voice/gd_invalid') === 1, 'onceKey:同局第二次无效手势不再播语音');
assert(game.nodeId === 'n00' && !game.voicePlaying, '无效手势不推进剧情(原静默语义保持)');
resetTimers();
}
// ================================================================ ② simon turn cue + 看门狗
realLog('== ②/①b simon:首轮 turn cue(earcon 事件+confirm 震+your_turn)与 input 看门狗 ==');
{
const { audio, playLog, game, events, vibrated } = makeRig(SimonGame);
await audio.unlock();
game.start();
await advance(() => game.phase === 'tutorial' && game.teachAwait); // T0 六音教学第 1 步
for (const g of ['up', 'down', 'left', 'right', 'tap', 'doubletap']) {
await advance(() => game.teachAwait && game.phase === 'tutorial');
await gesture(game, g);
}
// round4 H2/H4 前提锚定:announce 时点固定第 1 轮序列为无 up/doubletap 的两音
//(guide.test 的 Math.random 是真随机——不固定则 gd_relisten 断言会随占用预告
// 动态置空而偶发跳过;固定后本块行为与 round3 基线一致)
await advance(() => game.phase === 'announce');
game.sequence[0] = 'down';
game.sequence[1] = 'tap';
await advance(() => game.phase === 'input');
assert(game.round === 1, '教学走完进入第 1 轮 input');
// ② 首轮 turn cue:序列播完 400ms → confirm 震 + turn earcon 事件 + your_turn
assert(events.some((e) => e.type === 'turn' && e.name === 'turn'), 'turn earcon 事件发出');
assert(vibrated([20]), 'turn cue 带 confirm 轻震');
assert(voicePlays(playLog, 'voice/your_turn') === 1, '第 1 轮保留 your_turn 语音');
// ①b input 看门狗:round4 H4 首级 10s→5s,nudge+gd_relisten
const tInput = now;
await advance(() => events.some((e) => e.type === 'nudge'));
assert(now - tInput === 5000, `H4:input 首级 5s 后 nudge 触发(实际 ${now - tInput}ms)`);
assert(events.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_relisten'),
'input 5s 无输入发 gd_relisten 事件');
assert(voicePlays(playLog, 'voice/gd_relisten') === 1, 'gd_relisten 语音播放');
assert(game.chances === 1, '看门狗提示不消耗重听机会');
// 25s 升级(round2 F1.2):取消自动重播序列 → 只发 confirm 震 + turn earcon(纯标记)
const nudgesBefore = events.filter((e) => e.type === 'nudge').length;
const turnsBefore = events.filter((e) => e.type === 'turn').length;
const vibBefore = vibrateLog.length;
await advance(() => events.filter((e) => e.type === 'nudge').length > nudgesBefore);
assert(now - tInput === 25000, `25s 升级触发(实际 ${now - tInput}ms)`);
assert(game.phase === 'input' && game.round === 1, '升级不再自动重播序列(仍停在 input 等玩家)');
assert(events.filter((e) => e.type === 'turn').length === turnsBefore + 1, '升级发 turn earcon 事件(纯标记)');
assert(vibrateLog.slice(vibBefore).some((p) => JSON.stringify(p) === JSON.stringify([20])),
'升级发 confirm 轻震');
assert(voicePlays(playLog, 'voice/round_01') === 1, '序列未重播(round_01 仍只有第 1 轮一次)');
await idle(); // 级数用尽:看门狗自动停止
assert(game.phase === 'input', '升级后仍等待输入(免费泄题已废除)');
// 正常答题清第 1 轮:答题手势逐个 disarm,round1→round2 过渡期看门狗不触发
const nudgeBefore = events.filter((e) => e.type === 'nudge').length;
for (const g of game.sequence) await gesture(game, g);
assert(game.phase === 'roundend', '按序答题进入 roundend');
await advance(() => game.phase === 'input' && game.round === 2);
assert(events.filter((e) => e.type === 'nudge').length === nudgeBefore,
'答题手势复位看门狗:第 2 轮开启前无 nudge');
resetTimers();
}
// ================================================================ ④ F1.4 idle-only
realLog('== ④ F1.4 idle-only:对白在播时 error/watchdog 语音跳过(skipped 事件、不入队),turn 保持 queue ==');
{
currentGameDir = GAME_DIRS.StorylineGame;
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); };
await audio.unlock();
const guide = new Guide(audio);
const events = [];
guide.setEventHook((evt) => events.push({ ...evt, t: now }));
// watchdog:对白在播时到点 → 只发 skipped 事件,语音不入队
guide.watchdog({ arm: true, at: [1000], say: ['gd_continue'] });
audio.play('voice/n00_intro'); // 长语音占住通道(mock 句长 2s)
await flush();
assert(audio.isVoicePlaying(), '前提:对白正在播放');
await advance(() => events.some((e) => e.type === 'watchdog'));
const wd = events.find((e) => e.type === 'watchdog');
assert(wd && wd.voiceId === 'gd_continue' && wd.skipped === true,
'watchdog 语音被 idle-skip(事件带 skipped:true)');
assert(voicePlays(playLog, 'voice/gd_continue') === 0, 'watchdog 语音未入队播放');
// error:对白在播 → 同规则只发事件;空闲 → 正常播放
guide.error({ voice: 'gd_invalid' });
assert(events.some((e) => e.type === 'error' && e.skipped === true),
'error 语音对白在播时跳过(skipped 事件)');
assert(voicePlays(playLog, 'voice/gd_invalid') === 0, 'error 语音未入队播放');
await advance(() => !audio.isVoicePlaying()); // 等在播对白自然结束
guide.error({ voice: 'gd_invalid' });
assert(voicePlays(playLog, 'voice/gd_invalid') === 1, '空闲时 error 语音正常播放');
assert(events.filter((e) => e.type === 'error' && !e.skipped).length === 1,
'空闲播放的 error 事件不带 skipped 标记');
// turn:保持 queue,不受 idle-only 影响(对白在播时照常排队)
audio.play('voice/n01_wake');
await flush();
assert(audio.isVoicePlaying(), '前提:对白正在播放');
guide.turn({ voice: 'gd_answer' });
assert(voicePlays(playLog, 'voice/gd_answer') === 1, 'turn 语音不受 idle-only 影响(照常入队)');
assert(events.some((e) => e.type === 'turn'), 'turn 事件照常发出');
resetTimers();
}
// ================================================================ ⑤ guide=off 全静
realLog('== ⑤ guide=off:全部 API 静默 no-op(无事件、无 gd_* 播放、无定时器注册)==');
{
settings.set({ guide: 'off' });
const { audio, playLog, game, events, vibrated } = makeRig(StorylineGame);
await audio.unlock();
game.start();
await waitChainDone(game);
assert(!game.voicePlaying, 'n00 链播完进入等待态');
assert(events.length === 0, 'off 档零向导事件(state/turn/watchdog/nudge 均无)');
// 静置 30s:无任何向导定时器 → 虚拟时钟完全不走(挂了看门狗定时器就会被推进)
const t0 = now;
await idle();
assert(now === t0, `off 档等待态无任何定时器注册(时钟纹丝不动,t0=${t0} now=${now})`);
await gesture(game, 'left'); // 无效手势
assert(voicePlays(playLog, 'voice/gd_invalid') === 0, 'off 档不播 gd_invalid');
assert(!vibrated([60, 40, 60]), 'off 档无向导 error 震');
assert(playLog.every((e) => !e.id.includes('gd_')), 'off 档全程无 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();
}
// ================================================================ ⑩ H1~H5(round4)
realLog('== ⑩a H2/H3/H4 simon:占用预告三句、被占用轮无重听建议、5s 看门狗、余量句 ==');
async function driveSimonToAnnounce(rig) {
const { audio, game } = rig;
await audio.unlock();
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 === 'announce');
return game;
}
/** 测试钩子(13 号文档允许直改实例字段):固定当前轮序列;未指定位用无 up/dt 填充。 */
function fixSequence(game, recipe) {
const fill = ['down', 'right', 'tap', 'left'];
for (let i = 0; i < game.sequence.length; i++) {
game.sequence[i] = i < recipe.length ? recipe[i] : fill[i % fill.length];
}
}
{
const rig = makeRig(SimonGame);
const { playLog, game, events } = rig;
await driveSimonToAnnounce(rig);
// R1(含 up):序列播放完成后、「轮到你」提示前播 gd_occ_up
fixSequence(game, ['up', 'tap']);
await advance(() => game.phase === 'input');
assert(voicePlays(playLog, 'voice/gd_occ_up') === 1, 'H2:含 up 轮次播 gd_occ_up(序列播放完成后)');
const iOcc = playLog.findIndex((e) => e.id === 'voice/gd_occ_up');
const iTurn = playLog.findIndex((e) => e.id === 'voice/your_turn');
assert(iOcc > -1 && iTurn > iOcc, 'H2:占用预告在「轮到你」提示之前(插在轮次链中)');
// 被占用轮 input 看门狗:H4 首级 5s 只发 nudge,不播重听建议句(say 动态置空)
const tInput = now;
const rl0 = voicePlays(playLog, 'voice/gd_relisten');
const nb0 = events.filter((e) => e.type === 'nudge').length;
await advance(() => events.filter((e) => e.type === 'nudge').length > nb0);
assert(now - tInput === 5000, `H4:input 看门狗首级 5s(实际 ${now - tInput}ms)`);
assert(voicePlays(playLog, 'voice/gd_relisten') === rl0,
'H2:up 被占用轮看门狗只发 nudge earcon,不播重听建议句');
// 期望恰为 up 时上滑=作答(00 §5.3 豁免同款)→ 清第 1 轮
for (const g of game.sequence) await gesture(game, g);
await advance(() => game.phase === 'announce' && game.round === 2);
// R2(仅含 doubletap):gd_occ_dt;重听建议不受影响(建议只关联 up 占用)
fixSequence(game, ['down', 'doubletap', 'left']);
await advance(() => game.phase === 'input');
assert(voicePlays(playLog, 'voice/gd_occ_dt') === 1, 'H2:含 doubletap 轮次播 gd_occ_dt');
const t2 = now;
const rl1 = voicePlays(playLog, 'voice/gd_relisten');
const nb1 = events.filter((e) => e.type === 'nudge').length;
await advance(() => events.filter((e) => e.type === 'nudge').length > nb1);
assert(now - t2 === 5000, 'H4:看门狗首级恒 5s(升级级 25s 与级数不变)');
assert(voicePlays(playLog, 'voice/gd_relisten') === rl1 + 1,
'H2:仅 doubletap 占用不影响上滑重听建议');
for (const g of game.sequence) await gesture(game, g);
await advance(() => game.phase === 'announce' && game.round === 3);
// R3(up+doubletap 双占用):gd_occ_both;看门狗同样静默只 nudge
fixSequence(game, ['up', 'doubletap', 'left', 'right']);
await advance(() => game.phase === 'input');
assert(voicePlays(playLog, 'voice/gd_occ_both') === 1, 'H2:up+doubletap 双占用播 gd_occ_both');
const rl2 = voicePlays(playLog, 'voice/gd_relisten');
const nb2 = events.filter((e) => e.type === 'nudge').length;
await advance(() => events.filter((e) => e.type === 'nudge').length > nb2);
assert(voicePlays(playLog, 'voice/gd_relisten') === rl2, 'H2:双占用轮看门狗同样不播重听建议');
for (const g of game.sequence) await gesture(game, g);
await advance(() => game.phase === 'announce' && game.round === 4);
// H3:余量句与真实余量一致——第 1 次免费重听用掉(2→1)播 left1,第 2 次(1→0)播 left0
fixSequence(game, []); // 无占用填充轮
await advance(() => game.phase === 'input');
game.chances = 2; // 测试钩子:满额免费重听(初始 1 + 第 4 轮完成 +1,上限 2)
const rel0 = voicePlays(playLog, 'voice/relisten');
const l1_0 = voicePlays(playLog, 'voice/gd_chances_left1');
await gesture(game, 'up'); // 期望非 up → 主动重听①:2→1
await advance(() => voicePlays(playLog, 'voice/gd_chances_left1') === l1_0 + 1); // 余量句在 relisten 链尾
assert(game.chances === 1 && voicePlays(playLog, 'voice/gd_chances_left1') === l1_0 + 1,
'H3:第 1 次免费重听用掉播 gd_chances_left1(余量句与真实余量一致)');
assert(voicePlays(playLog, 'voice/relisten') === rel0 + 1, 'H3:重听照常播 relisten 并重播序列');
await advance(() => game.phase === 'input');
assert(game.inputIdx === 0, 'H3:重听后输入进度清零');
const l0_0 = voicePlays(playLog, 'voice/gd_chances_left0');
await gesture(game, 'up'); // 重听②:1→0
await advance(() => voicePlays(playLog, 'voice/gd_chances_left0') === l0_0 + 1);
assert(game.chances === 0 && voicePlays(playLog, 'voice/gd_chances_left0') === l0_0 + 1,
'H3:第 2 次免费重听用掉播 gd_chances_left0(「用完了」只在真实余量为 0 时出现)');
await advance(() => game.phase === 'input');
const co0 = voicePlays(playLog, 'voice/chances_out');
await gesture(game, 'up'); // 余 0 再求重听:拒绝路径(机会账)行为不变
assert(game.phase === 'input' && voicePlays(playLog, 'voice/chances_out') === co0 + 1,
'H3:余 0 再求重听仍拒绝并播 chances_out(答错救援的机会账路径不变)');
while (game.phase === 'input') await gesture(game, game.sequence[game.inputIdx]);
game.stop();
resetTimers();
}
realLog('== ⑩b H5 rhythm:教学应答窗口无任何尝试 → gd_miss_none(none 优先级最高)==');
{
const rig = makeRig(RhythmGame);
const { audio, playLog, game } = rig;
await audio.unlock();
game.start();
await advance(() => audio.isVoicePlaying()); // welcome 开播
await gesture(game, 'tap'); // 播放中单击 = 跳过 → 教学 T1
await advance(() => game.phase === 'tut' && game._cues.length > 0);
// T1 整句不出手(窗口关闭时该音未判定)→ 首次失败归因 gd_miss_none
await advance(() => voicePlays(playLog, 'voice/gd_miss_none') === 1);
assert(game._tutFails === 1, '前提:T1 首次失败(整句不出手)');
assert(voicePlays(playLog, 'voice/gd_miss_none') === 1, 'H5:无尝试播 gd_miss_none(这拍没接)');
assert(voicePlays(playLog, 'voice/gd_miss_late') === 0 && voicePlays(playLog, 'voice/gd_miss_wrong') === 0,
'H5:没出手不再被归因为「慢了半拍」(归因真话律:宁可粗不可假)');
game.stop();
resetTimers();
}
realLog('== ⑩c H1 escape:目标句后按房间图播 gd_room_*(同房间不播,移动坐标系)==');
{
// 方向映射按图校验(直连单元断言)
const rig0 = makeRig(EscapeGame);
const { game: g0 } = rig0;
await rig0.audio.unlock();
g0.x = 1; g0.y = 1;
assert(g0._roomDirVoice([0, 1]) === 'voice/gd_room_l', 'H1:目标在西 → gd_room_l');
assert(g0._roomDirVoice([2, 1]) === 'voice/gd_room_r', 'H1:目标在东 → gd_room_r');
assert(g0._roomDirVoice([1, 0]) === 'voice/gd_room_u', 'H1:目标在北(上面的房间)→ gd_room_u');
assert(g0._roomDirVoice([1, 2]) === 'voice/gd_room_d', 'H1:目标在南(下面的房间)→ gd_room_d');
assert(g0._roomDirVoice([1, 1]) === null, 'H1:目标在同一房间不播');
g0.stop();
resetTimers();
// 三处目标句接线点:found_key → cabinet(2,1);gd_next_fuse → box(0,0);fix_box → gate(2,2)
const rig = makeRig(EscapeGame);
const { audio, playLog, game } = rig;
storageData.set('soundgame.escape.guideDone', '1'); // 二周目直达 REOPEN→PLAY
await audio.unlock();
game.start();
await advance(() => game.phase === 'REOPEN');
await advance(() => !audio.isVoicePlaying()); // reopen_prompt 播完
await gesture(game, 'tap'); // 直接开始 → PLAY (1,1)
await advance(() => game.phase === 'PLAY' && !audio.isVoicePlaying());
assert(game.x === 1 && game.y === 1 && !game.hasKey, '前提:二周目 PLAY 起点、身上无钥匙');
// ① found_key:储物间拾钥匙 → 铁柜房在东(右面的房间)
await gesture(game, 'left'); // (0,1)
await gesture(game, 'down'); // (0,2) 储物间(首到播 room_storage)
await advance(() => !audio.isVoicePlaying()); // 房间描述播完再单击(否则单击=跳句)
await gesture(game, 'tap'); // 检查 → found_key(800ms 后)→ 方位句
await advance(() => playLog.some((e) => e.id === 'voice/gd_room_r'));
const ifk = playLog.findIndex((e) => e.id === 'voice/found_key');
assert(ifk > -1 && playLog.findIndex((e) => e.id === 'voice/gd_room_r') > ifk,
'H1:found_key 后接播 gd_room_r(目标铁柜房按图在东)');
// ② gd_next_fuse:开柜得保险丝 → 电箱房在西(左面的房间)
await gesture(game, 'right'); await gesture(game, 'right'); // (1,2) (2,2)
await gesture(game, 'up'); // (2,1) 铁柜房
await gesture(game, 'doubletap'); // 开柜
await advance(() => playLog.some((e) => e.id === 'voice/gd_room_l'));
const inf = playLog.findIndex((e) => e.id === 'voice/gd_next_fuse');
assert(inf > -1 && playLog.findIndex((e) => e.id === 'voice/gd_room_l') > inf,
'H1:gd_next_fuse 后接播 gd_room_l(目标电箱房按图在西)');
// ③ fix_box:修电箱通电 → 大门在东(右面的房间)
await gesture(game, 'left'); await gesture(game, 'left'); // (1,1) (0,1)
await gesture(game, 'up'); // (0,0) 电箱房
await gesture(game, 'doubletap'); // 修电箱
await advance(() => playLog.filter((e) => e.id === 'voice/gd_room_r').length >= 2);
const ifx = playLog.findIndex((e) => e.id === 'voice/fix_box');
assert(ifx > -1 && playLog.map((e) => e.id).lastIndexOf('voice/gd_room_r') > ifx,
'H1:fix_box 后接播 gd_room_r(目标大门按图在东)');
assert(game.powerOn, '前提:电箱已通电(接线点均在真实解谜链上)');
game.stop();
resetTimers();
}
// ---------------------------------------------------------------- 汇总
realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`);
if (failures.length > 0) {
realLog('失败清单:');
for (const f of failures) realLog(` - ${f}`);
process.exit(1);
}
realLog('全部断言通过。');