四泳道并行全流水线(资产→开发→测试→玩家迭代闭环),双双 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(压缩候选)
1330 lines
67 KiB
JavaScript
1330 lines
67 KiB
JavaScript
/**
|
||
* 《守塔人》无头试玩仿真测试(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('全部断言通过。');
|