/** * 向导层无头测试(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 音符)'); // live round2 P1:菜单 1s 去抖独占期(防瞬穿)——关菜单手势须在独占期后到达 const menuT0 = tSec(); await advance(() => tSec() >= menuT0 + 1.05); 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 menuT1 = tSec(); await advance(() => tSec() >= menuT1 + 1.05); // live round2 P1:菜单 1s 去抖独占期后再操作 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'); game.freeRelistens = 0; // 钩子:免费层置空,本段专测扣真机会路径(round2 live P1 两层账,免费层断言见 simon playtest 与 13 号文档 §15) 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(round1 live P1 改口,13 号文档 §9):轮结算**每轮必播表扬**,既有台词池 // [correct, gd_clear] 按轮轮播——第 1 轮 = correct「答对了!」(原"gd_clear 每局 // 限 1 次"口径已被 round1 live 推翻:12 轮仅 3 轮有表扬) await gesture(game, 'doubletap'); // 期望 doubletap → 答对 await gesture(game, 'left'); await advance(() => game.phase === 'roundend'); assert(voicePlays(playLog, 'voice/correct') === 1, 'G6:每轮答对必播表扬(第 1 轮 correct「答对了!」)'); await advance(() => game.phase === 'input' && game.round === 2); // G6:第 2 轮答完 → 表扬轮播到 gd_clear;且超过预设历史纪录(钩子 best=1)→ // 同链追加 gd_record(每局 1 次)。链有两句(gd_clear 在前),须等链尾播完再计数。 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); await advance(() => voicePlays(playLog, 'voice/gd_record') === 1); // 等表扬+纪录链播完再断言 assert(voicePlays(playLog, 'voice/gd_clear') === 1, 'G6:表扬池轮播(第 2 轮 gd_clear「漂亮,过了。」)'); assert(voicePlays(playLog, 'voice/gd_record') === 1, 'G6:局中破个人纪录播 gd_record(破纪录了)'); 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:双击=作答同样成立(玩法映射与向导档无关)'); game.freeRelistens = 0; // 钩子:免费层置空,上滑直接走扣真机会路径(round2 live P1) 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:dir_* 方向呼喊与呼啸同窗(spawn 当刻,round4 R1 改口) =='); { // 设计依据(research/04-playtests/live/round1/space-xiaozhao.md P1-1 + 13 号文档 §11): // G7 原裁定为「loud 警报档(撞击前 500ms)同窗呼喊」,但第 1 轮全拟真实测每颗陨石的 // 呼喊都落在撞击判定前 ~0.4s——听完再滑必死,与教学培养的"听语音滑"直接矛盾。 // round4 R1 把呼喊提前到**陨石呼啸响起瞬间**(spawn 当刻):呼喊=反应窗口起点的指引, // 与 whoosh 同窗;loud 警报保留在撞击前 500ms 承担"临近警示",二者**明确错峰**、 // 不再绑定(撞击点落在保护期内的陨石不呼喊,protect_ 长句教学优先)。 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 dir = shout.id.slice('voice/dir_'.length); assert(['left', 'right', 'up', 'down'].includes(dir), 'G7:呼喊方向合法'); const whooshes = playLog.filter((e) => e.id === `sfx/whoosh_${dir}`).map((e) => e.t); assert(whooshes.some((t) => Math.abs(t - shout.t) < 60), `G7:dir_* 与同向呼啸同窗(≤60ms,实际最小 ${Math.min(...whooshes.map((t) => Math.abs(t - shout.t)))}ms)`); // 错峰验证:呼喊后第一声 loud 警报(同颗陨石的撞击前 500ms 档)应 ≥2s 才到 // (逼近时长 ≥2.8s → 呼喊到 loud 档间隔 ≥2.3s,取 2s 保守下界) await advance(() => playLog.some((e) => e.id === 'sfx/alarm' && e.opts.volume === 1 && e.t > shout.t)); const nextLoud = playLog.find((e) => e.id === 'sfx/alarm' && e.opts.volume === 1 && e.t > shout.t); assert(nextLoud.t - shout.t >= 2000, `G7:呼喊先于其 loud 警报 ≥2s(错峰:呼喊在窗口起点,实际 ${nextLoud.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(round2 live P1 改口,13 号文档 §15):重听两层账——免费 2 次/局开局即给足、 // 先用不碰 chances;余量句与免费余量一致:第 1 次(2→1)播 left1、第 2 次(1→0) // 播 left0;免费与真机会皆空时再求重听 → 拒绝并播 chances_out(机会账路径不变)。 fixSequence(game, []); // 无占用填充轮 await advance(() => game.phase === 'input'); assert(game.freeRelistens === 2, 'H3:免费重听每局 2 次开局即给足(教学 gd_economy 承诺两次)'); 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.freeRelistens === 1 && 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.freeRelistens === 0 && game.chances === 1 && voicePlays(playLog, 'voice/gd_chances_left0') === l0_0 + 1, 'H3:第 2 次免费重听用掉播 gd_chances_left0(「用完了」只在免费余量为 0 时出现)'); await advance(() => game.phase === 'input'); game.chances = 0; // 测试钩子:真机会一并置空,下一求重听走拒绝路径 const co0 = voicePlays(playLog, 'voice/chances_out'); await gesture(game, 'up'); // 两账皆空再求重听:拒绝路径(机会账)行为不变 assert(game.phase === 'input' && voicePlays(playLog, 'voice/chances_out') === co0 + 1, 'H3:两账皆空再求重听仍拒绝并播 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('全部断言通过。');