Files
SoundGame/web/keeper/test/playtest.mjs
T
xsl 4b39ae2e49 feat(new8/batch3-4): cipher/teahouse/keeper/bridge 全套交付——new8 八款全部可交付收官
四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 R1-R2 连续达标裁定可交付:
- cipher《暗码》223 断言:摩尔斯解码嵌叙事/四结局矩阵/暴露度逐NIGHT验算/三层兜底;R2 三修(链首标记音/预警震/结局计数句+4句补录)4/4 全满分档
- teahouse《深夜茶馆》205 断言:泡茶时机窗/经济逐晚对账/多晚弧线;连续 2 轮 4/4 愿意玩 P0=0
- keeper《守塔人》257 断言:时空签名教学-兑现/插叙触发回返/trust×truth 档位/4结局;R2 F1/F2 坐实(震型豁免/首播跳句锁)4/4
- bridge《星环舰长》336 断言:巡检节奏逐毫秒/危机×系统态矩阵/仲裁7场景/忠诚差分/评级验算;R1 当场修F1-F4、R2 F5 菜单顺延保护 4/4 上行

公共:index.html 四卡片+profiles.js 四桶+check_all_assets 登记(预接线)、00-program 八款进度账收账、
gen_voice_dual 四游戏段、README/HANDOVER 阵容更新(11 款)、voice_text.json 四游戏键、mureka 台账(累计$1.26/$2.00)

验证:四游戏 playtest 223/205/257/336 全绿 + guide 122/122 + 全项目 15 游戏资产终检绿
遗留:keeper 三代人声滤波人耳试听(最高优先,ASR 对滤波句失明)、bridge 音频 32MB 超软指引 2MB(压缩候选)
2026-09-14 05:48:13 +08:00

