- 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条(全为设计内部冲突按承诺清单/归因真话律取优)
1368 lines
70 KiB
JavaScript
1368 lines
70 KiB
JavaScript
/**
|
||
* 《深海回音》无头试玩仿真测试(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('全部断言通过。');
|