Files
SoundGame/web/abyss/test/playtest.mjs
T
xsl 31372615b2 feat(new8/batch2): 《深海回音》abyss +《雨夜专案》detective 全套交付(多agent流水线:策划→资产→开发→测试→真资产复跑)
- abyss:game.js 242 断言全绿(剧本A-F六路径/氧气门三形态/sonarGate仲裁/结算死窗/双轨看门狗/radio 8项修复继承);资产 148句+11key变体(电台滤波带内能量47-62%→69-80%)
- detective:game.js 265 断言真资产下全绿(矩阵不变式独立重算/指证全路径/评级12组注入/VERDICT死窗/二周目变体);真资产复跑回炉3处实现修复(无进展轨墙钟化/菜单暂停记账/停止播报真停声——mock时长掩盖的正确性bug)
- 资产:TTS 539条×2格式、Mureka 7首 $0.21(累计$1.08/$2.00)、SYN12+SA3 19件(QC抓出1例AAC过冲并修复)、封面×2、check_all_assets 10游戏全绿(abyss 298/298、detective 279/279)
- 平台接线:大厅双卡片、BUCKETS 增 abyss/detective 桶(H4 断言更新 10 游戏)、voice_text.json 10游戏991句
- 回归:14 套件全绿(10 playtest+guide+engine-voice+engine-input+api-smoke 12/12)
- 设计偏差登记:abyss 7条/detective 9条(全为设计内部冲突按承诺清单/归因真话律取优)
2026-09-10 01:40:31 +08:00

