按 docs/design/26-audio-interaction-playbook.md 重构三款游戏(对白重写 + 双性别语音重生成 580 文件)。 引擎层(web/engine/,其余 12 款游戏零改动): - floor.js 发言权管理器:SPEAKING/YOUR_TURN/BUSY/ACTION 四态、交接提示音、 等待声(220/330Hz 嘟-嘟,20s 后放缓)、跨链音乐压低(audio.js holdDuck) - receipts.js 输入回执三件套(ok/invalid/busy,100ms 内,原则 4) - confusion.js 迷茫检测(乱点/无效/静默信号 → 三级接管,§4.5) - hints.js 等待点 + 三档提示(full→short→提示音,30s 台账跨点去重,原则 9) - novice.js 手势解锁阶梯(up/down/doubletap 跨局持久,§4.3) - pipeline.js 输入管道(回执收口、防连点 400ms、双击折算、explainOnce) 游戏层: - storyline R3:开场 71.7s 规则念白 → 3 秒内动手;双击=重播(反模式5)废除; 抉择点三档提示+主动重听;断点存档复述(§4.12) - escape R3:单击=检查+使用合并(双击用法废除);长按=我在哪(四向玩法裁定); 撞墙归因每方向一次(原则 7);迷茫 L2 缩小/L3 演示;坐标存档续玩 - simon R3:新手三音色,按实力渐进解锁(+up/down/dt);失败表替代机会账 (归因 2s 内→免费重听→放慢→结算,原则 15);播放期手势=busy 回执 测试:三款 playtest 重写(含 §6.2 乱点机器人:无声操作/被动重复/打断失败全 0, 首操作 ~2s);guide.test 改为 Guide 层单测+rhythm/space 段。全部通过 (38+33+22+72+19+35 断言)。设计单见 docs/design/26-apply-*.md。
421 lines
19 KiB
JavaScript
421 lines
19 KiB
JavaScript
/**
|
||
* 《暗室》R3 无头试玩测试(26 号文档 playbook 重构版)。
|
||
*
|
||
* 覆盖:§4.10 引导首 3 分钟(3 秒内动手)、单击=检查+使用合并、撞墙失败归因、
|
||
* 长按=我在哪、迷茫三级接管(含缩小与演示)、断点复述、回执与无声操作指标(§6.2)、
|
||
* 资产引用校验。
|
||
*
|
||
* 运行:tools/node/bin/node web/escape/test/playtest.mjs
|
||
*/
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import url from 'node:url';
|
||
|
||
const GAME_DIR = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..');
|
||
const AUDIO_DIR = path.join(GAME_DIR, 'audio');
|
||
|
||
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}`); }
|
||
}
|
||
const consoleErrs = [];
|
||
const consoleWarns = [];
|
||
console.error = (...a) => { consoleErrs.push(a.join(' ')); };
|
||
console.warn = (...a) => { consoleWarns.push(a.join(' ')); };
|
||
console.log = () => {};
|
||
|
||
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); };
|
||
globalThis.setInterval = (cb, ms = 0) => {
|
||
const id = timerSeq++;
|
||
timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb, args: [], interval: Number(ms) || 0 });
|
||
return id;
|
||
};
|
||
globalThis.clearInterval = (id) => { timers.delete(id); };
|
||
const realDateNow = Date.now;
|
||
Date.now = () => now;
|
||
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);
|
||
if (t.interval) timers.set(nextId, { fireAt: now + t.interval, cb: t.cb, args: [], interval: t.interval });
|
||
t.cb(...t.args);
|
||
}
|
||
throw new Error('虚拟时钟定时器超上限(疑似死循环)');
|
||
}
|
||
const idle = () => advance(null);
|
||
const tick = (ms) => { now += ms; };
|
||
|
||
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); },
|
||
};
|
||
Object.defineProperty(globalThis, 'navigator', {
|
||
value: { maxTouchPoints: 1, vibrate: () => true }, configurable: true,
|
||
});
|
||
class MockParam {
|
||
constructor(v = 1) { this.value = v; }
|
||
setValueAtTime() {} linearRampToValueAtTime() {} cancelScheduledValues() {}
|
||
}
|
||
class MockGainNode {
|
||
constructor() { this.gain = new MockParam(); }
|
||
connect() {} disconnect() {}
|
||
}
|
||
class MockAudioContext {
|
||
constructor() { this.state = 'running'; this.currentTime = 0; this.destination = {}; }
|
||
createGain() { return new MockGainNode(); }
|
||
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, connect() {}, start() {}, stop() {} }; }
|
||
createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; }
|
||
createOscillator() { return { type: 'sine', frequency: new MockParam(440), connect() {}, start() {}, stop() {} }; }
|
||
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;
|
||
const requestedUrls = [];
|
||
globalThis.fetch = async (u) => {
|
||
const s = String(u);
|
||
requestedUrls.push(s);
|
||
if (s.includes('/voice/')) return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
|
||
const p = path.join(GAME_DIR, decodeURIComponent(s));
|
||
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 { makeInputPipeline } = await import('../../engine/pipeline.js');
|
||
const { EscapeGame } = await import('../game.js');
|
||
settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 });
|
||
|
||
function makeRig() {
|
||
const audio = new WebAudioEngine('audio/');
|
||
const playLog = [];
|
||
const origPlay = WebAudioEngine.prototype.play;
|
||
audio.play = (id, opts) => { playLog.push({ id, t: now }); return origPlay.call(audio, id, opts); };
|
||
const game = new EscapeGame(audio, {});
|
||
game.floor.bedEnabled = false;
|
||
const receiptLog = [];
|
||
const origR = game.receipts.play.bind(game.receipts);
|
||
game.receipts.play = (kind) => { receiptLog.push({ kind, t: now }); return origR(kind); };
|
||
const chimeLog = [];
|
||
const origChime = game.floor.chime.bind(game.floor);
|
||
game.floor.chime = () => { chimeLog.push(now); return origChime(); };
|
||
const pipe = makeInputPipeline({
|
||
floor: game.floor, confusion: game.confusion, receipts: game.receipts,
|
||
invalidHints: game.invalidHints, game, ladder: game.ladder, opts: game.PIPELINE_OPTS,
|
||
});
|
||
return { audio, playLog, receiptLog, chimeLog, game, pipe };
|
||
}
|
||
const voicePlays = (playLog, id) => playLog.filter((e) => e.id === id).length;
|
||
|
||
// ================================================================ 引导(§4.10 首 3 分钟)
|
||
|
||
realLog('== 引导:3 秒内动手、教一个用一个 ==');
|
||
{
|
||
const rig = makeRig();
|
||
const { audio, playLog, receiptLog, game, pipe } = rig;
|
||
await audio.unlock();
|
||
game.start();
|
||
await flush();
|
||
assert(voicePlays(playLog, 'voice/g0_new') === 1 && game.floor.speaking,
|
||
'0~3 秒:开场第一句 = 「朝任意方向,滑一下。」');
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await pipe('tap'); await flush(); // 错误手势 → 纠错句
|
||
assert(voicePlays(playLog, 'voice/g_wrong_gesture') === 1, '引导期点按 → 纠错句(做错当场重教)');
|
||
assert(receiptLog.some((r) => r.kind === 'invalid'), '纠错同时给 invalid 回执');
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await pipe('right'); await flush(); // 任意滑动 → 第一步
|
||
assert(playLog.some((e) => e.id === 'sfx/step'), '滑动 → 脚步声(内容音即反馈)');
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
// 撞墙两次教「无效操作长什么样」
|
||
await pipe('up'); await flush(); // (1,1) 向上 = 撞墙
|
||
assert(voicePlays(playLog, 'voice/wall') >= 1, '撞墙 → 「那边是墙,换个方向。」');
|
||
assert(receiptLog.some((r) => r.kind === 'invalid'), '撞墙 = invalid 回执(失败表:方向错)');
|
||
await advance(() => game.floor.state === 'YOUR_TURN' || game.phase === 'GUIDE');
|
||
await pipe('up'); await flush(); // 第二次撞墙 → g3
|
||
await advance(() => voicePlays(playLog, 'voice/g3_new') >= 1);
|
||
assert(true, '两次撞墙后教向左滑');
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await pipe('left'); await flush(); // g3 答案
|
||
assert(game.x === 0 && game.y === 1, '向左滑 → 移动到 (0,1)');
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await pipe('down'); await flush(); // g4 答案 → 储物间
|
||
assert(game.x === 0 && game.y === 2, '向下滑 → 储物间 (0,2)');
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await pipe('tap'); await flush(); // 检查 → 拾钥匙
|
||
assert(game.hasKey === true && playLog.some((e) => e.id === 'sfx/pickup'), '单击检查 → 拾得钥匙');
|
||
await advance(() => game.phase === 'PLAY');
|
||
assert(game.phase === 'PLAY', '引导完成进入自由探索(ACTION 态)');
|
||
game.stop();
|
||
}
|
||
|
||
// ================================================================ 探索:检查+使用合并、长按帮助
|
||
|
||
realLog('== 探索:单击=检查+使用,长按=我在哪 ==');
|
||
{
|
||
const rig = makeRig();
|
||
const { audio, playLog, receiptLog, game, pipe } = rig;
|
||
await audio.unlock();
|
||
storageData.clear();
|
||
game.start(); await flush();
|
||
// 快进引导
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await pipe('right'); await flush();
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await pipe('up'); await flush(); await advance(() => game.floor.state === 'YOUR_TURN' || game.guideStep >= 2);
|
||
await pipe('up'); await flush(); await advance(() => voicePlays(playLog, 'voice/g3_new') >= 1);
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await pipe('left'); await flush(); await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await pipe('down'); await flush(); await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await pipe('tap'); await flush(); await advance(() => game.phase === 'PLAY');
|
||
assert(game.phase === 'PLAY' && game.hasKey, '到达 PLAY,手上有钥匙');
|
||
|
||
// 长按 = 我在哪(目标 + 方位)
|
||
await pipe('longpress'); await flush();
|
||
assert(voicePlays(playLog, 'voice/lp_cabinet') === 1, '长按 → 目标句(找铁柜)');
|
||
await advance(() => voicePlays(playLog, 'voice/nav_right') >= 1);
|
||
assert(true, '长按 → 方位句(向右滑)');
|
||
await advance(() => game.floor.state === 'ACTION' || !game.floor.speaking);
|
||
|
||
// 去铁柜房 (2,1):右右上
|
||
for (const [g2] of [['right'], ['right'], ['up']]) {
|
||
await pipe(g2); await flush();
|
||
await advance(() => !game.floor.speaking || game.floor.state === 'ACTION');
|
||
}
|
||
assert(game.x === 2 && game.y === 1, '走到铁柜房 (2,1)');
|
||
// 单击 = 用钥匙开柜(旧双击用法已合并进单击)
|
||
await pipe('tap'); await flush();
|
||
await advance(() => voicePlays(playLog, 'voice/unlock_cabinet') >= 1);
|
||
assert(game.cabinetOpen === true && game.hasFuse === true && !game.hasKey,
|
||
'单击 = 钥匙开铁柜 → 得保险丝(无需双击)');
|
||
await advance(() => !game.floor.speaking);
|
||
|
||
// 修电箱 (0,0):左左上
|
||
for (const g2 of ['left', 'left', 'up']) {
|
||
await pipe(g2); await flush();
|
||
await advance(() => !game.floor.speaking || game.floor.state === 'ACTION');
|
||
}
|
||
assert(game.x === 0 && game.y === 0, '走到电箱房 (0,0)');
|
||
await pipe('tap'); await flush();
|
||
await advance(() => voicePlays(playLog, 'voice/fix_box') >= 1);
|
||
assert(game.powerOn === true, '单击 = 保险丝修电箱 → 通电');
|
||
await advance(() => !game.floor.speaking);
|
||
|
||
// 大门 (2,2):右右下下
|
||
for (const g2 of ['right', 'right', 'down', 'down']) {
|
||
await pipe(g2); await flush();
|
||
await advance(() => !game.floor.speaking || game.floor.state === 'ACTION');
|
||
}
|
||
assert(game.x === 2 && game.y === 2, '走到大门 (2,2)');
|
||
await pipe('tap'); await flush();
|
||
await advance(() => voicePlays(playLog, 'voice/escape') >= 1);
|
||
assert(game.phase === 'ENDING', '单击 = 开门逃脱 → 结算');
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
assert(game.floor.waitPoint && game.floor.waitPoint.conf.id === 'settle',
|
||
'结算链播完交出结算等待点(不留安静态)');
|
||
game.stop();
|
||
}
|
||
|
||
// ================================================================ 迷茫接管(三级)
|
||
|
||
realLog('== 迷茫接管:L1 方位 → L2 缩小 → L3 演示 ==');
|
||
{
|
||
const rig = makeRig();
|
||
const { audio, playLog, receiptLog, chimeLog, game, pipe } = rig;
|
||
await audio.unlock();
|
||
storageData.clear();
|
||
game.start(); await flush();
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await pipe('right'); await flush();
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await pipe('up'); await flush(); await advance(() => game.floor.state === 'YOUR_TURN' || game.guideStep >= 2);
|
||
await pipe('up'); await flush(); await advance(() => voicePlays(playLog, 'voice/g3_new') >= 1);
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await pipe('left'); await flush(); await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await pipe('down'); await flush(); await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await pipe('tap'); await flush(); await advance(() => game.phase === 'PLAY');
|
||
|
||
// 乱撞墙 3 次(10s 内)→ L1 接管:说目标方位
|
||
const navBefore = playLog.filter((e) => e.id.startsWith('voice/nav_')).length;
|
||
const t0 = now;
|
||
for (let i = 0; i < 3; i++) { await pipe('down'); await flush(); tick(1000); } // (0,2) 向下 = 墙
|
||
assert(game.confusion.level >= 1, '10s 内 3 次撞墙 → 迷茫接管 L1');
|
||
// L1 反馈 = 方位句(30s 内已说过则降为交接提示音——原则 9 台账)
|
||
const chimeBefore = chimeLog.length;
|
||
await advance(() => playLog.filter((e) => e.id.startsWith('voice/nav_')).length > navBefore
|
||
|| chimeLog.length > chimeBefore);
|
||
assert(playLog.filter((e) => e.id.startsWith('voice/nav_')).length > navBefore
|
||
|| chimeLog.length > chimeBefore, 'L1:给方位句或提示音(台账降档)');
|
||
await advance(() => !game.floor.speaking && game.floor.state === 'ACTION');
|
||
|
||
// 过冷却再撞 → L2:只放行一个方向
|
||
const tL1 = now;
|
||
await advance(() => now - tL1 >= 16000);
|
||
await advance(() => !game.floor.speaking);
|
||
for (let i = 0; i < 3; i++) { await pipe('down'); await flush(); tick(1000); }
|
||
await advance(() => game.confusion.level >= 2 || game._restrictDir);
|
||
assert(game._restrictDir, 'L2:接管指定唯一放行方向(缩小选择)');
|
||
// 非放行方向 = 无效(不移动)
|
||
const yBefore = game.y;
|
||
await pipe('up'); await flush(); // 目标在北边 → 放行 up;down 应被拦
|
||
assert(game.y === yBefore, 'L2 期间非放行方向不产生移动(只给回执)');
|
||
game.stop();
|
||
}
|
||
|
||
// ================================================================ 断点复述(§4.12)
|
||
|
||
realLog('== 断点复述 ==');
|
||
{
|
||
const rig1 = makeRig();
|
||
await rig1.audio.unlock();
|
||
storageData.clear();
|
||
rig1.game.start(); await flush();
|
||
await advance(() => rig1.game.floor.state === 'YOUR_TURN');
|
||
await rig1.pipe('right'); await flush();
|
||
await advance(() => rig1.game.floor.state === 'YOUR_TURN');
|
||
await rig1.pipe('up'); await flush(); await advance(() => rig1.game.floor.state === 'YOUR_TURN' || rig1.game.guideStep >= 2);
|
||
await rig1.pipe('up'); await flush(); await advance(() => voicePlays(rig1.playLog, 'voice/g3_new') >= 1);
|
||
await advance(() => rig1.game.floor.state === 'YOUR_TURN');
|
||
await rig1.pipe('left'); await flush(); await advance(() => rig1.game.floor.state === 'YOUR_TURN');
|
||
await rig1.pipe('down'); await flush(); await advance(() => rig1.game.floor.state === 'YOUR_TURN');
|
||
await rig1.pipe('tap'); await flush(); await advance(() => rig1.game.phase === 'PLAY');
|
||
// 走一步存档再退出
|
||
await rig1.pipe('right'); await flush();
|
||
rig1.game.stop();
|
||
|
||
const rig2 = makeRig();
|
||
const { audio, playLog, game, pipe } = rig2;
|
||
await audio.unlock();
|
||
game.start(); await flush();
|
||
assert(voicePlays(playLog, 'voice/sys_continue_ask') === 1, '回来先问:继续上次还是重新开始');
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
await pipe('tap'); await flush();
|
||
assert(game.phase === 'PLAY' && game.x === 1 && game.y === 2, '单击 = 从存档坐标继续探索');
|
||
await advance(() => voicePlays(playLog, 'voice/lp_key') >= 1 || voicePlays(playLog, 'voice/lp_cabinet') >= 1);
|
||
assert(true, '续玩先播复述句(目标 + 方位)');
|
||
game.stop();
|
||
}
|
||
|
||
// ================================================================ 乱点机器人(§6.2)
|
||
|
||
realLog('== 乱点机器人(§6.2 指标)==');
|
||
{
|
||
const rig = makeRig();
|
||
const { audio, playLog, receiptLog, chimeLog, game, pipe } = rig;
|
||
await audio.unlock();
|
||
storageData.clear();
|
||
let seed = 7;
|
||
const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
|
||
const gestures = [];
|
||
const botPipe = (gg) => { gestures.push({ g: gg, t: now }); return pipe(gg); };
|
||
|
||
game.start(); await flush();
|
||
const t0 = now;
|
||
await advance(() => game.floor.state === 'YOUR_TURN');
|
||
botPipe('right'); await flush(); // 确定性第一步
|
||
|
||
let firstSuccessMs = null;
|
||
for (let step = 0; step < 150; step++) {
|
||
const roll = rnd();
|
||
if (roll < 0.35) {
|
||
botPipe('tap'); await flush();
|
||
} else if (roll < 0.55) {
|
||
botPipe('tap'); await flush(1); botPipe('tap'); await flush(1);
|
||
} else if (roll < 0.85) {
|
||
const dirs = ['up', 'down', 'left', 'right'];
|
||
botPipe(dirs[Math.floor(rnd() * 4)]); await flush();
|
||
} else {
|
||
const before = now;
|
||
await advance(() => now - before >= 8000 + rnd() * 15000);
|
||
}
|
||
if (firstSuccessMs === null && receiptLog.some((r) => r.kind === 'ok')) firstSuccessMs = now - t0;
|
||
if (game.phase === 'ENDING') break;
|
||
}
|
||
|
||
let silentOps = 0;
|
||
for (const ge of gestures) {
|
||
const receipt = receiptLog.some((r) => r.t >= ge.t && r.t <= ge.t + 100);
|
||
const content = playLog.some((p) => p.t >= ge.t && p.t <= ge.t + 100);
|
||
if (!receipt && !content) silentOps++;
|
||
}
|
||
assert(silentOps === 0, `无声操作 = 0(实际 ${silentOps}/${gestures.length})`);
|
||
assert(firstSuccessMs !== null && firstSuccessMs <= 20000, `第一次成功 ≤ 20s(实际 ${firstSuccessMs}ms)`);
|
||
|
||
let passiveRepeats = 0;
|
||
const repeatIds = new Map();
|
||
const lastPlay = new Map();
|
||
for (const p of playLog) {
|
||
if (!p.id.startsWith('voice/')) continue;
|
||
const sayAgainAt = playLog.some((q) => q.id === 'voice/sys_say_again' && q.t <= p.t && p.t - q.t < 2500);
|
||
if (lastPlay.has(p.id) && p.t - lastPlay.get(p.id) < 30000 && !sayAgainAt) {
|
||
passiveRepeats++;
|
||
repeatIds.set(p.id, (repeatIds.get(p.id) || 0) + 1);
|
||
}
|
||
lastPlay.set(p.id, p.t);
|
||
}
|
||
if (passiveRepeats) realLog(` 重复明细: ${[...repeatIds.entries()].map(([k, v]) => `${k}×${v}`).join(' ')}`);
|
||
assert(passiveRepeats === 0, `被动原句重复 = 0(实际 ${passiveRepeats})`);
|
||
game.stop();
|
||
}
|
||
|
||
// ================================================================ 资产引用校验
|
||
|
||
realLog('== 资产引用校验 ==');
|
||
{
|
||
const src = fs.readFileSync(path.join(GAME_DIR, 'game.js'), 'utf8');
|
||
const ids = [...new Set([...src.matchAll(/'((?:voice|sfx|bgm)\/[A-Za-z0-9_.-]+)'/g)].map((m) => m[1]))];
|
||
assert(ids.length > 0, `game.js 提取到 ${ids.length} 个资产 id`);
|
||
const both = (rel) => ['.ogg', '.m4a'].every((ext) => fs.existsSync(path.join(AUDIO_DIR, rel + ext)));
|
||
let missing = 0;
|
||
for (const id of ids) {
|
||
if (id.startsWith('voice/')) {
|
||
const name = id.slice(6);
|
||
const ok = both(`voice/male/${name}`) || both(`voice/female/${name}`) || both(`voice/${name}`);
|
||
if (!ok) { missing++; realLog(` ✗ 资产缺失: ${id}`); }
|
||
} else if (!both(id)) {
|
||
missing++; realLog(` ✗ 资产缺失: ${id}`);
|
||
}
|
||
}
|
||
assert(missing === 0, `全部 ${ids.length} 个引用 id 均有 ogg+m4a,零缺失`);
|
||
}
|
||
|
||
realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`);
|
||
if (failures.length > 0) {
|
||
realLog('失败清单:');
|
||
for (const f of failures) realLog(` - ${f}`);
|
||
process.exit(1);
|
||
}
|
||
realLog('全部断言通过。');
|