/** * 向导层无头测试(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', { // maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产(键盘路由由 engine-input K9 专测) value: { maxTouchPoints: 1, 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 = []; // 26 号改造中的游戏可能已无 Guide(发言权层接管):无钩则空事件表 if (game.guide && game.guide.setEventHook) 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); // ================================================================ ⓪ Guide 层单测(stub 游戏夹具) // 26 号重构后 storyline/escape/simon/rhythm 不再使用 Guide(发言权/等待点接管), // Guide 仍服务 space/rpg 等未改造游戏;这里用最小夹具锁 Guide 自身语义。 realLog('== ⓪ Guide 层:状态签名 / turn 排队 / error idle-only / off 全静 =='); { 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 guide = new Guide(audio); const events = []; guide.setEventHook((evt) => events.push({ ...evt, t: now })); await audio.unlock(); guide.state('explore'); assert(events.some((e) => e.type === 'state' && e.name === 'explore'), 'state() 发状态签名事件'); guide.turn({ voice: 'gd_continue' }); assert(events.some((e) => e.type === 'turn'), 'turn() 发交接事件'); // F1.4 idle-only:对白在播时 error 语音跳过(skipped 事件、不入队) audio.play('voice/menu', { queue: false }); await flush(); assert(audio.isVoicePlaying(), '夹具对白在播'); guide.error({ voice: 'gd_invalid' }); assert(events.some((e) => e.type === 'error' && e.skipped), '对白在播时 error 语音跳过(skipped)'); await advance(() => !audio.isVoicePlaying()); assert(!playLog.some((e) => e.id === 'voice/gd_invalid'), '被跳过的 error 不入队迟到'); // off:全部 API 静默 no-op(无事件、无定时器) settings.set({ guide: 'off' }); const evBefore = events.length; guide.state('battle'); guide.turn({ voice: 'gd_continue' }); guide.error({ voice: 'gd_invalid' }); guide.watchdog({ arm: true, at: [1000], onEscalate: () => assert(false, 'off 档不应触发升级') }); assert(events.length === evBefore, 'off 档全部 API 静默(零事件)'); await advance(() => false, ); settings.set({ guide: 'full' }); } // ================================================================ ⑥⑦ rhythm(已迁移) // 26 号深度重构(docs/design/26-apply-rhythm.md)后 rhythm 不再接 Guide: // 发言权/等待点/三档提示/迷茫接管改走 engine/{floor,hints,confusion,receipts}. // 原 ⑥ G1/G3(gd_answer 落窗前 1 小节、菜单恢复 gd_count+count-in)与 // ⑦ G2(教学连 2 败降速 25%/封顶跳步)断言已随交互层重写迁至 // web/rhythm/test/playtest.mjs(场景 H 菜单恢复 / 场景 F 教学 DDA / 场景 J // gd_answer+交接提示音同窗)。此处保留空位防止节号漂移。 // ================================================================ ⑨ 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_