配对码与认领(B): - POST /api/pair/start|join:6 位码 10 分钟单次有效、仅存内存零泄露面、5 连错 per-IP 封锁 10 分钟;join=新设备档案按桶 LWW 并入老档案+token 改绑(复用 claim-merge);日志脱敏 - 大厅:生成配对码(speechSynthesis 逐位朗读+aria-live+重听)/从其他设备转入(one-time-code 输入框)/从本机移除/401 过期自愈引导/aria 补全/玩家名可听化 - api-smoke 12/12(配对全流程/频控/封锁/过期自愈/移除/digitsToCn)+ CDP 真浏览器 E2E 22/22 键盘语音双套资产(A,路线 a): - 164 变体句(8 游戏 grep 实测,超出调研预估 153:含轻触/点两下/按住变体)×2 性别=328 生成 656 文件 36.9 分钟;触屏主资产零字节改动(PCM 逐样本比对后还原) - 引擎 _candidates 三跳路由 key→gender→平铺(inputMode 跟随);缓存键 mode|gender|id;engine-input 新增 K9 路由断言(72 条) - check_all_assets 增 key 列(两性别一致且 ⊆ 主集)8 游戏全绿;voice_text.json 增 __key__ 表;live control.mjs key 会话对照键盘措辞验收(集成) - voice-ids 文档:8 游戏键盘变体小节由 KEY_LINES 程序化生成(与台词表逐字断言一致);补齐 storyline/escape/simon 三个历史缺失文档;design README 增 new8 索引 - mock 适配:playtest persona 加 maxTouchPoints=1 保持触屏链断言语义零改动 回归:12 套件全绿(8 playtest + guide 122 + engine-voice 19 + engine-input 72 + api-smoke 12/12) 同批入库:批次 2/3 已完稿设计文档(17-abyss/18-detective/19-cipher 全套,20-22 在途)
191 lines
7.1 KiB
JavaScript
191 lines
7.1 KiB
JavaScript
/**
|
||
* 无头试玩 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', {
|
||
// maxTouchPoints: 1 —— 转写 harness 是手机 persona:inputMode() 判 touch,
|
||
// voice 走触屏措辞主资产(与 voice_text.json 主表文本一致;键盘变体桌面
|
||
// 路由由 harness/engine-input.test.mjs K9 专测)
|
||
value: { maxTouchPoints: 1, 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,
|
||
};
|
||
}
|