1368 lines
70 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/17-abyss.md §9 playtest 断言清单)。
*
* 方案:
* - mock 浏览器环境:window/localStorage/navigator/fetch/AudioContext
* (decodeAudioData 返回固定 2.0s 时长的假 buffer,语音链靠引擎 endTimer +
* onComplete 推进,虚拟时钟驱动,不真实等待);资产缺失时靠游戏层 30s 单句
* 看门狗自动续链(N1 机制)——测试对「资产已落地 / 未落地」两种状态都成立;
* - 使用**真实** engine/audio.js + settings.js + guide.js;
* - 手势直接注入 game.onGesture(与 attachGestures 回调同路径);
* - 虚拟时钟:patch setTimeout/clearTimeout/performance.now;
* - 环境音循环(amb_deck/amb_deep/breath_loop)为 buffer.loop 句柄(无重触发
* 定时器),全程虚拟时钟可空闲、可复现;
* - navigator.maxTouchPoints=1(手机 persona:inputMode() 判 touch;键盘路由
* 由本文件「键盘变体」块按 settings.input 覆盖验证)。
*
* 覆盖:剧本 A-F 六条路径到 4 结局(oxy 逐检查点验算 §4.5)、氧气门三形态、
* pendingChoice 盲玩公平、sonarGate 仲裁(危机拍前置/空闲门/零重叠)、
* 结算死窗(幽灵 tap 不截链)、看门狗双轨(乱按 96s 抢救 + 双轨去重)、
* 中断复述(F4 锁)、存档 schema、菜单循环、键盘变体路由、静态审计。
*
* 运行:tools/node/bin/node web/abyss/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.abyss';
// ---------------------------------------------------------------- 断言收集
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}`); }
}
// console 捕获(引擎/游戏的 warn/error 进日志,测试输出用 realLog)
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(); // id -> { fireAt, cb, args }
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,
});
/** 排空微任务(fetch/decode 的 promise 链)。 */
async function flush(rounds = 8) {
for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r));
}
/** 推进虚拟时钟:逐个触发到期定时器,直到没有待触发定时器(= 游戏在等手势)
* 或 cond() 满足。返回 'cond' | 'idle'。 */
async function advance(cond = null) {
for (let n = 0; n < 50000; 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', {
// maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产
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, // 固定句长 2s(虚拟时钟推进语音链;与资产是否落地无关)
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;
// fetch:读真实资产目录;blockedAssets 中的片段强制 404(资产缺失测试用)
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: 'abyss' }) };
}
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 { AbyssGame } = 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]); // error 震型
const WARN_PATTERN = JSON.stringify([30, 50, 30, 50, 30]); // warning 震型
const BEAT_PATTERN = JSON.stringify([8]); // beat 震型(G4 轻震)
const DANGER_PATTERN = JSON.stringify([25, 40, 25, 40, 25]); // danger 震型
/** 声呐信息音与语音零重叠校验(§6.2 R1;sonarGate 排队语义)。 */
function sonarVoiceOverlaps(game) {
const bad = [];
for (const sp of game._sonarPlays) {
for (const rec of game._voiceIntervals) {
if (rec.start <= sp.t && sp.t < rec.end) bad.push(`${sp.id}@${sp.t}ms 撞 ${rec.id}`);
}
}
return bad;
}
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 AbyssGame(audio, {});
return { audio, playLog, game };
}
let gestureCount = 0;
async function g(game, gesture) {
gestureCount++;
game.onGesture(gesture);
await flush();
}
/** 结束一个测试块:作废在途链,避免污染后续块的虚拟时钟。 */
function teardown(game) {
game.guide.disarm();
game.stop();
}
/**
* 救援推进器:语音在播时等它自然播完(不跳句);搜索等待单击发脉冲;抉择武装
* 即选;链末等待态单击继续——直到 ENDING 结算态(或 stop() 条件命中,返回
* 已消耗的抉择数,供续跑)。
*/
async function playRun(game, choices, opts = {}) {
let i = opts.from || 0;
const stop = opts.stop || (() => false);
let guard = 0;
while (!(game.phase === 'ENDING' && game.awaitingRestart)) {
if (++guard > 900) throw new Error(`playRun 卡住(node=${game.nodeId} phase=${game.phase} vp=${game.voicePlaying} search=${game.searchArmed})`);
if (stop()) return i;
if (game.phase === 'MENU') { await g(game, 'down'); continue; } // 安全网:误入菜单即关闭
if (game.searchArmed) { await g(game, 'tap'); continue; } // 单击=发脉冲
if (game.choiceArmed) {
const c = choices[i++];
if (!c) throw new Error(`playRun 抉择脚本耗尽(node=${game.nodeId})`);
await g(game, c);
continue;
}
if (!game.voicePlaying) { await g(game, 'tap'); continue; } // 链末=继续
await advance(() => stop() || !game.voicePlaying || game.choiceArmed || game.searchArmed);
}
return i;
}
// ---- 抉择脚本(p1..p9 遭遇顺序;§9 剧本 A-E + 补剧本 F 单载右路)
// A:全右 + p9 左 → e1(saved=5)
const SCRIPT_A = ['right', 'right', 'right', 'right', 'right', 'right', 'right', 'right', 'left'];
// B:同 A 但 p9 右 → e2
const SCRIPT_B = [...SCRIPT_A.slice(0, 8), 'right'];
// C:p1左 p2左 p4左,余右 → 氧气门走终局取消链 → p9 左(oxy=2≥1)→ e3(saved=3)
const SCRIPT_C = ['left', 'left', 'right', 'left', 'right', 'right', 'left'];
// D:p1左 p4左,余右 → 单载(stayer=chen → p7_chen)→ p7 左=带船长 → chen_last
// → p8 右 → oxy_end=0 → p9 左封锁(g9_dry 自动右)→ e4(saved=4)
const SCRIPT_D = ['left', 'right', 'right', 'left', 'right', 'right', 'left', 'right', 'left'];
// E:p2左 p3[无knowsPipe]左 p4左 p6左 → mid-dive floor 至 0 干涸链 → e4(saved=3)
const SCRIPT_E = ['right', 'left', 'left', 'left', 'right', 'left', 'left'];
// F:p1左 p4左 p5左,余右 → 单载(stayer=zheng → p7_zheng)→ p7 右=带小郑
// → gao_last、p8 跳过 → p9 左封锁 → e4(saved=4)
const SCRIPT_F = ['left', 'right', 'right', 'left', 'left', 'right', 'right', 'left'];
/**
* 手动推进到(第 n 潜)搜索等待:INTRO 链末逐节点单击、语音自然播完——
* 纯 advance 不出手(虚拟时钟会停在链末等待态 idle,测试会走偏)。
*/
async function reachSearch(game, n = 1) {
let guard = 0;
while (!(game.searchArmed && game.diveN === n)) {
if (++guard > 300) throw new Error(`reachSearch 卡住(node=${game.nodeId} phase=${game.phase})`);
if (game.searchArmed) return; // 已在(更早的)搜索等待
if (game.choiceArmed) throw new Error('reachSearch 意外遇到抉择(node=' + game.nodeId + ')');
if (!game.voicePlaying) { await g(game, 'tap'); continue; }
await advance(() => game.searchArmed || !game.voicePlaying);
}
}
/**
* 叙事链推进器:语音在播时等它自然播完(不跳句),链末等待态单击继续,
* 搜索等待单击发脉冲——直到 cond() 满足。遇到未应答抉择抛错(调用方先选)。
*/
async function tapTo(game, cond, maxSteps = 80) {
for (let i = 0; i < maxSteps; i++) {
if (cond()) return;
if (game.searchArmed) { await g(game, 'tap'); continue; }
if (game.choiceArmed) throw new Error(`tapTo 遇到未应答的抉择(node=${game.nodeId})——调用方需先做选择`);
if (game.voicePlaying) {
await advance(() => cond() || !game.voicePlaying);
continue;
}
await g(game, 'tap'); // 链末单击 = 继续
}
throw new Error(`tapTo 超步限(node=${game.nodeId} phase=${game.phase})`);
}
// voice 资产是否已落地(测试对两种状态都成立;缺失时走 30s 单句看门狗续链)
const voiceLanded = ['ogg', 'm4a'].some((ext) =>
fs.existsSync(path.join(AUDIO_DIR, 'voice/female/b_open.' + ext)));
// ================================================================ 主路径试玩
// (§9 关键路径:剧本 A 全程 e1,oxy 逐点与 §4.5 公式一致,≤70 手势)
realLog('== 主路径 · 剧本 A(e1,guide=off)==');
{
clearSave();
resetSettings('off');
const g0 = gestureCount;
const { audio, playLog, game } = makeRig();
// §7 全局:解锁前 play 为 no-op
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/deck'), '开局播放 bgm/deck(甲板,开局淡入)');
assert(voicePlays(playLog, 'voice/sys_new_dive') === 1, '新救援 sys_new_dive 先行');
assert(game.phase === 'INTRO' && game.nodeId === 'b_open', '进入宏状态 INTRO(b_open 起播)');
// 1. INTRO 链(链末等待单击):四句逐句 + sys_skip 一次;guide=off 零 gd_brief
await advance(() => game.currentVoiceId === 'voice/b_open');
assert(game.voicePlaying && game.currentVoiceId === 'voice/b_open', 'sys_new_dive 播完接 b_open(voice 层有播放记录)');
assert(voicePlays(playLog, 'voice/gd_brief') === 0, 'guide=off:开场不插 gd_brief');
// 逐节点推进(每节点链末等待单击,§2.2「链完单击」)
for (const [node, vid] of [['b_rule', 'voice/b_rule'], ['b_goal', 'voice/b_goal'], ['b_go', 'voice/b_go']]) {
await advance(() => !game.voicePlaying);
await g(game, 'tap');
await advance(() => game.currentVoiceId === vid);
assert(game.nodeId === node, `INTRO 链末单击推进至 ${node}`);
}
assert(voicePlays(playLog, 'voice/sys_skip') === 1, '开场 sys_skip 提示播一次(每实例一次)');
// 2. b_go 链末单击 → DIVE(1):下潜起播扣 3(账目断言)+ 下潜链 auto → 搜索武装
await advance(() => !game.voicePlaying);
assert(game.phase === 'INTRO' && !game.voicePlaying, 'b_go 链末等待单击(INTRO ──链完单击──> DIVE)');
await g(game, 'tap');
assert(game.phase === 'DIVE' && game.diveN === 1 && game.oxy === 9,
'单击开始下潜 → DIVE(1),oxy 12-3=9(每次下潜开始扣 3,§6.3 承诺)');
assert(playLog.some((e) => e.id === 'bgm/dive'), '进 DIVE crossfade bgm/dive');
await reachSearch(game);
assert(game.searchArmed && game.nodeId === 'dv1_teach', '下潜链 auto → 搜索等待武装(dv1_down→dv1_teach)');
for (const id of ['voice/dv1_down', 'voice/dv1_teach']) {
assert(voicePlays(playLog, id) === 1, `下潜链播出 ${id}`);
}
// guide=off:搜索等待挂起零定时器(虚拟时钟可空闲)、零提示、零超时惩罚
assert((await advance()) === 'idle', 'guide=off 搜索等待零定时器(虚拟时钟可空闲)');
assert(voicePlays(playLog, 'voice/gd_ping1') === 0 && voicePlays(playLog, 'voice/gd_ping2') === 0,
'guide=off:搜索等待零向导提示(无超时惩罚)');
// 3. 搜索单击 → sonar_ping → 静默一拍 → echo_metal_l → dv1_metal(承诺音)→
// dv1_link(link_beep)→ COMMS(1):z1_a/b/knock(垫 pipe_knock 两遍)
const sonar0 = voicePlays(playLog, 'sfx/sonar_ping');
await g(game, 'tap');
assert(voicePlays(playLog, 'sfx/sonar_ping') === sonar0 + 1, '搜索等待单击恒=发一枚声呐脉冲(非跳句、非无效)');
await advance(() => game.phase === 'COMMS');
assert(voicePlays(playLog, 'sfx/echo_metal_l') === 1, '回波链播出 echo_metal_l(金属回波=海燕号船身承诺音)');
for (const id of ['voice/dv1_metal', 'voice/dv1_link', 'voice/z1_a']) {
assert(voicePlays(playLog, id) === 1, `回波→接通→COMMS 链播出 ${id}`);
}
assert(playLog.some((e) => e.id === 'sfx/link_beep'), '电台接通 link_beep');
assert(playLog.some((e) => e.id === 'sfx/static_burst'), 'z1_a 垫 static_burst(电台杂音)');
// R3:语音在播时 amb 常驻声呐元素降 -6dB(duck 标记)
await tapTo(game, () => game.currentVoiceId === 'voice/z1_b');
assert(voicePlays(playLog, 'voice/z1_b') === 1, '回波→接通→COMMS 链播出 voice/z1_b');
assert(game._ambDucked === true, 'R3:语音在播时 amb_deep 降 -6dB(duck 标记)');
// z1_knock 播完垫 pipe_knock 两遍(信息层,语音播完才响,R1)
await tapTo(game, () => game.currentVoiceId === 'voice/z1_knock');
await tapTo(game, () => game.promptPlaying);
const iKnockV = lastIndexOf(playLog, 'voice/z1_knock');
const knocks = playLog.filter((e) => e.id === 'sfx/pipe_knock' && e.t > 0)
.filter((e) => playLog.findIndex((x) => x === e) > iKnockV).length;
assert(knocks >= 2, `z1_knock 语音播完垫 pipe_knock 两遍(实际 ${knocks},R1 零重叠)`);
// 4. p1 不可跳 + pendingChoice(V1 时序:播完才生效、连点不丢、连滑取最后)
const vib0 = vibrateLog.length;
await g(game, 'tap');
assert(game.promptPlaying && game.currentVoiceId === 'voice/p1', 'p* 播放中单击不跳句(PROMPT_RE 门)');
await g(game, 'left');
assert(game.pendingChoice === 'left', 'p* 播放中左滑缓存 pendingChoice(盲玩公平)');
await g(game, 'right');
assert(game.pendingChoice === 'right', 'p* 播放中再滑覆盖缓存(最后一次为准)');
const iP1Last = lastIndexOf(playLog, 'voice/p1');
await advance(() => game.nodeId === 'd1_up');
const idsSoFar = playLog.map((e) => e.id);
const iConfirm = idsSoFar.indexOf('sfx/ui_confirm');
assert(iConfirm > iP1Last && idsSoFar.indexOf('voice/d1_up') > iConfirm,
'V1:生效时序 = p1 播完 → ui_confirm 结算音 → 结果句(播完才生效,零插播)');
assert(game.pendingChoice === null && game.flags.knowsPipe === false && game.oxy === 9,
'V1:p1 右 0 气(knowsPipe=false,oxy=9 不动)');
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === BEAT_PATTERN),
'G4:p* 不可跳句空点单击播 beat 轻震(可区分于真确认 [20])');
// 5. p2 右 → SURFACE(1):出水 sfx + d1_surf + 档位句(账目断言)→ 存档
await tapTo(game, () => game.choiceArmed); // d1_up → z1_bye → p2
await g(game, 'right');
assert(game.flags.hasLog === false && game.bond.zhou === 0, 'p2 右:hasLog=false、bond_zhou=0');
await tapTo(game, () => game.phase === 'SURFACE' && game.currentVoiceId === 'voice/d1_surf');
assert(playLog.some((e) => e.id === 'sfx/surf_splash') && playLog.some((e) => e.id === 'sfx/ascent_chime'),
'上浮结算垫 surf_splash + ascent_chime(破水出水+确认音)');
await advance(() => game.phase === 'DIVE' && game.diveN === 2);
assert(game.oxy === 6, 'SURFACE(1) 后 DIVE(2):oxy 9-3=6(§4.5 逐点验算)');
assert(voicePlays(playLog, 'voice/oxy_full') === 1, '首次结算播 oxy_full(oxy=9 ≥3 潜气量,账目真实)');
assert(voicePlays(playLog, 'voice/oxy_ok') === 0 && voicePlays(playLog, 'voice/oxy_low') === 0
&& voicePlays(playLog, 'voice/oxy_crit') === 0, '未跨档不播其他档位句');
const sd1 = saveData();
assert(sd1 && sd1.phase === 'DIVE' && sd1.dive === 2 && sd1.oxy === 6 && sd1.node === 'dv2_down',
'DIVE 进入即存档:phase=DIVE / dive=2 / oxy=6 / node=dv2_down');
const keysNeeded = ['schema', 'updatedAt', 'summary', 'phase', 'node', 'dive', 'oxy',
'supply', 'saved', 'stayer', 'flags', 'endings', 'runs'];
assert(keysNeeded.every((k) => k in sd1), '存档 data 覆盖 §8.1 全部字段(含 summary 字符串)');
assert(sd1.saved.zhou === 1 && sd1.saved.bai === 0, 'saved 标量:小舟救上(教学潜保底)');
assert(sd1.summary === '第2潜·机舱 · 氧气6格 · 结局0/4', `summary 格式(实际: ${sd1.summary})`);
assert(Number.isFinite(Date.parse(sd1.updatedAt)), 'updatedAt 为 ISO 时间戳');
// 6. DIVE(2):脉冲 → echo_debris+pipe_knock(D1 暗号兑现)→ p3 右危机拍
await reachSearch(game);
await g(game, 'tap');
await advance(() => game.phase === 'COMMS');
const iDebris = lastIndexOf(playLog, 'sfx/echo_debris');
const iKnockD2 = playLog.map((e) => e.id).lastIndexOf('sfx/pipe_knock');
const iFound = lastIndexOf(playLog, 'voice/dv2_found');
assert(iDebris >= 0 && iDebris < iKnockD2 && iKnockD2 < iFound,
'D2 搜索:echo_debris → pipe_knock → dv2_found(敲管=老白的信号,D1 教学兑现)');
const danger0 = vibrateLog.filter((v) => JSON.stringify(v.pattern) === DANGER_PATTERN).length;
await tapTo(game, () => game.choiceArmed); // bai_a → bai_b → p3
await g(game, 'right'); // p3 右 = 切割快切 → 危机拍
await advance(() => game.currentVoiceId === 'voice/d2_flash');
assert(voicePlays(playLog, 'sfx/spark_flash') === 1, 'D2 危机拍:spark_flash 前置起播');
assert(vibrateLog.filter((v) => JSON.stringify(v.pattern) === DANGER_PATTERN).length === danger0 + 1,
'D2 危机拍 danger 震型(签名+震同窗)');
const iSpark = lastIndexOf(playLog, 'sfx/spark_flash');
const iCut = lastIndexOf(playLog, 'voice/d2_cut');
assert(iCut < iSpark, 'spark_flash 在 d2_cut 语音播完后才起(R1:危机音不叠语音)');
await tapTo(game, () => game.choiceArmed); // d2_flash → bai_out → p4
await g(game, 'right'); // p4 右 = 直接上浮
await tapTo(game, () => game.phase === 'SURFACE' && game.diveN === 2);
assert(game.oxy === 9 && game.supply === true,
'SURFACE(2):supply 后 oxy 6+3=9(应急瓶全局唯一一次 +3)');
await advance(() => voicePlays(playLog, 'voice/oxy_gain') === 1);
assert(voicePlays(playLog, 'voice/d2_supply') === 1 && voicePlays(playLog, 'voice/oxy_gain') === 1,
'补给链播出 d2_supply → oxy_gain(恰一次)');
assert(voicePlays(playLog, 'voice/oxy_full') === 1, '补给后 tier=full 与已播档一致 → 不重播(账目真实)');
// 7. DIVE(3):脉冲 → echo_groan(预警)→ p5 右 / p6 右 → SURFACE(3) ok 档 → 氧气门双载
await reachSearch(game);
await g(game, 'tap');
await advance(() => game.phase === 'COMMS');
assert(voicePlays(playLog, 'sfx/echo_groan') === 1 && voicePlays(playLog, 'voice/dv3_groan') === 1,
'D3 搜索:echo_groan 船体呻吟预警 + dv3_groan 解读');
await tapTo(game, () => game.choiceArmed);
await g(game, 'right'); // p5 右 = 先带小郑
assert(game.stayer === 'chen' && game.saved.zheng === 1 && game.saved.chen === 0,
'p5 右:小郑上(saved.zheng=1),stayer=chen');
for (const id of ['voice/d3_zheng', 'voice/d3_zheng_e', 'voice/chen_stay']) {
await advance(() => voicePlays(playLog, id) === 1);
}
await tapTo(game, () => game.choiceArmed); // d3_zheng → d3_zheng_e → chen_stay → p6
await g(game, 'right'); // p6 右 = 约定来接
await tapTo(game, () => game.phase === 'SURFACE' && game.diveN === 3);
assert(game.oxy === 6, 'SURFACE(3):oxy=6(§4.5 逐点验算)');
await advance(() => voicePlays(playLog, 'voice/oxy_ok') === 1);
assert(voicePlays(playLog, 'voice/oxy_ok') === 1, 'SURFACE(3):oxy=6 → ok 档跨档播报(§4.5)');
await tapTo(game, () => game.phase === 'DIVE' && game.diveN === 4);
assert(game.diveForm === 'double', '氧气门:oxy=6 ≥4 → DIVE(4) 双载形态');
assert(game.oxy === 2, 'DIVE(4) 双载起播 oxy 6-3-1=2(双载额外 +1,§4.3)');
// 8. DIVE(4):滑坡危机拍(landslide 前置 2s + danger 震循环)→ p7 双载提示
await reachSearch(game);
await g(game, 'tap');
await advance(() => game.phase === 'COMMS');
assert(voicePlays(playLog, 'sfx/echo_groan') === 2 && voicePlays(playLog, 'sfx/echo_metal_r') === 1,
'D4 搜索:echo_groan 渐强 + echo_metal_r(§6.3:接近指挥舱必播金属回波)');
assert(voicePlays(playLog, 'sfx/landslide') === 1, 'D4 危机拍:landslide 前置起播');
const dVibs = vibrateLog.filter((v) => JSON.stringify(v.pattern) === DANGER_PATTERN).length;
assert(dVibs >= 3, `D4 danger 震循环(每遍 ≥400ms,实际 ${dVibs} 遍)`);
const iSlide = lastIndexOf(playLog, 'sfx/landslide');
const iSlideV = lastIndexOf(playLog, 'voice/dv4_slide');
assert(iSlide < iSlideV, 'landslide 前置 2s 后才接 dv4_slide 语音(R4 危机拍优先)');
await tapTo(game, () => game.choiceArmed); // gao_a → gao_b → p7
assert(voicePlays(playLog, 'voice/p7') === 1 && voicePlays(playLog, 'voice/p7_chen') === 0
&& voicePlays(playLog, 'voice/p7_zheng') === 0, '双载形态播 p7(快慢浮)');
await g(game, 'right'); // p7 右 = 快浮
await tapTo(game, () => game.choiceArmed); // d4_fast → d4_fast_e → gao_c → p8
assert(voicePlays(playLog, 'voice/gao_c') === 1 && voicePlays(playLog, 'voice/gao_confess') === 0,
'hasLog=false → 心结引入播 gao_c(条件差分)');
await g(game, 'right'); // p8 右 = 不问
await tapTo(game, () => game.phase === 'SURFACE' && game.diveN === 4);
await advance(() => voicePlays(playLog, 'voice/oxy_crit') === 1);
assert(game.oxy === 2 && voicePlays(playLog, 'voice/oxy_crit') === 1,
'SURFACE(4):oxy=2 → crit 档跨档播报(§4.5:双载收队 2-lefts)');
assert(vibrateLog.some((v) => JSON.stringify(v.pattern) === WARN_PATTERN),
'G2:oxy_crit 播报位附 warning 震');
// 9. 签收:g9_in → g9_knot → p9 左(oxy=2≥1 可用)→ d9_bell + bell_toll → ENDING(e1)
await tapTo(game, () => game.promptPlaying);
assert(game.nodeId === 'p9' && voicePlays(playLog, 'voice/g9_in') === 1
&& voicePlays(playLog, 'voice/g9_knot') === 1, '签收链 g9_in → g9_knot → p9');
await g(game, 'left'); // p9 左(可用)——提示播放中缓存,播完生效
await advance(() => game.phase === 'ENDING');
assert(game.flags.bell === true, 'p9 左(oxy≥1):bell=true');
assert(game.endingId === 'e1', 'saved=5 且 bell → ENDING(e1 海燕归钟)');
await advance(() => game.awaitingRestart);
for (const id of ['voice/e1_a', 'voice/e1_b', 'voice/ep_lead', 'voice/ep_zhou_no',
'voice/ep_bai_no', 'voice/ep_chen_ok', 'voice/ep_zheng_ok', 'voice/ep_gao_up',
'voice/ep_trench_bell']) {
assert(voicePlays(playLog, id) === 1, `结局链播出 ${id}`);
}
assert(voicePlays(playLog, 'sfx/bell_toll') === 1, '船钟 payoff 音 bell_toll(p9 左兑现)');
const sd2 = saveData();
assert(sd2 && sd2.phase === 'ENDING' && sd2.endings.e1 === true && sd2.summary === '救援收队·海燕归钟 · 结局1/4',
`ENDING 存档:endings 收集 e1,summary 收队格式(实际: ${sd2 && sd2.summary})`);
assert(game._breathLog.some((e) => e.on === true) && game._breathLog[game._breathLog.length - 1].on === false,
'低氧混音签名:D4(oxy=2 crit)期间 breath_loop 在播、收队即停');
assert(game._ambDucked === false, 'R3:链歇后 amb 恢复全响(duck 解除)');
// 双通道总账(§9 失败即不通过项):全程信息音与语音零重叠 + 声呐账目
const overlaps = sonarVoiceOverlaps(game);
assert(overlaps.length === 0, `全程 echo_*/pipe_knock/groan 与语音零重叠(${overlaps.length} 处违规)`);
assert(voicePlays(playLog, 'sfx/sonar_ping') === 4, '四潜各恰一枚玩家脉冲(sonar_ping 计数=4)');
assert(voicePlays(playLog, 'sfx/surf_splash') === 4 && voicePlays(playLog, 'sfx/ascent_chime') === 4,
'每潜上浮出水 sfx 各 4 次');
assert(voicePlays(playLog, 'voice/d1_surf') === 1 && voicePlays(playLog, 'voice/d2_surf') === 1
&& voicePlays(playLog, 'voice/d3_surf') === 1 && voicePlays(playLog, 'voice/d4_surf') === 1,
'四条上浮结算句各一次(d1..d4_surf)');
assert(['voice/dry_a', 'voice/dry_b', 'voice/dv4_none', 'voice/gao_final',
'voice/g9_dry', 'voice/p7_chen', 'voice/chen_last', 'voice/gao_last']
.every((id) => voicePlays(playLog, id) === 0), '剧本 A 全程零软失败/差分句(成功不教学)');
// 资产缺失兼容:未落地走 30s 单句看门狗自动续链;已落地则真实播放
if (!voiceLanded) {
assert(consoleWarns.some((w) => w.includes('看门狗') && w.includes('自动续链')),
'资产未落地:单句 30s 看门狗自动续链(N1 兜底,不卡死)');
assert(consoleErrs.some((e) => e.includes('资产缺失')), '资产缺失仅 console.error,不抛异常');
} else {
assert(game.endingId === 'e1' && game.awaitingRestart, '资产已落地:真实播放推进到 e1 结算态');
}
// ENDING 双击重听结局链(清结算态)+ 重播期间首句可跳(G5:F4 锁不随重开复活)
const e1aBefore = voicePlays(playLog, 'voice/e1_a');
await g(game, 'doubletap');
assert(game.awaitingRestart === false && game.nodeId === 'e1', '结局链播完双击 → 重听结局链(清结算态)');
await g(game, 'tap'); // 重播 e1_a 中单击 = 跳本句
await advance(() => game.currentVoiceId === 'voice/e1_b');
assert(voicePlays(playLog, 'voice/e1_b') === 2, '重听期间单击跳本句,链继续(复播可即掐)');
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.oxy === 12 && game.diveN === 0
&& !game.flags.knowsPipe && game.stayer === null && game.saved.gao === 0,
'结算单击 = 新救援:回 INTRO,oxy/saved/stayer/flags 全部归零');
assert(game.endings.e1 === true, 'endings 结局收集跨局保留');
assert(voicePlays(playLog, 'voice/sys_new_dive') === 2, 'sys_new_dive 随新救援再播');
assert(voicePlays(playLog, 'voice/sys_skip') === 1, 'sys_skip 每实例只播一次,重开不重播');
assert(gestureCount - g0 <= 70, `关键路径手势预算 ≤70(实际 ${gestureCount - g0})`);
teardown(game);
}
// ================================================================ 四结局矩阵 + 氧气门三形态
// (§9 边界:B→e2 / C→取消链→e3 / D→单载 p7_chen+chen_last→e4 /
// E→干涸链→e4 / F→单载 p7_zheng+gao_last+p8 跳过→e4;oxy 逐检查点与 §4.5 一致)
realLog('== 四结局矩阵(剧本 B / C / D / E / F)==');
{
// ---- 剧本 B:与 A 同路但 p9 右 → e2
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await playRun(game, SCRIPT_B);
assert(game.endingId === 'e2' && game.oxy === 2 && game.flags.bell === false,
'剧本 B(p9 右)→ e2 满载的黎明,oxy=2,bell=false');
assert(voicePlays(playLog, 'voice/e2_a') === 1 && voicePlays(playLog, 'voice/e2_b') === 1,
'e2 专属结局主句 + 钩子播出');
assert(voicePlays(playLog, 'voice/ep_trench_deep') === 1 && voicePlays(playLog, 'voice/ep_trench_bell') === 0,
'bell=false → 尾声沟底播 ep_trench_deep(钟留在沟底)');
teardown(game);
}
// ---- 剧本 C:p1左 p2左 p4左 余右 → 终局取消链 → p9 左 → e3
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
// 逐检查点 oxy 验算(§9 边界:D1 后 7 / D2 后 supply 5 / D3 后 2)
await playRun(game, SCRIPT_C, {
stop: () => game.phase === 'SURFACE' && game.diveN === 1,
});
assert(game.oxy === 7, '剧本 C:D1 后(p1左+p2左)oxy 12-3-1-1=7(ok 档)');
await playRun(game, SCRIPT_C, {
from: 2,
stop: () => game.phase === 'SURFACE' && game.diveN === 2,
});
await advance(() => voicePlays(playLog, 'voice/oxy_low') === 1);
assert(game.oxy === 5 && game.supply === true && voicePlays(playLog, 'voice/oxy_low') === 1,
'剧本 C:D2 后 p4左 floor 前 oxy=2 → supply +3=5(low 档播报)');
const ci = await playRun(game, SCRIPT_C, {
from: 4,
stop: () => game.nodeId === 'c_none',
});
assert(game.phase === 'SURFACE' && game.nodeId === 'c_none' && game.diveForm === 'cancel',
'剧本 C:SURFACE(3) oxy=2 ≤2 → 氧气门走终局取消链(dv4_none 起播)');
assert(game.oxy === 2 && voicePlays(playLog, 'voice/oxy_crit') === 1,
'剧本 C:D3 后 oxy=2(crit 档,取消门与 b_rule「气不够一潜必须上浮」一致)');
await playRun(game, SCRIPT_C, { from: ci });
assert(voicePlays(playLog, 'voice/dv4_none') === 1 && voicePlays(playLog, 'voice/gao_final') === 1,
'取消链完整播出 dv4_none → gao_final(措辞克制、零描写)');
assert(voicePlays(playLog, 'voice/dv4_down') === 0 && voicePlays(playLog, 'voice/p7') === 0
&& voicePlays(playLog, 'voice/p8') === 0, '取消路径跳过第四潜(无 p7/p8)');
assert(game.endingId === 'e3' && game.flags.bell === true,
'剧本 C:p9 左(oxy=2≥1 可用)→ e3 最后一潜(saved=3 × bell)');
assert(game.saved.zhou === 1 && game.saved.bai === 1 && game.saved.zheng === 1
&& game.saved.chen === 0 && game.saved.gao === 0, '剧本 C saved=3(小舟/老白/小郑)');
assert(voicePlays(playLog, 'voice/ep_zhou_ok') === 1 && voicePlays(playLog, 'voice/ep_bai_ok') === 1
&& voicePlays(playLog, 'voice/ep_chen_lost') === 1 && voicePlays(playLog, 'voice/ep_gao_lost') === 1,
'剧本 C 尾声:bond_zhou/bai=1 播 ok,陈医官/船长留守未归播 lost(P2 回响点名)');
teardown(game);
}
// ---- 剧本 D:p1左 p4左 余右 → 单载 p7_chen → p7 左=带船长 → chen_last → e4
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await playRun(game, SCRIPT_D, {
stop: () => game.phase === 'SURFACE' && game.diveN === 1,
});
assert(game.oxy === 8, '剧本 D:D1 后(仅 p1 左)oxy 12-3-1=8(ok 档)');
await playRun(game, SCRIPT_D, {
from: 2,
stop: () => game.phase === 'SURFACE' && game.diveN === 2,
});
assert(game.oxy === 6, '剧本 D:D2 后 p4左 oxy 8-3-2=3 → supply +3=6(ok,不重播)');
await playRun(game, SCRIPT_D, {
from: 4,
stop: () => game.phase === 'SURFACE' && game.diveN === 3,
});
await advance(() => voicePlays(playLog, 'voice/oxy_low') === 1);
assert(game.oxy === 3 && voicePlays(playLog, 'voice/oxy_low') === 1,
'剧本 D:SURFACE(3) oxy=3(low 档跨档播报)');
await playRun(game, SCRIPT_D, {
from: 6,
stop: () => game.phase === 'DIVE' && game.diveN === 4 && game.voicePlaying,
});
assert(game.diveForm === 'single' && game.oxy === 0,
'剧本 D:氧气门 oxy=3 → 单载形态;DIVE(4) oxy 3-3=0');
await playRun(game, SCRIPT_D, { from: 6 });
assert(voicePlays(playLog, 'voice/p7_chen') === 1 && voicePlays(playLog, 'voice/p7') === 0
&& voicePlays(playLog, 'voice/p7_zheng') === 0, '单载·留守=陈医官 → 播 p7_chen(按 stayer 选提示)');
assert(voicePlays(playLog, 'voice/d4_pick_gao') === 1 && voicePlays(playLog, 'voice/chen_last') === 1
&& voicePlays(playLog, 'voice/d4_stay_lost') === 0, 'p7 左=带船长 → d4_pick_gao → chen_last 留别');
assert(voicePlays(playLog, 'voice/p8') === 1 && voicePlays(playLog, 'voice/gao_c') === 1,
'船长在乘 → p8 可达(gao_c 差分)');
assert(game.endingId === 'e4' && game.flags.bell === false && game.oxy === 0,
'剧本 D:oxy_end=0 → p9 左封锁 → e4 按规程上浮(saved=4)');
assert(voicePlays(playLog, 'voice/g9_dry') === 1 && voicePlays(playLog, 'voice/d9_rest') === 1
&& voicePlays(playLog, 'voice/d9_bell') === 0, 'p9 左封锁:g9_dry 播出后自动走右(不摆假选项)');
assert(game.saved.gao === 1 && game.saved.chen === 0 && game.saved.zheng === 1,
'剧本 D saved=4(小舟/老白/小郑/船长),陈医官留守未归');
teardown(game);
}
// ---- 剧本 E:p2左 p3[无knowsPipe]左 p4左 p6左 → mid-dive floor 0 干涸链 → e4
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
const ei = await playRun(game, SCRIPT_E, {
stop: () => game.currentVoiceId === 'voice/dry_a',
});
assert(game.currentVoiceId === 'voice/dry_a' && game.oxy === 0,
'剧本 E:p6 左 -2 从 2 floor 至 0 → dry_a 当场合播(mid-dive 干涸链)');
await playRun(game, SCRIPT_E, { from: ei });
assert(voicePlays(playLog, 'voice/dry_a') === 1 && voicePlays(playLog, 'voice/dry_b') === 1,
'干涸链 dry_a → 上浮结算内 dry_b(归因真话:气花在了人身上)');
assert(voicePlays(playLog, 'voice/d3_spare_e') === 1 && game.saved.zheng === 1,
'干涸不中断当前救援:小郑照常救上(人已挂上吊索不失去)');
assert(game.endingId === 'e4' && game.flags.spareGift === true && game.oxy === 0,
'剧本 E:取消链 + p9 左封锁 → e4(saved=3,spareGift=true)');
assert(voicePlays(playLog, 'voice/dv4_none') === 1, '剧本 E 走终局取消链(gate oxy=0 ≤2)');
teardown(game);
}
// ---- 剧本 F:p5左(stayer=zheng)单载右路 → p7_zheng + gao_last + p8 跳过 → e4
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await playRun(game, SCRIPT_F);
assert(voicePlays(playLog, 'voice/p7_zheng') === 1 && voicePlays(playLog, 'voice/p7') === 0
&& voicePlays(playLog, 'voice/p7_chen') === 0, '剧本 F:单载·留守=小郑 → 播 p7_zheng');
assert(voicePlays(playLog, 'voice/d4_pick_zheng') === 1 && voicePlays(playLog, 'voice/gao_last') === 1,
'p7 右=带留守者 → d4_pick_zheng → gao_last 船长留别(p8 跳过,直达上浮)');
assert(voicePlays(playLog, 'voice/p8') === 0 && voicePlays(playLog, 'voice/gao_c') === 0,
'单载右路少 p8(每局实走 7-9 抉择点验证)');
assert(game.endingId === 'e4' && game.saved.chen === 1 && game.saved.zheng === 1
&& game.saved.gao === 0, '剧本 F saved=4(陈医官 p5 左救上、小郑单载带回、船长留别)→ e4');
teardown(game);
}
}
// ================================================================ 状态播报与无效手势
// (§2.3 上滑状态链 G6 顺序 + 搜索双击无回波零静默 + onceKey + idle-only F1.4 + G1 去重)
realLog('== 状态播报与无效手势(guide=full)==');
{
// ---- RIG A:搜索等待上滑(prog → oxy → sys_help 顺序;G1 第 2 次纯音频标记)
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
const earcons = [];
const origEarcon = game.guide._playEarcon.bind(game.guide);
game.guide._playEarcon = (name) => { earcons.push(name); return origEarcon(name); };
await audio.unlock();
game.start(); await flush();
await reachSearch(game); // D1 搜索
await g(game, 'up');
await advance(() => !game.voicePlaying && game.searchArmed);
const iP1 = lastIndexOf(playLog, 'voice/prog_d1');
const iR1 = lastIndexOf(playLog, 'voice/oxy_full');
const iH1 = lastIndexOf(playLog, 'voice/sys_help');
assert(iP1 >= 0 && iP1 < iR1 && iR1 < iH1,
'G6:搜索上滑状态链顺序 prog_d1 → oxy_full → sys_help(状态在前、帮助垫后)');
assert(game.searchArmed && game.phase === 'DIVE', '状态播报后留在搜索等待');
await g(game, 'up'); // 同一等待点第 2 次:纯音频标记
await advance(() => !game.voicePlaying && game.searchArmed);
assert(voicePlays(playLog, 'voice/sys_help') === 1,
'G1:同一等待点第 2 次上滑不重播 sys_help 口令(提示永不逐字重复)');
assert(voicePlays(playLog, 'voice/prog_d1') === 2 && voicePlays(playLog, 'voice/oxy_full') === 2,
'G1:状态句照播(复述状态=合法帮助,去重只针对口令)');
assert(vibrateLog.some((v) => JSON.stringify(v.pattern) === BEAT_PATTERN)
&& earcons.includes('nudge'), 'G1:第 2 次帮助播 beat 轻震 + nudge earcon(零新增词表)');
teardown(game);
}
// ---- RIG B:搜索等待无效手势(左滑/无回波双击)零静默 + onceKey 同局限次
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
const vib0 = vibrateLog.length;
await audio.unlock();
game.start(); await flush();
await reachSearch(game);
await g(game, 'left');
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === ERR_PATTERN),
'搜索等待左滑 → error 震(无效手势零静默)');
assert(voicePlays(playLog, 'voice/gd_invalid') === 1, '搜索等待左滑 → gd_invalid(full 档播出)');
await g(game, 'doubletap'); // 尚无回波 → 无效(首潜前零静默)
assert(voicePlays(playLog, 'voice/gd_invalid') === 1,
'再次无效手势 gd_invalid 不重播(onceKey 同局限次)');
const errN = vibrateLog.slice(vib0).filter((v) => JSON.stringify(v.pattern) === ERR_PATTERN).length;
assert(errN === 2, `无效手势震反馈每次都有(零静默,实际 ${errN} 次)`);
teardown(game);
}
// ---- RIG C: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 reachSearch(game);
await g(game, 'tap'); // 发脉冲 → 回波链 → COMMS z1_a 播放中
await advance(() => game.currentVoiceId === 'voice/z1_a');
const origIvp = audio.isVoicePlaying.bind(audio);
audio.isVoicePlaying = () => true; // 白盒:资产缺失时引擎无真语音,stub 出「在播」语义
await g(game, 'left');
audio.isVoicePlaying = origIvp;
assert(guideEvents.some((e) => e.type === 'error' && e.skipped === true),
'对白在播时无效手势 → 纠错句 idle-only 跳过(事件带 skipped)');
assert(voicePlays(playLog, 'voice/gd_invalid') === 0 && game.voicePlaying,
'在播对白不被纠错句顶掉,gd_invalid 未播出');
await advance(() => !game.voicePlaying); // z1_a 链末
await g(game, 'left');
assert(voicePlays(playLog, 'voice/gd_invalid') === 1,
'语音空闲后再次无效 → gd_invalid 播出(idle 跳过不占 onceKey)');
assert(vibrateLog.slice(vib0).filter((v) => JSON.stringify(v.pattern) === ERR_PATTERN).length === 2,
'两次无效手势 error 震都发出(零静默)');
teardown(game);
}
// ---- RIG D:COMMS 上滑状态链 + 局面复述;SURFACE 上滑自动链继续
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await reachSearch(game);
await g(game, 'tap');
await advance(() => game.phase === 'COMMS');
await advance(() => !game.voicePlaying); // z1_a 链末等待
const z1aBefore = voicePlays(playLog, 'voice/z1_a');
await g(game, 'up');
await advance(() => !game.voicePlaying);
const iP = lastIndexOf(playLog, 'voice/prog_d1');
const iR = lastIndexOf(playLog, 'voice/oxy_full');
const iH = lastIndexOf(playLog, 'voice/sys_help');
assert(iP >= 0 && iP < iR && iR < iH, 'G6:COMMS 上滑状态链 prog_d1 → oxy_full → sys_help');
await advance(() => voicePlays(playLog, 'voice/z1_a') === z1aBefore + 1);
assert(voicePlays(playLog, 'voice/z1_a') === z1aBefore + 1, '状态播完重播当前节点(局面复述)');
teardown(game);
}
}
// ================================================================ 菜单交互
// (§9 边界:菜单返回不丢节点、循环顺序、帮助、保存并回大厅;queue 不顶句)
realLog('== 菜单交互(guide=off,p1 期间呼出)==');
{
clearSave(); resetSettings('off');
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await reachSearch(game);
await g(game, 'tap');
await tapTo(game, () => game.promptPlaying); // p1 提示播放中呼出菜单
await g(game, 'down');
assert(game.phase === 'MENU', '下滑呼出模态菜单');
await advance(() => !game.voicePlaying); // sys_menu + 首项报名
await g(game, 'tap');
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 iMenu = playLog.findIndex((e) => e.id === 'voice/sys_menu');
const iRes = playLog.findIndex((e) => e.id === 'voice/sys_menu_resume');
const iRst = playLog.findIndex((e) => e.id === 'voice/sys_menu_restart');
const iLob = playLog.findIndex((e) => e.id === 'voice/sys_menu_lobby');
assert(iMenu >= 0 && iMenu < iRes && iRes < iRst && iRst < iLob,
'菜单项 queue 依序播出(sys_menu → 回到水下 → 重新开始这次救援 → 保存并回大厅),在播句不被截断');
assert(cnt('voice/sys_menu_restart') === 1 && cnt('voice/sys_menu_lobby') === 1
&& cnt('voice/sys_menu_resume') === 2,
'单击循环顺序 = [回到水下, 重新开始这次救援, 保存并回大厅],末项单击回绕首项再报名');
await g(game, 'up');
await advance(() => !game.voicePlaying);
assert(cnt('voice/sys_help') >= 1 && cnt('voice/sys_settings') >= 1,
'菜单内上滑播 sys_help 帮助 + sys_settings 设置提示');
await g(game, 'doubletap'); // 当前项 = 回到水下
assert(game.phase === 'COMMS' && 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, 'down'); // 下滑关闭
assert(game.phase === 'COMMS' && game.nodeId === 'p1', '下滑关闭菜单 → 回原状态');
await advance(() => game.choiceArmed);
// 保存并回大厅(第三项)
await g(game, 'down');
await advance(() => !game.voicePlaying);
await g(game, 'tap');
await advance(() => !game.voicePlaying);
await g(game, 'tap'); // 第二次切换 = 保存并回大厅
await advance(() => !game.voicePlaying);
const lobBefore = voicePlays(playLog, 'voice/sys_menu_lobby');
await g(game, 'doubletap');
assert(cnt('voice/sys_menu_lobby') === lobBefore + 1, '确认回大厅 → 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);
}
// ================================================================ 中断复述
// (§9 边界:杀进程重进(node=bai_b)→ sys_continue_ask → 单击恢复
// (恢复首句不可跳,第二句起可跳);右滑=新救援(runs 口径同结算重开))
realLog('== 中断复述(guide=off,预置存档 node=bai_b)==');
{
clearSave(); resetSettings('off');
storageData.set(SAVE_KEY, JSON.stringify({
schema: 1,
updatedAt: new Date().toISOString(),
summary: '第2潜·机舱 · 氧气5格 · 结局0/4',
phase: 'COMMS',
node: 'bai_b',
dive: 2,
oxy: 5,
supply: true,
saved: { zhou: 1, bai: 0, chen: 0, zheng: 0, gao: 0 },
stayer: null,
flags: { knowsPipe: false, hasLog: false, valve: false, spareGift: false, gaoPeace: false, bell: false },
bond: { zhou: 0, bai: 0 },
form: null,
dryPending: false,
endings: {},
runs: 3,
}));
{
const { audio, playLog, game } = makeRig();
const vib0 = vibrateLog.length;
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 === 'COMMS' && game.nodeId === 'bai_b' && game.diveN === 2,
'单击继续 → 恢复 phase+node+dive');
assert(game.oxy === 5 && game.supply === true && game.runs === 3,
'oxy / supply / runs 全部还原');
assert(game.currentVoiceId === 'voice/bai_b', '恢复重播当前节点链(局面复述,L5)');
await g(game, 'tap'); // F4 锁:恢复链首句播完前单击不跳
assert(game.currentVoiceId === 'voice/bai_b',
'F4:恢复后首句播完前单击不跳句(先听完再武装跳句)');
assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === BEAT_PATTERN),
'G4:F4 锁内空点单击播 beat 轻震');
await advance(() => !game.voicePlaying); // bai_b 播完 → 链末等待
await g(game, 'tap'); // 链末继续 → p3 提示句
await advance(() => game.choiceArmed);
assert(game.nodeId === 'p3' && game.promptPlaying === false, '恢复局内推进正常:bai_b 链末 → p3');
// 上滑状态链(G6):prog_d2 → oxy_low → sys_help(oxy=5 → low 档整句)
await g(game, 'up');
await advance(() => !game.voicePlaying);
const iP = lastIndexOf(playLog, 'voice/prog_d2');
const iR = lastIndexOf(playLog, 'voice/oxy_low');
const iH = lastIndexOf(playLog, 'voice/sys_help');
assert(iP >= 0 && iP < iR && iR < iH, '恢复局上滑状态链顺序(G6):prog_d2 → oxy_low → sys_help');
await advance(() => game.choiceArmed); // 状态播完局面复述回 p3
teardown(game);
}
// ASK 右滑 = 新救援(runs 口径同结算重开:+1)
clearSave(); resetSettings('off');
storageData.set(SAVE_KEY, JSON.stringify({
schema: 1, updatedAt: new Date().toISOString(), summary: 'x',
phase: 'COMMS', node: 'bai_b', dive: 2, oxy: 5, supply: true,
saved: { zhou: 1, bai: 0, chen: 0, zheng: 0, gao: 0 }, stayer: null,
flags: { knowsPipe: false, hasLog: false, valve: false, spareGift: false, gaoPeace: false, bell: false },
bond: { zhou: 0, bai: 0 }, form: null, dryPending: false, endings: { e1: true }, runs: 3,
}));
{
const { audio, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'ASK' && !game.voicePlaying);
await g(game, 'right');
assert(game.phase === 'INTRO' && game.oxy === 12 && game.runs === 4
&& game.saveStash === null && game.endings.e1 === true,
'ASK 右滑 = 新救援:跨潜变量重置、runs 3+1(与结算重开同口径)、结局收集保留');
teardown(game);
}
}
// ================================================================ 看门狗双轨
// (§7.2/§9:搜索 10s/25s 两级不同句 + 双轨去重;乱按 96s 抢救(无进展轨
// 60/120s);COMMS 链末 25s/50s;p* 25s/50s 复述两级;ENDING 25s/50s;
// guide=off 全程零定时器零向导事件(主路径块已断言 idle))
realLog('== 看门狗双轨(guide=full)==');
{
// ---- A:搜索等待两级不同句 + 双轨同级交叉登记去重(纯干等不轰炸)
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await reachSearch(game);
const tS = now;
await advance(() => voicePlays(playLog, 'voice/gd_ping1') === 1);
assert(now - tS === 10000, `搜索等待 10s → gd_ping1(实际 ${now - tS}ms)`);
await advance(() => voicePlays(playLog, 'voice/gd_ping2') === 1);
assert(now - tS === 25000, `25s → gd_ping2(与第一级不同句,实际 ${now - tS}ms)`);
await advance(() => now >= tS + 80000);
assert(voicePlays(playLog, 'voice/gd_ping1') === 1 && voicePlays(playLog, 'voice/gd_ping2') === 1,
'两级提示各播一次(绝不逐字重播)');
assert(voicePlays(playLog, 'voice/gd_prog1') === 0 && voicePlays(playLog, 'voice/gd_prog2') === 0,
'双轨去重:无操作轨已播 ping1/ping2 → 无进展轨 60s/120s 同级跳过(不轰炸)');
teardown(game);
}
// ---- B:乱按 96s 抢救——每 2s 一个无效滑动:无操作轨零播出(被手势复位),
// 无进展轨 60s→gd_prog1、120s→gd_prog2(乱按饿不死)
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
const vib0 = vibrateLog.length;
await audio.unlock();
game.start(); await flush();
await reachSearch(game);
const t0 = now;
for (let k = 1; k <= 69; k++) { // 每 1.9s 注入一个无效左滑,覆盖 130s(96s+ 升级窗)
const beat = t0 + k * 1900;
setTimeout(() => {}, Math.max(0, beat - now)); // 节拍锚:虚拟时钟在 beat 处可停
await advance(() => now >= beat);
await g(game, 'left');
}
assert(voicePlays(playLog, 'voice/gd_ping1') === 0 && voicePlays(playLog, 'voice/gd_ping2') === 0
&& voicePlays(playLog, 'voice/gd_continue') === 0 && voicePlays(playLog, 'voice/gd_again') === 0,
'乱按 96s+:无操作轨零播出(任何手势复位,两级永不触发)');
assert(voicePlays(playLog, 'voice/gd_prog1') === 1,
`无进展轨 60s → gd_prog1(乱按抢救,实际 ${now}ms 起点 ${t0}ms)`);
assert(voicePlays(playLog, 'voice/gd_prog2') === 1, '无进展轨 120s → gd_prog2(用尽即停)');
const errN = vibrateLog.slice(vib0).filter((v) => JSON.stringify(v.pattern) === ERR_PATTERN).length;
assert(errN >= 60, `每次乱按都有 error 震反馈(零静默,实际 ${errN} 次)`);
assert(game.searchArmed && game.oxy === 9, '乱按期间剧情零推进(无效手势不作进展)');
teardown(game);
}
// ---- C:COMMS 链末 25s/50s gd_continue/gd_again + 双轨去重
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await reachSearch(game);
await g(game, 'tap');
await advance(() => game.phase === 'COMMS');
await advance(() => !game.voicePlaying); // z1_a 链末等待
const tW = now;
await advance(() => voicePlays(playLog, 'voice/gd_continue') === 1);
assert(now - tW === 25000, `COMMS 链末 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 + 80000);
assert(voicePlays(playLog, 'voice/gd_again') === 1 && voicePlays(playLog, 'voice/gd_prog1') === 0,
'两级用尽彻底静默;prog1 同级被 gd_continue 交叉登记跳过');
// F1:链末等待中发无效手势,看门狗被 disarm 拆掉后必须重挂(阈值内触发)
await g(game, 'tap'); // 手势复位 + 继续 → z1_b 链末
await advance(() => !game.voicePlaying);
const tW2 = now;
await g(game, 'left'); // 无效手势(F1:拆看门狗不补挂 = 34.2s 静默死局的根因)
assert(voicePlays(playLog, 'voice/gd_invalid') === 1, 'F1 前置:链末无效手势照常零静默(gd_invalid)');
const gc0 = voicePlays(playLog, 'voice/gd_continue');
await advance(() => voicePlays(playLog, 'voice/gd_continue') === gc0 + 1);
assert(now - tW2 === 25000, `F1:链末等待中发无效手势,看门狗仍在阈值内触发(25s 整,实际 ${now - tW2}ms)`);
teardown(game);
}
// ---- D:p* 播完 25s/50s 重播提示句两级后静默;ENDING 25s/50s;签名总账
clearSave(); resetSettings('full');
{
const { audio, playLog, game } = makeRig();
const guideEvents = [];
game.guide.setEventHook((ev) => guideEvents.push(ev));
await audio.unlock();
game.start(); await flush();
await advance(() => game.currentVoiceId === 'voice/gd_brief');
assert(voicePlays(playLog, 'voice/gd_brief') === 1, 'full 档开场链最前插 gd_brief(F8 先例)');
assert(guideEvents.some((e) => e.type === 'state' && e.name === 'explore'), 'INTRO 进入播 explore 签名');
await reachSearch(game);
assert(guideEvents.filter((e) => e.type === 'turn').length === 1, '搜索武装 turn 事件(轮到你发脉冲)');
await g(game, 'tap');
await advance(() => game.phase === 'COMMS');
await advance(() => !game.voicePlaying);
await g(game, 'tap'); // → z1_b
await advance(() => !game.voicePlaying);
await g(game, 'tap'); // → z1_knock
await advance(() => !game.voicePlaying);
await g(game, 'tap'); // → p1 提示句
await advance(() => game.choiceArmed);
const tP = now;
await advance(() => voicePlays(playLog, 'voice/p1') === 2);
assert(now - tP === 25000, `p* 播完 25s 无操作 → 重播当前 p* 提示句(第一级,实际 ${now - tP}ms)`);
await advance(() => voicePlays(playLog, 'voice/p1') === 3);
assert(now - tP === 50000, `50s → 再复述一次(第二级,实际 ${now - tP}ms)`);
await advance(() => now >= tP + 70000);
assert(voicePlays(playLog, 'voice/p1') === 3, 'p* 复述两级后彻底静默');
await g(game, 'left'); // 抉择后正常生效
assert(game.flags.knowsPipe === true && game.nodeId === 'd1_listen', '复述期间剧情不推进,玩家抉择后正常生效');
// 打到 ENDING(playRun 内含搜索脉冲/链末推进;p1 已消耗 left,余 p2..p9 = 8 抉择)
await playRun(game, SCRIPT_A.slice(1));
await advance(() => game.phase === 'ENDING' && game.awaitingRestart);
const tE = now;
const gc0 = voicePlays(playLog, 'voice/gd_continue');
const ga0 = voicePlays(playLog, 'voice/gd_again');
await advance(() => voicePlays(playLog, 'voice/gd_continue') === gc0 + 1);
assert(now - tE === 25000, `ENDING 结算 25s 静默 → gd_continue(实际 ${now - tE}ms)`);
await advance(() => voicePlays(playLog, 'voice/gd_again') === ga0 + 1);
assert(now - tE === 50000, `50s → gd_again(第二级新句,实际 ${now - tE}ms)`);
await advance(() => now >= tE + 70000);
assert(voicePlays(playLog, 'voice/gd_again') === ga0 + 1, 'ENDING 两级用尽彻底静默');
await g(game, 'tap');
assert(game.phase === 'INTRO' && game.runs === 2, '结算单击重开新救援');
// 向导签名总账(§7.1):choice ×9(p1..p9 各一次)/ settle / danger ×2 / turn ×4
const choiceEvents = guideEvents.filter((e) => e.type === 'state' && e.name === 'choice').length;
assert(choiceEvents === 9, `每个 p* 起播发一次 choice 签名(实际 ${choiceEvents})`);
assert(guideEvents.filter((e) => e.type === 'state' && e.name === 'settle').length >= 5,
'SURFACE×4 + ENDING 进入 settle 签名');
assert(guideEvents.filter((e) => e.type === 'state' && e.name === 'danger').length === 2,
'D2/D4 危机拍各一次 danger 签名(全游戏仅两处)');
assert(guideEvents.filter((e) => e.type === 'turn').length === 4, '四潜搜索武装各一次 turn 签名');
teardown(game);
}
}
// ================================================================ 双通道仲裁
// (sonarGate 空闲门排队补发 / 搜索双击重听回波 / R3 duck 账目)
realLog('== 双通道仲裁(sonarGate 空闲门 / 回波重听 / R3 duck)==');
{
clearSave(); resetSettings('off');
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await reachSearch(game);
await g(game, 'tap');
await advance(() => game.currentVoiceId === 'voice/z1_a');
// sonarGate:语音在播时信息音挂队、不顶不叠(stub 引擎在播语义,与资产状态无关)
const metal0 = voicePlays(playLog, 'sfx/echo_metal_l');
const origIvp2 = audio.isVoicePlaying.bind(audio);
audio.isVoicePlaying = () => true;
game._sonar('sfx/echo_metal_l'); // 白盒:在播对白期间请求一枚信息回波
audio.isVoicePlaying = origIvp2;
await flush();
assert(voicePlays(playLog, 'sfx/echo_metal_l') === metal0,
'R1:语音在播时信息回波不立即播出(sonarGate 挂队)');
await advance(() => !game.voicePlaying); // 链末:voice idle → 补发
assert(voicePlays(playLog, 'sfx/echo_metal_l') === metal0 + 1,
'sonarGate:句间空闲补发排队回波(晚于在播句结束,零重叠)');
assert(game._ambDucked === false, 'R3:链歇后 amb duck 解除');
teardown(game);
// 搜索双击=重听上一条回波+其解读句(D2 搜索时重听 D1 的金属回波)
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await playRun(game, SCRIPT_A, {
stop: () => game.searchArmed && game.diveN === 2,
});
assert(game.searchArmed && game.diveN === 2, '推进到 D2 搜索等待(已有 D1 回波记录)');
const m0 = voicePlays(playLog, 'sfx/echo_metal_l');
const v0 = voicePlays(playLog, 'voice/dv1_metal');
await g(game, 'doubletap');
await reachSearch(game);
assert(voicePlays(playLog, 'sfx/echo_metal_l') === m0 + 1
&& voicePlays(playLog, 'voice/dv1_metal') === v0 + 1,
'R5:搜索双击重听上一条回波+其解读句(同序重播:echo_metal_l → dv1_metal)');
assert(game.searchArmed && game.phase === 'DIVE', '重听后回到搜索等待(turn+看门狗重挂)');
teardown(game);
}
}
// ================================================================ 结算死窗(H1 内化)
// (终局取消链 / 干涸链 / e* 结局链开播 800ms 内注入在途手势 → 被吞、链完整有序)
realLog('== 结算死窗(幽灵手势不截结算链)==');
{
// ---- 取消链死窗(剧本 C)
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
const ci = await playRun(game, SCRIPT_C, {
stop: () => game.nodeId === 'c_none' && game.voicePlaying,
});
assert(game.currentVoiceId === 'voice/dv4_none', '前置:取消链首句 dv4_none 在播');
await g(game, 'down'); // 幽灵手势(800ms 死窗内):不得开菜单截链
assert(game.phase === 'SURFACE' && game.nodeId === 'c_none',
'H1:取消链开播 800ms 内的下滑被吞(菜单不开、链不被截)');
await g(game, 'tap');
assert(game.currentVoiceId === 'voice/dv4_none' || voicePlays(playLog, 'voice/dv4_none') === 1,
'H1:窗内 tap 不跳取消链首句');
await playRun(game, SCRIPT_C, { from: ci });
const iNone = playLog.findIndex((e) => e.id === 'voice/dv4_none');
const iFinal = playLog.findIndex((e) => e.id === 'voice/gao_final');
const iG9 = playLog.findIndex((e) => e.id === 'voice/g9_in');
assert(iNone >= 0 && iNone < iFinal && iFinal < iG9, '取消链完整有序:dv4_none → gao_final → 签收');
assert(game.endingId === 'e3', '死窗后照常走到 e3');
teardown(game);
}
// ---- 干涸链死窗(剧本 E)
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
const ei = await playRun(game, SCRIPT_E, {
stop: () => game.currentVoiceId === 'voice/dry_a',
});
await g(game, 'down');
assert(game.phase !== 'MENU', 'H1:干涸链 dry_a 开播 800ms 内的下滑被吞');
await playRun(game, SCRIPT_E, { from: ei });
assert(voicePlays(playLog, 'voice/dry_a') === 1 && game.endingId === 'e4',
'干涸链完整播出(dry_a 未被截),照常到 e4');
teardown(game);
}
// ---- 结局链死窗(剧本 B)+ 死窗后手势照常
clearSave(); resetSettings('off');
{
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await playRun(game, SCRIPT_B, {
stop: () => game.phase === 'ENDING' && game.voicePlaying,
});
assert(game.currentVoiceId === 'voice/e2_a', '前置:结局链首句 e2_a 在播');
await g(game, 'down');
assert(game.phase === 'ENDING', 'H1:结局链开播 800ms 内的下滑被吞(菜单不开)');
await g(game, 'tap');
assert(game.currentVoiceId === 'voice/e2_a' || voicePlays(playLog, 'voice/e2_a') === 1,
'H1:窗内 tap 不跳结局链首句(结算出口手势死窗)');
await advance(() => now >= game._graceUntil + 50); // 死窗过期
await g(game, 'down');
assert(game.phase === 'MENU', '死窗过期后下滑照常开菜单');
await g(game, 'down'); // 关闭 → 重播结局链(局面复述)
await advance(() => game.awaitingRestart);
assert(game.endingId === 'e2' && voicePlays(playLog, 'voice/e2_a') >= 1,
'死窗后手势照常:菜单往返结局链完整、结算出口正常');
teardown(game);
}
}
// ================================================================ 键盘变体路由
// (settings.input='key' → 引擎候选链前置 voice/<gender>/key/ 探测;'touch' 不探测)
realLog('== 键盘变体路由(settings.input)==');
{
// key:操作提示句走键盘措辞变体资产位(引擎自动路由,游戏代码用普通 id)
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(urlHits('audio/voice/female/gd_brief') >= 1 || urlHits('audio/voice/female/key/gd_brief') >= 1,
'input=key:变体缺失自然回退主资产(成对生成由资产侧保证)');
teardown(game);
}
// touch:零 key/ 探测
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);
}
}
// ================================================================ 资产缺失专项
// (屏蔽任意 voice 资产 → 30s 看门狗自动续链到结局,不卡死;NPC 滤波资产缺失回退原声可播)
realLog('== 资产缺失专项(guide=off,屏蔽 bai_b)==');
{
clearSave(); resetSettings('off');
blockedAssets.add('voice/female/bai_b');
blockedAssets.add('voice/bai_b'); // NPC 平铺回退位一并屏蔽(最坏情况)
const { audio, playLog, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await playRun(game, SCRIPT_A);
assert(game.endingId === 'e1' && game.awaitingRestart === true,
'屏蔽任意 voice 资产仍打到 e1 结局,不卡死(30s 单句看门狗续链)');
assert(voicePlays(playLog, 'voice/gd_ping1') === 0 && voicePlays(playLog, 'voice/gd_continue') === 0
&& voicePlays(playLog, 'voice/gd_prog1') === 0,
'guide=off 全程零向导语音(gd_*)');
blockedAssets.delete('voice/female/bai_b');
blockedAssets.delete('voice/bai_b');
teardown(game);
}
// ================================================================ 静态审计
// (数字规范:分档整句字面量、无运行时 id 拼接;summary 格式)
realLog('== 静态审计 ==');
{
const src = fs.readFileSync(path.join(GAME_DIR, 'game.js'), 'utf8');
for (const id of ['voice/prog_d1', 'voice/prog_d2', 'voice/prog_d3', 'voice/prog_d4',
'voice/oxy_full', 'voice/oxy_ok', 'voice/oxy_low', 'voice/oxy_crit', 'voice/oxy_gain',
'voice/dry_a', 'voice/dry_b', 'voice/gd_ping1', 'voice/gd_ping2',
'voice/gd_prog1', 'voice/gd_prog2', 'voice/gd_continue', 'voice/gd_again',
'voice/p7', 'voice/p7_chen', 'voice/p7_zheng', 'voice/g9_dry']) {
assert(src.includes(`'${id}'`), `分档/差分整句字面量存在:${id}`);
}
assert(!/voice\/\$\{/.test(src), '无模板字符串拼接 voice id');
assert(!/['"]voice\/[A-Za-z0-9_]+['"]\s*\+/.test(src), '无字符串相加拼接 voice id');
assert(!/\+\s*['"]voice\//.test(src), '无前置相加拼接 voice id');
// 资产 id 盘点:game.js 提取到的 id 全部在 17-abyss-voice-ids 台词表词形内
const ids = [...new Set([...src.matchAll(/'((?:voice|sfx|bgm)\/[A-Za-z0-9_.-]+)'/g)].map((m) => m[1]))];
assert(ids.length >= 60, `game.js 提取到 ${ids.length} 个资产 id(约 70 节点规模)`);
assert(ids.every((id) => /^((voice\/(p[1-9](_chen|_zheng)?|[a-z0-9_]+))|sfx\/[a-z0-9_]+|bgm\/[a-z]+)$/.test(id)),
'资产 id 全部为字面量小写词形(无拼接碎片)');
// 存档 summary 人话格式(DIVE 进入时机)
clearSave(); resetSettings('off');
const { audio, game } = makeRig();
await audio.unlock();
game.start(); await flush();
await reachSearch(game); // D1 搜索(DIVE 存档已落)
const sd = saveData();
assert(sd && sd.schema === 1, '存档 schema=1');
assert(sd && sd.summary === '第1潜·找船 · 氧气9格 · 结局0/4',
`summary 格式「第N潜·<舱段> · 氧气K格 · 结局X/4」(实际: ${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('全部断言通过。');