/** * 《深海回音》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('全部断言通过。');