Files
SoundGame/web/harness/mock.mjs
T

188 lines
6.8 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.
/**
* 无头试玩 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,
};
}