1330 lines
67 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.
/**
* 《守塔人》无头试玩仿真测试(docs/design/21-keeper.md §9 playtest 断言清单)。
*
* 方案:
* - mock 浏览器环境:window/localStorage/navigator/fetch/AudioContext
* (decodeAudioData 返回固定 2.0s 时长的假 buffer,语音链靠 onComplete 推进,
* 虚拟时钟驱动,不真实等待);
* - 使用**真实** engine/audio.js + settings.js + guide.js;
* - 手势直接注入 game.onGesture(与 attachGestures 回调同路径);
* - 底噪/引擎为游戏层 buffer.loop 持句柄(无重触发定时器),全程可复现;
* - 底噪起停流水经 game._ambLog 断言(vinyl 与 storm/room/engine 零重叠,§6.2)。
*
* 运行:tools/node/bin/node web/keeper/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 SAVE_KEY = 'soundgame.progress.keeper';
// ---------------------------------------------------------------- 断言收集
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, ...args) => {
const id = timerSeq++;
const step = (...a) => {
if (intervals.has(id)) return;
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;
};
const intervals = new Set();
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('虚拟时钟定时器超上限(疑似死循环)');
}
// ---------------------------------------------------------------- 浏览器 mock
const winListeners = new Map();
globalThis.window = {
addEventListener: (type, fn) => {
if (!winListeners.has(type)) winListeners.set(type, []);
winListeners.get(type).push(fn);
},
AudioContext: null,
};
function 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: { 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() {}
}
function fakeBuffer() {
return { duration: 2.0, numberOfChannels: 1, sampleRate: 44100, getChannelData: () => new Float32Array(16) };
}
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(fakeBuffer()); }
resume() { this.state = 'running'; return Promise.resolve(); }
}
globalThis.window.AudioContext = MockAudioContext;
const requestedUrls = [];
const blockedAssets = new Set();
globalThis.fetch = async (u, opts = {}) => {
const s = String(u);
requestedUrls.push(s);
if (s.startsWith('/api/')) {
if ((opts.method || 'GET') === 'PUT') {
return { ok: true, status: 200, json: async () => ({ ok: true, game: 'keeper' }) };
}
return { ok: false, status: 401, json: async () => ({ error: 'unauthorized' }) };
}
const blocked = [...blockedAssets].some((b) => s.includes(b));
const p = path.join(GAME_DIR, decodeURIComponent(s));
if (blocked || !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 { KeeperGame } = await import('../game.js');
function resetSettings(guide) {
settings.set({ guide, voice: 'female', speed: 1.0, bgm: 1.0, input: 'touch' });
}
resetSettings('off');
function clearSave() { storageData.delete(SAVE_KEY); }
function saveData() {
const raw = storageData.get(SAVE_KEY);
if (!raw) return null;
try { return JSON.parse(raw); } catch (_) { return null; }
}
const voicePlays = (playLog, id) => playLog.filter((e) => e.id === id).length;
const urlHits = (frag) => requestedUrls.filter((u) => u.includes(frag)).length;
const lastIndexOf = (playLog, id) => playLog.map((e) => e.id).lastIndexOf(id);
const ERR_PATTERN = JSON.stringify([60, 40, 60]);
const WARN_PATTERN = JSON.stringify([30, 50, 30, 50, 30]);
const DANGER_PATTERN = JSON.stringify([25, 40, 25, 40, 25]);
const BEAT_PATTERN = JSON.stringify([8]);
function makeRig() {
const audio = new WebAudioEngine('audio/');
const playLog = [];
const origPlay = audio.play.bind(audio);
audio.play = (id, opts) => { playLog.push({ id, t: now, opts }); return origPlay(id, opts); };
const game = new KeeperGame(audio, {});
const volLog = []; // _setLoopVol 请求流水(storm 分幕 gain 断言)
const origSetVol = game._setLoopVol.bind(game);
game._setLoopVol = (k, v) => { volLog.push({ k, v, t: now }); return origSetVol(k, v); };
return { audio, playLog, game, volLog };
}
let gestureCount = 0;
async function g(game, gesture) {
gestureCount++;
game.onGesture(gesture);
await flush();
}
function teardown(game) {
game.guide.disarm();
game.stop();
}
/** 链末单击推进到 cond;抉择点须由调用方先选(tapUntil 遇抉择抛错)。 */
async function tapUntil(game, cond, maxSteps = 120) {
for (let i = 0; i < maxSteps; i++) {
if (cond()) return;
if (game.voicePlaying) {
await advance(() => cond() || !game.voicePlaying);
continue;
}
if (game.choiceArmed) {
throw new Error(`tapUntil 遇到未应答的抉择(node=${game.nodeId})——调用方需先做选择`);
}
await g(game, 'tap');
if (!cond() && !game.voicePlaying) {
await advance(() => cond() || game.voicePlaying);
}
}
throw new Error(`tapUntil 超步限(node=${game.nodeId} phase=${game.phase})`);
}
// §9 剧本(按遭遇顺序喂抉择):A=全左(e1) B=p9右(e2) C=全右(e3) D=全右+p9右(e4)
// E=半卷灯灭差分 F=插叙2 兜底(p3 右 p4 左)
const SCRIPT_A = ['left', 'left', 'left', 'left', 'left', 'left', 'left', 'left', 'left'];
const SCRIPT_B = ['left', 'left', 'left', 'left', 'left', 'right', 'right', 'right', 'right'];
const SCRIPT_C = ['right', 'right', 'right', 'right', 'right', 'right', 'left', 'left'];
const SCRIPT_D = ['right', 'right', 'right', 'right', 'right', 'right', 'left', 'right'];
const SCRIPT_E = ['left', 'left', 'right', 'right', 'left', 'left', 'left', 'right'];
const SCRIPT_F = ['left', 'left', 'right', 'left', 'left', 'right', 'left', 'left', 'left'];
/** 从头打一局到 ENDING 结算态(WATCH 链末单击继续;抉择按脚本;CALM/MEMORY 自动)。 */
async function playNight(game, choices) {
let i = 0; let guard = 0;
while (!(game.phase === 'ENDING' && game.awaitingRestart)) {
if (++guard > 500) throw new Error(`playNight 卡住(node=${game.nodeId} phase=${game.phase})`);
if (game.phase === 'MENU') { await g(game, 'down'); continue; }
if (game.choiceArmed) {
const c = choices[i++];
if (!c) throw new Error(`playNight 抉择脚本耗尽(node=${game.nodeId})`);
await g(game, c);
continue;
}
if (game.phase === 'CALM') { await advance(() => game.phase !== 'CALM'); continue; }
if (!game.voicePlaying) {
// MEMORY 静默拍(进入插叙 600ms + 教学句起播前):推进虚拟时钟等链起播,不 tap
if (game.phase === 'MEMORY') { await advance(() => game.voicePlaying || game.phase !== 'MEMORY'); continue; }
await g(game, 'tap');
continue;
}
await advance(() => !game.voicePlaying);
}
}
// ================================================================ 主路径 · 剧本 A
// (§9 关键路径:信任满·全真相·出海接人·说出 → e1,≤60 次手势)
realLog('== 主路径 · 剧本 A(E1,guide=off)==');
{
clearSave();
resetSettings('off');
const g0 = gestureCount;
const { audio, playLog, game, volLog } = makeRig();
audio.play('voice/b_open');
await flush();
assert(audio._voice === null && consoleWarns.some((w) => w.includes('尚未解锁')),
'启动遮罩点按前无音频播放(play 为 no-op)');
await audio.unlock();
playLog.length = 0;
game.start();
await flush();
assert(playLog.some((e) => e.id === 'bgm/storm'), '开局播放 bgm/storm(风暴夜常驻)');
assert(voicePlays(playLog, 'voice/sys_new_night') === 1, '新风暴夜 sys_new_night 先行');
assert(game.phase === 'INTRO' && game.act === 1, '进入宏状态 INTRO(幕一)');
// INTRO 链(自动流)→ 链末等待单击 → C1
await advance(() => game.currentVoiceId === 'voice/b_open');
assert(game.nodeId === 'b_open' && game.voicePlaying, 'sys_new_night 播完接 b_open');
assert(voicePlays(playLog, 'voice/gd_brief') === 0, 'guide=off:开场不插 gd_brief');
await tapUntil(game, () => game.nodeId === 'c1_tune');
for (const id of ['voice/b_open', 'voice/b_rain', 'voice/b_rule', 'voice/b_goal']) {
assert(voicePlays(playLog, id) === 1, `INTRO 链播出 ${id}`);
}
assert(voicePlays(playLog, 'voice/sys_skip') === 1, '开场 sys_skip 提示播一次(每实例一次)');
assert(voicePlays(playLog, 'sfx/foghorn') === 1, '雾角兑现点①:b_rain 口播后 foghorn(开场)');
assert(playLog.some((e) => e.id === 'sfx/amb_storm') === false, 'amb 走游戏层 loop 句柄(不占 play 日志)');
await flush();
assert(game._loops.amb_storm && game._loops.amb_room, '现在签名:amb_storm + amb_room 循环起播(§6.2)');
// C1 链 → p1 提示句
await tapUntil(game, () => game.promptPlaying, 24);
assert(game.nodeId === 'p1' && game.currentVoiceId === 'voice/p1', 'C1 链末推进至 p1 提示句播放中');
assert(voicePlays(playLog, 'voice/c1_tune') === 1 && voicePlays(playLog, 'voice/amo_a') === 1
&& voicePlays(playLog, 'voice/amo_b') === 1, 'C1 呼救链 c1_tune→amo_a→amo_b 各播一次');
assert(voicePlays(playLog, 'sfx/radio_tune') === 1 && voicePlays(playLog, 'sfx/radio_beep') === 1,
'SA3→SYN 接线:radio_tune(调台)/ radio_beep(接通)就位');
assert(game._loops.amb_engine, '通话期 amb_engine 常驻(§6.6 R3)');
assert(game.choiceArmed === false, '提示播放中 choiceArmed=false(W2:播完即真)');
// p* 不可跳 + pendingChoice 缓存(盲玩公平)
await g(game, 'tap');
assert(game.currentVoiceId === 'voice/p1' && game.promptPlaying, 'p* 播放中单击不跳句(PROMPT_RE 门)');
await g(game, 'left');
assert(game.pendingChoice === 'left', 'p* 播放中左滑缓存 pendingChoice');
await advance(() => game.nodeId === 'c1_hold');
assert(game.pendingChoice === null && game.trust === 1, '播完 pendingChoice 生效并清空:p1 左 trust=1(§4.2)');
// p2 左 → c1_open → rev_teach → 插叙1(时空签名时序断言)
await tapUntil(game, () => game.choiceArmed, 24); // c1_hold(o)(e) → amo_c → p2
assert(voicePlays(playLog, 'voice/c1_hold') === 1 && voicePlays(playLog, 'voice/c1_holdo') === 1
&& voicePlays(playLog, 'voice/c1_holde') === 1, 'p1 左路:接住人→声音稳住→收句(首个正反馈链)');
assert(voicePlays(playLog, 'voice/amo_c') === 1, 'amo_c 回澜号伏笔播出');
await g(game, 'left');
assert(game.rev1 === true && game.truth === 1, 'p2 左 → rev1=true(truth=1)');
await advance(() => game.phase === 'MEMORY'); // §6.3 步 1:结果句播完 → 静默一拍 → 自动进入插叙
assert(voicePlays(playLog, 'sfx/hatch_open') === 1, 'c1_open 配 hatch_open(暗格开启)');
await advance(() => game.currentVoiceId === 'voice/rev_teach');
assert(voicePlays(playLog, 'voice/rev_teach') === 1, '插叙1 前播 rev_teach(时空签名教学,全局一次)');
await advance(() => voicePlays(playLog, 'sfx/memory_in') === 1);
assert(audio._bgm && audio._bgm.id === 'bgm/memory', '插叙进入:BGM crossfade → bgm/memory(§6.3 步 2)');
await flush();
assert(game._loops.amb_vinyl && !game._loops.amb_storm && !game._loops.amb_room && !game._loops.amb_engine,
'底噪互斥:插叙期间 vinyl 唯一底噪(storm/room/engine 全停,§6.2/§6.6 R2)');
// 锚入句首播不可跳(G5)+ 中段句可跳
await advance(() => game.currentVoiceId === 'voice/rev_g1');
const vib0 = vibrateLog.length;
await g(game, 'tap');
assert(game.currentVoiceId === 'voice/rev_g1', '锚入句 rev_g1 首播单击不可跳(G5)');
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === BEAT_PATTERN),
'G4:锚句空点单击补 beat 轻震');
await advance(() => game.currentVoiceId === 'voice/rev_g3');
await g(game, 'tap'); // 中段句可跳
await flush();
assert(game.currentVoiceId === 'voice/rev_g4', '插叙中段句(rev_g3)可跳(§2.3 MEMORY 行)');
// (回返断言在下方 CALM(1) 块:transition 级联后按阶段断言)
// CALM(1):c1_diary 播完自动流入(@calm1 转移);foghorn + lull_1 + tr_half(跨档 thin→half)→ C2
await advance(() => game.phase === 'CALM');
assert(game.act === 1, 'CALM(1) 时仍为幕一(进 C2 才推进 act,amb gain 0.5 档)');
assert(voicePlays(playLog, 'sfx/foghorn') === 2, '雾角兑现点②:CALM(1)');
assert(voicePlays(playLog, 'sfx/memory_out') === 1, '插叙回返:memory_out(唱针抬起)成对出现');
assert(audio._bgm && audio._bgm.id === 'bgm/storm', '回返后 BGM 淡回 bgm/storm');
await flush();
assert(game._loops.amb_storm && game._loops.amb_room && game._loops.amb_vinyl === null,
'回返后底噪回现在签名(storm+room 回、vinyl 停)');
await advance(() => game.phase !== 'CALM');
assert(voicePlays(playLog, 'voice/lull_1') === 1, 'CALM(1) 播 lull_1(风加到十级)');
assert(voicePlays(playLog, 'voice/tr_half') === 1, '信任跨档(thin→half=1)→ CALM(1) 被动播报 tr_half');
const sd1 = saveData();
assert(sd1 && sd1.phase === 'CALM' && sd1.act === 1,
'CALM(1) 进入即存档(写入时机):phase=CALM / act=1 幕一');
assert(sd1.summary === '第1幕·风暴起 · 信任半 · 结局0/4', `summary 人话格式(实际: ${sd1 && sd1.summary})`);
assert(['schema', 'updatedAt', 'summary', 'phase', 'node', 'act', 'timeline', 'trust',
'flags', 'p7', 'endings', 'runs'].every((k) => k in sd1), '存档 data 覆盖 §8.1 全部字段(含 summary)');
// C2:p3 左 → 插叙2 → c2_key;p4 左 → c4_true 直进(插叙2 已完成,兜底不播)
await tapUntil(game, () => game.choiceArmed, 20); // c2_back → amo_d → p3
assert(voicePlays(playLog, 'voice/c2_back') === 1, 'C2 起始 c2_back(信号重连 radio_tune)');
await g(game, 'left'); // p3 左
assert(game.trust === 2 && game.rev2 === true, 'p3 左 → trust=2 且 rev2=true(插叙2 主触发)');
await tapUntil(game, () => game.phase === 'MEMORY', 20);
await advance(() => game.nodeId === 'c2_key' && !game.voicePlaying);
for (const id of ['voice/rev_f1', 'voice/rev_f2', 'voice/rev_f3', 'voice/rev_f4', 'voice/rev_f5']) {
assert(voicePlays(playLog, id) === 1, `插叙2 播出 ${id}`);
}
assert(game.truth === 2, '插叙2 完成 → truth=2');
await g(game, 'tap'); // c2_key 链末 → amo_e
await tapUntil(game, () => game.choiceArmed, 16); // amo_e → p4
await g(game, 'left'); // p4 左
assert(game.trust === 3, 'p4 左 → trust=3');
await advance(() => game.nodeId === 'c4_trueo' && !game.voicePlaying);
assert(voicePlays(playLog, 'sfx/memory_in') === 2 && voicePlays(playLog, 'voice/rev_f1') === 1,
'插叙2 已完成:c4_true 直进 c4_trueo,兜底插叙绝不重播(memory_in 仍=2)');
// 危机拍1:c4_trueo 播完自动进入(@crisis1 转移);hull_crack 前置 2s + danger 签名/震同窗
await advance(() => voicePlays(playLog, 'sfx/hull_crack') === 1);
assert(voicePlays(playLog, 'voice/c4_true') === 1 && voicePlays(playLog, 'voice/c4_trueo') === 1
&& voicePlays(playLog, 'sfx/memory_in') === 2 && voicePlays(playLog, 'voice/rev_f1') === 1,
'插叙2 已完成:c4_true 直进 c4_trueo,兜底插叙绝不重播(memory_in 仍=2)');
assert(vibrateLog.some((v) => JSON.stringify(v.pattern) === DANGER_PATTERN), '危机拍1 danger 震型');
await advance(() => game.nodeId === 'amo_f' && !game.voicePlaying);
assert(voicePlays(playLog, 'voice/c2_crack') === 1, '危机拍1:c2_crack(听!船板裂了)前置 2s 后播出');
// p5 左 → CALM(2)(tr_deep 跨档)
await g(game, 'tap'); // amo_f 等待 → p5 提示
await advance(() => game.choiceArmed);
await g(game, 'left'); // p5 左
assert(game.trust === 4, 'p5 左 → trust=4(理论上限)');
await tapUntil(game, () => game.phase === 'CALM', 16); // c5_patch(o) 等待单击 → @calm2
assert(game.act === 2, 'CALM(2) 时仍为幕二(进 C3 才推进 act)');
await advance(() => game.phase !== 'CALM');
assert(game.act === 3, 'CALM(2) 后进入幕三(amb gain 0.7 档)');
assert(voicePlays(playLog, 'voice/lull_2') === 1, 'CALM(2) 播 lull_2(第二程到顶)');
assert(voicePlays(playLog, 'voice/tr_deep') === 1, '信任跨档(half→deep=4)→ CALM(2) 播报 tr_deep');
assert(voicePlays(playLog, 'sfx/foghorn') === 3, '雾角兑现点③:CALM(2)');
// C3:c3_page → p6 左 → 插叙3 → c6_reade(truth=3)
await tapUntil(game, () => game.choiceArmed, 20); // c3_eye → c3_page → p6
assert(voicePlays(playLog, 'voice/c3_page') === 1 && voicePlays(playLog, 'sfx/flash_click') === 1,
'c3_page 条件播出(rev2=true):配 flash_click(手电尾盖)');
await g(game, 'left'); // p6 左
assert(game.rev3 === true && game.truth === 3, 'p6 左 → rev3=true(truth=3 全真相)');
await advance(() => game.nodeId === 'c6_reade' && !game.voicePlaying);
for (const id of ['voice/rev_c1', 'voice/rev_c2', 'voice/rev_c3', 'voice/rev_c4', 'voice/rev_c5']) {
assert(voicePlays(playLog, id) === 1, `插叙3 播出 ${id}`);
}
assert(voicePlays(playLog, 'sfx/memory_in') === 3 && voicePlays(playLog, 'sfx/memory_out') === 3,
'memory_in/out 成对 ×3(插叙 1/2/3 各一)');
// p7 左(满发条)→ 危机拍2 → p8_boat 左 → c9_return →(rev2 → 无 c3_dark)→ 签收
await g(game, 'tap'); // c6_reade 链末 → p7 提示(等待后单击)
await advance(() => game.choiceArmed);
await g(game, 'left'); // p7 左 = 离塔救人
assert(game.p7 === 'boat' && game.trust === 4, 'p7 左 → p7=boat(核心抉择不动 trust)');
// c7_go(等待单击)→ c3_wall(危机拍2:wave_wall 前置 2s)→ p8_boat 提示
await tapUntil(game, () => game.nodeId === 'p8_boat' && game.choiceArmed, 24);
assert(voicePlays(playLog, 'voice/c7_go') === 1 && voicePlays(playLog, 'sfx/lamp_mech') === 1,
'rev_f4 发条知识兑现:rev2=true 播 c7_go + lamp_mech(满发条版)');
if (!(voicePlays(playLog, 'sfx/wave_wall') === 1)) {
realLog(' [debug] playLog tail: ' + playLog.slice(-14).map((e) => e.id).join(' | '));
realLog(` [debug] node=${game.nodeId} armed=${game.choiceArmed} p7=${game.p7} dangers=${vibrateLog.filter((v) => JSON.stringify(v.pattern) === DANGER_PATTERN).length}`);
}
assert(voicePlays(playLog, 'sfx/wave_wall') === 1
&& vibrateLog.filter((v) => JSON.stringify(v.pattern) === DANGER_PATTERN).length >= 2,
'危机拍2:wave_wall + danger 震(全游戏仅两处 danger)');
await g(game, 'left'); // p8_boat 左 = 循渔歌
// c8_song → c8_songo → c9_return(各等待单击)→ @dark(rev2 直进签收)→ g9_in(auto) → g9_knot → p9 提示
await tapUntil(game, () => game.choiceArmed, 24);
assert(voicePlays(playLog, 'voice/c8_songo') === 1 && voicePlays(playLog, 'voice/c9_return') === 1,
'p8_boat 左 → c8_song/o(循歌找到)→ c9_return(接驳艇回来了)');
assert(voicePlays(playLog, 'voice/c3_dark') === 0, 'rev2=true:c9_return 后无 c3_dark(灯未灭)');
assert(voicePlays(playLog, 'voice/p9a') === 1 && voicePlays(playLog, 'voice/p9b') === 0,
'truth=3 → p9a 全真相版提示句(p9b 不播)');
await g(game, 'left'); // p9 左 = 说出
assert(game.p9told === true, 'p9 左 → p9told=true(心结解开)');
// ENDING(e1) + 尾声回响逐项点名(d9_tell_a → amo_g → d9_telle 链末单击推进)
await tapUntil(game, () => game.phase === 'ENDING' && game.awaitingRestart, 40);
assert(game.endingId === 'e1', 'trust≥3 且 p9 左 → ENDING(e1 灯与归人)');
for (const id of ['voice/e1_a', 'voice/e1_b', 'voice/ep_lead', 'voice/ep_amo_home',
'voice/ep_lamp_full', 'voice/ep_log_told', 'voice/ep_dad', 'voice/ep_shore']) {
assert(voicePlays(playLog, id) === 1, `结局链播出 ${id}`);
}
assert(voicePlays(playLog, 'voice/ep_amo_solo') === 0 && voicePlays(playLog, 'voice/ep_amo_dawn') === 0
&& voicePlays(playLog, 'voice/ep_lamp_dark') === 0 && voicePlays(playLog, 'voice/ep_log_kept') === 0,
'尾声差分互斥:阿澈/灯/日志各只播本局对应一句');
assert(voicePlays(playLog, 'sfx/foghorn') === 4, '雾角兑现点④=ep_shore,全剧恰四处(§6.5 断言)');
assert(voicePlays(playLog, 'voice/rev_teach') === 1, '教学承诺:rev_teach 全局恰一次');
const sd2 = saveData();
assert(sd2 && sd2.phase === 'ENDING' && sd2.ending === 'e1' && sd2.endings.e1 === true,
'ENDING 进入即存档,endings 收集 e1');
assert(sd2.summary === '风暴夜已过·灯与归人 · 结局1/4', `已收夜 summary(实际: ${sd2 && sd2.summary})`);
// 结算态双击重听结局链 → 单击重开
const e1aBefore = voicePlays(playLog, 'voice/e1_a');
await g(game, 'doubletap');
assert(game.awaitingRestart === false, '结算双击 → 重听结局链(清结算态)');
await advance(() => game.awaitingRestart);
assert(voicePlays(playLog, 'voice/e1_a') === e1aBefore + 1, '结局链同 id 序列重播完成,回结算态');
await g(game, 'tap');
assert(game.phase === 'INTRO' && game.runs === 2 && game.trust === 0 && game.truth === 0,
'结算单击 = 新风暴夜:INTRO,trust/truth 归零,runs=2');
assert(game.endings.e1 === true, 'endings 结局收集跨夜保留');
assert(voicePlays(playLog, 'voice/sys_skip') === 1, 'sys_skip 每实例只播一次,重开不重播');
assert(gestureCount - g0 <= 60, `关键路径手势预算 ≤60(实际 ${gestureCount - g0})`);
teardown(game);
}
// ================================================================ 四结局矩阵 + 差分剧本
// (§9 边界:剧本 B → e2;C → e3;D → e4;E → 灯灭差分;F → 插叙2 兜底)
realLog('== 四结局矩阵(剧本 B / C / D)+ 灯灭差分(E)+ 插叙2 兜底(F)==');
{
// ---- 剧本 B:trust=4 / truth=2,p7 右 p8_reef 右 p9 右 → e2
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await playNight(game, SCRIPT_B);
assert(game.endingId === 'e2' && game.trust === 4 && game.truth === 2 && !game.p9told,
'剧本 B → e2 守夜人(trust=4/truth=2/p9 右)');
assert(voicePlays(playLog, 'voice/c9_land') === 1, 'p7 右 + trust≥3 → c9_land(自渡成功)');
assert(voicePlays(playLog, 'voice/p9b') === 1, 'truth=2<3 → p9b 碎片版');
assert(voicePlays(playLog, 'voice/d9_keep') === 1 && voicePlays(playLog, 'voice/amo_h') === 1,
'p9 右 → d9_keep/amo_h(守住秘密)');
for (const id of ['voice/e2_a', 'voice/e2_b', 'voice/ep_amo_solo', 'voice/ep_lamp_hold',
'voice/ep_log_kept', 'voice/ep_dad']) {
assert(voicePlays(playLog, id) === 1, `剧本 B 尾声播出 ${id}`);
}
teardown(game);
}
// ---- 剧本 C:全右 trust=0 / truth=0 → m6_seal 封锁 → e3
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await playNight(game, SCRIPT_C);
assert(game.endingId === 'e3' && game.trust === 0 && game.truth === 0 && game.p9told,
'剧本 C → e3 半明半暗(trust=0/truth=0/p9 左)');
assert(voicePlays(playLog, 'voice/m6_seal') === 1, 'rev2=false → m6_seal 封锁句播出(p6 不摆假选项)');
assert(voicePlays(playLog, 'voice/p6') === 0 && voicePlays(playLog, 'voice/c6_reade') === 0
&& voicePlays(playLog, 'voice/c6_shut') === 1,
'p6 封锁:提示句与左路不播,自动走右 c6_shut');
assert(voicePlays(playLog, 'voice/c9_found') === 1, 'p7 右 + trust=0 → c9_found(天亮被找到,零死亡)');
assert(voicePlays(playLog, 'voice/p9b') === 1 && voicePlays(playLog, 'voice/d9_tell_b') === 1,
'truth=0 → p9b + d9_tell_b(碎片版,句句为真)');
assert(voicePlays(playLog, 'voice/ep_amo_dawn') === 1 && voicePlays(playLog, 'voice/ep_lamp_hold') === 1,
'剧本 C 尾声:ep_amo_dawn + ep_lamp_hold');
assert(voicePlays(playLog, 'voice/ep_log_told') === 0 && voicePlays(playLog, 'voice/ep_log_kept') === 0,
'truth=0 日志未出场:ep_log_* 两句皆不播');
assert(voicePlays(playLog, 'voice/ep_dad') === 0, 'rev2=false:ep_dad(父亲签字)不播');
assert(voicePlays(playLog, 'voice/tr_thin') === 0,
'跨档才播纪律:trust=0=开局基准档(thin)→ CALM 无跨档,tr_thin 不重复播报');
teardown(game);
}
// ---- 剧本 D:同 C 但 p9 右 → e4
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await playNight(game, SCRIPT_D);
assert(game.endingId === 'e4' && game.trust === 0 && !game.p9told,
'剧本 D → e4 各自的塔(trust=0/p9 右)');
assert(voicePlays(playLog, 'voice/e4_a') === 1 && voicePlays(playLog, 'voice/e4_b') === 1,
'e4 专属结局主句 + 钩子播出');
teardown(game);
}
// ---- 剧本 E:p3 右 p4 右(无插叙2)→ c7_go2 半卷 + c3_dark 灯灭 + 800ms 死窗
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
// 打到 p7 左之前的 c6_shut
let i = 0; let guard = 0;
while (game.nodeId !== 'c6_shut') {
if (++guard > 300) throw new Error('剧本 E 前置卡住');
if (game.choiceArmed) { await g(game, SCRIPT_E[i++]); continue; }
if (game.phase === 'CALM') { await advance(() => game.phase !== 'CALM'); continue; }
if (!game.voicePlaying) {
if (game.phase === 'MEMORY') { await advance(() => game.voicePlaying || game.phase !== 'MEMORY'); continue; }
await g(game, 'tap');
continue;
}
await advance(() => !game.voicePlaying);
}
assert(voicePlays(playLog, 'voice/m6_seal') === 1 && game.rev2 === false,
'剧本 E:p3 右 p4 右 → 无插叙2(rev2=false → m6_seal)');
await g(game, 'tap'); // c6_shut → p7
await advance(() => game.choiceArmed); // p7 提示
await g(game, 'left'); // p7 左
await tapUntil(game, () => game.nodeId === 'p8_boat' && game.choiceArmed, 24); // c7_go2(等待单击)→ c3_wall(2s) → p8 提示
assert(voicePlays(playLog, 'voice/c7_go') === 0 && voicePlays(playLog, 'voice/c7_go2') === 1,
'rev2=false → 播 c7_go2(半卷发条版),绝不播 c7_go');
assert(voicePlays(playLog, 'sfx/lamp_mech') === 0, '半卷版无发条知识:lamp_mech 不播');
await g(game, 'left'); // p8_boat 左
await tapUntil(game, () => game.nodeId === 'c3_dark' && game.voicePlaying, 24); // c8_song→songo→c9_return→@dark→c3_dark
assert(voicePlays(playLog, 'voice/c9_return') === 1, '剧本 E:p7 左 → c9_return(接驳艇回来)');
// 800ms 死窗:在途 tap 被吞、链完整播出
await g(game, 'tap');
assert(game.currentVoiceId === 'voice/c3_dark' && game.voicePlaying,
'结算死窗:c3_dark 开播 800ms 内在途 tap 被吞(不跳句)');
await tapUntil(game, () => game.choiceArmed, 16); // c3_dark(auto) → @signoff → g9_in(auto) → g9_knot(等待单击)→ p9 提示
assert(voicePlays(playLog, 'voice/c3_dark') === 1, 'c3_dark 完整播出后自动进签收');
await g(game, 'right'); // p9 右
await tapUntil(game, () => game.phase === 'ENDING' && game.awaitingRestart, 40);
assert(game.endingId === 'e4' && game.trust === 2, '剧本 E:trust=2<3 且 p9 右 → e4');
assert(voicePlays(playLog, 'voice/ep_lamp_dark') === 1 && voicePlays(playLog, 'voice/ep_lamp_full') === 0,
'灯三态:p7 左 + ¬rev2 → ep_lamp_dark(复刻祖父之夜回响),full 不播');
assert(voicePlays(playLog, 'voice/c3_dark') >= 1 && voicePlays(playLog, 'voice/ep_shore') === 1,
'b_goal 货轮承诺闭环:c3_dark「过境的货轮,平安」+ ep_shore「航道平安」');
teardown(game);
}
// ---- 剧本 F:p3 右 p4 左 → 插叙2 兜底在 c4_true 与 c4_trueo 之间,c2_key 随后
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await playNight(game, SCRIPT_F);
const ids = playLog.map((e) => e.id);
const iTrue = ids.indexOf('voice/c4_true');
const iF1 = ids.indexOf('voice/rev_f1');
const iKey = ids.indexOf('voice/c2_key');
const iTrueO = ids.indexOf('voice/c4_trueo');
assert(iTrue >= 0 && iTrue < iF1 && iF1 < iKey && iKey < iTrueO,
'插叙2 兜底时序:c4_true → rev_f1..f5 → c2_key → c4_trueo(§9 剧本 F)');
assert(game.rev2 === true && game.trust === 3 && game.truth === 2,
'剧本 F:p4 左兜底触发 rev2(trust=3 / truth=2)');
assert(game.endingId === 'e1', '剧本 F:trust=3 且 p9 左 → e1');
teardown(game);
}
}
// ================================================================ 时空签名断言(核心)
// (§9:vinyl 与 storm/room 播放区间零重叠;过渡音成对;插叙期间 engine 停;
// 锚句在位且首播不可跳;回返后 bgm=storm)
realLog('== 时空签名断言(剧本 A 全夜底噪流水,guide=off)==');
{
clearSave(); resetSettings('off');
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await playNight(game, SCRIPT_A);
assert(game.endingId === 'e1', '前置:剧本 A 全夜走完 → e1');
// _ambLog → 各 key 的 on/off 区间
const intervalsOf = (id) => {
const out = []; let open = null;
for (const e of game._ambLog.filter((x) => x.id === id)) {
if (e.on && open === null) open = e.t;
if (!e.on && open !== null) { out.push([open, e.t]); open = null; }
}
if (open !== null) out.push([open, Infinity]); // 未关(结尾仍在播)
return out;
};
const vinyl = intervalsOf('amb_vinyl');
const storm = intervalsOf('amb_storm');
const room = intervalsOf('amb_room');
const engine = intervalsOf('amb_engine');
assert(vinyl.length === 3, `vinyl 起停 ×3(插叙 1/2/3,实际 ${vinyl.length})`);
const overlap = (a, bs) => bs.some(([s, e]) => a[0] < e && s < a[1]);
assert(vinyl.every((v) => !overlap(v, storm)), '硬约束:vinyl 与 amb_storm 播放区间零重叠');
assert(vinyl.every((v) => !overlap(v, room)), '硬约束:vinyl 与 amb_room 播放区间零重叠');
assert(vinyl.every((v) => !overlap(v, engine)), '硬约束:插叙期间 amb_engine 停播(R2)');
assert(storm.length === 4 && room.length === 4,
`storm/room 各 4 段(开局+三次回返,实际 ${storm.length}/${room.length})`);
assert(vinyl.every((v, i) => v[1] !== Infinity), '每段插叙都正常回返(vinyl 全部关闭)');
// 过渡音与 BGM 时序
const ins = playLog.filter((e) => e.id === 'sfx/memory_in').map((e) => e.t);
const outs = playLog.filter((e) => e.id === 'sfx/memory_out').map((e) => e.t);
assert(ins.length === 3 && outs.length === 3, 'memory_in/out 各 ×3 且成对');
assert(ins.every((t, i) => outs[i] > t), '每次 memory_out 都在对应 memory_in 之后');
const bgmMem = playLog.filter((e) => e.id === 'bgm/memory').length;
const bgmStorm = playLog.filter((e) => e.id === 'bgm/storm').length;
assert(bgmMem === 3 && bgmStorm >= 4, `插叙进/回 BGM 切换 ×3(memory=${bgmMem} storm=${bgmStorm})`);
teardown(game);
}
// ================================================================ 信任/真相账目真实
// (§9:上滑链顺序 prog_m →〔mem: prog_mem〕→ tr → tru → sys_help(G6);档位句逐值对应;tr_thin 附 warning 震)
realLog('== 信任/真相账目(上滑状态链顺序 + 档位逐值 + G2 warning 震)==');
{
// ---- RIG A:MEMORY 中上滑 → 时位句先行(prog_mem_f 在链首)
clearSave(); resetSettings('full');
storageData.set(SAVE_KEY, JSON.stringify({
schema: 1, updatedAt: new Date().toISOString(),
summary: '第2幕·风暴顶 · 信任半 · 结局0/4',
phase: 'MEMORY', node: 'rev_f2', act: 2, timeline: 'mem_f',
trust: 1, flags: { rev1: true, rev2: true, rev3: false },
p7: null, p9told: false, taught: true, endings: {}, runs: 1,
lastTier: 'half', memSet: 'f2', memRet: 'c2_key', ending: null,
}));
{
const { audio, playLog, game } = makeRig();
const vib0 = vibrateLog.length;
await audio.unlock();
game.start(); await flush();
assert(game.phase === 'ASK', '带存档进游戏 → sys_continue_ask 询问态');
await g(game, 'tap'); // 继续上次 → 恢复链
await advance(() => game.currentVoiceId === 'voice/prog_m2');
assert(game.phase === 'MEMORY' && game.timeline === 'mem_f', '恢复至 MEMORY(timeline=mem_f,存档带时空位)');
// 恢复链时位句先行:prog_m2 → prog_mem_f → 重播 rev_f2 起链(在中断复述块详断言,此处验证播过)
await advance(() => game.currentVoiceId === 'voice/rev_f2');
assert(voicePlays(playLog, 'voice/prog_m2') === 1 && voicePlays(playLog, 'voice/prog_mem_f') === 1,
'MEMORY 恢复链时位句先行:prog_m2 → prog_mem_f');
// MEMORY 中上滑:状态链首段 = 时位句 prog_mem_f(§6.4/§7.4 G6)
await g(game, 'up');
await advance(() => lastIndexOf(playLog, 'voice/sys_help') >= 0 && game.currentVoiceId === 'voice/sys_help');
const iM = lastIndexOf(playLog, 'voice/prog_mem_f');
const iT = lastIndexOf(playLog, 'voice/tr_half');
const iU = lastIndexOf(playLog, 'voice/tru_two');
const iH = lastIndexOf(playLog, 'voice/sys_help');
assert(iM >= 0 && iM < iT && iT < iU && iU < iH,
'MEMORY 上滑状态链顺序:prog_mem_f(时位句先行)→ tr_half → tru_two → sys_help(G6)');
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === WARN_PATTERN) === false,
'trust=1(half 档)→ 无 warning 震(G2 只罩 tr_thin)');
assert(game.phase === 'MEMORY' && game._loops.amb_vinyl, '上滑帮助链期间仍在 MEMORY(底噪仍 vinyl)');
teardown(game);
}
// ---- RIG B:now 线上滑(预置 trust=0)→ tr_thin + warning 震 + tru_zero
clearSave(); resetSettings('full');
storageData.set(SAVE_KEY, JSON.stringify({
schema: 1, updatedAt: new Date().toISOString(),
summary: '第2幕·风暴顶 · 信任薄 · 结局0/4',
phase: 'WATCH', node: 'amo_e', act: 2, timeline: 'now',
trust: 0, flags: { rev1: false, rev2: false, rev3: false },
p7: null, p9told: false, taught: false, endings: {}, runs: 2,
lastTier: 'thin', memSet: null, memRet: null, ending: null,
}));
{
const { audio, playLog, game } = makeRig();
const vib0 = vibrateLog.length;
await audio.unlock();
game.start(); await flush();
await g(game, 'tap'); // 继续上次 → 重播 amo_e
await advance(() => !game.voicePlaying);
await g(game, 'up');
await advance(() => !game.voicePlaying);
const ids = playLog.map((e) => e.id);
const iP = lastIndexOf(playLog, 'voice/prog_m2');
const iT = lastIndexOf(playLog, 'voice/tr_thin');
const iU = lastIndexOf(playLog, 'voice/tru_zero');
const iH = lastIndexOf(playLog, 'voice/sys_help');
assert(iP >= 0 && iP < iT && iT < iU && iU < iH,
'WATCH 上滑状态链顺序:prog_m2 → tr_thin → tru_zero → sys_help(G6 状态在前帮助垫后)');
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === WARN_PATTERN),
'G2:tr_thin(trust=0)整句播报附 warning 震');
await advance(() => !game.voicePlaying);
assert(voicePlays(playLog, 'voice/amo_e') === 2, '状态播完重播当前节点(局面复述)');
teardown(game);
}
}
// ================================================================ 看门狗双轨
// (§9:链末 25/50 gd_continue/gd_again;p* 25/50 复述两遍;乱按 96s 无操作轨零播出 +
// 无进展轨 60s→gd_prog1、120s→gd_prog2;有真实进展零 prog;guide=off 零定时器)
realLog('== 看门狗双轨(guide=full)==');
{
// ---- 链末等待:full 25s/50s → gd_continue/gd_again(两级不同句,用尽静默)
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'WATCH' && !game.voicePlaying); // c1_tune 链末等待
const tW = now;
await advance(() => voicePlays(playLog, 'voice/gd_continue') === 1);
assert(now - tW === 25000, `WATCH 链末 25s 静默 → gd_continue(实际 ${now - tW}ms)`);
await advance(() => voicePlays(playLog, 'voice/gd_again') === 1);
assert(now - tW === 50000, `50s → gd_again(第二级新句,实际 ${now - tW}ms)`);
await advance(() => now >= tW + 75000);
assert(voicePlays(playLog, 'voice/gd_continue') === 1 && voicePlays(playLog, 'voice/gd_again') === 1,
'两级用尽彻底静默');
teardown(game);
}
// ---- p* 播完 25s/50s → 重播当前 p* 提示句两遍
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await tapUntil(game, () => game.choiceArmed, 24); // → p1 播完
const tP = now;
await advance(() => voicePlays(playLog, 'voice/p1') === 2);
assert(now - tP === 25000, `p* 播完 25s 无操作 → 重播 p1(第一级,实际 ${now - tP}ms)`);
await advance(() => voicePlays(playLog, 'voice/p1') === 3);
assert(now - tP === 50000, `50s → 再复述一次(第二级,实际 ${now - tP}ms)`);
await advance(() => now >= tP + 75000);
assert(voicePlays(playLog, 'voice/p1') === 3, 'p* 复述两级后彻底静默');
await g(game, 'left');
assert(game.trust === 1 && game.nodeId === 'c1_hold', '复述期间剧情不推进,玩家抉择照常生效');
teardown(game);
}
// ---- 乱按 126s:无效滑动每 2s 一次 → 无操作轨零播出;无进展轨 60s/120s 照常开火
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'WATCH' && !game.voicePlaying); // c1_tune 链末等待(双轨挂载点)
const t0 = now;
// 虚拟时钟只在定时器处推进:每 2s 插一个空定时器锚点,保证手势节奏真实
// (否则 advance 会一跳 25s 直接触发看门狗——live 玩家 2s 一按不会让它到点)
const waitV = async (ms) => {
const target = now + ms;
setTimeout(() => {}, ms);
await advance(() => now >= target);
};
for (let i = 0; i < 63; i++) { // 每 2s 一个无效滑动,直到 t0+126s
if (i > 0) await waitV(2000);
await g(game, i % 2 ? 'left' : 'right'); // 无效(非抉择节点)
}
assert(now - t0 === 124000, `乱按节奏 62 间隔 × 2s = 124s(覆盖 120s 第二级,实际 ${now - t0}ms)`);
assert(voicePlays(playLog, 'voice/gd_continue') === 0 && voicePlays(playLog, 'voice/gd_again') === 0
&& voicePlays(playLog, 'voice/gd_invalid') <= 1,
'乱按 126s:无操作轨被手势反复复位 → gd_continue/gd_again 零播出(无效手势零静默靠 gd_invalid onceKey)');
assert(voicePlays(playLog, 'voice/gd_prog1') === 1, '无进展轨 60s → gd_prog1 照常开火(无效手势不复位)');
assert(voicePlays(playLog, 'voice/gd_prog2') === 1, '无进展轨 120s → gd_prog2(乱按也被抢救)');
teardown(game);
}
// ---- 有真实进展 → 全程零 prog
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'WATCH' && !game.voicePlaying);
await g(game, 'tap'); // 真实进展(推进到 amo_a)→ prog 轨作废
await advance(() => now >= 140000 || game.choiceArmed);
assert(voicePlays(playLog, 'voice/gd_prog1') === 0 && voicePlays(playLog, 'voice/gd_prog2') === 0,
'有真实进展:无进展轨全程零播出(不误伤正常玩家)');
teardown(game);
}
// ---- MEMORY 链末不挂看门狗 + guide=off 零定时器
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await tapUntil(game, () => game.choiceArmed, 24);
await g(game, 'left'); // p1 左
await tapUntil(game, () => game.choiceArmed, 24); // → p2
await g(game, 'left'); // p2 左
await advance(() => game.phase === 'MEMORY');
const tM = now;
await advance(() => game.phase === 'WATCH'); // 插叙 auto 回返
assert(now - tM < 25000, 'MEMORY 链 auto 回返(链末不设等待点,看门狗无处挂)');
assert(voicePlays(playLog, 'voice/gd_continue') === 0, '插叙全程零 gd_*(§7.2 MEMORY 行)');
assert((await advance()) === 'idle', 'guide=off + 等待点之外全程零定时器(虚拟时钟可空闲)');
teardown(game);
}
}
// ================================================================ 键盘变体路由
// (§9:key → voice/<gender>/key/ 探测;touch 反之;auto(触屏能力检测)不探测)
realLog('== 键盘变体路由(settings.input)==');
{
clearSave(); resetSettings('full');
settings.set({ input: 'key' });
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await advance(() => game.currentVoiceId === 'voice/gd_brief');
assert(urlHits('audio/voice/female/key/gd_brief') >= 1,
"input=key:gd_brief 先探测键盘变体位 voice/female/key/(游戏代码用普通 id,引擎路由)");
assert(voicePlays(playLog, 'voice/gd_brief') === 1, 'input=key:变体命中照常播出(成对生成)');
teardown(game);
}
clearSave(); resetSettings('full');
settings.set({ input: 'touch' });
{
const keyHits0 = urlHits('audio/voice/female/key/');
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await advance(() => game.currentVoiceId === 'voice/gd_brief');
assert(urlHits('audio/voice/female/key/') === keyHits0,
'input=touch:不探测键盘变体位(触屏措辞主资产直出)');
assert(voicePlays(playLog, 'voice/gd_brief') === 1, 'touch 会话照常播出 gd_brief(主资产)');
teardown(game);
}
clearSave(); resetSettings('full');
settings.set({ input: 'auto' }); // 触屏能力检测(maxTouchPoints=1)→ touch 提示
{
const keyHits0 = urlHits('audio/voice/female/key/');
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await advance(() => game.currentVoiceId === 'voice/gd_brief');
assert(urlHits('audio/voice/female/key/') === keyHits0,
'input=auto(触屏设备,尚无键盘输入):按 touch 路由,不探测 key/ 位');
teardown(game);
}
}
// ================================================================ 中断复述(含时空位)
// (§9:杀进程重进 timeline=mem_f → sys_continue_ask → 单击 → prog_m2 → prog_mem_f →
// 重播 rev_f3 起链;恢复首句不可跳,第二句起可跳;now 态存档重播节点链)
realLog('== 中断复述(guide=off,预置存档 timeline=mem_f / node=rev_f3)==');
{
clearSave(); resetSettings('off');
storageData.set(SAVE_KEY, JSON.stringify({
schema: 1, updatedAt: new Date().toISOString(),
summary: '第2幕·风暴顶 · 信任半 · 结局0/4',
phase: 'MEMORY', node: 'rev_f3', act: 2, timeline: 'mem_f',
trust: 2, flags: { rev1: true, rev2: true, rev3: false },
p7: null, p9told: false, taught: true, endings: {}, runs: 2,
lastTier: 'half', memSet: 'f2', memRet: 'c2_key', ending: null,
}));
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
assert(game.phase === 'ASK' && game.currentVoiceId === 'voice/sys_continue_ask',
'带存档重进 → sys_continue_ask 询问');
await g(game, 'tap');
assert(game.phase === 'MEMORY' && game.memSet === 'f2' && game.timeline === 'mem_f',
'单击继续 → 恢复 MEMORY:memSet=f2 / timeline=mem_f(时空位还原)');
assert(game.trust === 2 && game.rev1 === true && game.rev2 === true && game.runs === 3,
'trust/flags/runs 全部还原(runs 统一 +1 口径 → 3)');
await advance(() => game.currentVoiceId === 'voice/prog_m2');
await g(game, 'tap');
assert(game.currentVoiceId === 'voice/prog_m2', 'F4:恢复链首句(prog_m2)播完前单击不跳句');
await advance(() => game.currentVoiceId === 'voice/prog_mem_f');
await g(game, 'tap'); // 第二句起可跳(对照)
await flush();
assert(game.currentVoiceId !== 'voice/prog_mem_f', 'F4 对照:恢复链第二句起跳句重新武装');
await advance(() => game.nodeId === 'c2_key' && !game.voicePlaying);
const ids = playLog.map((e) => e.id);
const iP = ids.indexOf('voice/prog_m2');
const iM = ids.indexOf('voice/prog_mem_f');
const iR = ids.indexOf('voice/rev_f3');
assert(iP >= 0 && iP < iM && iM < iR, '恢复链时位句先行:prog_m2 → prog_mem_f → 重播 rev_f3 起链');
assert(voicePlays(playLog, 'voice/rev_f3') === 1 && voicePlays(playLog, 'voice/rev_f5') === 1,
'从 rev_f3 重播到链末 rev_f5,回返 c2_key(≤5s 回到可玩)');
teardown(game);
}
// ASK 右滑 = 新风暴夜(存档保留再询问)
{
const { audio, game: game2 } = makeRig();
await audio.unlock();
game2.start(); await flush();
assert(game2.phase === 'ASK', '再次带存档重进 → ASK 询问态');
const r0 = game2.runs;
await g(game2, 'right');
assert(game2.phase === 'INTRO' && game2.trust === 0 && game2.runs === r0 + 1,
'ASK 右滑 = 新风暴夜:跨幕变量重置、runs +1(统一口径)');
teardown(game2);
}
// now 态存档:恢复 = 重播当前节点链
clearSave(); resetSettings('off');
storageData.set(SAVE_KEY, JSON.stringify({
schema: 1, updatedAt: new Date().toISOString(),
summary: '第2幕·风暴顶 · 信任半 · 结局0/4',
phase: 'WATCH', node: 'amo_e', act: 2, timeline: 'now',
trust: 2, flags: { rev1: true, rev2: true, rev3: false },
p7: null, p9told: false, taught: true, endings: {}, runs: 2,
lastTier: 'half', memSet: null, memRet: null, ending: null,
}));
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await g(game, 'tap');
assert(game.phase === 'WATCH' && game.nodeId === 'amo_e', 'now 态存档恢复 = 重播当前节点链');
await flush();
assert(game.timeline === 'now' && !game._loops.amb_vinyl, 'now 态恢复底噪签名正确(无 vinyl)');
assert(game._loops.amb_storm && game._loops.amb_room, 'now 态恢复底噪签名正确(storm+room 起播)');
teardown(game);
}
}
// ================================================================ pendingChoice / p* 门 / idle-only
realLog('== pendingChoice 时序 / p* 不可跳 / idle-only 纠错 ==');
{
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await tapUntil(game, () => game.promptPlaying, 24); // p1 提示播放中
await g(game, 'left');
await g(game, 'tap'); // 压力连点:p* 不可跳
await g(game, 'tap');
assert(game.currentVoiceId === 'voice/p1' && game.pendingChoice === 'left',
'p* 播放中连点:不跳句、不清缓存、不提前生效');
await g(game, 'right');
assert(game.pendingChoice === 'right', '播中再滑覆盖缓存(最后一次为准)');
await g(game, 'left');
const iP1Last = lastIndexOf(playLog, 'voice/p1');
await advance(() => game.nodeId === 'c1_hold');
const ids = playLog.map((e) => e.id);
const iConfirm = ids.indexOf('sfx/ui_confirm');
assert(iConfirm > iP1Last && ids.indexOf('voice/c1_hold') > iConfirm,
'生效时序 = p1 播完 → ui_confirm 结算音 → 结果句(播完才生效,零插播)');
assert(game.trust === 1 && game.pendingChoice === null, '缓存恰好生效一次(不丢不重复)');
teardown(game);
}
// idle-only(F1.4):对白在播时纠错句跳过且不占 onceKey
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
const guideEvents = [];
game.guide.setEventHook((ev) => guideEvents.push(ev));
const vib0 = vibrateLog.length;
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'WATCH' && game.voicePlaying); // c1_tune 播放中
await g(game, 'left');
assert(guideEvents.some((e) => e.type === 'error' && e.skipped === true),
'对白在播时无效手势 → 纠错句 idle-only 跳过(事件带 skipped)');
assert(voicePlays(playLog, 'voice/gd_invalid') === 0, '在播对白不被纠错句顶掉');
await advance(() => !game.voicePlaying);
await g(game, 'left');
assert(voicePlays(playLog, 'voice/gd_invalid') === 1, '语音空闲后再次无效 → gd_invalid 播出(不占 onceKey)');
assert(vibrateLog.slice(vib0).filter((v) => JSON.stringify(v.pattern) === ERR_PATTERN).length === 2,
'两次无效手势 error 震都发出(零静默)');
teardown(game);
}
}
// ================================================================ 菜单交互(含插叙中开关菜单)
realLog('== 菜单交互(guide=off / full)==');
{
clearSave(); resetSettings('off');
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await tapUntil(game, () => game.promptPlaying, 24); // p1 提示播放中呼出菜单
await g(game, 'down');
assert(game.phase === 'MENU', '下滑呼出模态菜单');
await flush();
await g(game, 'tap'); // 播放中单击循环 → queue 排队
await advance(() => !game.voicePlaying);
await g(game, 'tap');
await advance(() => !game.voicePlaying);
await g(game, 'tap');
await advance(() => !game.voicePlaying);
const cnt = (id) => voicePlays(playLog, id);
const ids = playLog.map((e) => e.id);
const iMenu = ids.indexOf('voice/sys_menu');
const cycle = ids.slice(iMenu + 1, iMenu + 7);
assert(JSON.stringify(cycle) === JSON.stringify([
'voice/sys_menu_resume', 'voice/sys_menu_restart', 'voice/sys_menu_lobby', 'voice/sys_menu_resume',
]), `菜单单击循环顺序 = [回到值守, 重新开始今夜, 保存并回大厅] 回绕(实际: ${cycle.join(' → ')})`);
assert(cnt('voice/sys_menu_restart') === 1 && cnt('voice/sys_menu_lobby') === 1
&& cnt('voice/sys_menu_resume') === 2, '菜单项 queue 依序播出零截断');
await g(game, 'up'); // 菜单内上滑帮助
await advance(() => !game.voicePlaying);
assert(cnt('voice/sys_help') >= 1 && cnt('voice/sys_settings') === 1, '菜单内上滑播 sys_help + 设置提示');
await g(game, 'doubletap'); // 当前项 = 回到值守
assert(game.phase === 'WATCH' && game.nodeId === 'p1', '双击确认「回到值守」→ phase/node 不变');
await advance(() => game.choiceArmed);
assert(cnt('voice/p1') >= 2, '菜单关闭后重播当前节点(局面复述,含抉择提示)');
// 保存并回大厅
await g(game, 'down');
await advance(() => !game.voicePlaying);
await g(game, 'tap');
await g(game, 'tap'); // 第二项 = 保存并回大厅
await g(game, 'doubletap');
assert(cnt('voice/sys_menu_lobby') >= 2, '确认回大厅 → sys_menu_lobby 播出');
assert(game.leaving === true, 'leaving 置位(跳转大厅前先播确认句)');
const sd = saveData();
assert(sd && sd.schema === 1 && typeof sd.summary === 'string' && sd.summary.length > 0,
'回大厅确认后 localStorage 有最新 data(含 summary)');
teardown(game);
}
realLog('== 插叙中开关菜单(底噪签名不丢)==');
{
clearSave(); resetSettings('off');
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await tapUntil(game, () => game.choiceArmed, 24);
await g(game, 'left'); // p1 左
await tapUntil(game, () => game.choiceArmed, 24); // → p2
await g(game, 'left'); // p2 左
await advance(() => game.currentVoiceId === 'voice/rev_g2'); // 插叙1 中段
assert(game.phase === 'MEMORY' && game._loops.amb_vinyl, '插叙进行中(vinyl 常驻)');
await g(game, 'down'); // 插叙中开菜单(停插叙链)
assert(game.phase === 'MENU', 'MEMORY 中下滑 → 模态菜单(插叙链暂停)');
await flush();
assert(game._loops.amb_vinyl && game._loops.amb_storm === null,
'菜单期间底噪仍 vinyl(timeline 不丢,§6.4 菜单复述行)');
await g(game, 'down'); // 关菜单
assert(game.phase === 'MEMORY', '关闭菜单回 MEMORY');
await advance(() => game.currentVoiceId === 'voice/rev_g2');
assert(voicePlays(playLog, 'voice/rev_g2') >= 2, '关闭菜单重播当前回忆节点(rev_g2 起重播)');
await advance(() => game.nodeId === 'c1_diary' && !game.voicePlaying);
assert(voicePlays(playLog, 'sfx/memory_out') === 1, '菜单打断后插叙照常回返(memory_out → c1_diary)');
teardown(game);
}
// ================================================================ 结算链死窗(ENDING 链)
realLog('== 结算链死窗(ENDING 结局链 800ms,guide=off)==');
{
clearSave(); resetSettings('off');
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await playNight(game, SCRIPT_D); // 最短路径到 e4
assert(game.phase === 'ENDING' && game.awaitingRestart, '前置:剧本 D → e4 结算态');
await g(game, 'doubletap'); // 重听结局链
await advance(() => game.voicePlaying && game.currentVoiceId === 'voice/e4_a');
const vib0 = vibrateLog.length;
await g(game, 'tap'); // 800ms 死窗内在途 tap
await g(game, 'right');
assert(game.currentVoiceId === 'voice/e4_a' && game.voicePlaying,
'结局链开播 800ms 内手势一律吞掉(在途 tap/右滑不跳句、不逃逸)');
assert(vibrateLog.slice(vib0).length === 0, '死窗内吞手势不产生任何震动反馈');
await advance(() => game.awaitingRestart);
assert(voicePlays(playLog, 'voice/e4_a') === 2 && voicePlays(playLog, 'voice/e4_b') === 2
&& voicePlays(playLog, 'voice/ep_shore') === 2, '结局链完整有序播出(重播版一字不缺)');
teardown(game);
}
// ================================================================ R2 修复回归
// (research/new8/keeper/round1/synthesis.md F1/F2;断言增量登记本块)
realLog('== R2 修复回归(F1 tr_thin 首幕豁免 / F2 gd_brief 首播锁)==');
{
// ---- F1a:首夜首幕(runs=1, trust=0)上滑查状态 → tr_thin 播报无 warning 震(confirm 代)
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
const vib0 = vibrateLog.length;
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'WATCH' && !game.voicePlaying); // c1_tune 链末等待
await g(game, 'up');
await advance(() => !game.voicePlaying);
assert(voicePlays(playLog, 'voice/tr_thin') === 1, 'F1a 前置:首夜上滑播 tr_thin(trust=0)');
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === WARN_PATTERN) === false,
'F1:首夜首幕 tr_thin 不再附 warning 震(自然起点不是警告)');
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === JSON.stringify([20])),
'F1:首夜首幕 tr_thin 改附 confirm 轻震(触感不缺席)');
teardown(game);
}
// ---- F1b:第二夜(runs=2, trust=0)同状态上滑 → warning 震恢复(跨夜不骗老玩家)
clearSave(); resetSettings('full');
storageData.set(SAVE_KEY, JSON.stringify({
schema: 1, updatedAt: new Date().toISOString(),
summary: '风暴夜已过·灯与归人 · 结局1/4',
phase: 'ENDING', node: 'e1', act: 3, timeline: 'now',
trust: 4, flags: { rev1: true, rev2: true, rev3: true },
p7: 'boat', p9told: true, taught: true, endings: { e1: true }, runs: 2,
lastTier: 'deep', memSet: null, memRet: null, ending: 'e1',
}));
{
const { audio, playLog, game } = makeRig();
const vib0 = vibrateLog.length;
await audio.unlock();
game.start(); await flush();
await g(game, 'tap'); // ASK 单击 = 继续上次(runs 统一 +1 口径 → 3,非首夜)
assert(game.phase === 'ENDING' && game.runs === 3, 'F1b 前置:续玩后 runs=3(非首夜)');
await advance(() => game.awaitingRestart); // 结局链播完
await g(game, 'tap'); // 结算单击 = 新风暴夜
assert(game.phase === 'INTRO' && game.runs === 4 && game.trust === 0, 'F1b 前置:新夜 runs=4 trust=0');
await advance(() => game.phase === 'WATCH' && !game.voicePlaying);
await g(game, 'up');
await advance(() => !game.voicePlaying);
assert(voicePlays(playLog, 'voice/tr_thin') === 1, 'F1b 前置:非首夜上滑播 tr_thin');
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === WARN_PATTERN),
'F1:非首夜 tr_thin 恢复 warning 震(首幕豁免只罩首夜首查)');
teardown(game);
}
// ---- F2:gd_brief 首播锁(full 档)——首播单击不跳+beat,第二句起恢复可跳
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
const vib0 = vibrateLog.length;
await audio.unlock();
game.start(); await flush();
await advance(() => game.currentVoiceId === 'voice/gd_brief');
const gbrief0 = voicePlays(playLog, 'voice/gd_brief');
await g(game, 'tap');
assert(game.currentVoiceId === 'voice/gd_brief' && game.voicePlaying,
'F2:gd_brief 首播单击不跳(操作教学完整播出)');
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === BEAT_PATTERN),
'F2:锁内单击补 beat 轻震(G4 签名)');
await advance(() => game.currentVoiceId === 'voice/b_open');
await g(game, 'tap'); // 第二句起恢复跳句自由
await flush();
assert(game.currentVoiceId !== 'voice/b_open', 'F2:锁仅罩首播一句,第二句起跳句恢复');
await advance(() => voicePlays(playLog, 'voice/gd_brief') === gbrief0);
teardown(game);
}
}
// ================================================================ 资产缺失兜底
realLog('== 资产缺失(guide=off,屏蔽 c8_songo)==');
{
clearSave(); resetSettings('off');
blockedAssets.add('c8_songo');
const errBefore = consoleErrs.length;
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await playNight(game, SCRIPT_A);
assert(consoleErrs.slice(errBefore).some((e) => e.includes('c8_songo')),
'屏蔽资产仅 console.error,不抛异常');
assert(consoleWarns.some((w) => w.includes('看门狗') && w.includes('c8_songo')),
'单句 30s 看门狗判定资产缺失并自动续链');
assert(game.endingId === 'e1' && game.awaitingRestart === true, '屏蔽 voice 资产仍打到 e1 结局,不卡死');
assert(voicePlays(playLog, 'voice/gd_continue') === 0 && voicePlays(playLog, 'voice/gd_again') === 0
&& voicePlays(playLog, 'voice/gd_prog1') === 0,
'guide=off 全程零向导语音(gd_*)零定时器');
blockedAssets.delete('c8_songo');
teardown(game);
}
// ================================================================ 设置联动与引擎行为
realLog('== 设置联动与引擎行为(guide=off)==');
{
clearSave(); resetSettings('off');
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'WATCH');
await tapUntil(game, () => game.promptPlaying, 24); // p1
settings.set({ voice: 'male', speed: 1.7 });
await g(game, 'left');
await advance(() => game.nodeId === 'c1_hold');
assert(urlHits('audio/voice/male/c1_hold') > 0, 'settings.voice=male → 下一句起走 male 目录');
assert(audio._voice && audio._voice.st.tempo === 1.7, 'settings.speed=1.7 下一句起生效(SoundTouch tempo)');
settings.set({ speed: 1.0, voice: 'female' });
fireWindowEvent('soundgame:longpress');
assert(audio._voice && audio._voice.st.tempo === 1.7, '长按 → 旁白临时 1.7 倍速(不变调)');
fireWindowEvent('soundgame:longpressend');
assert(audio._voice && audio._voice.st.tempo === 1.0, '松开 → 恢复 settings.speed');
await tapUntil(game, () => game.choiceArmed, 24);
assert(audio._bgm && audio._bgm.id === 'bgm/storm', 'WATCH 垫乐 bgm/storm');
settings.set({ bgm: 0 });
await flush();
assert(audio._bgm === null, 'settings.bgm=0 立即停播');
settings.set({ bgm: 1 });
await flush();
assert(audio._bgm && audio._bgm.id === 'bgm/storm', 'bgm 恢复后自动补播最后状态垫乐');
teardown(game);
}
// ================================================================ 静态审计
// (数字规范:分档整句字面量、无运行时 id 拼接;资产盘点;双性别/key 配对;summary 格式)
realLog('== 静态审计 ==');
{
const src = fs.readFileSync(path.join(GAME_DIR, 'game.js'), 'utf8');
for (const id of ['voice/prog_m1', 'voice/prog_m2', 'voice/prog_m3', 'voice/prog_mem_g',
'voice/prog_mem_f', 'voice/tr_deep', 'voice/tr_half', 'voice/tr_thin',
'voice/tru_zero', 'voice/tru_one', 'voice/tru_two', 'voice/tru_full',
'voice/gd_prog1', 'voice/gd_prog2']) {
assert(src.includes(`'${id}'`), `分档整句字面量存在:${id}`);
}
assert(src.includes("say: ['gd_continue', 'gd_again']"),
'看门狗两级不同句 gd_continue/gd_aken 字面量存在(guide say 裸 id,guide 层拼前缀)');
assert(!/voice\/\$\{/.test(src), '无模板字符串拼接 voice id');
assert(!/['"]voice\/[A-Za-z0-9_]+['"]\s*\+/.test(src), '无字符串相加拼接 voice id');
assert(!/\+\s*['"]voice\//.test(src), '无前置相加拼接 voice id');
// 资产盘点:game.js 引用的 voice/bgm/sfx 全量齐备(ogg+m4a)
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)));
const missingVoiceBgm = [];
const missingSfx = [];
for (const id of ids) {
if (id.startsWith('voice/')) {
const name = id.slice(6);
if (!(both(`voice/male/${name}`) || both(`voice/female/${name}`) || both(`voice/${name}`))) {
missingVoiceBgm.push(id);
}
} else if (id.startsWith('bgm/')) {
if (!both(id)) missingVoiceBgm.push(id);
} else if (!both(id)) {
missingSfx.push(id);
}
}
assert(missingVoiceBgm.length === 0,
`voice/bgm 资产零缺失(${ids.length} 个引用 id,实际缺: ${missingVoiceBgm.join(',') || '无'})`);
assert(missingSfx.length === 0, `sfx 资产零缺失(17 件程序合成全部落盘,实际缺: ${missingSfx.join(',') || '无'})`);
// 双性别 / NPC 平铺 / key 变体抽查(§5.3)
assert(both('voice/male/p1') && both('voice/female/p1'), '抉择提示 p1 双性别齐备');
assert(both('voice/male/key/p1') && both('voice/female/key/p1'), '键盘变体 p1 key/ 子目录双性别齐备');
assert(both('voice/amo_a') && !fs.existsSync(path.join(AUDIO_DIR, 'voice/male/amo_a.ogg')),
'NPC 阿澈 amo_a 仅平铺目录(不做双性别,电台滤波质感)');
assert(both('voice/rev_g2') && !fs.existsSync(path.join(AUDIO_DIR, 'voice/female/rev_g2.ogg')),
'NPC 祖父 rev_g2 仅平铺目录(唱片滤波质感)');
assert(both('voice/male/gd_brief') && both('voice/female/key/sys_help'),
'gd_brief/sys_help 双性别 + key 变体齐备');
// 存档 summary 人话格式(新开局)
clearSave(); resetSettings('off');
const { audio, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await tapUntil(game, () => game.choiceArmed, 24); // → p1 播完
await g(game, 'left'); // p1 左:抉择结算后首存(写入时机)
const sd = saveData();
assert(sd && sd.schema === 1 && sd.timeline === 'now', '存档 schema=1 且带 timeline 时空位');
assert(sd.summary === '第1幕·风暴起 · 信任半 · 结局0/4', `summary 格式(实际: ${sd && sd.summary})`);
assert(sd && Number.isFinite(Date.parse(sd.updatedAt)), 'updatedAt 为 ISO 时间戳');
teardown(game);
}
// ================================================================ 汇总
realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`);
if (failures.length > 0) {
realLog('失败清单:');
for (const f of failures) realLog(` - ${f}`);
process.exit(1);
}
realLog('全部断言通过。');