Files
xsl 566241e05d feat(platform/P1): 配对码跨设备 + 认领打磨 + 键盘语音双套资产(两 agent 并行 + 集成收口)
配对码与认领(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 在途)
2026-09-10 00:43:23 +08:00

191 lines
7.1 KiB
JavaScript
Raw Permalink 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', {
// 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,
};
}