/** * 无头试玩 mock 层(转写 harness 专用,逻辑对齐各游戏 test/playtest.mjs 的 mock)。 * * 提供:虚拟时钟(setTimeout/clearTimeout/performance.now)、假 AudioContext、 * 读真实资产目录的 fetch(记录 404)、navigator.vibrate 记录、window 事件。 * 每个 Node 进程只 install 一次(patch 全局)。 */ import fs from 'node:fs'; import path from 'node:path'; import url from 'node:url'; const VOICE_TEXT = JSON.parse( fs.readFileSync(path.join(path.dirname(url.fileURLToPath(import.meta.url)), 'voice_text.json'), 'utf8'), ); // 中文 TTS 正常语速 ≈ 4.5 字/秒 + 起止余量;无文本 sfx 0.35s;bgm 90s function estimateDuration(gameName, url) { const m = String(url).match(/audio\/(voice|sfx|bgm)\/([^/?]+?)\.\w+$/); if (!m) return 2.0; const [, layer, id] = m; if (layer === 'bgm') return 90; if (layer === 'sfx') return 0.35; const t = (VOICE_TEXT[gameName] || {})[id] || (VOICE_TEXT[gameName] || {})[id.replace(/^(male|female)\//, '')]; return t ? Math.max(0.8, t.length / 4.5 + 0.4) : 2.0; } export function installMock(gameDir) { const gameName = path.basename(gameDir); const realLog = console.log.bind(console); let now = 0; // 虚拟毫秒 let timerSeq = 1; const timers = new Map(); const realSetImmediate = setImmediate; globalThis.setTimeout = (cb, ms = 0, ...args) => { const id = timerSeq++; timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb, args }); return id; }; globalThis.clearTimeout = (id) => { timers.delete(id); }; // 真·可重复的虚拟 interval:rhythm 的 40ms 调度泵、escape 的 5s 卡关轮询都依赖它 const intervals = new Set(); globalThis.setInterval = (cb, ms = 0, ...args) => { const id = timerSeq++; intervals.add(id); const step = (...a) => { if (!intervals.has(id)) return; // 已被 clearInterval cb(...a); if (intervals.has(id)) timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step, args }); }; timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step, args }); 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 < 200000; 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(...t.args); } throw new Error('虚拟时钟定时器超上限(疑似死循环)'); } const idle = () => advance(null); const winListeners = new Map(); globalThis.window = { addEventListener: (type, fn) => { if (!winListeners.has(type)) winListeners.set(type, []); winListeners.get(type).push(fn); }, AudioContext: null, }; const fireWindowEvent = (type) => { for (const fn of winListeners.get(type) || []) fn(); }; 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({ pattern: a[0], t: now }); 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 绝对排程的游戏才能在无头环境推进 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(ab) { // 时长按这块 buffer 自己的 url 估算(round3 修复:原来读 lastFetched—— // prewarm 与 play 并发 fetch 时 voice 会随机继承 bgm 的 90s 时长,语音 // 结束定时器形同虚设、链全靠兜底看门狗推进,tap 也会被"仍在播"的语音吃掉) return Promise.resolve({ duration: estimateDuration(gameName, ab && ab.__url), numberOfChannels: 1, sampleRate: 44100, getChannelData: () => new Float32Array(16), }); } resume() { this.state = 'running'; return Promise.resolve(); } } globalThis.window.AudioContext = MockAudioContext; // 静默引擎日志;fetch 记录 404(= 玩家此刻听到静默,转写时如实标注) console.error = () => {}; console.warn = () => {}; console.log = () => {}; const requestedUrls = []; const missingAssets = []; const fetchedLog = []; // { url, ok } 全量记录,供回退判定 let lastFetched = null; globalThis.fetch = async (u) => { const s = String(u); requestedUrls.push(s); lastFetched = s; const p = path.join(gameDir, decodeURIComponent(s)); if (!fs.existsSync(p)) { missingAssets.push({ url: s, t: now }); fetchedLog.push({ url: s, ok: false }); return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) }; } const data = fs.readFileSync(p); fetchedLog.push({ url: s, ok: true }); const ab = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); ab.__url = s; // 供 decodeAudioData 按自身 url 估时长(见上) return { ok: true, status: 200, arrayBuffer: async () => ab, }; }; return { get now() { return now; }, flush, advance, idle, fireWindowEvent, vibrateLog, requestedUrls, missingAssets, fetchedLog, realLog, }; }