/** * 向导层无头测试(web/harness/guide.test.mjs)。 * * settings.guide='full' 下,用 storyline + simon 两个代表游戏断言三 API 行为: * ① 看门狗:storyline 进入等待态 8s → nudge earcon 事件 + gd_continue 语音 * (20s 升级 = 复用 playHelpAndResume,升级后不自动重挂);simon input * 10s → gd_relisten、25s → 免扣机会自动重听一次; * ② turn cue:simon 首轮序列播完 → turn earcon 事件 + confirm 震 + your_turn; * ③ error 纠错:storyline 无效手势 → error 震 + gd_invalid(onceKey 同局只播一次); * ④ 等级衰减: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'), }; 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); }; 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.currentTime = 0; this.destination = {}; } 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, 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 { StorylineGame } = await import('../storyline/game.js'); const { SimonGame } = await import('../simon/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]; 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); }; 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)), }; } 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:等待态看门狗(8s nudge+gd_continue,20s 升级 playHelpAndResume)=='); { const { audio, playLog, game, events, vibrated } = makeRig(StorylineGame); await audio.unlock(); game.start(); await waitChainDone(game); // 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 === 8000, `8s 后 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(() => playLog.some((e) => e.id === 'voice/help')); assert(now - tArm === 20000, `20s 升级触发 playHelpAndResume(实际 ${now - tArm}ms)`); await advance(() => playLog.some((e) => e.id === 'voice/status1')); assert(voicePlays(playLog, 'voice/status1') === 1, '升级链播当前进度(status1)'); assert(game.voicePlaying, '升级后重播当前节点(帮助→进度→重播)'); // 升级帮助播完后不自动重挂(L4"之后停止,不轰炸"):链走完后无定时器 → idle await idle(); const nudges = events.filter((e) => e.type === 'nudge').length; await idle(); assert(events.filter((e) => e.type === 'nudge').length === nudges, '升级后不自动重挂看门狗(无后续提醒)'); // 玩家手势后恢复自动挂载:n01 链播完重新挂上,且 8s 节奏到点触发一次 await gesture(game, 'tap'); // → n01,链播(tap 同时复位 _guideHelped) await waitChainDone(game); await gesture(game, 'up'); // 帮助(复位 + 自带语音)→ 重播 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 === 8000, `重新挂载后仍按 8s 节奏触发(实际 ${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); } 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 看门狗:10s nudge+gd_relisten;25s 免扣机会自动重听 await advance(() => events.some((e) => e.type === 'nudge')); assert(events.some((e) => e.type === 'watchdog' && e.voiceId === 'gd_relisten'), 'input 10s 无输入发 gd_relisten 事件'); assert(voicePlays(playLog, 'voice/gd_relisten') === 1, 'gd_relisten 语音播放'); assert(game.chances === 1, '看门狗提示不消耗重听机会'); const chancesBefore = game.chances; await advance(() => game.phase === 'playback'); assert(game._guideRelistens === 1, '25s 升级:免扣机会自动重听序列一次'); assert(game.chances === chancesBefore, '自动重听不扣机会'); await advance(() => 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'); assert(game._guideRelistens === 1, '第 2 轮不再消耗自动重听额度'); 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' }); } // ---------------------------------------------------------------- 汇总 realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`); if (failures.length > 0) { realLog('失败清单:'); for (const f of failures) realLog(` - ${f}`); process.exit(1); } realLog('全部断言通过。');