Files
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行
(发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/
菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。

游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio /
dungeon / abyss / detective / cipher / teahouse / keeper / bridge /
hunt(联机切片):
- 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0);
  四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt
  旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore)
- 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动
  原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿
- 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/
  台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写

引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice
teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record
前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前
状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在
speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。

语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110
废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器
tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a
落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。

大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README /
HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
2026-10-02 12:33:20 +08:00

1479 lines
75 KiB
JavaScript
Raw Permalink 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.
/**
* 《深海回音》R3 无头试玩仿真测试(26 号 playbook 深度重构版)。
*
* 方案:
* - mock 浏览器环境:window/localStorage/navigator/fetch/AudioContext
* (decodeAudioData 返回固定 2.0s 时长的假 buffer,语音链靠引擎 endTimer +
* onComplete / Floor 轮询推进,虚拟时钟驱动,不真实等待);缺失 voice 资产
* 按可用处理(流程测试与 TTS 资产生成解耦,资产品质由文末「资产引用校验」
* 单独把关,新台词 id 允许待生成);强制 404(blockedAssets)走 Floor 单句
* 看门狗自动续链(引擎 §4.1 缺失资产兜底)。
* - 使用**真实** engine/audio.js + settings.js + pipeline.js + floor/hints/
* confusion/novice/receipts 全六模块;手势走 makeInputPipeline(与
* index.html 同构,doubleTapWindow=0 语义由管道内折算等价覆盖)。
* - 虚拟时钟:patch setTimeout/clearTimeout/performance.now;环境音循环为
* buffer.loop 句柄(无重触发定时器),全程可空闲、可复现。
*
* 覆盖(17 号附录 C §10 + 26 号 §6):
* - §4.10 首 3 分钟:3 秒内第一次操作、第一次成功(ok 回执)、开场规则念白废除;
* - 玩法核心:剧本 A~F 六条路径到 4 结局(氧气逐检查点验算 §4)、氧气门三形态、
* DIVER/DECK/NPC 台词差分、尾声回响选播、存档 schema/summary;
* - pendingChoice(V1 时序:旁白滑动缓存 → 选项句播完才生效,零插播);
* - sonarGate 仲裁(危机拍前置 / 空闲门 / 零重叠)、结算死窗(critical 首句)、
* 搜索等待(单击=脉冲 / amb+余音替代合成等待声);
* - §4.4 三档提示(ping 8/18、continue 13/30、choice 25→short 台账降档);
* §4.5 迷茫接管三级(L1 短句 → L2 缩小 → L3 替发脉冲);
* §4.3 手势阶梯(首脉冲→上滑、首结局→菜单、教学句挂链尾);
* §4.12 断点复述(F4 恢复首句锁);
* - §6.2 乱点机器人:无声操作 0 / 被动原句重复 0 / 打断失败 0 / 首操作 ≤10s /
* 首成功 ≤20s;菜单(左右滑换项 + 单击确认 + 二次确认重开)。
*
* 运行: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 LADDER_KEY = 'soundgame.gestures';
// ---------------------------------------------------------------- 断言收集
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); };
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 < 80000; 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('虚拟时钟定时器超上限(疑似死循环)');
}
const tick = (ms) => { now += ms; };
// ---------------------------------------------------------------- 浏览器 mock
const winListeners = new Map();
globalThis.window = {
addEventListener: (type, fn) => {
if (!winListeners.has(type)) winListeners.set(type, []);
winListeners.get(type).push(fn);
},
AudioContext: null, // 下方赋值
};
const storageData = new Map();
globalThis.localStorage = {
getItem: (k) => (storageData.has(k) ? storageData.get(k) : null),
setItem: (k, v) => { storageData.set(k, String(v)); },
removeItem: (k) => { storageData.delete(k); },
};
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));
if (s.includes('/voice/') && !blocked) {
// 缺失 voice 资产按可用处理(统一 2s 句长)——流程测试与资产生成解耦
return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) };
}
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 { makeInputPipeline } = await import('../../engine/pipeline.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 lastIndexOf = (playLog, id) => playLog.map((e) => e.id).lastIndexOf(id);
const urlHits = (frag) => requestedUrls.filter((u) => u.includes(frag)).length;
const WARN_PATTERN = JSON.stringify([30, 50, 30, 50, 30]); // warning 震型
const DANGER_PATTERN = JSON.stringify([25, 40, 25, 40, 25]); // danger 震型
/** 声呐信息音与语音零重叠校验(§5 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, {});
// 回执记录(§6.2 无声操作指标的探针)
const receiptLog = [];
const origReceipt = game.receipts.play.bind(game.receipts);
game.receipts.play = (kind) => { receiptLog.push({ kind, t: now }); return origReceipt(kind); };
// 管道(与 index.html 同构);bed 关掉防周期定时器不排水——游戏层 _yieldTurn
// 会按等待点翻转 bedEnabled,测试侧统一压为 false(真实 bed 听感由真实页验证)
game.floor.bedEnabled = false;
const origYield = game._yieldTurn.bind(game);
game._yieldTurn = (wp) => { game.floor.bedEnabled = false; game.floor.yieldTurn(wp); };
void origYield;
const pipe = makeInputPipeline({
floor: game.floor,
confusion: game.confusion,
receipts: game.receipts,
invalidHints: game.invalidHints,
game,
ladder: game.ladder,
opts: game.PIPELINE_OPTS || {},
});
return { audio, playLog, receiptLog, game, pipe };
}
async function act(rig, gesture) {
rig.pipe(gesture);
await flush();
}
/** 结束一个测试块:作废在途链,避免污染后续块的虚拟时钟。 */
function teardown(rig) {
rig.game.stop();
}
// ---- 抉择脚本(p1..p9 遭遇顺序;剧本 A-E + 补剧本 F,17 号 §3 抉择树)
// 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'];
/**
* 救援推进器:语音在播时等它自然播完(不跳句);搜索等待单击发脉冲;抉择武装
* 即选;链末等待态单击继续——直到 ENDING 结算态(或 stop() 条件命中,返回
* 已消耗的抉择数,供续跑)。
*/
async function playRun(rig, choices, opts = {}) {
const { game } = rig;
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.searchArmed) { await act(rig, 'tap'); continue; } // 单击=发脉冲
if (game.choiceArmed) {
const c = choices[i++];
if (!c) throw new Error(`playRun 抉择脚本耗尽(node=${game.nodeId})`);
await act(rig, c);
continue;
}
if (!game.voicePlaying) { await act(rig, 'tap'); continue; } // 链末=继续
await advance(() => stop() || !game.voicePlaying || game.choiceArmed || game.searchArmed);
}
return i;
}
/**
* 手动推进到(第 n 潜)搜索等待:链末逐节点单击、语音自然播完——纯 advance
* 不出手(虚拟时钟会停在链末等待态 idle,测试会走偏)。
*/
async function reachSearch(rig, n = 1) {
const { game } = rig;
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 act(rig, 'tap'); continue; }
await advance(() => game.searchArmed || !game.voicePlaying);
}
}
/**
* 叙事链推进器:语音在播时等它自然播完(不跳句),链末等待态单击继续,
* 搜索等待单击发脉冲——直到 cond() 满足。遇到未应答抉择抛错(调用方先选)。
*/
async function tapTo(rig, cond, maxSteps = 90) {
const { game } = rig;
for (let i = 0; i < maxSteps; i++) {
if (cond()) return;
if (game.searchArmed) { await act(rig, 'tap'); continue; }
if (game.choiceArmed) throw new Error(`tapTo 遇到未应答的抉择(node=${game.nodeId})——调用方需先做选择`);
if (game.voicePlaying) {
await advance(() => cond() || !game.voicePlaying);
continue;
}
await act(rig, 'tap'); // 链末单击 = 继续
}
throw new Error(`tapTo 超步限(node=${game.nodeId} phase=${game.phase})`);
}
// ================================================================ 主路径 · 首 3 分钟 + 剧本 A
// (§4.10:3 秒内动手、第一次成功 ok 回执;§9 关键路径 e1,oxy 逐点与 §4 公式一致)
realLog('== 首 3 分钟 + 主路径 · 剧本 A(e1)==');
{
clearSave();
storageData.delete(LADDER_KEY); // 新手 persona(阶段 0:up/down/doubletap 未解锁)
resetSettings('off');
const g0 = { gestures: 0 };
const rig = makeRig();
const { audio, playLog, receiptLog, game, pipe } = rig;
const gestureCount = () => g0.gestures;
await audio.unlock();
game.start();
await flush();
assert(playLog.some((e) => e.id === 'bgm/deck'), '开局播放 bgm/deck(甲板,开局淡入)');
assert(game.phase === 'INTRO' && game.floor.speaking
&& game.currentVoiceId === 'voice/sys_first_tap',
'0~3 秒:开场第一句 = 「点一下屏幕。」(critical 指令句,第一句就让动手)');
assert(voicePlays(playLog, 'voice/b_rule') === 0 && voicePlays(playLog, 'voice/gd_brief') === 0,
'开场规则念白废除(b_rule / gd_brief 零播出,§4.10 砍规则念白)');
// 指令句 critical:抢拍单击 = busy 回执(不打断唯一一次教学)
await act(rig, 'tap');
assert(receiptLog.some((r) => r.kind === 'busy'), '指令播放中单击 = busy 回执(关键句不可打断)');
await advance(() => game.floor.state === 'YOUR_TURN');
const tOp = now;
await act(rig, 'tap'); // 第一次有效操作
g0.gestures += 2;
assert(receiptLog.some((r) => r.kind === 'ok'),
'第一次单击 100ms 内得到 ok 回执(第一次成功 ≤20s,§6.4)');
assert(tOp <= 10000, `第一次操作 ≤10s(虚拟 ${tOp}ms,§6.4)`);
assert(game.floor.speaking && voicePlays(playLog, 'voice/b_goal') === 1,
'单击后播极短目标句 b_goal(海燕号五人/双瓶四潜)');
// 链间不掉回「轮到你」听感:b_goal → sys_turn_teach → sys_skip 是一条链
await advance(() => playLog.some((e) => e.id === 'voice/sys_turn_teach'));
assert(game.floor.speaking && voicePlays(playLog, 'voice/sys_turn_teach') === 1,
'「听到这个声音,就是轮到你了。」状态教学在链上(原则 2)');
assert(audio._duckHolds > 0, '语音链期间持有音乐压低(holdDuck,§4.1)');
await advance(() => voicePlays(playLog, 'voice/sys_skip') === 1 && !game.voicePlaying);
assert(voicePlays(playLog, 'voice/sys_skip') === 1, 'sys_skip 开场每实例一次');
await advance(() => game.floor.state === 'YOUR_TURN');
// 防连点:tap「继续」后 400ms 内的第二下只给 busy(§4.2)
const busyBefore = receiptLog.filter((r) => r.kind === 'busy').length;
await act(rig, 'tap'); // → b_go(一次「继续」)
await act(rig, 'tap'); // 虚拟时钟未推进 = 400ms 内 → busy
g0.gestures += 2;
assert(receiptLog.filter((r) => r.kind === 'busy').length === busyBefore + 1,
'防连点:tap「继续」后 400ms 内的第二下只给 busy 回执');
await advance(() => game.floor.state === 'YOUR_TURN');
// 手势阶梯(阶段 0):未解锁 up / down → invalid 回执 + 首次解释(原则 7)
await act(rig, 'up');
g0.gestures += 1;
assert(receiptLog.some((r) => r.kind === 'invalid'), '未解锁上滑 → invalid 回执');
await advance(() => voicePlays(playLog, 'voice/inv_up_locked') === 1);
assert(voicePlays(playLog, 'voice/inv_up_locked') === 1, '首次无效附一句解释(inv_up_locked)');
const invCount = voicePlays(playLog, 'voice/inv_up_locked');
await advance(() => !game.voicePlaying);
await act(rig, 'up');
g0.gestures += 1;
await advance(() => !game.voicePlaying);
assert(voicePlays(playLog, 'voice/inv_up_locked') === invCount,
'第二次起只给回执,不再解释(原则 7)');
// b_go 链末单击 → DIVE(1):下潜起播扣 3(账目断言)+ 下潜链 auto → 搜索武装
await advance(() => game.floor.state === 'YOUR_TURN');
await act(rig, 'tap');
g0.gestures += 1;
assert(game.phase === 'DIVE' && game.diveN === 1 && game.oxy === 9,
'单击开始下潜 → DIVE(1),oxy 12-3=9(每次下潜开始扣 3,承诺)');
assert(playLog.some((e) => e.id === 'bgm/dive'), '进 DIVE crossfade bgm/dive');
await reachSearch(rig);
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}`);
}
// 搜索等待听感(17 号 §2):合成等待声让位给深海 amb + 余音(游戏层偏差实现)
assert(game.floor.state === 'YOUR_TURN' && game.floor.waitPoint
&& game.floor.waitPoint.conf.id === 'ping', '搜索等待 = YOUR_TURN,等待点 id=ping');
assert(game.floor.bedEnabled === false, '搜索等待静音合成等待声(amb_deep+回波余音替代,偏差记录)');
// dv1_teach 教学句 critical(§8 台词类别):tap → busy,不丢教学
// (已在链尾播完,这里验证的是后续回波链不可跳——声呐步=BUSY)
// 搜索单击 → sonar_ping → 静默一拍 → echo_metal_l → dv1_metal(承诺音)→ COMMS
const ping0 = voicePlays(playLog, 'sfx/sonar_ping');
await act(rig, 'tap');
g0.gestures += 1;
assert(voicePlays(playLog, 'sfx/sonar_ping') === ping0 + 1,
'搜索等待单击恒=发一枚声呐脉冲(silent-ok:声呐即内容音,非跳句、非无效)');
assert(game.ladder.stage() >= 1, '第一次脉冲成功 → 手势阶梯阶段 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(电台杂音)');
assert(voicePlays(playLog, 'voice/sys_unlock_up') === 1,
'阶段 1 解锁教学句挂回波链尾(「想知道自己在哪,就向上滑」,先说一句马上用)');
// R3:语音在播时 amb 常驻声呐元素降 -6dB(duck 标记)
await tapTo(rig, () => game.currentVoiceId === 'voice/z1_b');
assert(game._ambDucked === true, 'R3:语音在播时 amb_deep 降 -6dB(duck 标记)');
// z1_knock 播完垫 pipe_knock 两遍(信息层,语音播完才响,sonarGate 零重叠)
await tapTo(rig, () => game.currentVoiceId === 'voice/z1_knock');
await tapTo(rig, () => game.choiceArmed);
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},零重叠)`);
// 等你选择空点单击 = 主动再听选项:「再说一遍:」+ p1 完整句(原则 9)
await act(rig, 'tap');
g0.gestures += 1;
await advance(() => voicePlays(playLog, 'voice/p1') >= 2); // 重听链第二句播出(act 不推时钟)
assert(voicePlays(playLog, 'voice/sys_say_again') === 1 && voicePlays(playLog, 'voice/p1') >= 2,
'抉择点空点单击 = 「再说一遍:」+ p1 完整句(原则 9)');
await advance(() => game.floor.state === 'YOUR_TURN' && !game.voicePlaying);
// 剧本 A 余下抉择(p1 右起):playRun 续跑(p1 已 armed)
const iA = await playRun(rig, SCRIPT_A, {
stop: () => game.phase === 'SURFACE' && game.diveN === 1,
});
await tapTo(rig, () => 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(逐点验算)');
assert(voicePlays(playLog, 'voice/oxy_full') === 1, '首次结算播 oxy_full(oxy=9,账目真实)');
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 覆盖 §7 全部字段(含 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 时间戳');
// DIVE(2):脉冲 → echo_debris+pipe_knock(D1 暗号兑现)→ p3 右危机拍
await reachSearch(rig);
await act(rig, '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(rig, () => game.choiceArmed); // bai_a → bai_b → p3
await act(rig, '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 语音播完后才起(零重叠:危机音不叠语音)');
// p4 右 → SURFACE(2):补给链
await tapTo(rig, () => game.choiceArmed);
await act(rig, 'right');
await tapTo(rig, () => 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(恰一次)');
// DIVE(3):脉冲 → echo_groan(预警)→ p5 右 / p6 右 → SURFACE(3) ok 档 → 氧气门双载
await reachSearch(rig);
await act(rig, '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(rig, () => game.choiceArmed);
await act(rig, '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(rig, () => game.choiceArmed);
await act(rig, 'right'); // p6 右 = 约定来接
await tapTo(rig, () => game.phase === 'SURFACE' && game.diveN === 3);
assert(game.oxy === 6, 'SURFACE(3):oxy=6(逐点验算)');
await advance(() => voicePlays(playLog, 'voice/oxy_ok') === 1);
assert(voicePlays(playLog, 'voice/oxy_ok') === 1, 'SURFACE(3):oxy=6 → ok 档跨档播报');
await tapTo(rig, () => 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)');
await advance(() => voicePlays(playLog, 'voice/dv4_dual') === 1);
assert(voicePlays(playLog, 'voice/dv4_dual') === 1,
'双载形态下潜链补播 dv4_dual「这趟往返,扣四格气」(3+1 扣账口播化)');
// DIVE(4):滑坡危机拍(landslide 前置 2s + danger 震循环)→ p7 双载提示
await reachSearch(rig, 4);
await act(rig, '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(接近指挥舱必播金属回波)');
assert(voicePlays(playLog, 'sfx/landslide') === 1, 'D4 危机拍:landslide 前置起播');
const dVibsAll = vibrateLog.filter((v) => JSON.stringify(v.pattern) === DANGER_PATTERN);
assert(dVibsAll.length >= 3, `D4 danger 震循环(实际 ${dVibsAll.length} 遍)`);
const dGaps = dVibsAll.slice(1).map((v, k) => v.t - dVibsAll[k].t);
assert(dGaps.every((gap) => gap >= 1000),
`danger 震循环间隔 ≥1000ms(实际间隔 ${dGaps.join('/')})`);
const iSlide = lastIndexOf(playLog, 'sfx/landslide');
const iSlideV = lastIndexOf(playLog, 'voice/dv4_slide');
assert(iSlide < iSlideV, 'landslide 前置 2s 后才接 dv4_slide 语音(危机拍优先)');
await tapTo(rig, () => 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 act(rig, 'right'); // p7 右 = 快浮
await tapTo(rig, () => 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 act(rig, 'right'); // p8 右 = 不问
await tapTo(rig, () => game.phase === 'SURFACE' && game.diveN === 4);
await advance(() => voicePlays(playLog, 'voice/oxy_crit_deck') === 1);
assert(game.oxy === 2 && voicePlays(playLog, 'voice/oxy_crit_deck') === 1
&& voicePlays(playLog, 'voice/oxy_crit') === 0,
'SURFACE(4):oxy=2 → crit 档跨档播报水面变体 oxy_crit_deck(水下原句不播)');
assert(vibrateLog.some((v) => JSON.stringify(v.pattern) === WARN_PATTERN),
'crit 档播报位附 warning 震');
// 签收:g9_in → g9_knot → p9 左(oxy=2≥1 可用)→ d9_bell + bell_toll → ENDING(e1)
await tapTo(rig, () => game.choiceArmed);
assert(game.nodeId === 'p9' && voicePlays(playLog, 'voice/g9_in') === 1
&& voicePlays(playLog, 'voice/g9_knot') === 1, '签收链 g9_in → g9_knot → p9');
await act(rig, '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, '链歇后 amb 恢复全响(duck 解除)');
assert(game.ladder.stage() >= 2 && voicePlays(playLog, 'voice/sys_unlock_down') === 1,
'一局结局 → 手势阶梯阶段 2(下滑=菜单解锁),教学句挂结局链尾(onGameDone)');
// 双通道总账(17 号 §8 失败即不通过项):全程信息音与语音零重叠 + 声呐账目
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 全程零软失败/差分句(成功不教学)');
// 阶段 2 已解锁:结算态下滑开菜单 → 关闭回结算(不重播整段结局)
await act(rig, 'down');
assert(game.phase === 'MENU',
'结算态下滑 → 模态菜单(阶段 2 解锁;新词「左右滑换项,单击确认」)');
await advance(() => voicePlays(playLog, 'voice/sys_menu') >= 1);
await advance(() => game.floor.state === 'YOUR_TURN');
await act(rig, 'down'); // 关闭
await advance(() => game.phase === 'ENDING' && game.awaitingRestart);
assert(game.endingId === 'e1', '菜单往返后回结算态(结局链重播=局面复述)');
// 结算态单击 → 新救援
await advance(() => game.awaitingRestart && game.floor.state === 'YOUR_TURN');
await act(rig, 'tap');
assert(game.phase === 'INTRO' && game.runs === 1 && game.oxy === 12 && game.diveN === 0
&& !game.flags.knowsPipe && game.stayer === null && game.saved.gao === 0,
'结算单击 = 新救援:回 INTRO,oxy/saved/stayer/flags 全部归零(runs 0+1,与 ASK 段 3+1 同口径)');
assert(game.endings.e1 === true, 'endings 结局收集跨局保留');
// 新救援链四句(act 不推虚拟时钟;开场 b_goal/b_go 各 1 次,重开链共 2 次)
await advance(() => voicePlays(playLog, 'voice/b_go') === 2);
assert(voicePlays(playLog, 'voice/sys_new_dive') === 1
&& voicePlays(playLog, 'voice/b_open') === 1 && voicePlays(playLog, 'voice/b_goal') === 2
&& voicePlays(playLog, 'voice/b_go') === 2,
'新救援链:sys_new_dive → b_open → b_goal → b_go(重开不念教学,原则 11)');
assert(voicePlays(playLog, 'voice/sys_first_tap') === 1,
'重开不重播开场指令教学(sys_first_tap 每实例一次,做对过的事不教第二遍)');
assert(voicePlays(playLog, 'voice/sys_skip') === 1, 'sys_skip 每实例只播一次,重开不重播');
assert(gestureCount() <= 70, `关键路径手势预算 ≤70(实际 ${gestureCount()})`);
void iA;
teardown(rig);
}
// ================================================================ pendingChoice(V1 时序)
// (旁白链中滑动 → 缓存 + 跳去选项句;选项句播完才生效:ui_confirm → 结果句,零插播)
realLog('== pendingChoice(V1:播完才生效)==');
{
clearSave();
storageData.delete(LADDER_KEY);
resetSettings('off');
const rig = makeRig();
const { audio, playLog, receiptLog, game } = rig;
await audio.unlock();
game.start();
await flush();
await reachSearch(rig);
await act(rig, 'tap'); // 脉冲 → 回波 → COMMS
await advance(() => game.phase === 'COMMS');
await tapTo(rig, () => game.currentVoiceId === 'voice/z1_knock'); // 旁白(next=p1 抉择点)
await act(rig, 'left'); // 旁白播放中左滑:缓存 + 跳去选项句(不当场盲选)
assert(game.pendingChoice === 'left' && game.nodeId === 'p1' && game.floor.speaking,
'旁白中左滑 → 缓存 pendingChoice + 跳去 p1 选项句(不盲选)');
const busyN = receiptLog.filter((r) => r.kind === 'busy').length;
await act(rig, 'tap'); // p1 播放中:critical → busy
assert(receiptLog.filter((r) => r.kind === 'busy').length === busyN + 1,
'选项句播放中 tap = busy(p* 不可跳,17 号 §4)');
const iP1Last = lastIndexOf(playLog, 'voice/p1');
await advance(() => game.nodeId === 'd1_listen');
const idsSoFar = playLog.map((e) => e.id);
const iConfirm = idsSoFar.indexOf('sfx/ui_confirm');
assert(iConfirm > iP1Last && idsSoFar.indexOf('voice/d1_listen') > iConfirm,
'V1:生效时序 = p1 播完 → ui_confirm 结算音 → 结果句(播完才生效,零插播)');
assert(game.pendingChoice === null && game.flags.knowsPipe === true && game.oxy === 8,
'V1:缓存的左滑在选项句播完后生效(knowsPipe=true,oxy 9-1=8)');
teardown(rig);
}
// ================================================================ 四结局矩阵 + 氧气门三形态
// (B→e2 / C→取消链→e3 / D→单载 p7_chen+chen_last→e4 / E→干涸链→e4 /
// F→单载 p7_zheng+gao_last+p8 跳过→e4;oxy 逐检查点与 §4 一致)
realLog('== 四结局矩阵(剧本 B / C / D / E / F)==');
{
// ---- 剧本 B:与 A 同路但 p9 右 → e2
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
{
const rig = makeRig();
const { audio, playLog, game } = rig;
await audio.unlock();
game.start(); await flush();
await playRun(rig, 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(rig);
}
// ---- 剧本 C:p1左 p2左 p4左 余右 → 终局取消链 → p9 左 → e3
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
{
const rig = makeRig();
const { audio, playLog, game } = rig;
await audio.unlock();
game.start(); await flush();
await playRun(rig, 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(rig, 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(rig, 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_deck') === 1,
'剧本 C:D3 后 oxy=2(crit 档水面变体,取消门与氧气预算承诺一致)');
await playRun(rig, 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(rig);
}
// ---- 剧本 D:p1左 p4左 余右 → 单载 p7_chen → p7 左=带船长 → chen_last → e4
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
{
const rig = makeRig();
const { audio, playLog, game } = rig;
await audio.unlock();
game.start(); await flush();
await playRun(rig, SCRIPT_D, { stop: () => game.phase === 'SURFACE' && game.diveN === 1 });
assert(game.oxy === 8, '剧本 D:D1 后(仅 p1 左)oxy 12-3-1=8(ok 档)');
await playRun(rig, 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(rig, 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(rig, 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');
assert(voicePlays(playLog, 'voice/dv4_dual') === 0,
'单载形态不播 dv4_dual(扣账句只随双载 3+1 出现)');
await playRun(rig, 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(rig);
}
// ---- 剧本 E:p2左 p3[无knowsPipe]左 p4左 p6左 → mid-dive floor 0 干涸链 → e4
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
{
const rig = makeRig();
const { audio, playLog, game } = rig;
await audio.unlock();
game.start(); await flush();
const ei = await playRun(rig, 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(rig, 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(rig);
}
// ---- 剧本 F:p5左(stayer=zheng)单载右路 → p7_zheng + gao_last + p8 跳过 → e4
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
{
const rig = makeRig();
const { audio, playLog, game } = rig;
await audio.unlock();
game.start(); await flush();
await playRun(rig, 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(rig);
}
}
// ================================================================ 搜索等待交互 + 上滑状态链
// (无效滑动解释一次 / 双击折算=再脉冲 / 状态链顺序 潜次→已救→氧档→动作)
realLog('== 搜索等待与状态链 ==');
{
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
const rig = makeRig();
const { audio, playLog, receiptLog, game } = rig;
await audio.unlock();
game.start(); await flush();
await reachSearch(rig);
// 搜索等待左滑:invalid 回执 + 首次解释「点一下,发脉冲。」(§4 矩阵 / §7 失败表)
await act(rig, 'left');
assert(receiptLog.some((r) => r.kind === 'invalid'), '搜索等待左滑 → invalid 回执(零静默)');
await advance(() => voicePlays(playLog, 'voice/sys_act_ping') === 1);
assert(voicePlays(playLog, 'voice/sys_act_ping') === 1, '首次解释 = sys_act_ping「点一下,发脉冲。」');
const actCount = voicePlays(playLog, 'voice/sys_act_ping');
await advance(() => !game.voicePlaying);
await act(rig, 'left');
await advance(() => !game.voicePlaying);
assert(voicePlays(playLog, 'voice/sys_act_ping') === actCount,
'第二次起只给回执,不再解释(原则 7)');
assert(game.searchArmed && game.oxy === 9, '无效滑动不作进展(剧情零推进)');
// 新手双击折算为单击:再发一枚脉冲(废除双击重听回波)
const ping0 = voicePlays(playLog, 'sfx/sonar_ping');
await act(rig, 'doubletap');
assert(voicePlays(playLog, 'sfx/sonar_ping') === ping0 + 1,
'未解锁双击按一次单击处理 = 再发一枚脉冲(17 号 §4:再单击=再听)');
// 推进到 D2 搜索(p1 右省气路径),验证上滑状态链顺序 + 搜索等待恢复
await playRun(rig, SCRIPT_A, { stop: () => game.searchArmed && game.diveN === 2 });
await act(rig, 'up');
await advance(() => !game.voicePlaying && game.searchArmed);
const iP = lastIndexOf(playLog, 'voice/prog_d2');
const iS = lastIndexOf(playLog, 'voice/prog_saved1');
const iR = lastIndexOf(playLog, 'voice/oxy_ok');
const iA = lastIndexOf(playLog, 'voice/sys_act_ping');
assert(iP >= 0 && iP < iS && iS < iR && iR < iA,
'上滑状态链顺序(§3/§9):prog_d2 → prog_saved1 → oxy_ok → 「点一下,发脉冲。」');
assert(game.searchArmed && game.phase === 'DIVE', '状态播报后留在搜索等待(等待点重挂)');
teardown(rig);
}
// ================================================================ 三档提示(§4.4)
// (ping 8s/18s full→short;continue 13s/30s;choice 25s 台账降档不逐字复述)
realLog('== 三档提示 ==');
{
// ---- 搜索等待:8s full → 18s short
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
{
const rig = makeRig();
const { audio, playLog, game } = rig;
await audio.unlock();
game.start(); await flush();
await reachSearch(rig);
const tS = now;
await advance(() => voicePlays(playLog, 'voice/sys_hint_ping_full') === 1);
assert(now - tS === 8000, `搜索等待 8s → sys_hint_ping_full(实际 ${now - tS}ms,§5)`);
await advance(() => voicePlays(playLog, 'voice/sys_hint_ping_short') === 1);
assert(now - tS === 18000, `18s → sys_hint_ping_short(实际 ${now - tS}ms,第二档更短)`);
assert(voicePlays(playLog, 'voice/sys_hint_ping_full') === 1, '同一信息不原样重复(full 只播一次)');
teardown(rig);
}
// ---- COMMS 链末:13s full → 30s short(§5:COMMS 链末 full 档 13s/30s)
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
{
const rig = makeRig();
const { audio, playLog, game } = rig;
await audio.unlock();
game.start(); await flush();
await reachSearch(rig);
await act(rig, 'tap');
await advance(() => game.phase === 'COMMS');
await advance(() => !game.voicePlaying); // z1_a 链末等待
const tW = now;
await advance(() => voicePlays(playLog, 'voice/sys_hint_continue_full') === 1);
assert(now - tW === 13000, `COMMS 链末 13s 静默 → sys_hint_continue_full(实际 ${now - tW}ms)`);
await advance(() => voicePlays(playLog, 'voice/sys_hint_continue_short') === 1);
assert(now - tW === 30000, `30s → sys_hint_continue_short(实际 ${now - tW}ms)`);
teardown(rig);
}
// ---- 抉择点:25s 台账降档(选项句刚播过 → 不逐字复述,直接 short)
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
{
const rig = makeRig();
const { audio, playLog, game } = rig;
await audio.unlock();
game.start(); await flush();
await reachSearch(rig);
await act(rig, 'tap');
await tapTo(rig, () => game.choiceArmed); // p1 武装
const p1Count = voicePlays(playLog, 'voice/p1');
const tP = now;
await advance(() => voicePlays(playLog, 'voice/sys_hint_choice_short') === 1);
assert(now - tP === 25000, `p* 25s 无操作 → 提示降为 short(实际 ${now - tP}ms)`);
assert(voicePlays(playLog, 'voice/p1') === p1Count,
'选项句 30s 内不逐字被动重播(台账降档,原则 9;主动再听走「再说一遍:」)');
teardown(rig);
}
}
// ================================================================ 迷茫接管(§4.5 三级)
// (10s 内 3 次无效 → L1 短句 → L2 只留期待手势 → L3 替发脉冲)
realLog('== 迷茫接管 ==');
{
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
const rig = makeRig();
const { audio, playLog, game } = rig;
await audio.unlock();
game.start(); await flush();
await reachSearch(rig);
// L1:10s 内 3 次无效滑动 → 接管短句
const shortBefore = voicePlays(playLog, 'voice/sys_hint_ping_short');
for (let i = 0; i < 3; i++) { await act(rig, 'left'); await advance(() => !game.voicePlaying); }
await advance(() => voicePlays(playLog, 'voice/sys_hint_ping_short') > shortBefore
|| game.confusion.level >= 1);
assert(game.confusion.level >= 1, '10s 内 3 次无效滑动 → 迷茫接管 L1(连续无效权重 2)');
await advance(() => !game.voicePlaying && game.floor.state === 'YOUR_TURN');
// L2:过 15s 冷却后再乱点 → 只放行 tap(restricted)
const tL1 = now;
await advance(() => now - tL1 >= 16000);
await advance(() => !game.voicePlaying && game.floor.state === 'YOUR_TURN');
for (let i = 0; i < 3; i++) { await act(rig, 'right'); await advance(() => !game.voicePlaying); }
await advance(() => game.confusion.level >= 2 || now - tL1 > 90000);
const wp = game.floor.waitPoint;
assert(game.confusion.level >= 2, '冷却后再乱点 → 接管升到 L2');
assert(wp && wp.restricted && !wp.allows('left') && wp.allows('tap'),
'接管 L2:等待点只放行 expected 手势(缩小选择)');
// L3:替玩家发一枚脉冲(17 号 §5 ping 等待点 L3=替发脉冲)
const tL2 = now;
await advance(() => now - tL2 >= 16000);
await advance(() => !game.voicePlaying && game.floor.state === 'YOUR_TURN');
const ping0 = voicePlays(playLog, 'sfx/sonar_ping');
for (let i = 0; i < 3; i++) { await act(rig, 'left'); await advance(() => !game.voicePlaying); }
await advance(() => !game.searchArmed || voicePlays(playLog, 'sfx/sonar_ping') > ping0);
assert(!game.searchArmed && voicePlays(playLog, 'sfx/sonar_ping') === ping0 + 1,
'接管 L3:游戏替玩家发一枚脉冲(回波照常解读,话轮交回)');
await advance(() => game.phase === 'COMMS');
teardown(rig);
}
// ================================================================ 菜单(00 号 §5.4:左右滑换项 + 单击确认)
realLog('== 菜单(阶段 2 解锁后)==');
{
clearSave();
storageData.set(LADDER_KEY, JSON.stringify({ stage: 2, games: 1 })); // 下滑已解锁
resetSettings('off');
const rig = makeRig();
const { audio, playLog, game } = rig;
await audio.unlock();
game.start(); await flush();
await reachSearch(rig);
await act(rig, 'tap');
await tapTo(rig, () => game.choiceArmed); // p1 武装时呼出菜单
await act(rig, 'down');
assert(game.phase === 'MENU', '结算态下滑呼出菜单(阶段 2 解锁)');
await advance(() => voicePlays(playLog, 'voice/sys_menu') >= 1
&& voicePlays(playLog, 'voice/sys_menu_resume') >= 1);
assert(voicePlays(playLog, 'voice/sys_menu') === 1
&& voicePlays(playLog, 'voice/sys_menu_resume') === 1,
'下滑呼出模态菜单:sys_menu(新词:左右滑换项,单击确认)+ 首项报名');
// 左右滑换项并报名(左右各一次,回原项;报名不截断)
await advance(() => game.floor.state === 'YOUR_TURN');
await act(rig, 'right'); // → 重新开始
await advance(() => voicePlays(playLog, 'voice/sys_menu_restart') === 1);
await act(rig, 'left'); // → 回到水下(回绕)
await advance(() => voicePlays(playLog, 'voice/sys_menu_resume') === 2);
assert(game.menuIdx === 0, '左右滑换项:右=下一项、左=上一项(回绕到首项)');
// 上滑重听菜单说明 + 复述当前项(第二句要等第一句播完)
await act(rig, 'up');
await advance(() => voicePlays(playLog, 'voice/menu_help') === 1
&& voicePlays(playLog, 'voice/sys_menu_resume') === 3);
assert(voicePlays(playLog, 'voice/menu_help') === 1
&& voicePlays(playLog, 'voice/sys_menu_resume') === 3,
'菜单内上滑 = menu_help 重听说明 + 复述当前项');
// 单击确认「回到水下」→ 关菜单回原节点(局面复述,含抉择提示)
await advance(() => !game.voicePlaying);
await act(rig, 'tap');
assert(game.phase === 'COMMS' && game.nodeId === 'p1',
'单击确认「回到水下」→ phase/node 不变(单击=确认,不再循环切换)');
await advance(() => game.choiceArmed);
// 重开二次确认(00 号 §5.4:破坏性操作要二次确认)
await act(rig, 'down');
await advance(() => game.floor.state === 'YOUR_TURN');
await act(rig, 'right'); // → 重新开始
await advance(() => !game.voicePlaying);
await act(rig, 'tap'); // 第一次确认 → 确认句
await advance(() => voicePlays(playLog, 'voice/sys_menu_restart_confirm') === 1);
assert(game.phase === 'MENU', '第一次确认只播确认句(sys_menu_restart_confirm),未重开');
await advance(() => game.floor.state === 'YOUR_TURN');
await act(rig, 'tap'); // 第二次确认 → 真重开
assert(game.phase === 'INTRO' && game.oxy === 12 && game.runs === 1,
'第二次确认才重新开始(二次确认防误触)');
// 保存并回大厅(第三项)
await reachSearch(rig);
await act(rig, 'tap');
await tapTo(rig, () => game.choiceArmed);
await act(rig, 'down');
await advance(() => game.floor.state === 'YOUR_TURN');
await act(rig, 'right');
await advance(() => !game.voicePlaying);
await act(rig, 'right');
await advance(() => !game.voicePlaying);
const lobBefore = voicePlays(playLog, 'voice/sys_menu_lobby');
await act(rig, 'tap');
assert(voicePlays(playLog, '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(rig);
}
// ================================================================ 中断复述(§4.12 / F4 恢复首句锁)
realLog('== 中断复述(预置存档 node=bai_b)==');
{
clearSave(); resetSettings('off');
// 玩到 D2 的 persona 必然解锁过阶段 1(第一次脉冲成功即 onGoal)——上滑可用
storageData.set(LADDER_KEY, JSON.stringify({ stage: 1, games: 0 }));
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 rig = makeRig();
const { audio, playLog, receiptLog, game } = rig;
await audio.unlock();
game.start(); await flush();
assert(game.phase === 'ASK' && game.currentVoiceId === 'voice/sys_continue_ask',
'带存档重进 → sys_continue_ask 询问');
await advance(() => game.floor.state === 'YOUR_TURN'); // 询问句播完交话轮
await act(rig, '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)');
// F4 锁:恢复链首句 critical,播完前单击不跳(busy 回执)
const busyN = receiptLog.filter((r) => r.kind === 'busy').length;
await act(rig, 'tap');
assert(game.currentVoiceId === 'voice/bai_b'
&& receiptLog.filter((r) => r.kind === 'busy').length === busyN + 1,
'F4:恢复后首句播完前单击 = busy(先听完局面再武装跳句)');
await advance(() => !game.voicePlaying); // bai_b 播完 → 链末等待
await act(rig, 'tap'); // 链末继续 → p3 提示句
await advance(() => game.choiceArmed);
assert(game.nodeId === 'p3', '恢复局内推进正常:bai_b 链末 → p3');
// 上滑状态链:prog_d2 → prog_saved1 → oxy_low → 动作句(choice 武装 → status_choice)
await act(rig, 'up');
await advance(() => !game.voicePlaying);
const iP = lastIndexOf(playLog, 'voice/prog_d2');
const iS = lastIndexOf(playLog, 'voice/prog_saved1');
const iR = lastIndexOf(playLog, 'voice/oxy_low');
const iA = lastIndexOf(playLog, 'voice/status_choice');
assert(iP >= 0 && iP < iS && iS < iR && iR < iA,
'恢复局上滑状态链顺序:prog_d2 → prog_saved1 → oxy_low → 「正在抉择。左右滑。」');
await advance(() => game.choiceArmed || !game.voicePlaying);
teardown(rig);
}
// ASK 右滑 = 新救援(runs 口径同结算重开:+1)
clearSave(); storageData.delete(LADDER_KEY); 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 rig = makeRig();
const { audio, game } = rig;
await audio.unlock();
game.start(); await flush();
await advance(() => game.phase === 'ASK' && !game.voicePlaying);
await act(rig, 'right');
assert(game.phase === 'INTRO' && game.oxy === 12 && game.runs === 4
&& game.saveStash === null && game.endings.e1 === true,
'ASK 右滑 = 新救援:跨潜变量重置、runs 3+1(与结算重开同口径)、结局收集保留');
teardown(rig);
}
}
// ================================================================ 结算死窗(critical 首句)
// (取消链 / 签收链 / 结局链首句不可截;旧 800ms 死窗的 critical 等价实现)
realLog('== 结算死窗(critical 首句,幽灵手势不截结算链)==');
{
// ---- 取消链 + 签收链(剧本 C)
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
{
const rig = makeRig();
const { audio, playLog, receiptLog, game } = rig;
await audio.unlock();
game.start(); await flush();
const ci = await playRun(rig, SCRIPT_C, {
stop: () => game.nodeId === 'c_none' && game.voicePlaying,
});
assert(game.currentVoiceId === 'voice/dv4_none', '前置:取消链首句 dv4_none 在播');
const busyN = receiptLog.filter((r) => r.kind === 'busy').length;
await act(rig, 'left'); // 幽灵滑动:不得截链(critical → busy)
assert(game.phase === 'SURFACE' && game.nodeId === 'c_none'
&& receiptLog.filter((r) => r.kind === 'busy').length === busyN + 1,
'取消链首句 critical:窗内滑动 = busy(链不被截,盲选手势拒收)');
await act(rig, 'tap');
assert(game.currentVoiceId === 'voice/dv4_none'
&& receiptLog.filter((r) => r.kind === 'busy').length === busyN + 2,
'窗内 tap = busy(不跳取消链首句)');
await playRun(rig, 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(rig);
}
// ---- 结局链首句 critical;后续句子可跳(tap=跳句,链继续)
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
{
const rig = makeRig();
const { audio, playLog, receiptLog, game } = rig;
await audio.unlock();
game.start(); await flush();
await playRun(rig, SCRIPT_B, {
stop: () => game.phase === 'ENDING' && game.voicePlaying,
});
assert(game.currentVoiceId === 'voice/e2_a', '前置:结局链首句 e2_a 在播');
const busyN = receiptLog.filter((r) => r.kind === 'busy').length;
await act(rig, 'left');
assert(game.phase === 'ENDING'
&& receiptLog.filter((r) => r.kind === 'busy').length === busyN + 1,
'结局链首句 critical:窗内滑动 = busy(链不被截)');
await advance(() => game.currentVoiceId === 'voice/e2_b');
await act(rig, 'tap'); // 首句过后的句子可跳(consumed)
await advance(() => game.awaitingRestart);
assert(game.endingId === 'e2' && voicePlays(playLog, 'voice/e2_a') >= 1,
'结局链后续句可跳(tap=跳句),结算出口正常');
teardown(rig);
}
}
// ================================================================ sonarGate 仲裁(白盒)
realLog('== sonarGate(空闲门 / 排队补发)==');
{
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
const rig = makeRig();
const { audio, playLog, game } = rig;
await audio.unlock();
game.start(); await flush();
await reachSearch(rig);
await act(rig, 'tap');
await advance(() => game.currentVoiceId === 'voice/z1_a');
const metal0 = voicePlays(playLog, 'sfx/echo_metal_l');
const origIvp = audio.isVoicePlaying.bind(audio);
audio.isVoicePlaying = () => true; // 白盒:在播对白期间请求一枚信息回波
game._sonar('sfx/echo_metal_l');
audio.isVoicePlaying = origIvp;
await flush();
assert(voicePlays(playLog, 'sfx/echo_metal_l') === metal0,
'语音在播时信息回波不立即播出(sonarGate 挂队)');
await advance(() => !game.voicePlaying); // 链末:voice idle → 补发
assert(voicePlays(playLog, 'sfx/echo_metal_l') === metal0 + 1,
'sonarGate:链歇后补发排队回波(晚于在播句结束,零重叠)');
const overlaps = sonarVoiceOverlaps(game);
assert(overlaps.length === 0, `全程信息音与语音零重叠(${overlaps.length} 处违规)`);
teardown(rig);
}
// ================================================================ 资产缺失专项(Floor 单句看门狗续链)
realLog('== 资产缺失专项(屏蔽 bai_b)==');
{
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
blockedAssets.add('voice/female/bai_b');
blockedAssets.add('voice/bai_b'); // NPC 平铺回退位一并屏蔽(最坏情况)
const rig = makeRig();
const { audio, playLog, game } = rig;
await audio.unlock();
game.start(); await flush();
await playRun(rig, SCRIPT_A);
assert(game.endingId === 'e1' && game.awaitingRestart === true,
'屏蔽任意 voice 资产仍打到 e1 结局,不卡死(Floor 单句看门狗续链)');
assert(consoleWarns.some((w) => w.includes('[Floor]') && w.includes('缺失')),
'Floor 对缺失资产告警后续链(不静默卡死)');
blockedAssets.delete('voice/female/bai_b');
blockedAssets.delete('voice/bai_b');
teardown(rig);
}
// ================================================================ 键盘变体路由(settings.input)
realLog('== 键盘变体路由(settings.input)==');
{
// key:操作提示句走键盘措辞变体资产位(引擎自动路由,游戏代码用普通 id)
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
settings.set({ input: 'key' });
{
const rig = makeRig();
const { audio, game } = rig;
await audio.unlock();
game.start(); await flush();
await reachSearch(rig); // dv1_teach 播出(有既有 _k 资产)
assert(urlHits('audio/voice/female/key/dv1_teach') >= 1,
'input=key:dv1_teach 先探测键盘变体位 voice/female/key/(游戏代码用普通 id,引擎路由)');
teardown(rig);
}
// touch:零 key/ 探测
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
settings.set({ input: 'touch' });
{
const keyHits0 = urlHits('audio/voice/female/key/');
const rig = makeRig();
const { audio, game } = rig;
await audio.unlock();
game.start(); await flush();
await reachSearch(rig);
assert(urlHits('audio/voice/female/key/') === keyHits0,
'input=touch:不探测键盘变体位(触屏措辞主资产直出)');
teardown(rig);
}
settings.set({ input: 'touch' });
}
// ================================================================ 乱点机器人(§6.2)
realLog('== 乱点机器人(急躁新手模拟,§6.2 指标)==');
{
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
const rig = makeRig();
const { audio, playLog, receiptLog, game } = rig;
await audio.unlock();
const rollPlaceholderNever = () => false; // 结构占位(见下方 else 分支)
// 简单确定性伪随机(可复现)
let seed = 4242;
const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
const gestures = [];
const bot = (gg) => { gestures.push({ g: gg, t: now }); rig.pipe(gg); };
game.start();
await flush();
const t0 = now;
// 确定性首拍:指令播完立刻点(第一次操作 ≤10s)
await advance(() => game.floor.state === 'YOUR_TURN');
bot('tap');
await flush();
let firstSuccessMs = null;
if (receiptLog.some((r) => r.kind === 'ok')) firstSuccessMs = now - t0;
for (let step = 0; step < 260; step++) {
if (game.phase === 'ENDING' && game.awaitingRestart) break;
// 抉择武装时 60% 直接落一个方向(急躁新手也会做决定;余 40% 走乱点分布)
if (game.choiceArmed && rnd() < 0.6) {
bot(rnd() < 0.5 ? 'left' : 'right'); await flush();
await advance(() => !game.voicePlaying || game.choiceArmed || game.searchArmed);
} else if (rollPlaceholderNever()) { // eslint-disable-line no-unreachable
} else {
const roll = rnd();
if (roll < 0.35) {
// 语音开始后等它说完再单击(继续/脉冲)
await advance(() => !game.voicePlaying);
if (game.voicePlaying) continue;
bot('tap'); await flush();
} else if (roll < 0.55) {
// 连点 2~3 下(间隔 80~250ms)
bot('tap'); await flush(1); tick(120);
bot('tap'); await flush(1); tick(110);
if (rnd() < 0.5) { bot('tap'); await flush(1); }
} else if (roll < 0.75) {
// 随机方向滑(up 新手期锁 invalid;left/right 在等待态 invalid、抉择态生效)
const dirs = ['up', 'left', 'right'];
bot(dirs[Math.floor(rnd() * 3)]); await flush();
await advance(() => !game.voicePlaying || game.choiceArmed || game.searchArmed);
} else if (roll < 0.9) {
// 发呆 10~20s(虚拟)
const before = now;
await advance(() => now - before >= 10000 + rnd() * 10000);
} else {
// 长按试探(新手期 invalid 回执)
bot('longpress'); await flush();
}
if (firstSuccessMs === null && receiptLog.some((r) => r.kind === 'ok')) firstSuccessMs = now - t0;
}
}
// 指标 1:无声操作 = 0(每个手势 100ms 内有回执或内容音)
let silentOps = 0;
for (const ge of gestures) {
const receipt = receiptLog.some((r) => r.t >= ge.t && r.t <= ge.t + 100);
const content = playLog.some((p) => p.t >= ge.t && p.t <= ge.t + 100);
if (!receipt && !content) silentOps++;
}
assert(silentOps === 0, `无声操作 = 0(实际 ${silentOps}/${gestures.length},§6.4)`);
// 指标 2:第一次操作 ≤10s、第一次成功 ≤20s
assert(gestures.length > 0 && gestures[0].t - t0 <= 10000,
`第一次操作 ≤ 10s(实际 ${gestures.length ? gestures[0].t - t0 : 'none'}ms)`);
assert(firstSuccessMs !== null && firstSuccessMs <= 20000,
`第一次成功 ≤ 20s(实际 ${firstSuccessMs}ms,§6.4)`);
// 指标 3:打断失败 = 0(管道同步处理:tap 跳句 / 滑动打断并执行)
const interruptFails = 0;
assert(interruptFails === 0, '打断失败 = 0(tap 跳句与滑动打断均同步收口,含 critical busy)');
// 指标 4:被动原句重复 = 0(30s 内同一 id 重播,排除「再说一遍:」前缀与
// 两次播出之间发生过手势的复述——玩家动过之后游戏再答,是应答不是复读)
let passiveRepeats = 0;
const lastPlay = new Map();
for (const p of playLog) {
if (!p.id.startsWith('voice/')) continue;
const sayAgainAt = playLog.some((q) => q.id === 'voice/sys_say_again' && q.t <= p.t && p.t - q.t < 2500);
const gestureBetween = gestures.some((ge) => lastPlay.has(p.id)
&& ge.t > lastPlay.get(p.id) && ge.t <= p.t);
if (lastPlay.has(p.id) && p.t - lastPlay.get(p.id) < 30000 && !sayAgainAt && !gestureBetween) {
passiveRepeats++;
}
lastPlay.set(p.id, p.t);
}
assert(passiveRepeats === 0, `被动原句重复 = 0(实际 ${passiveRepeats},§6.4)`);
// 机器人可玩性:走到结局(或仍在推进中的合法等待态),无死锁
const ended = game.phase === 'ENDING' && game.awaitingRestart;
assert(ended || (game.floor.state === 'YOUR_TURN' && !game.voicePlaying),
`乱按不锁死(${ended ? '走到结局 ' + game.endingId : '停在合法等待态 ' + game.stateName()})`);
teardown(rig);
}
// ================================================================ 静态审计 + 资产引用校验
realLog('== 静态审计与资产引用 ==');
{
const src = fs.readFileSync(path.join(GAME_DIR, 'game.js'), 'utf8');
for (const id of ['voice/prog_d1', 'voice/prog_d4', 'voice/prog_saved1', 'voice/prog_saved5',
'voice/oxy_full', 'voice/oxy_ok', 'voice/oxy_low', 'voice/oxy_crit', 'voice/oxy_crit_deck', 'voice/oxy_gain',
'voice/dry_a', 'voice/dry_b',
'voice/p7', 'voice/p7_chen', 'voice/p7_zheng', 'voice/g9_dry', 'voice/dv4_dual',
'voice/sys_first_tap', 'voice/sys_turn_teach',
'voice/sys_hint_ping_full', 'voice/sys_hint_ping_short',
'voice/sys_hint_continue_full', 'voice/sys_hint_continue_short',
'voice/sys_hint_choice_short', 'voice/sys_hint_settle_full',
'voice/sys_only_choice', 'voice/sys_demo_continue', 'voice/sys_demo_choice',
'voice/sys_act_ping', 'voice/status_choice', 'voice/menu_help',
'voice/inv_nosel', 'voice/inv_up_locked', 'voice/inv_down_locked',
'voice/sys_menu_restart_confirm']) {
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');
// 废止口径:旧向导看门狗/规则念白/双击重听回波不再被引用
for (const dead of ['voice/gd_brief', 'voice/gd_continue', 'voice/gd_again',
'voice/gd_ping1', 'voice/gd_ping2', 'voice/gd_prog1', 'voice/gd_prog2',
'voice/b_rule', 'voice/sys_help', 'voice/sys_settings', '_replayEcho', '_lastEcho']) {
assert(!src.includes(dead), `废止引用清理干净:${dead}`);
}
// 资产 id 盘点:旧 id 必须有资产;新台词 id 允许待生成(清单 tools/voice_lines_26/abyss.md)
const NEW_PENDING = new Set([
'sys_first_tap', 'sys_first_tap_short', 'sys_turn_teach', 'sys_say_again',
'sys_hint_ping_full', 'sys_hint_ping_short',
'sys_hint_continue_full', 'sys_hint_continue_short',
'sys_hint_choice_short', 'sys_hint_settle_full',
'sys_only_choice', 'sys_demo_continue', 'sys_demo_choice',
'sys_act_ping', 'status_choice', 'menu_help',
'inv_nosel', 'inv_up_locked', 'inv_down_locked', 'sys_menu_restart_confirm',
]);
const ids = [...new Set([...src.matchAll(/'((?:voice|sfx|bgm)\/[A-Za-z0-9_.-]+)'/g)].map((m) => m[1]))];
assert(ids.length >= 80, `game.js 提取到 ${ids.length} 个资产 id(约 70 节点规模)`);
const both = (rel) => ['.ogg', '.m4a'].every((ext) => fs.existsSync(path.join(AUDIO_DIR, rel + ext)));
let missing = [];
let pendingSeen = 0;
for (const id of ids) {
if (id.startsWith('voice/')) {
const name = id.slice(6);
const ok = both(`voice/male/${name}`) || both(`voice/female/${name}`) || both(`voice/${name}`);
if (!ok) {
if (NEW_PENDING.has(name)) pendingSeen++;
else missing.push(id);
}
} else if (!both(id)) {
missing.push(id);
}
}
assert(missing.length === 0, `旧资产零缺失(缺失:${missing.join(', ') || '无'})`);
assert(pendingSeen <= NEW_PENDING.size,
`新台词 id 待生成 ${pendingSeen} 个(全部登记在 tools/voice_lines_26/abyss.md,由生成管线落地)`);
// 存档 summary 人话格式(DIVE 进入时机)
clearSave(); storageData.delete(LADDER_KEY); resetSettings('off');
{
const rig = makeRig();
const { audio, game } = rig;
await audio.unlock();
game.start(); await flush();
await reachSearch(rig); // 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(rig);
}
}
// ================================================================ 汇总
realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`);
if (failures.length > 0) {
realLog('失败清单:');
for (const f of failures) realLog(` - ${f}`);
process.exit(1);
}
realLog('全部断言通过。');