/** * 《深海回音》无头试玩仿真测试(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)'); await advance(() => voicePlays(playLog, 'voice/dv4_dual') === 1); assert(voicePlays(playLog, 'voice/dv4_dual') === 1, 'R2-F4:双载形态下潜链补播 dv4_dual「这趟往返,扣四格气」(3+1 扣账口播化)'); // 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 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), `R2-F8:danger 震循环间隔 ≥1000ms(实际间隔 ${dGaps.join('/')},R1 的 500ms 约 10 连震过密)`); 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_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(R2-F5;§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_deck') === 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'); assert(voicePlays(playLog, 'voice/dv4_dual') === 0, 'R2-F4:单载形态不播 dv4_dual(扣账句只随双载 3+1 出现)'); 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); // V1 复核(R1 遗留:报名与执行一致性)——快速连切 3 下(menuIdx 回绕到 0=回到 // 水下)立即双击:执行的恰是 menuIdx 当前项(报名队列滞后不劫持执行) await g(game, 'down'); await advance(() => !game.voicePlaying); await g(game, 'tap'); await g(game, 'tap'); await g(game, 'tap'); assert(game.menuIdx === 0, 'V1:连切 3 下回绕到首项(回到水下)'); await g(game, 'doubletap'); assert(game.phase === 'COMMS' && game.nodeId === 'p1', 'V1:双击执行 = menuIdx 当前项(回到水下 → 关菜单回原节点,无劫持)'); 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,R2-F10):prog_d2 → prog_saved1 → oxy_low → sys_help // (存档 saved.zhou=1;oxy=5 → low 档整句) await g(game, '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 iH = lastIndexOf(playLog, 'voice/sys_help'); assert(iP >= 0 && iP < iS && iS < iR && iR < iH, '恢复局上滑状态链顺序(G6+R2-F10):prog_d2 → prog_saved1 → 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 链末 13s/30s gd_continue/gd_again(R2-F7:full 档对齐 standard)+ 双轨去重 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 === 13000, `COMMS 链末 13s 静默 → gd_continue(R2-F7,实际 ${now - tW}ms)`); await advance(() => voicePlays(playLog, 'voice/gd_again') === 1); assert(now - tW === 30000, `30s → 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 === 13000, `F1:链末等待中发无效手势,看门狗仍在阈值内触发(13s 整,实际 ${now - tW2}ms)`); teardown(game); } // ---- D:p* 播完 25s/50s 重播提示句两级后静默;ENDING 13s/30s(R2-F7);签名总账 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 === 13000, `ENDING 结算 13s 静默 → gd_continue(R2-F7,实际 ${now - tE}ms)`); await advance(() => voicePlays(playLog, 'voice/gd_again') === ga0 + 1); assert(now - tE === 30000, `30s → 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); } } // ================================================================ R2 修复专项 // (round1/synthesis.md F1/F2/F3/F5/F6/F9/F10 逐条现场对应断言) realLog('== R2 修复专项(ping 即时 / 尾句保护 / 跳句复播 / crit 场景 / 状态链)=='); { // ---- F1:向导散句(gd_ping1)在播时单击发脉冲 → 先掐散句、ping 立即出音 clearSave(); resetSettings('full'); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await reachSearch(game); await advance(() => voicePlays(playLog, 'voice/gd_ping1') === 1); // 10s 看门狗第一句 await flush(10); // 等引擎 _load 落地、_voice 置位(fetch/decode 微任务链) assert(audio.isVoicePlaying(), '前置:gd_ping1 向导散句在播'); const ping0 = voicePlays(playLog, 'sfx/sonar_ping'); await g(game, 'tap'); // 照做单击:掐散句 + 立即出 ping(R1 根因:入队迟到 12~17s) assert(voicePlays(playLog, 'sfx/sonar_ping') === ping0 + 1, 'R2-F1:gd 行在播时单击 → sonar_ping 立即起播(不经 sonarGate 排队)'); assert(!audio.isVoicePlaying(), 'R2-F1:gd 散句被掐(玩家已照做,提示说完即过时)'); assert(game._sonarQueue.length === 0, 'R2-F1:脉冲不入 sonarGate 队(零迟到)'); await advance(() => game.phase === 'COMMS'); assert(voicePlays(playLog, 'sfx/echo_metal_l') === 1 && voicePlays(playLog, 'voice/dv1_metal') === 1, 'F1 后回波链正常走完(echo → 解读,顺序复原)'); teardown(game); } // ---- F3:回波→静默一拍→解读(wait ≥ 资产实长 2.76s + 一拍) 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/dv1_metal'); const tEcho = playLog.find((e) => e.id === 'sfx/echo_metal_l').t; const recMetal = game._voiceIntervals.find((r) => r.id === 'voice/dv1_metal'); assert(recMetal && recMetal.start - tEcho >= 3300, `R2-F3:echo_metal_l 起播 → dv1_metal 起播间隔 ${recMetal.start - tEcho}ms ≥ 3300(资产 2.76s + 静默一拍)`); teardown(game); } // ---- F2a:dyn 注入的档位句纳入 F5 尾句保护(播放中单击不跳 + beat) clearSave(); resetSettings('off'); { const { audio, playLog, game } = makeRig(); const vib0 = vibrateLog.length; await audio.unlock(); game.start(); await flush(); const ciA = await playRun(game, SCRIPT_A, { stop: () => game.currentVoiceId === 'voice/oxy_full', }); assert(game.nodeId === 's_tier' && game.currentVoiceId === 'voice/oxy_full', '前置:档位句 oxy_full 在播(dyn 注入 s_tier 链尾)'); await g(game, 'tap'); assert(game.voicePlaying && game.currentVoiceId === 'voice/oxy_full', 'R2-F2:档位句播放中单击不跳句(R1 根因:尾句保护按 _nodeSteps 重算漏掉 dyn 句)'); assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === BEAT_PATTERN), 'G4:档位句上空点单击播 beat 轻震'); await playRun(game, SCRIPT_A, { from: ciA }); // 链路未受污染,照常到 e1 assert(game.endingId === 'e1', 'F2 验证后剧本 A 照常走完(e1)'); teardown(game); } // ---- F2b:签收链入口 800ms 死窗(SURFACE(4)→g9_in 过场的在途 tap 被吞) clearSave(); resetSettings('off'); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await playRun(game, SCRIPT_B, { stop: () => game.nodeId === 'g9_in' && game.voicePlaying, }); assert(game.currentVoiceId === 'voice/g9_in', '前置:签收链首句 g9_in 在播'); await g(game, 'tap'); // 800ms 死窗内的在途 tap(R1 live:g9_in 被剁到 0.28s) assert(game.voicePlaying && game.currentVoiceId === 'voice/g9_in', 'R2-F2:签收链入口 800ms 死窗内的在途 tap 被吞(g9_in 不被掐)'); await playRun(game, SCRIPT_B, { from: 8 }); assert(game.endingId === 'e2' && voicePlays(playLog, 'voice/g9_in') === 1 && voicePlays(playLog, 'voice/g9_knot') === 1, '死窗后签收链完整有序(g9_in → g9_knot → p9)'); teardown(game); } // ---- F6:叙事尾句首播保护 / 复播可即掐(G5 后半句兑现) clearSave(); resetSettings('off'); { const { audio, playLog, game } = makeRig(); const vib0 = vibrateLog.length; await audio.unlock(); game.start(); await flush(); await reachSearch(game); await g(game, 'tap'); await advance(() => game.currentVoiceId === 'voice/z1_a'); await g(game, 'tap'); // 首播中的叙事尾句:不跳 assert(game.voicePlaying && game.currentVoiceId === 'voice/z1_a', 'R2-F6:首播中叙事尾句单击不跳(首播必完整,radio 吃尾根因不回潮)'); assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === BEAT_PATTERN), 'G4:首播保护句上的空点单击播 beat 轻震'); await advance(() => !game.voicePlaying); // z1_a 自然播完(链末等待,登记 _tailHeard) await g(game, 'down'); // 开菜单 await advance(() => game.phase === 'MENU' && !game.voicePlaying); await g(game, 'down'); // 关菜单 → 重播 z1_a(局面复述) await advance(() => game.currentVoiceId === 'voice/z1_a' && game.voicePlaying); await g(game, 'tap'); // 复播:单击即掐 assert(!game.voicePlaying && game.nodeId === 'z1_a', 'R2-F6:链自然播完后复述重播,尾句单击即跳(复播可即掐,sys_skip 承诺兑现)'); assert(voicePlays(playLog, 'voice/z1_a') === 2, 'z1_a 恰重播一次(菜单复述)'); await g(game, 'tap'); // 链末继续,剧情正常推进 await advance(() => game.currentVoiceId === 'voice/z1_b'); assert(game.nodeId === 'z1_b', '跳句后链末判定无错位(z1_a → z1_b 正常续链)'); teardown(game); } // ---- F5/F9/F10:水下 crit 状态链(原句保留)+ warning 震按档去重 + 已救人数 clearSave(); resetSettings('off'); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await playRun(game, SCRIPT_D, { stop: () => game.searchArmed && game.diveN === 4, }); assert(game.oxy === 0 && game.diveForm === 'single', '前置:剧本 D 单载 D4 搜索等待,oxy=0(crit)'); const warn0 = vibrateLog.filter((v) => JSON.stringify(v.pattern) === WARN_PATTERN).length; await g(game, 'up'); await advance(() => !game.voicePlaying && game.searchArmed); const iP = lastIndexOf(playLog, 'voice/prog_d4'); const iS = lastIndexOf(playLog, 'voice/prog_saved3'); // 剧本 D 此时 saved=3(舟/白/郑) const iR = lastIndexOf(playLog, 'voice/oxy_crit'); assert(iP >= 0 && iP < iS && iS < iR, 'R2-F10+F5:水下状态链 prog_d4 → prog_saved3 → oxy_crit(人数=N=3;水下保留原句)'); assert(voicePlays(playLog, 'voice/oxy_crit_deck') === 0, 'R2-F5:水下 crit 不用水面变体(oxy_crit_deck 零播出)'); const warn1 = vibrateLog.filter((v) => JSON.stringify(v.pattern) === WARN_PATTERN).length; assert(warn1 === warn0 + 1, 'G2:进入 crit 档首次状态链附 warning 震'); await g(game, 'up'); // 同档再次查状态 await advance(() => !game.voicePlaying && game.searchArmed); const warn2 = vibrateLog.filter((v) => JSON.stringify(v.pattern) === WARN_PATTERN).length; assert(warn2 === warn1, 'R2-F9:crit 档不变时复听状态链不重复 warning 震(按档进入去重)'); assert(voicePlays(playLog, 'voice/prog_saved3') === 2, 'F10:人数句照播(去重只针对震与口令)'); teardown(game); } } // ================================================================ 键盘变体路由 // (settings.input='key' → 引擎候选链前置 voice//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/prog_saved1', 'voice/prog_saved2', 'voice/prog_saved3', 'voice/prog_saved4', '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/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', 'voice/dv4_dual']) { 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('全部断言通过。');