/** * 《守塔人》无头试玩仿真测试(docs/design/21-keeper.md §9 playtest 断言清单)。 * * 方案: * - mock 浏览器环境:window/localStorage/navigator/fetch/AudioContext * (decodeAudioData 返回固定 2.0s 时长的假 buffer,语音链靠 onComplete 推进, * 虚拟时钟驱动,不真实等待); * - 使用**真实** engine/audio.js + settings.js + guide.js; * - 手势直接注入 game.onGesture(与 attachGestures 回调同路径); * - 底噪/引擎为游戏层 buffer.loop 持句柄(无重触发定时器),全程可复现; * - 底噪起停流水经 game._ambLog 断言(vinyl 与 storm/room/engine 零重叠,§6.2)。 * * 运行:tools/node/bin/node web/keeper/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.keeper'; // ---------------------------------------------------------------- 断言收集 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}`); } } 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(); 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, }); async function flush(rounds = 8) { for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r)); } async function advance(cond = null) { for (let n = 0; n < 200000; 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', { 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, 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; 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: 'keeper' }) }; } 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 { KeeperGame } = 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]); const WARN_PATTERN = JSON.stringify([30, 50, 30, 50, 30]); const DANGER_PATTERN = JSON.stringify([25, 40, 25, 40, 25]); const BEAT_PATTERN = JSON.stringify([8]); 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 KeeperGame(audio, {}); const volLog = []; // _setLoopVol 请求流水(storm 分幕 gain 断言) const origSetVol = game._setLoopVol.bind(game); game._setLoopVol = (k, v) => { volLog.push({ k, v, t: now }); return origSetVol(k, v); }; return { audio, playLog, game, volLog }; } let gestureCount = 0; async function g(game, gesture) { gestureCount++; game.onGesture(gesture); await flush(); } function teardown(game) { game.guide.disarm(); game.stop(); } /** 链末单击推进到 cond;抉择点须由调用方先选(tapUntil 遇抉择抛错)。 */ async function tapUntil(game, cond, maxSteps = 120) { for (let i = 0; i < maxSteps; i++) { if (cond()) return; if (game.voicePlaying) { await advance(() => cond() || !game.voicePlaying); continue; } if (game.choiceArmed) { throw new Error(`tapUntil 遇到未应答的抉择(node=${game.nodeId})——调用方需先做选择`); } await g(game, 'tap'); if (!cond() && !game.voicePlaying) { await advance(() => cond() || game.voicePlaying); } } throw new Error(`tapUntil 超步限(node=${game.nodeId} phase=${game.phase})`); } // §9 剧本(按遭遇顺序喂抉择):A=全左(e1) B=p9右(e2) C=全右(e3) D=全右+p9右(e4) // E=半卷灯灭差分 F=插叙2 兜底(p3 右 p4 左) const SCRIPT_A = ['left', 'left', 'left', 'left', 'left', 'left', 'left', 'left', 'left']; const SCRIPT_B = ['left', 'left', 'left', 'left', 'left', 'right', 'right', 'right', 'right']; const SCRIPT_C = ['right', 'right', 'right', 'right', 'right', 'right', 'left', 'left']; const SCRIPT_D = ['right', 'right', 'right', 'right', 'right', 'right', 'left', 'right']; const SCRIPT_E = ['left', 'left', 'right', 'right', 'left', 'left', 'left', 'right']; const SCRIPT_F = ['left', 'left', 'right', 'left', 'left', 'right', 'left', 'left', 'left']; /** 从头打一局到 ENDING 结算态(WATCH 链末单击继续;抉择按脚本;CALM/MEMORY 自动)。 */ async function playNight(game, choices) { let i = 0; let guard = 0; while (!(game.phase === 'ENDING' && game.awaitingRestart)) { if (++guard > 500) throw new Error(`playNight 卡住(node=${game.nodeId} phase=${game.phase})`); if (game.phase === 'MENU') { await g(game, 'down'); continue; } if (game.choiceArmed) { const c = choices[i++]; if (!c) throw new Error(`playNight 抉择脚本耗尽(node=${game.nodeId})`); await g(game, c); continue; } if (game.phase === 'CALM') { await advance(() => game.phase !== 'CALM'); continue; } if (!game.voicePlaying) { // MEMORY 静默拍(进入插叙 600ms + 教学句起播前):推进虚拟时钟等链起播,不 tap if (game.phase === 'MEMORY') { await advance(() => game.voicePlaying || game.phase !== 'MEMORY'); continue; } await g(game, 'tap'); continue; } await advance(() => !game.voicePlaying); } } // ================================================================ 主路径 · 剧本 A // (§9 关键路径:信任满·全真相·出海接人·说出 → e1,≤60 次手势) realLog('== 主路径 · 剧本 A(E1,guide=off)=='); { clearSave(); resetSettings('off'); const g0 = gestureCount; const { audio, playLog, game, volLog } = makeRig(); 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/storm'), '开局播放 bgm/storm(风暴夜常驻)'); assert(voicePlays(playLog, 'voice/sys_new_night') === 1, '新风暴夜 sys_new_night 先行'); assert(game.phase === 'INTRO' && game.act === 1, '进入宏状态 INTRO(幕一)'); // INTRO 链(自动流)→ 链末等待单击 → C1 await advance(() => game.currentVoiceId === 'voice/b_open'); assert(game.nodeId === 'b_open' && game.voicePlaying, 'sys_new_night 播完接 b_open'); assert(voicePlays(playLog, 'voice/gd_brief') === 0, 'guide=off:开场不插 gd_brief'); await tapUntil(game, () => game.nodeId === 'c1_tune'); for (const id of ['voice/b_open', 'voice/b_rain', 'voice/b_rule', 'voice/b_goal']) { assert(voicePlays(playLog, id) === 1, `INTRO 链播出 ${id}`); } assert(voicePlays(playLog, 'voice/sys_skip') === 1, '开场 sys_skip 提示播一次(每实例一次)'); assert(voicePlays(playLog, 'sfx/foghorn') === 1, '雾角兑现点①:b_rain 口播后 foghorn(开场)'); assert(playLog.some((e) => e.id === 'sfx/amb_storm') === false, 'amb 走游戏层 loop 句柄(不占 play 日志)'); await flush(); assert(game._loops.amb_storm && game._loops.amb_room, '现在签名:amb_storm + amb_room 循环起播(§6.2)'); // C1 链 → p1 提示句 await tapUntil(game, () => game.promptPlaying, 24); assert(game.nodeId === 'p1' && game.currentVoiceId === 'voice/p1', 'C1 链末推进至 p1 提示句播放中'); assert(voicePlays(playLog, 'voice/c1_tune') === 1 && voicePlays(playLog, 'voice/amo_a') === 1 && voicePlays(playLog, 'voice/amo_b') === 1, 'C1 呼救链 c1_tune→amo_a→amo_b 各播一次'); assert(voicePlays(playLog, 'sfx/radio_tune') === 1 && voicePlays(playLog, 'sfx/radio_beep') === 1, 'SA3→SYN 接线:radio_tune(调台)/ radio_beep(接通)就位'); assert(game._loops.amb_engine, '通话期 amb_engine 常驻(§6.6 R3)'); assert(game.choiceArmed === false, '提示播放中 choiceArmed=false(W2:播完即真)'); // p* 不可跳 + pendingChoice 缓存(盲玩公平) await g(game, 'tap'); assert(game.currentVoiceId === 'voice/p1' && game.promptPlaying, 'p* 播放中单击不跳句(PROMPT_RE 门)'); await g(game, 'left'); assert(game.pendingChoice === 'left', 'p* 播放中左滑缓存 pendingChoice'); await advance(() => game.nodeId === 'c1_hold'); assert(game.pendingChoice === null && game.trust === 1, '播完 pendingChoice 生效并清空:p1 左 trust=1(§4.2)'); // p2 左 → c1_open → rev_teach → 插叙1(时空签名时序断言) await tapUntil(game, () => game.choiceArmed, 24); // c1_hold(o)(e) → amo_c → p2 assert(voicePlays(playLog, 'voice/c1_hold') === 1 && voicePlays(playLog, 'voice/c1_holdo') === 1 && voicePlays(playLog, 'voice/c1_holde') === 1, 'p1 左路:接住人→声音稳住→收句(首个正反馈链)'); assert(voicePlays(playLog, 'voice/amo_c') === 1, 'amo_c 回澜号伏笔播出'); await g(game, 'left'); assert(game.rev1 === true && game.truth === 1, 'p2 左 → rev1=true(truth=1)'); await advance(() => game.phase === 'MEMORY'); // §6.3 步 1:结果句播完 → 静默一拍 → 自动进入插叙 assert(voicePlays(playLog, 'sfx/hatch_open') === 1, 'c1_open 配 hatch_open(暗格开启)'); await advance(() => game.currentVoiceId === 'voice/rev_teach'); assert(voicePlays(playLog, 'voice/rev_teach') === 1, '插叙1 前播 rev_teach(时空签名教学,全局一次)'); await advance(() => voicePlays(playLog, 'sfx/memory_in') === 1); assert(audio._bgm && audio._bgm.id === 'bgm/memory', '插叙进入:BGM crossfade → bgm/memory(§6.3 步 2)'); await flush(); assert(game._loops.amb_vinyl && !game._loops.amb_storm && !game._loops.amb_room && !game._loops.amb_engine, '底噪互斥:插叙期间 vinyl 唯一底噪(storm/room/engine 全停,§6.2/§6.6 R2)'); // 锚入句首播不可跳(G5)+ 中段句可跳 await advance(() => game.currentVoiceId === 'voice/rev_g1'); const vib0 = vibrateLog.length; await g(game, 'tap'); assert(game.currentVoiceId === 'voice/rev_g1', '锚入句 rev_g1 首播单击不可跳(G5)'); assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === BEAT_PATTERN), 'G4:锚句空点单击补 beat 轻震'); await advance(() => game.currentVoiceId === 'voice/rev_g3'); await g(game, 'tap'); // 中段句可跳 await flush(); assert(game.currentVoiceId === 'voice/rev_g4', '插叙中段句(rev_g3)可跳(§2.3 MEMORY 行)'); // (回返断言在下方 CALM(1) 块:transition 级联后按阶段断言) // CALM(1):c1_diary 播完自动流入(@calm1 转移);foghorn + lull_1 + tr_half(跨档 thin→half)→ C2 await advance(() => game.phase === 'CALM'); assert(game.act === 1, 'CALM(1) 时仍为幕一(进 C2 才推进 act,amb gain 0.5 档)'); assert(voicePlays(playLog, 'sfx/foghorn') === 2, '雾角兑现点②:CALM(1)'); assert(voicePlays(playLog, 'sfx/memory_out') === 1, '插叙回返:memory_out(唱针抬起)成对出现'); assert(audio._bgm && audio._bgm.id === 'bgm/storm', '回返后 BGM 淡回 bgm/storm'); await flush(); assert(game._loops.amb_storm && game._loops.amb_room && game._loops.amb_vinyl === null, '回返后底噪回现在签名(storm+room 回、vinyl 停)'); await advance(() => game.phase !== 'CALM'); assert(voicePlays(playLog, 'voice/lull_1') === 1, 'CALM(1) 播 lull_1(风加到十级)'); assert(voicePlays(playLog, 'voice/tr_half') === 1, '信任跨档(thin→half=1)→ CALM(1) 被动播报 tr_half'); const sd1 = saveData(); assert(sd1 && sd1.phase === 'CALM' && sd1.act === 1, 'CALM(1) 进入即存档(写入时机):phase=CALM / act=1 幕一'); assert(sd1.summary === '第1幕·风暴起 · 信任半 · 结局0/4', `summary 人话格式(实际: ${sd1 && sd1.summary})`); assert(['schema', 'updatedAt', 'summary', 'phase', 'node', 'act', 'timeline', 'trust', 'flags', 'p7', 'endings', 'runs'].every((k) => k in sd1), '存档 data 覆盖 §8.1 全部字段(含 summary)'); // C2:p3 左 → 插叙2 → c2_key;p4 左 → c4_true 直进(插叙2 已完成,兜底不播) await tapUntil(game, () => game.choiceArmed, 20); // c2_back → amo_d → p3 assert(voicePlays(playLog, 'voice/c2_back') === 1, 'C2 起始 c2_back(信号重连 radio_tune)'); await g(game, 'left'); // p3 左 assert(game.trust === 2 && game.rev2 === true, 'p3 左 → trust=2 且 rev2=true(插叙2 主触发)'); await tapUntil(game, () => game.phase === 'MEMORY', 20); await advance(() => game.nodeId === 'c2_key' && !game.voicePlaying); for (const id of ['voice/rev_f1', 'voice/rev_f2', 'voice/rev_f3', 'voice/rev_f4', 'voice/rev_f5']) { assert(voicePlays(playLog, id) === 1, `插叙2 播出 ${id}`); } assert(game.truth === 2, '插叙2 完成 → truth=2'); await g(game, 'tap'); // c2_key 链末 → amo_e await tapUntil(game, () => game.choiceArmed, 16); // amo_e → p4 await g(game, 'left'); // p4 左 assert(game.trust === 3, 'p4 左 → trust=3'); await advance(() => game.nodeId === 'c4_trueo' && !game.voicePlaying); assert(voicePlays(playLog, 'sfx/memory_in') === 2 && voicePlays(playLog, 'voice/rev_f1') === 1, '插叙2 已完成:c4_true 直进 c4_trueo,兜底插叙绝不重播(memory_in 仍=2)'); // 危机拍1:c4_trueo 播完自动进入(@crisis1 转移);hull_crack 前置 2s + danger 签名/震同窗 await advance(() => voicePlays(playLog, 'sfx/hull_crack') === 1); assert(voicePlays(playLog, 'voice/c4_true') === 1 && voicePlays(playLog, 'voice/c4_trueo') === 1 && voicePlays(playLog, 'sfx/memory_in') === 2 && voicePlays(playLog, 'voice/rev_f1') === 1, '插叙2 已完成:c4_true 直进 c4_trueo,兜底插叙绝不重播(memory_in 仍=2)'); assert(vibrateLog.some((v) => JSON.stringify(v.pattern) === DANGER_PATTERN), '危机拍1 danger 震型'); await advance(() => game.nodeId === 'amo_f' && !game.voicePlaying); assert(voicePlays(playLog, 'voice/c2_crack') === 1, '危机拍1:c2_crack(听!船板裂了)前置 2s 后播出'); // p5 左 → CALM(2)(tr_deep 跨档) await g(game, 'tap'); // amo_f 等待 → p5 提示 await advance(() => game.choiceArmed); await g(game, 'left'); // p5 左 assert(game.trust === 4, 'p5 左 → trust=4(理论上限)'); await tapUntil(game, () => game.phase === 'CALM', 16); // c5_patch(o) 等待单击 → @calm2 assert(game.act === 2, 'CALM(2) 时仍为幕二(进 C3 才推进 act)'); await advance(() => game.phase !== 'CALM'); assert(game.act === 3, 'CALM(2) 后进入幕三(amb gain 0.7 档)'); assert(voicePlays(playLog, 'voice/lull_2') === 1, 'CALM(2) 播 lull_2(第二程到顶)'); assert(voicePlays(playLog, 'voice/tr_deep') === 1, '信任跨档(half→deep=4)→ CALM(2) 播报 tr_deep'); assert(voicePlays(playLog, 'sfx/foghorn') === 3, '雾角兑现点③:CALM(2)'); // C3:c3_page → p6 左 → 插叙3 → c6_reade(truth=3) await tapUntil(game, () => game.choiceArmed, 20); // c3_eye → c3_page → p6 assert(voicePlays(playLog, 'voice/c3_page') === 1 && voicePlays(playLog, 'sfx/flash_click') === 1, 'c3_page 条件播出(rev2=true):配 flash_click(手电尾盖)'); await g(game, 'left'); // p6 左 assert(game.rev3 === true && game.truth === 3, 'p6 左 → rev3=true(truth=3 全真相)'); await advance(() => game.nodeId === 'c6_reade' && !game.voicePlaying); for (const id of ['voice/rev_c1', 'voice/rev_c2', 'voice/rev_c3', 'voice/rev_c4', 'voice/rev_c5']) { assert(voicePlays(playLog, id) === 1, `插叙3 播出 ${id}`); } assert(voicePlays(playLog, 'sfx/memory_in') === 3 && voicePlays(playLog, 'sfx/memory_out') === 3, 'memory_in/out 成对 ×3(插叙 1/2/3 各一)'); // p7 左(满发条)→ 危机拍2 → p8_boat 左 → c9_return →(rev2 → 无 c3_dark)→ 签收 await g(game, 'tap'); // c6_reade 链末 → p7 提示(等待后单击) await advance(() => game.choiceArmed); await g(game, 'left'); // p7 左 = 离塔救人 assert(game.p7 === 'boat' && game.trust === 4, 'p7 左 → p7=boat(核心抉择不动 trust)'); // c7_go(等待单击)→ c3_wall(危机拍2:wave_wall 前置 2s)→ p8_boat 提示 await tapUntil(game, () => game.nodeId === 'p8_boat' && game.choiceArmed, 24); assert(voicePlays(playLog, 'voice/c7_go') === 1 && voicePlays(playLog, 'sfx/lamp_mech') === 1, 'rev_f4 发条知识兑现:rev2=true 播 c7_go + lamp_mech(满发条版)'); if (!(voicePlays(playLog, 'sfx/wave_wall') === 1)) { realLog(' [debug] playLog tail: ' + playLog.slice(-14).map((e) => e.id).join(' | ')); realLog(` [debug] node=${game.nodeId} armed=${game.choiceArmed} p7=${game.p7} dangers=${vibrateLog.filter((v) => JSON.stringify(v.pattern) === DANGER_PATTERN).length}`); } assert(voicePlays(playLog, 'sfx/wave_wall') === 1 && vibrateLog.filter((v) => JSON.stringify(v.pattern) === DANGER_PATTERN).length >= 2, '危机拍2:wave_wall + danger 震(全游戏仅两处 danger)'); await g(game, 'left'); // p8_boat 左 = 循渔歌 // c8_song → c8_songo → c9_return(各等待单击)→ @dark(rev2 直进签收)→ g9_in(auto) → g9_knot → p9 提示 await tapUntil(game, () => game.choiceArmed, 24); assert(voicePlays(playLog, 'voice/c8_songo') === 1 && voicePlays(playLog, 'voice/c9_return') === 1, 'p8_boat 左 → c8_song/o(循歌找到)→ c9_return(接驳艇回来了)'); assert(voicePlays(playLog, 'voice/c3_dark') === 0, 'rev2=true:c9_return 后无 c3_dark(灯未灭)'); assert(voicePlays(playLog, 'voice/p9a') === 1 && voicePlays(playLog, 'voice/p9b') === 0, 'truth=3 → p9a 全真相版提示句(p9b 不播)'); await g(game, 'left'); // p9 左 = 说出 assert(game.p9told === true, 'p9 左 → p9told=true(心结解开)'); // ENDING(e1) + 尾声回响逐项点名(d9_tell_a → amo_g → d9_telle 链末单击推进) await tapUntil(game, () => game.phase === 'ENDING' && game.awaitingRestart, 40); assert(game.endingId === 'e1', 'trust≥3 且 p9 左 → ENDING(e1 灯与归人)'); for (const id of ['voice/e1_a', 'voice/e1_b', 'voice/ep_lead', 'voice/ep_amo_home', 'voice/ep_lamp_full', 'voice/ep_log_told', 'voice/ep_dad', 'voice/ep_shore']) { assert(voicePlays(playLog, id) === 1, `结局链播出 ${id}`); } assert(voicePlays(playLog, 'voice/ep_amo_solo') === 0 && voicePlays(playLog, 'voice/ep_amo_dawn') === 0 && voicePlays(playLog, 'voice/ep_lamp_dark') === 0 && voicePlays(playLog, 'voice/ep_log_kept') === 0, '尾声差分互斥:阿澈/灯/日志各只播本局对应一句'); assert(voicePlays(playLog, 'sfx/foghorn') === 4, '雾角兑现点④=ep_shore,全剧恰四处(§6.5 断言)'); assert(voicePlays(playLog, 'voice/rev_teach') === 1, '教学承诺:rev_teach 全局恰一次'); const sd2 = saveData(); assert(sd2 && sd2.phase === 'ENDING' && sd2.ending === 'e1' && sd2.endings.e1 === true, 'ENDING 进入即存档,endings 收集 e1'); assert(sd2.summary === '风暴夜已过·灯与归人 · 结局1/4', `已收夜 summary(实际: ${sd2 && sd2.summary})`); // 结算态双击重听结局链 → 单击重开 const e1aBefore = voicePlays(playLog, 'voice/e1_a'); await g(game, 'doubletap'); assert(game.awaitingRestart === false, '结算双击 → 重听结局链(清结算态)'); 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.trust === 0 && game.truth === 0, '结算单击 = 新风暴夜:INTRO,trust/truth 归零,runs=2'); assert(game.endings.e1 === true, 'endings 结局收集跨夜保留'); assert(voicePlays(playLog, 'voice/sys_skip') === 1, 'sys_skip 每实例只播一次,重开不重播'); assert(gestureCount - g0 <= 60, `关键路径手势预算 ≤60(实际 ${gestureCount - g0})`); teardown(game); } // ================================================================ 四结局矩阵 + 差分剧本 // (§9 边界:剧本 B → e2;C → e3;D → e4;E → 灯灭差分;F → 插叙2 兜底) realLog('== 四结局矩阵(剧本 B / C / D)+ 灯灭差分(E)+ 插叙2 兜底(F)=='); { // ---- 剧本 B:trust=4 / truth=2,p7 右 p8_reef 右 p9 右 → e2 clearSave(); resetSettings('off'); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await playNight(game, SCRIPT_B); assert(game.endingId === 'e2' && game.trust === 4 && game.truth === 2 && !game.p9told, '剧本 B → e2 守夜人(trust=4/truth=2/p9 右)'); assert(voicePlays(playLog, 'voice/c9_land') === 1, 'p7 右 + trust≥3 → c9_land(自渡成功)'); assert(voicePlays(playLog, 'voice/p9b') === 1, 'truth=2<3 → p9b 碎片版'); assert(voicePlays(playLog, 'voice/d9_keep') === 1 && voicePlays(playLog, 'voice/amo_h') === 1, 'p9 右 → d9_keep/amo_h(守住秘密)'); for (const id of ['voice/e2_a', 'voice/e2_b', 'voice/ep_amo_solo', 'voice/ep_lamp_hold', 'voice/ep_log_kept', 'voice/ep_dad']) { assert(voicePlays(playLog, id) === 1, `剧本 B 尾声播出 ${id}`); } teardown(game); } // ---- 剧本 C:全右 trust=0 / truth=0 → m6_seal 封锁 → e3 clearSave(); resetSettings('off'); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await playNight(game, SCRIPT_C); assert(game.endingId === 'e3' && game.trust === 0 && game.truth === 0 && game.p9told, '剧本 C → e3 半明半暗(trust=0/truth=0/p9 左)'); assert(voicePlays(playLog, 'voice/m6_seal') === 1, 'rev2=false → m6_seal 封锁句播出(p6 不摆假选项)'); assert(voicePlays(playLog, 'voice/p6') === 0 && voicePlays(playLog, 'voice/c6_reade') === 0 && voicePlays(playLog, 'voice/c6_shut') === 1, 'p6 封锁:提示句与左路不播,自动走右 c6_shut'); assert(voicePlays(playLog, 'voice/c9_found') === 1, 'p7 右 + trust=0 → c9_found(天亮被找到,零死亡)'); assert(voicePlays(playLog, 'voice/p9b') === 1 && voicePlays(playLog, 'voice/d9_tell_b') === 1, 'truth=0 → p9b + d9_tell_b(碎片版,句句为真)'); assert(voicePlays(playLog, 'voice/ep_amo_dawn') === 1 && voicePlays(playLog, 'voice/ep_lamp_hold') === 1, '剧本 C 尾声:ep_amo_dawn + ep_lamp_hold'); assert(voicePlays(playLog, 'voice/ep_log_told') === 0 && voicePlays(playLog, 'voice/ep_log_kept') === 0, 'truth=0 日志未出场:ep_log_* 两句皆不播'); assert(voicePlays(playLog, 'voice/ep_dad') === 0, 'rev2=false:ep_dad(父亲签字)不播'); assert(voicePlays(playLog, 'voice/tr_thin') === 0, '跨档才播纪律:trust=0=开局基准档(thin)→ CALM 无跨档,tr_thin 不重复播报'); teardown(game); } // ---- 剧本 D:同 C 但 p9 右 → e4 clearSave(); resetSettings('off'); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await playNight(game, SCRIPT_D); assert(game.endingId === 'e4' && game.trust === 0 && !game.p9told, '剧本 D → e4 各自的塔(trust=0/p9 右)'); assert(voicePlays(playLog, 'voice/e4_a') === 1 && voicePlays(playLog, 'voice/e4_b') === 1, 'e4 专属结局主句 + 钩子播出'); teardown(game); } // ---- 剧本 E:p3 右 p4 右(无插叙2)→ c7_go2 半卷 + c3_dark 灯灭 + 800ms 死窗 clearSave(); resetSettings('off'); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); // 打到 p7 左之前的 c6_shut let i = 0; let guard = 0; while (game.nodeId !== 'c6_shut') { if (++guard > 300) throw new Error('剧本 E 前置卡住'); if (game.choiceArmed) { await g(game, SCRIPT_E[i++]); continue; } if (game.phase === 'CALM') { await advance(() => game.phase !== 'CALM'); continue; } if (!game.voicePlaying) { if (game.phase === 'MEMORY') { await advance(() => game.voicePlaying || game.phase !== 'MEMORY'); continue; } await g(game, 'tap'); continue; } await advance(() => !game.voicePlaying); } assert(voicePlays(playLog, 'voice/m6_seal') === 1 && game.rev2 === false, '剧本 E:p3 右 p4 右 → 无插叙2(rev2=false → m6_seal)'); await g(game, 'tap'); // c6_shut → p7 await advance(() => game.choiceArmed); // p7 提示 await g(game, 'left'); // p7 左 await tapUntil(game, () => game.nodeId === 'p8_boat' && game.choiceArmed, 24); // c7_go2(等待单击)→ c3_wall(2s) → p8 提示 assert(voicePlays(playLog, 'voice/c7_go') === 0 && voicePlays(playLog, 'voice/c7_go2') === 1, 'rev2=false → 播 c7_go2(半卷发条版),绝不播 c7_go'); assert(voicePlays(playLog, 'sfx/lamp_mech') === 0, '半卷版无发条知识:lamp_mech 不播'); await g(game, 'left'); // p8_boat 左 await tapUntil(game, () => game.nodeId === 'c3_dark' && game.voicePlaying, 24); // c8_song→songo→c9_return→@dark→c3_dark assert(voicePlays(playLog, 'voice/c9_return') === 1, '剧本 E:p7 左 → c9_return(接驳艇回来)'); // 800ms 死窗:在途 tap 被吞、链完整播出 await g(game, 'tap'); assert(game.currentVoiceId === 'voice/c3_dark' && game.voicePlaying, '结算死窗:c3_dark 开播 800ms 内在途 tap 被吞(不跳句)'); await tapUntil(game, () => game.choiceArmed, 16); // c3_dark(auto) → @signoff → g9_in(auto) → g9_knot(等待单击)→ p9 提示 assert(voicePlays(playLog, 'voice/c3_dark') === 1, 'c3_dark 完整播出后自动进签收'); await g(game, 'right'); // p9 右 await tapUntil(game, () => game.phase === 'ENDING' && game.awaitingRestart, 40); assert(game.endingId === 'e4' && game.trust === 2, '剧本 E:trust=2<3 且 p9 右 → e4'); assert(voicePlays(playLog, 'voice/ep_lamp_dark') === 1 && voicePlays(playLog, 'voice/ep_lamp_full') === 0, '灯三态:p7 左 + ¬rev2 → ep_lamp_dark(复刻祖父之夜回响),full 不播'); assert(voicePlays(playLog, 'voice/c3_dark') >= 1 && voicePlays(playLog, 'voice/ep_shore') === 1, 'b_goal 货轮承诺闭环:c3_dark「过境的货轮,平安」+ ep_shore「航道平安」'); teardown(game); } // ---- 剧本 F:p3 右 p4 左 → 插叙2 兜底在 c4_true 与 c4_trueo 之间,c2_key 随后 clearSave(); resetSettings('off'); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await playNight(game, SCRIPT_F); const ids = playLog.map((e) => e.id); const iTrue = ids.indexOf('voice/c4_true'); const iF1 = ids.indexOf('voice/rev_f1'); const iKey = ids.indexOf('voice/c2_key'); const iTrueO = ids.indexOf('voice/c4_trueo'); assert(iTrue >= 0 && iTrue < iF1 && iF1 < iKey && iKey < iTrueO, '插叙2 兜底时序:c4_true → rev_f1..f5 → c2_key → c4_trueo(§9 剧本 F)'); assert(game.rev2 === true && game.trust === 3 && game.truth === 2, '剧本 F:p4 左兜底触发 rev2(trust=3 / truth=2)'); assert(game.endingId === 'e1', '剧本 F:trust=3 且 p9 左 → e1'); teardown(game); } } // ================================================================ 时空签名断言(核心) // (§9:vinyl 与 storm/room 播放区间零重叠;过渡音成对;插叙期间 engine 停; // 锚句在位且首播不可跳;回返后 bgm=storm) realLog('== 时空签名断言(剧本 A 全夜底噪流水,guide=off)=='); { clearSave(); resetSettings('off'); const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await playNight(game, SCRIPT_A); assert(game.endingId === 'e1', '前置:剧本 A 全夜走完 → e1'); // _ambLog → 各 key 的 on/off 区间 const intervalsOf = (id) => { const out = []; let open = null; for (const e of game._ambLog.filter((x) => x.id === id)) { if (e.on && open === null) open = e.t; if (!e.on && open !== null) { out.push([open, e.t]); open = null; } } if (open !== null) out.push([open, Infinity]); // 未关(结尾仍在播) return out; }; const vinyl = intervalsOf('amb_vinyl'); const storm = intervalsOf('amb_storm'); const room = intervalsOf('amb_room'); const engine = intervalsOf('amb_engine'); assert(vinyl.length === 3, `vinyl 起停 ×3(插叙 1/2/3,实际 ${vinyl.length})`); const overlap = (a, bs) => bs.some(([s, e]) => a[0] < e && s < a[1]); assert(vinyl.every((v) => !overlap(v, storm)), '硬约束:vinyl 与 amb_storm 播放区间零重叠'); assert(vinyl.every((v) => !overlap(v, room)), '硬约束:vinyl 与 amb_room 播放区间零重叠'); assert(vinyl.every((v) => !overlap(v, engine)), '硬约束:插叙期间 amb_engine 停播(R2)'); assert(storm.length === 4 && room.length === 4, `storm/room 各 4 段(开局+三次回返,实际 ${storm.length}/${room.length})`); assert(vinyl.every((v, i) => v[1] !== Infinity), '每段插叙都正常回返(vinyl 全部关闭)'); // 过渡音与 BGM 时序 const ins = playLog.filter((e) => e.id === 'sfx/memory_in').map((e) => e.t); const outs = playLog.filter((e) => e.id === 'sfx/memory_out').map((e) => e.t); assert(ins.length === 3 && outs.length === 3, 'memory_in/out 各 ×3 且成对'); assert(ins.every((t, i) => outs[i] > t), '每次 memory_out 都在对应 memory_in 之后'); const bgmMem = playLog.filter((e) => e.id === 'bgm/memory').length; const bgmStorm = playLog.filter((e) => e.id === 'bgm/storm').length; assert(bgmMem === 3 && bgmStorm >= 4, `插叙进/回 BGM 切换 ×3(memory=${bgmMem} storm=${bgmStorm})`); teardown(game); } // ================================================================ 信任/真相账目真实 // (§9:上滑链顺序 prog_m →〔mem: prog_mem〕→ tr → tru → sys_help(G6);档位句逐值对应;tr_thin 附 warning 震) realLog('== 信任/真相账目(上滑状态链顺序 + 档位逐值 + G2 warning 震)=='); { // ---- RIG A:MEMORY 中上滑 → 时位句先行(prog_mem_f 在链首) clearSave(); resetSettings('full'); storageData.set(SAVE_KEY, JSON.stringify({ schema: 1, updatedAt: new Date().toISOString(), summary: '第2幕·风暴顶 · 信任半 · 结局0/4', phase: 'MEMORY', node: 'rev_f2', act: 2, timeline: 'mem_f', trust: 1, flags: { rev1: true, rev2: true, rev3: false }, p7: null, p9told: false, taught: true, endings: {}, runs: 1, lastTier: 'half', memSet: 'f2', memRet: 'c2_key', ending: null, })); { const { audio, playLog, game } = makeRig(); const vib0 = vibrateLog.length; await audio.unlock(); game.start(); await flush(); assert(game.phase === 'ASK', '带存档进游戏 → sys_continue_ask 询问态'); await g(game, 'tap'); // 继续上次 → 恢复链 await advance(() => game.currentVoiceId === 'voice/prog_m2'); assert(game.phase === 'MEMORY' && game.timeline === 'mem_f', '恢复至 MEMORY(timeline=mem_f,存档带时空位)'); // 恢复链时位句先行:prog_m2 → prog_mem_f → 重播 rev_f2 起链(在中断复述块详断言,此处验证播过) await advance(() => game.currentVoiceId === 'voice/rev_f2'); assert(voicePlays(playLog, 'voice/prog_m2') === 1 && voicePlays(playLog, 'voice/prog_mem_f') === 1, 'MEMORY 恢复链时位句先行:prog_m2 → prog_mem_f'); // MEMORY 中上滑:状态链首段 = 时位句 prog_mem_f(§6.4/§7.4 G6) await g(game, 'up'); await advance(() => lastIndexOf(playLog, 'voice/sys_help') >= 0 && game.currentVoiceId === 'voice/sys_help'); const iM = lastIndexOf(playLog, 'voice/prog_mem_f'); const iT = lastIndexOf(playLog, 'voice/tr_half'); const iU = lastIndexOf(playLog, 'voice/tru_two'); const iH = lastIndexOf(playLog, 'voice/sys_help'); assert(iM >= 0 && iM < iT && iT < iU && iU < iH, 'MEMORY 上滑状态链顺序:prog_mem_f(时位句先行)→ tr_half → tru_two → sys_help(G6)'); assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === WARN_PATTERN) === false, 'trust=1(half 档)→ 无 warning 震(G2 只罩 tr_thin)'); assert(game.phase === 'MEMORY' && game._loops.amb_vinyl, '上滑帮助链期间仍在 MEMORY(底噪仍 vinyl)'); teardown(game); } // ---- RIG B:now 线上滑(预置 trust=0)→ tr_thin + warning 震 + tru_zero clearSave(); resetSettings('full'); storageData.set(SAVE_KEY, JSON.stringify({ schema: 1, updatedAt: new Date().toISOString(), summary: '第2幕·风暴顶 · 信任薄 · 结局0/4', phase: 'WATCH', node: 'amo_e', act: 2, timeline: 'now', trust: 0, flags: { rev1: false, rev2: false, rev3: false }, p7: null, p9told: false, taught: false, endings: {}, runs: 2, lastTier: 'thin', memSet: null, memRet: null, ending: null, })); { const { audio, playLog, game } = makeRig(); const vib0 = vibrateLog.length; await audio.unlock(); game.start(); await flush(); await g(game, 'tap'); // 继续上次 → 重播 amo_e await advance(() => !game.voicePlaying); await g(game, 'up'); await advance(() => !game.voicePlaying); const ids = playLog.map((e) => e.id); const iP = lastIndexOf(playLog, 'voice/prog_m2'); const iT = lastIndexOf(playLog, 'voice/tr_thin'); const iU = lastIndexOf(playLog, 'voice/tru_zero'); const iH = lastIndexOf(playLog, 'voice/sys_help'); assert(iP >= 0 && iP < iT && iT < iU && iU < iH, 'WATCH 上滑状态链顺序:prog_m2 → tr_thin → tru_zero → sys_help(G6 状态在前帮助垫后)'); assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === WARN_PATTERN), 'G2:tr_thin(trust=0)整句播报附 warning 震'); await advance(() => !game.voicePlaying); assert(voicePlays(playLog, 'voice/amo_e') === 2, '状态播完重播当前节点(局面复述)'); teardown(game); } } // ================================================================ 看门狗双轨 // (§9:链末 25/50 gd_continue/gd_again;p* 25/50 复述两遍;乱按 96s 无操作轨零播出 + // 无进展轨 60s→gd_prog1、120s→gd_prog2;有真实进展零 prog;guide=off 零定时器) realLog('== 看门狗双轨(guide=full)=='); { // ---- 链末等待:full 25s/50s → gd_continue/gd_again(两级不同句,用尽静默) clearSave(); resetSettings('full'); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'WATCH' && !game.voicePlaying); // c1_tune 链末等待 const tW = now; await advance(() => voicePlays(playLog, 'voice/gd_continue') === 1); assert(now - tW === 25000, `WATCH 链末 25s 静默 → gd_continue(实际 ${now - tW}ms)`); await advance(() => voicePlays(playLog, 'voice/gd_again') === 1); assert(now - tW === 50000, `50s → gd_again(第二级新句,实际 ${now - tW}ms)`); await advance(() => now >= tW + 75000); assert(voicePlays(playLog, 'voice/gd_continue') === 1 && voicePlays(playLog, 'voice/gd_again') === 1, '两级用尽彻底静默'); teardown(game); } // ---- p* 播完 25s/50s → 重播当前 p* 提示句两遍 clearSave(); resetSettings('full'); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await tapUntil(game, () => game.choiceArmed, 24); // → p1 播完 const tP = now; await advance(() => voicePlays(playLog, 'voice/p1') === 2); assert(now - tP === 25000, `p* 播完 25s 无操作 → 重播 p1(第一级,实际 ${now - tP}ms)`); await advance(() => voicePlays(playLog, 'voice/p1') === 3); assert(now - tP === 50000, `50s → 再复述一次(第二级,实际 ${now - tP}ms)`); await advance(() => now >= tP + 75000); assert(voicePlays(playLog, 'voice/p1') === 3, 'p* 复述两级后彻底静默'); await g(game, 'left'); assert(game.trust === 1 && game.nodeId === 'c1_hold', '复述期间剧情不推进,玩家抉择照常生效'); teardown(game); } // ---- 乱按 126s:无效滑动每 2s 一次 → 无操作轨零播出;无进展轨 60s/120s 照常开火 clearSave(); resetSettings('full'); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'WATCH' && !game.voicePlaying); // c1_tune 链末等待(双轨挂载点) const t0 = now; // 虚拟时钟只在定时器处推进:每 2s 插一个空定时器锚点,保证手势节奏真实 // (否则 advance 会一跳 25s 直接触发看门狗——live 玩家 2s 一按不会让它到点) const waitV = async (ms) => { const target = now + ms; setTimeout(() => {}, ms); await advance(() => now >= target); }; for (let i = 0; i < 63; i++) { // 每 2s 一个无效滑动,直到 t0+126s if (i > 0) await waitV(2000); await g(game, i % 2 ? 'left' : 'right'); // 无效(非抉择节点) } assert(now - t0 === 124000, `乱按节奏 62 间隔 × 2s = 124s(覆盖 120s 第二级,实际 ${now - t0}ms)`); assert(voicePlays(playLog, 'voice/gd_continue') === 0 && voicePlays(playLog, 'voice/gd_again') === 0 && voicePlays(playLog, 'voice/gd_invalid') <= 1, '乱按 126s:无操作轨被手势反复复位 → gd_continue/gd_again 零播出(无效手势零静默靠 gd_invalid onceKey)'); assert(voicePlays(playLog, 'voice/gd_prog1') === 1, '无进展轨 60s → gd_prog1 照常开火(无效手势不复位)'); assert(voicePlays(playLog, 'voice/gd_prog2') === 1, '无进展轨 120s → gd_prog2(乱按也被抢救)'); teardown(game); } // ---- 有真实进展 → 全程零 prog clearSave(); resetSettings('full'); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'WATCH' && !game.voicePlaying); await g(game, 'tap'); // 真实进展(推进到 amo_a)→ prog 轨作废 await advance(() => now >= 140000 || game.choiceArmed); assert(voicePlays(playLog, 'voice/gd_prog1') === 0 && voicePlays(playLog, 'voice/gd_prog2') === 0, '有真实进展:无进展轨全程零播出(不误伤正常玩家)'); teardown(game); } // ---- MEMORY 链末不挂看门狗 + guide=off 零定时器 clearSave(); resetSettings('off'); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await tapUntil(game, () => game.choiceArmed, 24); await g(game, 'left'); // p1 左 await tapUntil(game, () => game.choiceArmed, 24); // → p2 await g(game, 'left'); // p2 左 await advance(() => game.phase === 'MEMORY'); const tM = now; await advance(() => game.phase === 'WATCH'); // 插叙 auto 回返 assert(now - tM < 25000, 'MEMORY 链 auto 回返(链末不设等待点,看门狗无处挂)'); assert(voicePlays(playLog, 'voice/gd_continue') === 0, '插叙全程零 gd_*(§7.2 MEMORY 行)'); assert((await advance()) === 'idle', 'guide=off + 等待点之外全程零定时器(虚拟时钟可空闲)'); teardown(game); } } // ================================================================ 键盘变体路由 // (§9:key → voice//key/ 探测;touch 反之;auto(触屏能力检测)不探测) realLog('== 键盘变体路由(settings.input)=='); { 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(voicePlays(playLog, 'voice/gd_brief') === 1, 'input=key:变体命中照常播出(成对生成)'); teardown(game); } 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); } clearSave(); resetSettings('full'); settings.set({ input: 'auto' }); // 触屏能力检测(maxTouchPoints=1)→ 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=auto(触屏设备,尚无键盘输入):按 touch 路由,不探测 key/ 位'); teardown(game); } } // ================================================================ 中断复述(含时空位) // (§9:杀进程重进 timeline=mem_f → sys_continue_ask → 单击 → prog_m2 → prog_mem_f → // 重播 rev_f3 起链;恢复首句不可跳,第二句起可跳;now 态存档重播节点链) realLog('== 中断复述(guide=off,预置存档 timeline=mem_f / node=rev_f3)=='); { clearSave(); resetSettings('off'); storageData.set(SAVE_KEY, JSON.stringify({ schema: 1, updatedAt: new Date().toISOString(), summary: '第2幕·风暴顶 · 信任半 · 结局0/4', phase: 'MEMORY', node: 'rev_f3', act: 2, timeline: 'mem_f', trust: 2, flags: { rev1: true, rev2: true, rev3: false }, p7: null, p9told: false, taught: true, endings: {}, runs: 2, lastTier: 'half', memSet: 'f2', memRet: 'c2_key', ending: null, })); { const { audio, playLog, game } = makeRig(); 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 === 'MEMORY' && game.memSet === 'f2' && game.timeline === 'mem_f', '单击继续 → 恢复 MEMORY:memSet=f2 / timeline=mem_f(时空位还原)'); assert(game.trust === 2 && game.rev1 === true && game.rev2 === true && game.runs === 3, 'trust/flags/runs 全部还原(runs 统一 +1 口径 → 3)'); await advance(() => game.currentVoiceId === 'voice/prog_m2'); await g(game, 'tap'); assert(game.currentVoiceId === 'voice/prog_m2', 'F4:恢复链首句(prog_m2)播完前单击不跳句'); await advance(() => game.currentVoiceId === 'voice/prog_mem_f'); await g(game, 'tap'); // 第二句起可跳(对照) await flush(); assert(game.currentVoiceId !== 'voice/prog_mem_f', 'F4 对照:恢复链第二句起跳句重新武装'); await advance(() => game.nodeId === 'c2_key' && !game.voicePlaying); const ids = playLog.map((e) => e.id); const iP = ids.indexOf('voice/prog_m2'); const iM = ids.indexOf('voice/prog_mem_f'); const iR = ids.indexOf('voice/rev_f3'); assert(iP >= 0 && iP < iM && iM < iR, '恢复链时位句先行:prog_m2 → prog_mem_f → 重播 rev_f3 起链'); assert(voicePlays(playLog, 'voice/rev_f3') === 1 && voicePlays(playLog, 'voice/rev_f5') === 1, '从 rev_f3 重播到链末 rev_f5,回返 c2_key(≤5s 回到可玩)'); teardown(game); } // ASK 右滑 = 新风暴夜(存档保留再询问) { const { audio, game: game2 } = makeRig(); await audio.unlock(); game2.start(); await flush(); assert(game2.phase === 'ASK', '再次带存档重进 → ASK 询问态'); const r0 = game2.runs; await g(game2, 'right'); assert(game2.phase === 'INTRO' && game2.trust === 0 && game2.runs === r0 + 1, 'ASK 右滑 = 新风暴夜:跨幕变量重置、runs +1(统一口径)'); teardown(game2); } // now 态存档:恢复 = 重播当前节点链 clearSave(); resetSettings('off'); storageData.set(SAVE_KEY, JSON.stringify({ schema: 1, updatedAt: new Date().toISOString(), summary: '第2幕·风暴顶 · 信任半 · 结局0/4', phase: 'WATCH', node: 'amo_e', act: 2, timeline: 'now', trust: 2, flags: { rev1: true, rev2: true, rev3: false }, p7: null, p9told: false, taught: true, endings: {}, runs: 2, lastTier: 'half', memSet: null, memRet: null, ending: null, })); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await g(game, 'tap'); assert(game.phase === 'WATCH' && game.nodeId === 'amo_e', 'now 态存档恢复 = 重播当前节点链'); await flush(); assert(game.timeline === 'now' && !game._loops.amb_vinyl, 'now 态恢复底噪签名正确(无 vinyl)'); assert(game._loops.amb_storm && game._loops.amb_room, 'now 态恢复底噪签名正确(storm+room 起播)'); teardown(game); } } // ================================================================ pendingChoice / p* 门 / idle-only realLog('== pendingChoice 时序 / p* 不可跳 / idle-only 纠错 =='); { clearSave(); resetSettings('off'); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await tapUntil(game, () => game.promptPlaying, 24); // p1 提示播放中 await g(game, 'left'); await g(game, 'tap'); // 压力连点:p* 不可跳 await g(game, 'tap'); assert(game.currentVoiceId === 'voice/p1' && game.pendingChoice === 'left', 'p* 播放中连点:不跳句、不清缓存、不提前生效'); await g(game, 'right'); assert(game.pendingChoice === 'right', '播中再滑覆盖缓存(最后一次为准)'); await g(game, 'left'); const iP1Last = lastIndexOf(playLog, 'voice/p1'); await advance(() => game.nodeId === 'c1_hold'); const ids = playLog.map((e) => e.id); const iConfirm = ids.indexOf('sfx/ui_confirm'); assert(iConfirm > iP1Last && ids.indexOf('voice/c1_hold') > iConfirm, '生效时序 = p1 播完 → ui_confirm 结算音 → 结果句(播完才生效,零插播)'); assert(game.trust === 1 && game.pendingChoice === null, '缓存恰好生效一次(不丢不重复)'); teardown(game); } // 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 advance(() => game.phase === 'WATCH' && game.voicePlaying); // c1_tune 播放中 await g(game, 'left'); assert(guideEvents.some((e) => e.type === 'error' && e.skipped === true), '对白在播时无效手势 → 纠错句 idle-only 跳过(事件带 skipped)'); assert(voicePlays(playLog, 'voice/gd_invalid') === 0, '在播对白不被纠错句顶掉'); await advance(() => !game.voicePlaying); await g(game, 'left'); assert(voicePlays(playLog, 'voice/gd_invalid') === 1, '语音空闲后再次无效 → gd_invalid 播出(不占 onceKey)'); assert(vibrateLog.slice(vib0).filter((v) => JSON.stringify(v.pattern) === ERR_PATTERN).length === 2, '两次无效手势 error 震都发出(零静默)'); teardown(game); } } // ================================================================ 菜单交互(含插叙中开关菜单) realLog('== 菜单交互(guide=off / full)=='); { clearSave(); resetSettings('off'); const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await tapUntil(game, () => game.promptPlaying, 24); // p1 提示播放中呼出菜单 await g(game, 'down'); assert(game.phase === 'MENU', '下滑呼出模态菜单'); await flush(); await g(game, 'tap'); // 播放中单击循环 → queue 排队 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 ids = playLog.map((e) => e.id); const iMenu = ids.indexOf('voice/sys_menu'); const cycle = ids.slice(iMenu + 1, iMenu + 7); assert(JSON.stringify(cycle) === JSON.stringify([ 'voice/sys_menu_resume', 'voice/sys_menu_restart', 'voice/sys_menu_lobby', 'voice/sys_menu_resume', ]), `菜单单击循环顺序 = [回到值守, 重新开始今夜, 保存并回大厅] 回绕(实际: ${cycle.join(' → ')})`); assert(cnt('voice/sys_menu_restart') === 1 && cnt('voice/sys_menu_lobby') === 1 && cnt('voice/sys_menu_resume') === 2, '菜单项 queue 依序播出零截断'); await g(game, 'up'); // 菜单内上滑帮助 await advance(() => !game.voicePlaying); assert(cnt('voice/sys_help') >= 1 && cnt('voice/sys_settings') === 1, '菜单内上滑播 sys_help + 设置提示'); await g(game, 'doubletap'); // 当前项 = 回到值守 assert(game.phase === 'WATCH' && 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, 'tap'); await g(game, 'tap'); // 第二项 = 保存并回大厅 await g(game, 'doubletap'); assert(cnt('voice/sys_menu_lobby') >= 2, '确认回大厅 → 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); } realLog('== 插叙中开关菜单(底噪签名不丢)=='); { clearSave(); resetSettings('off'); const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await tapUntil(game, () => game.choiceArmed, 24); await g(game, 'left'); // p1 左 await tapUntil(game, () => game.choiceArmed, 24); // → p2 await g(game, 'left'); // p2 左 await advance(() => game.currentVoiceId === 'voice/rev_g2'); // 插叙1 中段 assert(game.phase === 'MEMORY' && game._loops.amb_vinyl, '插叙进行中(vinyl 常驻)'); await g(game, 'down'); // 插叙中开菜单(停插叙链) assert(game.phase === 'MENU', 'MEMORY 中下滑 → 模态菜单(插叙链暂停)'); await flush(); assert(game._loops.amb_vinyl && game._loops.amb_storm === null, '菜单期间底噪仍 vinyl(timeline 不丢,§6.4 菜单复述行)'); await g(game, 'down'); // 关菜单 assert(game.phase === 'MEMORY', '关闭菜单回 MEMORY'); await advance(() => game.currentVoiceId === 'voice/rev_g2'); assert(voicePlays(playLog, 'voice/rev_g2') >= 2, '关闭菜单重播当前回忆节点(rev_g2 起重播)'); await advance(() => game.nodeId === 'c1_diary' && !game.voicePlaying); assert(voicePlays(playLog, 'sfx/memory_out') === 1, '菜单打断后插叙照常回返(memory_out → c1_diary)'); teardown(game); } // ================================================================ 结算链死窗(ENDING 链) realLog('== 结算链死窗(ENDING 结局链 800ms,guide=off)=='); { clearSave(); resetSettings('off'); const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await playNight(game, SCRIPT_D); // 最短路径到 e4 assert(game.phase === 'ENDING' && game.awaitingRestart, '前置:剧本 D → e4 结算态'); await g(game, 'doubletap'); // 重听结局链 await advance(() => game.voicePlaying && game.currentVoiceId === 'voice/e4_a'); const vib0 = vibrateLog.length; await g(game, 'tap'); // 800ms 死窗内在途 tap await g(game, 'right'); assert(game.currentVoiceId === 'voice/e4_a' && game.voicePlaying, '结局链开播 800ms 内手势一律吞掉(在途 tap/右滑不跳句、不逃逸)'); assert(vibrateLog.slice(vib0).length === 0, '死窗内吞手势不产生任何震动反馈'); await advance(() => game.awaitingRestart); assert(voicePlays(playLog, 'voice/e4_a') === 2 && voicePlays(playLog, 'voice/e4_b') === 2 && voicePlays(playLog, 'voice/ep_shore') === 2, '结局链完整有序播出(重播版一字不缺)'); teardown(game); } // ================================================================ R2 修复回归 // (research/new8/keeper/round1/synthesis.md F1/F2;断言增量登记本块) realLog('== R2 修复回归(F1 tr_thin 首幕豁免 / F2 gd_brief 首播锁)=='); { // ---- F1a:首夜首幕(runs=1, trust=0)上滑查状态 → tr_thin 播报无 warning 震(confirm 代) clearSave(); resetSettings('full'); { const { audio, playLog, game } = makeRig(); const vib0 = vibrateLog.length; await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'WATCH' && !game.voicePlaying); // c1_tune 链末等待 await g(game, 'up'); await advance(() => !game.voicePlaying); assert(voicePlays(playLog, 'voice/tr_thin') === 1, 'F1a 前置:首夜上滑播 tr_thin(trust=0)'); assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === WARN_PATTERN) === false, 'F1:首夜首幕 tr_thin 不再附 warning 震(自然起点不是警告)'); assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === JSON.stringify([20])), 'F1:首夜首幕 tr_thin 改附 confirm 轻震(触感不缺席)'); teardown(game); } // ---- F1b:第二夜(runs=2, trust=0)同状态上滑 → warning 震恢复(跨夜不骗老玩家) clearSave(); resetSettings('full'); storageData.set(SAVE_KEY, JSON.stringify({ schema: 1, updatedAt: new Date().toISOString(), summary: '风暴夜已过·灯与归人 · 结局1/4', phase: 'ENDING', node: 'e1', act: 3, timeline: 'now', trust: 4, flags: { rev1: true, rev2: true, rev3: true }, p7: 'boat', p9told: true, taught: true, endings: { e1: true }, runs: 2, lastTier: 'deep', memSet: null, memRet: null, ending: 'e1', })); { const { audio, playLog, game } = makeRig(); const vib0 = vibrateLog.length; await audio.unlock(); game.start(); await flush(); await g(game, 'tap'); // ASK 单击 = 继续上次(runs 统一 +1 口径 → 3,非首夜) assert(game.phase === 'ENDING' && game.runs === 3, 'F1b 前置:续玩后 runs=3(非首夜)'); await advance(() => game.awaitingRestart); // 结局链播完 await g(game, 'tap'); // 结算单击 = 新风暴夜 assert(game.phase === 'INTRO' && game.runs === 4 && game.trust === 0, 'F1b 前置:新夜 runs=4 trust=0'); await advance(() => game.phase === 'WATCH' && !game.voicePlaying); await g(game, 'up'); await advance(() => !game.voicePlaying); assert(voicePlays(playLog, 'voice/tr_thin') === 1, 'F1b 前置:非首夜上滑播 tr_thin'); assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === WARN_PATTERN), 'F1:非首夜 tr_thin 恢复 warning 震(首幕豁免只罩首夜首查)'); teardown(game); } // ---- F2:gd_brief 首播锁(full 档)——首播单击不跳+beat,第二句起恢复可跳 clearSave(); resetSettings('full'); { const { audio, playLog, game } = makeRig(); const vib0 = vibrateLog.length; await audio.unlock(); game.start(); await flush(); await advance(() => game.currentVoiceId === 'voice/gd_brief'); const gbrief0 = voicePlays(playLog, 'voice/gd_brief'); await g(game, 'tap'); assert(game.currentVoiceId === 'voice/gd_brief' && game.voicePlaying, 'F2:gd_brief 首播单击不跳(操作教学完整播出)'); assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === BEAT_PATTERN), 'F2:锁内单击补 beat 轻震(G4 签名)'); await advance(() => game.currentVoiceId === 'voice/b_open'); await g(game, 'tap'); // 第二句起恢复跳句自由 await flush(); assert(game.currentVoiceId !== 'voice/b_open', 'F2:锁仅罩首播一句,第二句起跳句恢复'); await advance(() => voicePlays(playLog, 'voice/gd_brief') === gbrief0); teardown(game); } } // ================================================================ 资产缺失兜底 realLog('== 资产缺失(guide=off,屏蔽 c8_songo)=='); { clearSave(); resetSettings('off'); blockedAssets.add('c8_songo'); const errBefore = consoleErrs.length; const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await playNight(game, SCRIPT_A); assert(consoleErrs.slice(errBefore).some((e) => e.includes('c8_songo')), '屏蔽资产仅 console.error,不抛异常'); assert(consoleWarns.some((w) => w.includes('看门狗') && w.includes('c8_songo')), '单句 30s 看门狗判定资产缺失并自动续链'); assert(game.endingId === 'e1' && game.awaitingRestart === true, '屏蔽 voice 资产仍打到 e1 结局,不卡死'); assert(voicePlays(playLog, 'voice/gd_continue') === 0 && voicePlays(playLog, 'voice/gd_again') === 0 && voicePlays(playLog, 'voice/gd_prog1') === 0, 'guide=off 全程零向导语音(gd_*)零定时器'); blockedAssets.delete('c8_songo'); teardown(game); } // ================================================================ 设置联动与引擎行为 realLog('== 设置联动与引擎行为(guide=off)=='); { clearSave(); resetSettings('off'); const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'WATCH'); await tapUntil(game, () => game.promptPlaying, 24); // p1 settings.set({ voice: 'male', speed: 1.7 }); await g(game, 'left'); await advance(() => game.nodeId === 'c1_hold'); assert(urlHits('audio/voice/male/c1_hold') > 0, 'settings.voice=male → 下一句起走 male 目录'); assert(audio._voice && audio._voice.st.tempo === 1.7, 'settings.speed=1.7 下一句起生效(SoundTouch tempo)'); settings.set({ speed: 1.0, voice: 'female' }); fireWindowEvent('soundgame:longpress'); assert(audio._voice && audio._voice.st.tempo === 1.7, '长按 → 旁白临时 1.7 倍速(不变调)'); fireWindowEvent('soundgame:longpressend'); assert(audio._voice && audio._voice.st.tempo === 1.0, '松开 → 恢复 settings.speed'); await tapUntil(game, () => game.choiceArmed, 24); assert(audio._bgm && audio._bgm.id === 'bgm/storm', 'WATCH 垫乐 bgm/storm'); settings.set({ bgm: 0 }); await flush(); assert(audio._bgm === null, 'settings.bgm=0 立即停播'); settings.set({ bgm: 1 }); await flush(); assert(audio._bgm && audio._bgm.id === 'bgm/storm', 'bgm 恢复后自动补播最后状态垫乐'); teardown(game); } // ================================================================ 静态审计 // (数字规范:分档整句字面量、无运行时 id 拼接;资产盘点;双性别/key 配对;summary 格式) realLog('== 静态审计 =='); { const src = fs.readFileSync(path.join(GAME_DIR, 'game.js'), 'utf8'); for (const id of ['voice/prog_m1', 'voice/prog_m2', 'voice/prog_m3', 'voice/prog_mem_g', 'voice/prog_mem_f', 'voice/tr_deep', 'voice/tr_half', 'voice/tr_thin', 'voice/tru_zero', 'voice/tru_one', 'voice/tru_two', 'voice/tru_full', 'voice/gd_prog1', 'voice/gd_prog2']) { assert(src.includes(`'${id}'`), `分档整句字面量存在:${id}`); } assert(src.includes("say: ['gd_continue', 'gd_again']"), '看门狗两级不同句 gd_continue/gd_aken 字面量存在(guide say 裸 id,guide 层拼前缀)'); assert(!/voice\/\$\{/.test(src), '无模板字符串拼接 voice id'); assert(!/['"]voice\/[A-Za-z0-9_]+['"]\s*\+/.test(src), '无字符串相加拼接 voice id'); assert(!/\+\s*['"]voice\//.test(src), '无前置相加拼接 voice id'); // 资产盘点:game.js 引用的 voice/bgm/sfx 全量齐备(ogg+m4a) const ids = [...new Set([...src.matchAll(/'((?:voice|sfx|bgm)\/[A-Za-z0-9_.-]+)'/g)].map((m) => m[1]))]; assert(ids.length > 0, `game.js 提取到 ${ids.length} 个资产 id`); const both = (rel) => ['.ogg', '.m4a'].every((ext) => fs.existsSync(path.join(AUDIO_DIR, rel + ext))); const missingVoiceBgm = []; const missingSfx = []; for (const id of ids) { if (id.startsWith('voice/')) { const name = id.slice(6); if (!(both(`voice/male/${name}`) || both(`voice/female/${name}`) || both(`voice/${name}`))) { missingVoiceBgm.push(id); } } else if (id.startsWith('bgm/')) { if (!both(id)) missingVoiceBgm.push(id); } else if (!both(id)) { missingSfx.push(id); } } assert(missingVoiceBgm.length === 0, `voice/bgm 资产零缺失(${ids.length} 个引用 id,实际缺: ${missingVoiceBgm.join(',') || '无'})`); assert(missingSfx.length === 0, `sfx 资产零缺失(17 件程序合成全部落盘,实际缺: ${missingSfx.join(',') || '无'})`); // 双性别 / NPC 平铺 / key 变体抽查(§5.3) assert(both('voice/male/p1') && both('voice/female/p1'), '抉择提示 p1 双性别齐备'); assert(both('voice/male/key/p1') && both('voice/female/key/p1'), '键盘变体 p1 key/ 子目录双性别齐备'); assert(both('voice/amo_a') && !fs.existsSync(path.join(AUDIO_DIR, 'voice/male/amo_a.ogg')), 'NPC 阿澈 amo_a 仅平铺目录(不做双性别,电台滤波质感)'); assert(both('voice/rev_g2') && !fs.existsSync(path.join(AUDIO_DIR, 'voice/female/rev_g2.ogg')), 'NPC 祖父 rev_g2 仅平铺目录(唱片滤波质感)'); assert(both('voice/male/gd_brief') && both('voice/female/key/sys_help'), 'gd_brief/sys_help 双性别 + key 变体齐备'); // 存档 summary 人话格式(新开局) clearSave(); resetSettings('off'); const { audio, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await tapUntil(game, () => game.choiceArmed, 24); // → p1 播完 await g(game, 'left'); // p1 左:抉择结算后首存(写入时机) const sd = saveData(); assert(sd && sd.schema === 1 && sd.timeline === 'now', '存档 schema=1 且带 timeline 时空位'); assert(sd.summary === '第1幕·风暴起 · 信任半 · 结局0/4', `summary 格式(实际: ${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('全部断言通过。');