/** * 《不眠频率》无头试玩仿真测试(docs/design/15-radio.md §9 playtest 断言清单)。 * * 方案: * - mock 浏览器环境:window/localStorage/navigator/fetch/AudioContext * (decodeAudioData 返回固定 2.0s 时长的假 buffer,语音链靠引擎 endTimer + * onComplete 推进,虚拟时钟驱动,不真实等待); * - 使用**真实** engine/audio.js + settings.js + guide.js; * - 手势直接注入 game.onGesture(与 attachGestures 回调同路径); * - 虚拟时钟:patch setTimeout/clearTimeout/performance.now; * - 铃声为无缝循环垫(R2 F3:游戏侧持循环句柄,无重触发定时器),全程可复现; * - 通话侧 amb 循环关闭(ambLoopEnabled=false,重触发定时器会让虚拟时钟永不空闲)。 * * 运行:tools/node/bin/node web/radio/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.radio'; // ---------------------------------------------------------------- 断言收集 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 = () => {}; // 游戏逐手势 console.log,静默 // ---------------------------------------------------------------- 虚拟时钟 let now = 0; // 虚拟毫秒 let timerSeq = 1; const timers = new Map(); // id -> { fireAt, cb, args } const realSetImmediate = setImmediate; globalThis.setTimeout = (cb, ms = 0, ...args) => { const id = timerSeq++; timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb, args }); return id; }; globalThis.clearTimeout = (id) => { timers.delete(id); }; globalThis.setInterval = (cb, ms = 0, ...args) => { const id = timerSeq++; const step = (...a) => { if (!intervals.has(id)) return; cb(...a); if (intervals.has(id)) timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step, args }); }; timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step, args }); return id; }; const intervals = new Set(); globalThis.clearInterval = (id) => { intervals.delete(id); timers.delete(id); }; Object.defineProperty(globalThis, 'performance', { value: { now: () => now }, configurable: true, }); /** 排空微任务(fetch/decode 的 promise 链)。 */ async function flush(rounds = 8) { for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r)); } /** * 推进虚拟时钟:逐个触发到期定时器,直到没有待触发定时器(= 游戏在等手势) * 或 cond() 满足。返回 'cond' | 'idle'。 */ async function advance(cond = null) { for (let n = 0; n < 50000; n++) { await flush(2); if (cond && cond()) return 'cond'; let nextId = -1; let nextAt = Infinity; for (const [id, t] of timers) if (t.fireAt < nextAt) { nextAt = t.fireAt; nextId = id; } if (nextId < 0) return 'idle'; now = nextAt; const t = timers.get(nextId); timers.delete(nextId); t.cb(...t.args); } throw new Error('虚拟时钟定时器超上限(疑似死循环)'); } // ---------------------------------------------------------------- 浏览器 mock const winListeners = new Map(); globalThis.window = { addEventListener: (type, fn) => { if (!winListeners.has(type)) winListeners.set(type, []); winListeners.get(type).push(fn); }, AudioContext: null, // 下方赋值 }; function fireWindowEvent(type) { for (const fn of winListeners.get(type) || []) fn(); } const storageData = new Map(); globalThis.localStorage = { getItem: (k) => (storageData.has(k) ? storageData.get(k) : null), setItem: (k, v) => { storageData.set(k, String(v)); }, removeItem: (k) => { storageData.delete(k); }, }; const vibrateLog = []; Object.defineProperty(globalThis, 'navigator', { // maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,语音走触屏措辞主资产(键盘路由由 engine-input K9 专测) 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 }; } // 向导层 earcon 运行时合成所需 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/')) { // 服务器 API:模拟 401(无有效会话)/200 if ((opts.method || 'GET') === 'PUT') { return { ok: true, status: 200, json: async () => ({ ok: true, game: 'radio' }) }; } 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 { RadioGame } = await import('../game.js'); function resetSettings(guide) { settings.set({ guide, voice: 'female', speed: 1.0, bgm: 1.0 }); } resetSettings('off'); function clearSave() { storageData.delete(SAVE_KEY); } function saveData() { const raw = storageData.get(SAVE_KEY); if (!raw) return null; try { return JSON.parse(raw); } catch (_) { return null; } } const voicePlays = (playLog, id) => playLog.filter((e) => e.id === id).length; const urlHits = (frag) => requestedUrls.filter((u) => u.includes(frag)).length; const lastIndexOf = (playLog, id) => playLog.map((e) => e.id).lastIndexOf(id); const ERR_PATTERN = JSON.stringify([60, 40, 60]); // error 震型 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 RadioGame(audio, {}); game.ambLoopEnabled = false; // 无头确定性:通话 amb 只起播一次,不重触发 return { audio, playLog, game }; } let gestureCount = 0; async function g(game, gesture) { gestureCount++; game.onGesture(gesture); await flush(); } /** 结束一个测试块:清看门狗/铃声/幕间定时器,避免污染后续块的虚拟时钟。 */ function teardown(game) { game.guide.disarm(); game.stop(); // 内部 seqToken++:被中断链不得经 30s 语音看门狗复活 } /** * 叙事链推进器:语音在播时等它自然播完(不跳句),链末等待态单击继续 * (§2.3「链末=继续」),直到 cond() 满足(抉择武装 / RING / 提示句在播等)。 * 途经 RING(幕间自动流转到下一通)时单击接听;幕间喘息等自动段推进定时器。 */ async function tapUntil(game, cond, maxSteps = 80) { for (let i = 0; i < maxSteps; i++) { if (cond()) return; if (game.phase === 'RING') { await g(game, 'tap'); continue; } // 接听 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 && game.phase !== 'RING') { await advance(() => cond() || game.voicePlaying || game.phase === 'RING'); } } throw new Error(`tapUntil 超步限(node=${game.nodeId} phase=${game.phase})`); } // 抉择脚本(按 p1..p9 遭遇顺序;p5 仅 p4=右、p7 仅 p6=左 可达) const SCRIPT_A = ['left', 'left', 'left', 'right', 'left', 'left', 'left', 'left', 'left']; const SCRIPT_B = [...SCRIPT_A.slice(0, 8), 'right']; // §9 剧本 C:p1右 p2左 p3右 p4右 p5右 p6右 p8右 p9左(p6 右 → 无 p7),rep=3 → e3 const SCRIPT_C = ['right', 'left', 'right', 'right', 'right', 'right', 'right', 'left']; // §9 剧本 D:p1右 p2右 p3右 p4左 p6右 p8右 p9右(p4 左 → 无 p5;p6 右 → 无 p7),rep=0 → e4 const SCRIPT_D = ['right', 'right', 'right', 'left', 'right', 'right', 'right']; // 剧本 E:全左但 p4 左(错过线索)→ heardArgument=false,C4 播 w1_say2 组;rep=9 → e1 const SCRIPT_E = ['left', 'left', 'left', 'left', 'left', 'left', 'left', 'left']; /** 从头开一局并按脚本打完到 ENDING 结算态(链末单击继续、RING 单击接听)。 */ async function playNight(game, choices) { let i = 0; let guard = 0; while (!(game.phase === 'ENDING' && game.awaitingRestart)) { if (++guard > 400) throw new Error(`playNight 卡住(node=${game.nodeId} phase=${game.phase})`); if (game.phase === 'RING') { await g(game, 'tap'); continue; } if (game.phase === 'BRIDGE') { await advance(() => game.phase !== 'BRIDGE'); continue; } if (game.choiceArmed) { const c = choices[i++]; if (!c) throw new Error(`playNight 抉择脚本耗尽(node=${game.nodeId})`); await g(game, c); continue; } if (!game.voicePlaying) { await g(game, 'tap'); continue; } // 链末=继续 await advance(() => !game.voicePlaying); } } // ================================================================ 主路径试玩 // (§9 关键路径:剧本 A 全程 E1,rep 逐点断言与 §4.2 一致,≤60 手势) realLog('== 主路径 · 剧本 A(E1,guide=off)=='); { clearSave(); resetSettings('off'); const g0 = gestureCount; const { audio, playLog, game } = makeRig(); // §7 全局:解锁前 play 为 no-op audio.play('voice/b_open'); await flush(); assert(audio._voice === null && consoleWarns.some((w) => w.includes('尚未解锁')), '启动遮罩点按前无音频播放(play 为 no-op)'); await audio.unlock(); playLog.length = 0; // 清掉解锁前 no-op 尝试的计数(无播放,仅探测记录) game.start(); await flush(); assert(playLog.some((e) => e.id === 'bgm/onair'), '开局播放 bgm/onair(直播间垫乐,全夜班不切)'); assert(voicePlays(playLog, 'voice/sys_new_night') === 1, '新夜班 sys_new_night 先行'); assert(game.phase === 'INTRO', '进入宏状态 INTRO'); // 1. INTRO 链完整(自动流)→ RING(c1):turn 事件与铃声断言 await advance(() => game.currentVoiceId === 'voice/b_open'); assert(game.nodeId === 'b_open' && game.voicePlaying && game.currentVoiceId === 'voice/b_open', 'sys_new_night 播完接 b_open(voice 层有播放记录)'); assert(voicePlays(playLog, 'voice/gd_brief') === 0, 'guide=off:开场不插 gd_brief'); await advance(() => game.phase === 'RING'); 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(game.callN === 1 && game.phase === 'RING', 'INTRO 链完自动进 RING(c1)'); await flush(); assert(game._ringSrc !== null, 'F3:RING 铃声循环垫起播(sfx/phone_ring 无缝 loop,游戏侧持句柄)'); assert(saveData() && saveData().phase === 'RING' && saveData().call === 1, 'RING 进入即存档(写入时机),phase=RING / call=1'); // off 档 RING 挂起:零向导提示、零超时惩罚;循环垫无重触发定时器(虚拟时钟可空闲) assert((await advance()) === 'idle' && game._ringSrc !== null, 'F3:RING 铃声循环垫持续(挂起态虚拟时钟可空闲 = 零重触发定时器,可复现)'); assert(voicePlays(playLog, 'voice/gd_ring1') === 0 && voicePlays(playLog, 'voice/gd_ring2') === 0, 'guide=off:RING 静默挂起零提示(看门狗不挂、零超时惩罚)'); // 2. 单击接听 → ONAIR(c1);叙事链末等待单击推进到 p1 await g(game, 'tap'); assert(game.phase === 'ONAIR' && game.nodeId === 'z1_ring_in' && game.voicePlaying, '单击接听 → ONAIR(c1) z1_ring_in(线路引入)'); assert(game._ringSrc === null, 'F3:单击接听 → 铃声循环垫即停(不拖尾盖住通话)'); assert(game.rep === 0, '开局 rep=0'); await tapUntil(game, () => game.promptPlaying, 10); assert(game.nodeId === 'p1' && game.promptPlaying, 'C1 链末单击推进至 p1 提示句播放中'); assert(voicePlays(playLog, 'voice/z1_ring_in') === 1 && voicePlays(playLog, 'voice/z1_a') === 1 && voicePlays(playLog, 'voice/z1_b') === 1, '接听链 z1_ring_in→z1_a→z1_b 各播一次'); 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 === 'z1_song'); assert(game.pendingChoice === null, '播完 pendingChoice 立即生效并清空'); assert(game.rep === 1, 'p1 左 rep +1 → 1(与 §4.2 一致)'); assert(voicePlays(playLog, 'voice/z1_song') === 1, 'z1_song 恰播一次(不丢、不重复)'); // p2 左 → rep 3,选择结算后存档 await tapUntil(game, () => game.choiceArmed, 8); // z1_song→z1_songo→z1_sdone→p2 await g(game, 'left'); assert(game.rep === 3, 'p2 左 rep +2 → 3(+1+2=3,与 §9 一致)'); const sd1 = saveData(); assert(sd1 && sd1.rep === 3 && sd1.node === 'p2' && sd1.phase === 'ONAIR' && sd1.call === 1, '抉择结算后存档:node=p2 / rep=3 / phase=ONAIR'); const keysNeeded = ['schema', 'updatedAt', 'summary', 'phase', 'node', 'rep', 'knot', 'heardArgument', 'bond', 'endings', 'nights']; assert(keysNeeded.every((k) => k in sd1), '存档 data 覆盖 §8.1 全部字段(含 summary 字符串)'); assert(sd1.summary === '第1通·走丢的老人 · 口碑+3 · 结局0/4', `summary 人话格式(实际: ${sd1.summary})`); // C1 收敛 z1_ok1 → BRIDGE(rep 跨 low→mid 播报)→ RING(c2) await tapUntil(game, () => game.phase === 'BRIDGE', 10); assert(voicePlays(playLog, 'voice/rep_mid') === 1, '口碑跨档(low→mid)→ BRIDGE 被动播报 rep_mid'); await advance(() => game.phase === 'RING'); assert(game.callN === 2, 'C1 收敛 z1_ok1 → BRIDGE → RING(c2)'); // C2:接听后先测双击重听(节点播完态) await g(game, 'tap'); assert(game.phase === 'ONAIR' && game.nodeId === 'l1_in', '接听第二通 → l1_in(C2)'); await tapUntil(game, () => game.choiceArmed, 8); const p3Before = voicePlays(playLog, 'voice/p3'); await g(game, 'doubletap'); assert(game.nodeId === 'p3' && voicePlays(playLog, 'voice/p3') === p3Before + 1, 'ONAIR 播完态双击 → 当前节点链同 id 序列重播(p3)'); await g(game, 'tap'); assert(game.currentVoiceId === 'voice/p3' && !game.choiceArmed, '重播期间单击不跳 p* 提示句、nodeId 不变'); await advance(() => game.choiceArmed); assert(!game.voicePlaying, '重播完回原等待态(choiceArmed)'); // C2 左 → rep 5 await g(game, 'left'); assert(game.rep === 5, 'p3 左 rep +2 → 5'); await tapUntil(game, () => game.phase === 'RING', 10); assert(game.callN === 3, 'C2 收敛 → BRIDGE → RING(c3)'); // C3:arg_muffled 线索音 2 次、p4 右 → heardArgument、p5 左 await g(game, 'tap'); assert(game.nodeId === 'k1_in', '接听第三通 → k1_in(C3)'); await tapUntil(game, () => game.nodeId === 'k1_b' && !game.voicePlaying, 6); // k1_b 起播 arg_muffled(1 次)+ 6.5s 后第二次(节点内 sfxEcho;链末等待态 nodeId 不变) await advance(() => voicePlays(playLog, 'sfx/arg_muffled') >= 2); assert(voicePlays(playLog, 'sfx/arg_muffled') === 2, 'k1_b 起播 arg_muffled 线索音 2 次(信息层)'); await tapUntil(game, () => game.choiceArmed, 4); // → p4 await g(game, 'right'); // p4 右 = 问她 assert(game.heardArgument === true, 'p4 右后 heardArgument === true'); assert(game.rep === 6, 'p4 右 rep +1 → 6'); await tapUntil(game, () => game.choiceArmed, 6); // k1_ask→k1_asko→p5 await g(game, 'left'); // p5 左 = 真话 assert(game.rep === 7, 'p5 左 rep +1 → 7'); assert(game.bond.kid === 1, 'p5 左 → bond.kid=1'); await tapUntil(game, () => game.phase === 'RING', 12); assert(game.callN === 4, 'C3 收敛 → BRIDGE → RING(c4)'); // C4:w1_say 组(heardArgument 消费) await g(game, 'tap'); assert(game.nodeId === 'w1_in', '接听第四通 → w1_in(C4)'); await tapUntil(game, () => game.choiceArmed, 8); await g(game, 'left'); // p6 左 assert(game.rep === 9, 'p6 左(heardArgument)rep +2 → 9'); await tapUntil(game, () => game.choiceArmed, 6); // w1_say 组 → p7 assert(voicePlays(playLog, 'voice/w1_say') >= 1 && voicePlays(playLog, 'voice/w1_say2') === 0, 'C4 播 w1_say 组(heardArgument 消费),绝不播 w1_say2'); await g(game, 'left'); // p7 左 assert(game.rep === 11, 'p7 左 rep +2 → 11'); await tapUntil(game, () => game.phase === 'RING', 10); assert(game.callN === 5, 'C4 收敛 → BRIDGE → RING(c5)'); // C5:危机拍 + bond_zhou 回响 await g(game, 'tap'); assert(game.nodeId === 'h1_in', '接听第五通 → h1_in(C5)'); await tapUntil(game, () => game.choiceArmed, 8); await g(game, 'left'); // p8 左 assert(game.rep === 12, 'p8 左 rep +1 → 12(理论上限)'); await tapUntil(game, () => game.choiceArmed, 8); // h1_talk → 危机拍自动流 → 签收 p9 assert(voicePlays(playLog, 'sfx/brake_screech') >= 1, 'C5 危机拍:brake_screech 前置 2s 预警'); assert(vibrateLog.some((v) => JSON.stringify(v.pattern) === JSON.stringify([25, 40, 25, 40, 25])), '危机拍 danger 震型'); assert(voicePlays(playLog, 'voice/h1_echo') === 1, 'bond_zhou=1 → C5 插播 h1_echo 回响句(C1→C5)'); assert(voicePlays(playLog, 'voice/h1_talko') === 1 && voicePlays(playLog, 'voice/h1_danger') === 1, '危机拍整段自动流不等待(h1_talko→h1_danger)'); // p9 左 → knot → ENDING(e1) + 尾声回响 await g(game, 'left'); assert(game.knot === true, 'p9 左 → knot=true(心结解开)'); assert(game.rep === 12, 'p9 不动 rep'); await advance(() => game.phase === 'ENDING' && game.awaitingRestart); assert(game.endingId === 'e1', 'rep≥5 且直面 → ENDING(e1 天亮之前)'); for (const id of ['voice/e1_a', 'voice/e1_b', 'voice/ep_lead', 'voice/ep_zhou_ok', 'voice/ep_deer_ok', 'voice/ep_kid_ok', 'voice/ep_wife_ok', 'voice/ep_han_ok']) { assert(voicePlays(playLog, id) === 1, `结局链播出 ${id}`); } assert(voicePlays(playLog, 'sfx/onair_sting') === 1 && voicePlays(playLog, 'sfx/line_drop') >= 1 && voicePlays(playLog, 'sfx/tape_static') === 1, 'SA3 音效接线:onair_sting(开场台标)/ line_drop(幕间挂断)/ tape_static(p9 直面)各就位'); 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.summary})`); assert(['zhou', 'deer', 'kid', 'wife', 'han'].every((k) => game.bond[k] === 1), '剧本 A 五位来电者好感全 1'); // 归因真话律:剧本 A 全程零归因/补救句(成功不教学) assert(['voice/z1_bade', 'voice/k1_lulle', 'voice/k1_liee', 'voice/e4_b'] .every((id) => voicePlays(playLog, id) === 0), '剧本 A 全程无归因/补救句(成功不教学)'); // ENDING 双击重听结局链 const e1aBefore = voicePlays(playLog, 'voice/e1_a'); await g(game, 'doubletap'); assert(game.awaitingRestart === false && game.nodeId === 'e1', '结局链播完双击 → 重听结局链(清结算态、nodeId 不变)'); await g(game, 'tap'); assert(game.nodeId === 'e1' && !game.awaitingRestart, '重播期间单击 = 跳本句,不触发重开'); await advance(() => game.awaitingRestart); assert(voicePlays(playLog, 'voice/e1_a') === e1aBefore + 1, '结局链同 id 序列重播完成,回结算态'); // 结算态单击 → 新夜班 await g(game, 'tap'); assert(game.phase === 'INTRO' && game.nights === 2 && game.rep === 0 && !game.knot && !game.heardArgument, '结算单击 = 新夜班:回 INTRO,rep/knot/heardArgument 归零'); assert(game.endings.e1 === true, 'endings 结局收集跨夜保留'); assert(voicePlays(playLog, 'voice/sys_new_night') === 2, 'sys_new_night 随新夜班再播'); 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 补 w1_say2 差分; // 各局 rep 终值与 §4.2 一致) realLog('== 四结局矩阵(剧本 B / C / D / E)=='); { // ---- 剧本 B:与 A 同路但 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.rep === 12 && game.knot === false, '剧本 B(p9 右)→ e2 热线常亮,rep=12,knot=false'); assert(voicePlays(playLog, 'voice/e2_a') === 1 && voicePlays(playLog, 'voice/e2_b') === 1, 'e2 专属结局主句 + 钩子播出'); teardown(game); } // ---- 剧本 C:p1右 p2左 p3右 p4右 p5右 p6右 p8右 p9左 → e3, rep=3 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.rep === 3 && game.knot === true, '剧本 C → e3 一个人的频道,rep=3,knot=true'); assert(voicePlays(playLog, 'voice/e3_a') === 1 && voicePlays(playLog, 'voice/e3_b') === 1, 'e3 专属结局主句 + 钩子播出'); assert(voicePlays(playLog, 'voice/z1_ask') === 1 && voicePlays(playLog, 'voice/z1_adone') === 1, '剧本 C:p1 右 → 问路牌路径推进(z1_ask→z1_asko→z1_adone)'); assert(game.heardArgument === true && voicePlays(playLog, 'voice/w1_say') === 0 && voicePlays(playLog, 'voice/w1_say2') === 0, '剧本 C:p4 右埋线索但 p6 右本通结束 → 两组带话差分都不播'); assert(voicePlays(playLog, 'voice/ep_zhou_no') === 0, '剧本 C:老周被救到 → 不播 ep_zhou_no'); assert(voicePlays(playLog, 'voice/ep_deer_no') === 1, '剧本 C:p3 右错过小鹿 → 尾声播 ep_deer_no'); teardown(game); } // ---- 剧本 D:p1右 p2右 p3右 p4左 p6右 p8右 p9右 → e4, rep=0 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.rep === 0 && game.knot === false, '剧本 D → e4 静音,rep=0,knot=false'); assert(voicePlays(playLog, 'voice/z1_bad') === 1 && voicePlays(playLog, 'voice/z1_bade') === 1, '剧本 D:p1右+p2右 砸单组合 → 播 z1_bad/z1_bade 归因句(真话律)'); assert(voicePlays(playLog, 'voice/k1_lulle') === 1, '剧本 D:p4 左错过线索 → k1_lulle 诚实复盘'); assert(voicePlays(playLog, 'voice/w1_say') === 0 && voicePlays(playLog, 'voice/w1_say2') === 0, '剧本 D:p6 右本通结束 → 无带话差分(heardArgument=false 也不播)'); assert(voicePlays(playLog, 'voice/e4_a') === 1 && voicePlays(playLog, 'voice/e4_b') === 1, 'e4 专属结局主句 + 钩子播出'); for (const id of ['voice/ep_zhou_no', 'voice/ep_deer_no', 'voice/ep_kid_no', 'voice/ep_wife_no', 'voice/ep_han_no']) { assert(voicePlays(playLog, id) === 1, `剧本 D 全错过 → 尾声播出 ${id}`); } teardown(game); } // ---- 剧本 E:全左但 p4 左(错过线索)→ heardArgument=false → C4 播 w1_say2 组 clearSave(); resetSettings('off'); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await playNight(game, SCRIPT_E); assert(game.endingId === 'e1' && game.rep === 9, '剧本 E(p4 左错线索,其余全左)→ e1,rep=1+2+2+0+1+2+1=9'); assert(game.heardArgument === false, '剧本 E:p4 左 → heardArgument=false'); assert(voicePlays(playLog, 'voice/w1_say2') === 1 && voicePlays(playLog, 'voice/w1_say2o') === 1 && voicePlays(playLog, 'voice/w1_say2e') === 1 && voicePlays(playLog, 'voice/w1_say') === 0, '剧本 E:C4 左路消费差分 → 播 w1_say2 组整组,绝不播 w1_say 组'); assert(voicePlays(playLog, 'voice/k1_lulle') === 1, '剧本 E:p4 左 → k1_lulle 复盘句(诚实归因,非惩罚)'); teardown(game); } } // ================================================================ 状态播报与无效手势 // (§2.3 上滑状态链 + 无效手势零静默 + idle-only F1.4) realLog('== 状态播报与无效手势(guide=full)=='); { // ---- RIG A:RING 上滑状态播报(第几通 + 口碑档,留在 RING) clearSave(); resetSettings('full'); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'RING'); await g(game, 'up'); assert(game.currentVoiceId === 'voice/prog_c1', 'RING 上滑先播 prog_c1(第几通)'); await advance(() => !game.voicePlaying); const iP1 = lastIndexOf(playLog, 'voice/prog_c1'); const iR1 = lastIndexOf(playLog, 'voice/rep_low'); assert(iP1 >= 0 && iP1 < iR1, 'RING 上滑状态链顺序:prog_c1 → rep_low(rep=0 → low 档整句)'); assert(game.phase === 'RING', '状态播报后留在 RING'); teardown(game); } // ---- RIG B:RING 无效手势(左滑 / 首通前双击)零静默 + onceKey 同局限次 clearSave(); resetSettings('full'); { const { audio, playLog, game } = makeRig(); const vib0 = vibrateLog.length; await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'RING'); await g(game, 'left'); assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === ERR_PATTERN), 'RING 左滑 → error 震(无效手势零静默)'); assert(voicePlays(playLog, 'voice/gd_invalid') === 1, 'RING 左滑 → gd_invalid(full 档播出)'); await g(game, 'doubletap'); // 首通前无上一通收尾句 → 无效 assert(voicePlays(playLog, 'voice/gd_invalid') === 1, '再次无效手势 gd_invalid 不重播(onceKey 同局限次)'); const errN = vibrateLog.slice(vib0).filter((v) => JSON.stringify(v.pattern) === ERR_PATTERN).length; assert(errN === 2, `无效手势震反馈每次都有(零静默,实际 ${errN} 次)`); teardown(game); } // ---- RIG C:idle-only(F1.4,对白在播时纠错跳过且不占 onceKey)+ BRIDGE 上滑报状态 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 === 'RING'); await g(game, 'tap'); // 接听 → z1_ring_in 播放中 await g(game, 'left'); assert(guideEvents.some((e) => e.type === 'error' && e.skipped === true), '对白在播时无效手势 → 纠错句 idle-only 跳过(事件带 skipped)'); assert(voicePlays(playLog, 'voice/gd_invalid') === 0 && game.voicePlaying, '在播对白不被纠错句顶掉,gd_invalid 未播出'); await advance(() => !game.voicePlaying); // z1_ring_in 链末 await g(game, 'left'); assert(voicePlays(playLog, 'voice/gd_invalid') === 1, '语音空闲后再次无效 → gd_invalid 播出(idle 跳过不占 onceKey)'); assert(vibrateLog.slice(vib0).filter((v) => JSON.stringify(v.pattern) === ERR_PATTERN).length === 2, '两次无效手势 error 震都发出(零静默)'); // C1 → BRIDGE:上滑报状态(帮助 + 下一通 + 口碑档),自动链继续 await tapUntil(game, () => game.choiceArmed, 10); // → p1 await g(game, 'left'); // p1 左 await tapUntil(game, () => game.choiceArmed, 10); // → p2 await g(game, 'left'); // p2 左 await tapUntil(game, () => game.phase === 'BRIDGE', 10); // z1_stay→…→z1_ok1→@bridge await advance(() => !game.voicePlaying); // rep_mid 跨档播报播完 await g(game, 'up'); await advance(() => !game.voicePlaying); const iH = lastIndexOf(playLog, 'voice/sys_help'); const iP2 = lastIndexOf(playLog, 'voice/prog_c2'); const iR2 = lastIndexOf(playLog, 'voice/rep_mid'); assert(iP2 >= 0 && iP2 < iR2 && iR2 < iH, 'BRIDGE 上滑状态链顺序(G6):prog_c2(下一通)→ rep_mid → sys_help(状态在前、帮助垫后)'); await advance(() => game.phase === 'RING'); assert(game.callN === 2, 'BRIDGE 上滑后自动链继续进 RING(c2)'); teardown(game); } } // ================================================================ 菜单交互 // (§9 边界:菜单返回不丢节点、循环顺序、帮助、保存并回大厅;queue 不顶句) realLog('== 菜单交互(guide=off,C1 期间呼出)=='); { clearSave(); resetSettings('off'); const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'RING'); await g(game, 'tap'); // 接听 C1 await tapUntil(game, () => game.promptPlaying, 10); // p1 提示播放中呼出菜单 await g(game, 'down'); assert(game.phase === 'MENU', '下滑呼出模态菜单'); await flush(); assert(audio._bgm && audio._bgm.id === 'bgm/menu', '进菜单 BGM 切 bgm/menu'); 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 iMenu = playLog.findIndex((e) => e.id === 'voice/sys_menu'); const iRes = playLog.findIndex((e) => e.id === 'voice/sys_menu_resume'); const iRst = playLog.findIndex((e) => e.id === 'voice/sys_menu_restart'); const iLob = playLog.findIndex((e) => e.id === 'voice/sys_menu_lobby'); assert(iMenu >= 0 && iMenu < iRes && iRes < iRst && iRst < iLob, '菜单项 queue 依序播出(sys_menu → 回到直播 → 重新开始今晚 → 保存并回大厅),在播句不被截断'); assert(cnt('voice/sys_menu_restart') === 1 && cnt('voice/sys_menu_lobby') === 1 && cnt('voice/sys_menu_resume') === 2, '单击循环顺序 = [回到直播, 重新开始今晚, 保存并回大厅],末项单击回绕首项再报名'); await g(game, 'up'); await advance(() => !game.voicePlaying); assert(cnt('voice/sys_help') >= 1, '菜单内上滑播 sys_help 帮助'); await g(game, 'doubletap'); // 当前项 = 回到直播 assert(game.phase === 'ONAIR' && game.nodeId === 'p1', '双击确认「回到直播」→ phase/node 不变'); await advance(() => game.choiceArmed); assert(cnt('voice/p1') >= 2, '菜单关闭后重播当前节点(局面复述,含抉择提示)'); await g(game, 'down'); // 再开 await advance(() => !game.voicePlaying); await g(game, 'down'); // 下滑关闭 assert(game.phase === 'ONAIR' && game.nodeId === 'p1', '下滑关闭菜单 → 回原状态'); await advance(() => game.choiceArmed); // 保存并回大厅 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); } // ================================================================ 中断复述 // (§9 边界:杀进程重进 → sys_continue_ask → 单击恢复 + 上滑状态链顺序断言; // §8.1 断点续玩:单击=继续上次 / 右滑=新夜班) realLog('== 中断复述(guide=off,预置存档 node=k1_asko)=='); { clearSave(); resetSettings('off'); storageData.set(SAVE_KEY, JSON.stringify({ schema: 1, updatedAt: new Date().toISOString(), summary: '第3通·失眠的孩子 · 口碑+2 · 结局0/4', phase: 'ONAIR', node: 'k1_asko', call: 3, ending: null, rep: 2, knot: false, heardArgument: true, bond: { zhou: 1, deer: 1, kid: 0, wife: 0, han: 0 }, endings: {}, nights: 3, lastCloser: 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 === 'ONAIR' && game.nodeId === 'k1_asko' && game.callN === 3, '单击继续 → 恢复 phase+node+call'); assert(game.rep === 2 && game.heardArgument === true && game.nights === 3, 'rep / heardArgument / nights 全部还原'); await advance(() => !game.voicePlaying); assert(voicePlays(playLog, 'voice/k1_asko') === 1, '重播当前节点链(局面复述)'); await g(game, 'up'); await advance(() => !game.voicePlaying); const iH = lastIndexOf(playLog, 'voice/sys_help'); const iP = lastIndexOf(playLog, 'voice/prog_c3'); const iR = lastIndexOf(playLog, 'voice/rep_mid'); assert(iP >= 0 && iP < iR && iR < iH, '上滑状态链顺序(G6):prog_c3 → rep_mid → sys_help'); assert(voicePlays(playLog, 'voice/prog_c3') === 1, 'prog_c3 整句(第三通)'); await advance(() => !game.voicePlaying); assert(voicePlays(playLog, 'voice/k1_asko') === 2, '状态播完重播当前节点(局面复述)'); await g(game, 'tap'); // k1_asko 链末 → p5 await advance(() => game.choiceArmed); await g(game, 'left'); // p5 左 assert(game.rep === 3, '恢复局内抉择正常结算:p5 左 rep 2+1=3'); teardown(game); // ASK 右滑 = 新夜班(§8.1):复用上面会话的最新存档再进一次(storageData 保留 p5 抉择后的存档) { const { audio: audio2, game: game2 } = makeRig(); await audio2.unlock(); game2.start(); await flush(); assert(game2.phase === 'ASK', '再次带存档重进 → ASK 询问态'); await g(game2, 'right'); assert(game2.phase === 'INTRO' && game2.rep === 0 && !game2.knot && !game2.heardArgument && game2.saved === null, 'ASK 右滑 = 新夜班:跨通话变量重置、存档询问态清除'); teardown(game2); } } // ================================================================ 看门狗 // (§7.2/§9:RING 两级不同句 10s/25s;ONAIR 链末 25s/50s gd_continue/gd_again; // p* 播完 25s/50s 复述两级;ENDING 25s/50s;guide 计时、手势复位、用尽静默) realLog('== 看门狗(guide=full,RING 两级 / ONAIR 链末 / p* 两级 / ENDING 两级)=='); { clearSave(); resetSettings('full'); const { audio, playLog, game } = makeRig(); const guideEvents = []; game.guide.setEventHook((ev) => guideEvents.push(ev)); await audio.unlock(); game.start(); await flush(); await advance(() => game.currentVoiceId === 'voice/b_open'); // sys_new_night 播完接 b_open 链 assert(voicePlays(playLog, 'voice/gd_brief') === 1, 'full 档开场链最前插 gd_brief'); assert(guideEvents.some((e) => e.type === 'state' && e.name === 'explore'), 'INTRO 进入播 explore 签名'); await advance(() => game.phase === 'RING'); const tR = now; assert(guideEvents.some((e) => e.type === 'turn'), 'RING 进入 turn 事件(铃声 + turn cue)'); await advance(() => voicePlays(playLog, 'voice/gd_ring1') === 1); assert(now - tR === 10000, 'RING 看门狗第一级 10s → gd_ring1'); await advance(() => voicePlays(playLog, 'voice/gd_ring2') === 1); assert(now - tR === 25000, 'RING 看门狗第二级 25s → gd_ring2(与第一级不同句)'); const r1 = voicePlays(playLog, 'voice/gd_ring1'); const r2 = voicePlays(playLog, 'voice/gd_ring2'); assert(r1 === 1 && r2 === 1, '两级提示各播一次(绝不逐字重播)'); await advance(() => now >= tR + 40000); assert(voicePlays(playLog, 'voice/gd_ring1') === r1 && voicePlays(playLog, 'voice/gd_ring2') === r2 && game._ringSrc !== null, '两级用尽后只剩铃声循环垫,永不惩罚'); // ONAIR 链末等待看门狗(§7.2 叙事链末行 / §9:25s gd_continue、50s gd_again) await g(game, 'tap'); // 接听 → z1_ring_in await advance(() => !game.voicePlaying); // 链末等待,看门狗挂载 const tW = now; await advance(() => voicePlays(playLog, 'voice/gd_continue') === 1); assert(now - tW === 25000, 'ONAIR 链末 25s 静默 → gd_continue'); await advance(() => voicePlays(playLog, 'voice/gd_again') === 1); assert(now - tW === 50000, '50s → gd_again(第二级新句)'); await advance(() => now >= tW + 70000); assert(voicePlays(playLog, 'voice/gd_again') === 1, 'ONAIR 两级用尽彻底静默'); await g(game, 'tap'); // 手势复位看门狗,继续 → z1_a await tapUntil(game, () => game.choiceArmed, 8); // z1_a→z1_b→p1 const tP = now; await advance(() => voicePlays(playLog, 'voice/p1') === 2); assert(now - tP === 25000, 'p* 播完 25s 无操作 → 重播当前 p* 提示句(第一级)'); await advance(() => voicePlays(playLog, 'voice/p1') === 3); assert(now - tP === 50000, '50s → 再复述一次(第二级)'); await advance(() => now >= tP + 70000); assert(voicePlays(playLog, 'voice/p1') === 3, 'p* 复述两级后彻底静默'); await g(game, 'left'); assert(game.rep === 1 && game.nodeId === 'z1_song', '复述期间剧情不推进,玩家抉择后正常生效'); await tapUntil(game, () => game.choiceArmed, 8); // z1_song→z1_songo→z1_sdone→p2 await g(game, 'left'); // p2 左(rep 3) await tapUntil(game, () => game.phase === 'BRIDGE', 10); assert(voicePlays(playLog, 'voice/rep_mid') === 1, '口碑跨档(low→mid)→ BRIDGE 被动播报 rep_mid'); await advance(() => game.phase === 'RING'); await g(game, 'tap'); // 接听 c2 await tapUntil(game, () => game.choiceArmed, 8); // l1_in→l1_a→l1_b→p3 await g(game, 'left'); // p3 左(rep 5) await tapUntil(game, () => game.phase === 'BRIDGE', 10); assert(voicePlays(playLog, 'voice/rep_high') === 1, '口碑跨入 ≥5(mid→high)→ BRIDGE 播报 rep_high'); await advance(() => game.phase === 'RING'); // C3 起按剧本 A 剩余抉择打到 ENDING(playNight 内含接听/链末推进) await playNight(game, ['right', 'left', 'left', 'left', 'left', 'left']); assert(game.knot === true, 'p9 左 → knot=true'); await advance(() => game.phase === 'ENDING' && game.awaitingRestart); const tE = now; const gc0 = voicePlays(playLog, 'voice/gd_continue'); const ga0 = voicePlays(playLog, 'voice/gd_again'); await advance(() => voicePlays(playLog, 'voice/gd_continue') === gc0 + 1); assert(now - tE === 25000, 'ENDING 结算 25s 静默 → gd_continue'); await advance(() => voicePlays(playLog, 'voice/gd_again') === ga0 + 1); assert(now - tE === 50000, '50s → gd_again(第二级新句)'); await advance(() => now >= tE + 70000); assert(voicePlays(playLog, 'voice/gd_again') === ga0 + 1, '两级用尽彻底静默'); await g(game, 'tap'); assert(game.phase === 'INTRO' && game.nights === 2, '结算单击重开新夜班'); const choiceEvents = guideEvents.filter((e) => e.type === 'state' && e.name === 'choice').length; assert(choiceEvents === 9, `每个 p* 起播发一次 choice 签名(实际 ${choiceEvents})`); assert(guideEvents.some((e) => e.type === 'state' && e.name === 'settle'), 'BRIDGE/ENDING 进入 settle 签名'); assert(guideEvents.some((e) => e.type === 'state' && e.name === 'danger'), 'C5 危机拍 danger 签名(全局唯一使用点)'); teardown(game); } // ================================================================ 资产缺失 // (§9 边界:屏蔽任意 voice 资产 → 单句看门狗自动续链到结局,不卡死) realLog('== 资产缺失(guide=off,屏蔽 k1_asko)=='); { clearSave(); resetSettings('off'); blockedAssets.add('k1_asko'); 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('k1_asko')), '屏蔽资产仅 console.error,不抛异常'); assert(consoleWarns.some((w) => w.includes('看门狗') && w.includes('k1_asko')), '单句 30s 看门狗判定资产缺失并自动续链'); assert(game.endingId === 'e1' && game.awaitingRestart === true, '屏蔽 voice 资产仍打到 e1 结局,不卡死'); assert(voicePlays(playLog, 'voice/gd_ring1') === 0 && voicePlays(playLog, 'voice/gd_ring2') === 0 && voicePlays(playLog, 'voice/gd_continue') === 0 && voicePlays(playLog, 'voice/gd_again') === 0, 'guide=off 全程零向导语音(gd_*)'); blockedAssets.delete('k1_asko'); teardown(game); } // ================================================================ 设置联动 // (§7 全局:voice 性别目录切换、speed 从下一句生效、bgm=0 停播与自动恢复、 // 长按 1.7x 引擎接管) realLog('== 设置联动与引擎行为(guide=off)=='); { clearSave(); resetSettings('off'); const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'RING'); await g(game, 'tap'); await tapUntil(game, () => game.promptPlaying, 10); // p1 settings.set({ voice: 'male', speed: 1.7 }); await g(game, 'left'); // 提示播放中 → pendingChoice 缓存 await advance(() => game.nodeId === 'z1_song'); // 提示播完缓存生效,z1_song 以新设置起播 assert(urlHits('audio/voice/male/z1_song') > 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, 8); // z1_song→z1_songo→z1_sdone→p2 assert(audio._bgm && audio._bgm.id === 'bgm/onair', 'ONAIR/RING 垫乐 bgm/onair'); 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/onair', 'bgm 恢复后自动补播最后状态垫乐'); teardown(game); } // ================================================================ R2 修复回归 // (research/new8/radio/round1/synthesis.md F1-F5 + V1/V2;断言增量登记 // docs/design/15-radio.md 文末「R2 修复回归」小节) realLog('== R2 修复回归(F1 看门狗重挂 / F3 铃声垫 / F4 续玩跳句锁 / F5 链末句保护 / V1 抉择时序 / V2 菜单循环)=='); { // ---- F1a:ONAIR 链末等待态发无效手势——看门狗被 disarm 拆掉后必须重挂(阈值内触发) clearSave(); resetSettings('full'); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'RING'); await g(game, 'tap'); // 接听 → z1_ring_in await advance(() => !game.voicePlaying); // 链末等待(看门狗挂载) const t0 = now; await g(game, 'left'); // 无效手势(live 局 B t=445.4 同款:旧实现拆看门狗不补挂 → 34.2s 静默) assert(voicePlays(playLog, 'voice/gd_invalid') === 1, 'F1 前置:链末无效手势照常零静默(gd_invalid)'); await advance(() => voicePlays(playLog, 'voice/gd_continue') === 1); assert(now - t0 === 25000, `F1:链末等待中发无效手势,看门狗仍在阈值内触发(25s 整,实际 ${now - t0}ms)`); await advance(() => voicePlays(playLog, 'voice/gd_again') === 1); assert(now - t0 === 50000, `F1:重挂后第二级同样有效(50s 整,实际 ${now - t0}ms)`); teardown(game); } // ---- F1b:RING 无效手势不拆铃看门狗(gd_ring1 仍准点)且铃声垫不受影响 clearSave(); resetSettings('full'); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'RING'); await flush(); const t0 = now; await g(game, 'left'); // RING 无效手势 await advance(() => voicePlays(playLog, 'voice/gd_ring1') === 1); assert(now - t0 === 10000, `F1:RING 无效手势后铃看门狗仍 10s 准点(实际 ${now - t0}ms)`); assert(game._ringSrc !== null, 'F3:RING 无效手势不拆铃声循环垫'); teardown(game); } // ---- F1c:ASK 询问态无效手势看门狗重挂 clearSave(); resetSettings('full'); storageData.set(SAVE_KEY, JSON.stringify({ schema: 1, updatedAt: new Date().toISOString(), summary: '第1通·走丢的老人 · 口碑+0 · 结局0/4', phase: 'ONAIR', node: 'z1_a', call: 1, ending: null, rep: 0, knot: false, heardArgument: false, bond: { zhou: 0, deer: 0, kid: 0, wife: 0, han: 0 }, endings: {}, nights: 1, lastCloser: null, })); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'ASK' && !game.voicePlaying); // sys_continue_ask 播完 const t0 = now; await g(game, 'up'); // ASK 内无效手势(上滑不在 ASK 语义内) await advance(() => voicePlays(playLog, 'voice/gd_continue') === 1); assert(now - t0 === 25000, `F1:ASK 无效手势后看门狗仍 25s 准点(实际 ${now - t0}ms)`); teardown(game); } // ---- F4/F5:续玩恢复首句跳句锁 + 链末句保护(w1_say 组一段链三连断言: // 首句受锁不可跳 → 锁解除后中段句可跳(对照) → 末句受 F5 保护不可跳) clearSave(); resetSettings('off'); storageData.set(SAVE_KEY, JSON.stringify({ schema: 1, updatedAt: new Date().toISOString(), summary: '第4通·争吵的夫妻 · 口碑+9 · 结局0/4', phase: 'ONAIR', node: 'w1_say', call: 4, ending: null, rep: 9, knot: false, heardArgument: true, bond: { zhou: 1, deer: 1, kid: 1, wife: 0, han: 0 }, endings: {}, nights: 1, lastCloser: null, })); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'ASK' && !game.voicePlaying); await g(game, 'tap'); // 单击继续上次 → 恢复 w1_say 组 assert(game.phase === 'ONAIR' && game.currentVoiceId === 'voice/w1_say', 'F4 前置:断点续玩恢复,首句 w1_say 在播'); await g(game, 'tap'); assert(game.currentVoiceId === 'voice/w1_say', 'F4:恢复后首句播完前单击不跳句(先听完再武装跳句)'); await advance(() => game.currentVoiceId === 'voice/w1_sayo'); await g(game, 'tap'); await flush(); assert(game.currentVoiceId === 'voice/w1_saye', 'F4 对照:首句播完后跳句重新武装(w1_sayo 被正常跳过)'); await g(game, 'tap'); // w1_saye = 本节点末句(播完交接 p7 抉择提示) assert(game.currentVoiceId === 'voice/w1_saye' && game.voicePlaying, 'F5:链末句(交接抉择提示前)单击不可跳'); await advance(() => !game.voicePlaying); assert(voicePlays(playLog, 'voice/w1_saye') === 1, 'F5 对照:链末句完整播出不被吃尾'); await g(game, 'tap'); // 链末单击继续 await advance(() => game.choiceArmed); assert(game.nodeId === 'p7', 'F5 对照:末句播完后照常交接 p7 抉择提示'); teardown(game); } // ---- F4b:续玩恢复到结算态——结局链首句播完才武装跳句(live 局 B 被扔进结局链的陷阱场景) clearSave(); resetSettings('off'); storageData.set(SAVE_KEY, JSON.stringify({ schema: 1, updatedAt: new Date().toISOString(), summary: '已收播·静音 · 结局1/4', phase: 'ENDING', node: 'e4', call: 5, ending: 'e4', rep: 0, knot: false, heardArgument: false, bond: { zhou: 0, deer: 0, kid: 0, wife: 0, han: 0 }, endings: { e4: true }, nights: 2, lastCloser: null, })); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'ASK' && !game.voicePlaying); await g(game, 'tap'); // 单击继续上次 → 结局链重播 assert(game.phase === 'ENDING' && game.currentVoiceId === 'voice/e4_a', 'F4b 前置:续玩恢复到 ENDING(e4) 结局链首句'); await g(game, 'tap'); assert(game.currentVoiceId === 'voice/e4_a', 'F4b:恢复后首句(e4_a)播完前单击不跳句'); await advance(() => game.currentVoiceId === 'voice/e4_b'); await g(game, 'tap'); await flush(); assert(game.currentVoiceId === 'voice/ep_lead', 'F4b:首句播完后跳句武装(e4_b 被跳过,链照常走)'); teardown(game); } // ---- F5b:@ending 过场转移前的链末句保护(d9_facee 点题句,live 局 A 被吃掉的尾句) clearSave(); resetSettings('off'); storageData.set(SAVE_KEY, JSON.stringify({ schema: 1, updatedAt: new Date().toISOString(), summary: '第5通·雨夜司机 · 口碑+12 · 结局0/4', phase: 'ONAIR', node: 'd9_facee', call: 5, ending: null, rep: 12, knot: true, heardArgument: true, bond: { zhou: 1, deer: 1, kid: 1, wife: 1, han: 1 }, endings: {}, nights: 1, lastCloser: null, })); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'ASK' && !game.voicePlaying); await g(game, 'tap'); assert(game.currentVoiceId === 'voice/d9_facee', 'F5b 前置:恢复至签收链末句 d9_facee'); await g(game, 'tap'); assert(game.currentVoiceId === 'voice/d9_facee', 'F5b:@ending 过场前的链末句单击不可跳'); await advance(() => game.phase === 'ENDING' && game.awaitingRestart); assert(game.endingId === 'e1' && voicePlays(playLog, 'voice/d9_facee') === 1, 'F5b 对照:末句完整播完后自动过场进 ENDING(e1)'); teardown(game); } // ---- V1:pendingChoice 生效时序——抉择语音播完才生效;播中连点不丢、不改、不提前 clearSave(); resetSettings('off'); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'RING'); await g(game, 'tap'); await tapUntil(game, () => game.promptPlaying, 10); // → p1 提示句播放中 await g(game, 'left'); await g(game, 'tap'); // 压力局连点:p* 不可跳…… await g(game, 'tap'); assert(game.currentVoiceId === 'voice/p1' && game.promptPlaying, 'V1 前置:p* 播放中连点不跳句'); assert(game.pendingChoice === 'left' && game.rep === 0 && game.nodeId === 'p1', 'V1:连点不清缓存、不提前生效(rep 未动、节点未进)'); await g(game, 'right'); assert(game.pendingChoice === 'right', 'V1:播中再滑覆盖缓存(最后一次为准)'); await g(game, 'left'); const iP1Last = lastIndexOf(playLog, 'voice/p1'); await advance(() => game.nodeId === 'z1_song'); const ids = playLog.map((e) => e.id); const iConfirm = ids.indexOf('sfx/ui_confirm'); assert(iConfirm > iP1Last && ids.indexOf('voice/z1_song') > iConfirm, 'V1:生效时序 = p1 播完 → ui_confirm 结算音 → 结果句(播完才生效,零插播)'); assert(game.rep === 1 && game.pendingChoice === null && game.nodeId === 'z1_song', 'V1:缓存恰好生效一次(p1 左 rep=1,缓存清空,不丢不重复)'); teardown(game); } // ---- V2:菜单全项循环——三项(含第三项「保存并回大厅」)逐项可达、循环无漂移、确认可执行 clearSave(); resetSettings('off'); { const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'RING'); await g(game, 'down'); // 呼出模态菜单 await advance(() => !game.voicePlaying); // sys_menu + 首项报名播完 for (let i = 0; i < 6; i++) { // 两个整循环(stress 局 38 次循环听不全第三项的回归) await g(game, 'tap'); await advance(() => !game.voicePlaying); } const ids = playLog.map((e) => e.id); const iHdr = ids.indexOf('voice/sys_menu'); const cycle = ids.slice(iHdr + 1, iHdr + 7); assert(JSON.stringify(cycle) === JSON.stringify([ 'voice/sys_menu_resume', 'voice/sys_menu_restart', 'voice/sys_menu_lobby', 'voice/sys_menu_resume', 'voice/sys_menu_restart', 'voice/sys_menu_lobby', ]), `V2:单击循环两个整周期逐项可达(实际: ${cycle.join(' → ')})`); assert(game.menuIdx === 0, 'V2:循环 6 次后回绕首项(模 3 无漂移)'); await g(game, 'tap'); await advance(() => !game.voicePlaying); await g(game, 'tap'); await advance(() => !game.voicePlaying); // 停在第二项 = 保存并回大厅 const lobBefore = voicePlays(playLog, 'voice/sys_menu_lobby'); await g(game, 'doubletap'); // 确认执行 await flush(); assert(game.leaving === true && voicePlays(playLog, 'voice/sys_menu_lobby') === lobBefore + 1, 'V2:双击确认「保存并回大厅」→ 确认句播出且 leaving 置位'); assert(saveData() && saveData().call === 1 && typeof saveData().summary === 'string', 'V2:确认执行落盘最新存档(含 summary,第1通)'); teardown(game); } } // ================================================================ R3 修复回归 // (research/new8/radio/round2/synthesis.md G1-G6 打磨轮;断言增量登记 // docs/design/15-radio.md 文末「R3 修复回归」小节) realLog('== R3 修复回归(G1 帮助链去重 / G2 rep_low warning 震 / G3 male 回退确认 / G4 空点轻震 / G5 首播复播显式规则 / G6 状态链重排)=='); { const WARN_PATTERN = JSON.stringify([30, 50, 30, 50, 30]); // warning 震型(G2) const BEAT_PATTERN = JSON.stringify([8]); // beat 震型(G1 标记 / G4 轻震) // ---- G6+G2+G1(ONAIR):上滑状态链「prog → rep → sys_help」+ rep_low warning 震 // + 同一等待点第 2 次起口令换纯音频标记(震 + nudge earcon),新等待点仍全量 clearSave(); resetSettings('full'); { const { audio, playLog, game } = makeRig(); const earcons = []; const origEarcon = game.guide._playEarcon.bind(game.guide); game.guide._playEarcon = (name) => { earcons.push(name); return origEarcon(name); }; const vib0 = vibrateLog.length; await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'RING'); await g(game, 'tap'); // 接听 → z1_ring_in await advance(() => !game.voicePlaying); // 链末等待(等待点 = S:z1_ring_in) await g(game, 'up'); // 该等待点第 1 次:全量口令 await advance(() => !game.voicePlaying); const iP1 = lastIndexOf(playLog, 'voice/prog_c1'); const iR1 = lastIndexOf(playLog, 'voice/rep_low'); const iH1 = lastIndexOf(playLog, 'voice/sys_help'); assert(iP1 >= 0 && iP1 < iR1 && iR1 < iH1, 'G6:ONAIR 上滑状态链顺序 prog_c1 → rep_low → sys_help(状态在前、帮助垫后)'); assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === WARN_PATTERN), 'G2:ONAIR 上滑复听 rep_low(rep=0)附 warning 震'); await advance(() => !game.voicePlaying); // 局面复述播完,回到同一链末等待 await g(game, 'up'); // 同一等待点第 2 次:纯音频标记 await advance(() => !game.voicePlaying); assert(voicePlays(playLog, 'voice/sys_help') === 1, 'G1:同一等待点第 2 次上滑不重播 sys_help 口令(提示永不逐字重复)'); assert(voicePlays(playLog, 'voice/prog_c1') === 2 && voicePlays(playLog, 'voice/rep_low') === 2, 'G1:状态句照播(复述状态=合法帮助,去重只针对口令)'); assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === BEAT_PATTERN), 'G1:第 2 次帮助播 beat 轻震标记(不加新台词的纯音频方案)'); assert(earcons.includes('nudge'), 'G1:第 2 次帮助播 nudge earcon(复用统一 8 音词表,零新增)'); await g(game, 'tap'); // 链末单击继续 → z1_a(新等待点) await advance(() => !game.voicePlaying); await g(game, 'up'); // 新等待点第 1 次 await advance(() => !game.voicePlaying); assert(voicePlays(playLog, 'voice/sys_help') === 2, 'G1:去重按等待点——新等待点(z1_a)首滑仍全量口令,非全局静默'); teardown(game); } // ---- G2(RING):上滑复听 rep_low 附 warning 震(live 局 B t=198.5/497.5 同款证据位, // R1 P3-3 遗留——原实现只在 BRIDGE 跨档播报时震且该分支实际不可达) clearSave(); resetSettings('off'); // off 档:warning 震来自游戏层,与向导档位无关 { const { audio, playLog, game } = makeRig(); const vib0 = vibrateLog.length; await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'RING'); await g(game, 'up'); // 状态播报:prog_c1 → rep_low(rep=0) await advance(() => !game.voicePlaying); const iP1 = lastIndexOf(playLog, 'voice/prog_c1'); const iR1 = lastIndexOf(playLog, 'voice/rep_low'); assert(iP1 >= 0 && iP1 < iR1, 'G2 前置:RING 上滑播 prog_c1 → rep_low(rep=0 → low 档)'); assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === WARN_PATTERN), 'G2:RING 上滑复听 rep_low 附 warning 震(R1 P3-3 遗留补齐)'); teardown(game); } // ---- G6+G2+G1(ENDING):上滑状态链「结局主句 → 口碑 → sys_help」+ 去重 clearSave(); resetSettings('full'); storageData.set(SAVE_KEY, JSON.stringify({ schema: 1, updatedAt: new Date().toISOString(), summary: '已收播·静音 · 结局1/4', phase: 'ENDING', node: 'e4', call: 5, ending: 'e4', rep: 0, knot: false, heardArgument: false, bond: { zhou: 0, deer: 0, kid: 0, wife: 0, han: 0 }, endings: { e4: true }, nights: 2, lastCloser: null, })); { const { audio, playLog, game } = makeRig(); const vib0 = vibrateLog.length; await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'ASK' && !game.voicePlaying); await g(game, 'tap'); // 续玩 → 结局链重播(F4 锁只锁首句) await advance(() => game.awaitingRestart); // 播完回结算态 await g(game, 'up'); // 第 1 次:e4_a → rep_low → sys_help await advance(() => !game.voicePlaying); const iE = lastIndexOf(playLog, 'voice/e4_a'); const iR = lastIndexOf(playLog, 'voice/rep_low'); const iH = lastIndexOf(playLog, 'voice/sys_help'); assert(iE >= 0 && iE < iR && iR < iH, 'G6:ENDING 上滑状态链顺序 e4_a(结局主句)→ rep_low → sys_help'); assert(vibrateLog.slice(vib0).some((v) => JSON.stringify(v.pattern) === WARN_PATTERN), 'G2:ENDING 上滑复听 rep_low 附 warning 震'); const helpN = voicePlays(playLog, 'voice/sys_help'); await g(game, 'up'); // 同一等待点(E:e4)第 2 次:纯音频标记 await advance(() => !game.voicePlaying); assert(voicePlays(playLog, 'voice/sys_help') === helpN, 'G1:ENDING 同一等待点第 2 次上滑不重播 sys_help 口令'); teardown(game); } // ---- G4+G5:不可跳句空点 beat 轻震(p* / F5 尾句 / F4 锁三处)+ // 「首播必完整、复播可即掐」——F4 锁只锁首播一句,复播(局面复述)不重新武装 clearSave(); resetSettings('off'); storageData.set(SAVE_KEY, JSON.stringify({ schema: 1, updatedAt: new Date().toISOString(), summary: '第4通·争吵的夫妻 · 口碑+9 · 结局0/4', phase: 'ONAIR', node: 'w1_say', call: 4, ending: null, rep: 9, knot: false, heardArgument: true, bond: { zhou: 1, deer: 1, kid: 1, wife: 0, han: 0 }, endings: {}, nights: 1, lastCloser: null, })); { const { audio, playLog, game } = makeRig(); const vib0 = vibrateLog.length; await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'ASK' && !game.voicePlaying); await g(game, 'tap'); // 恢复 w1_say 组(首句受 F4 锁) assert(game.currentVoiceId === 'voice/w1_say', 'G4 前置:断点续玩恢复,首句 w1_say 在播'); await g(game, 'tap'); // 锁内单击:不跳(G5 ASK 恢复首句例外) assert(game.currentVoiceId === 'voice/w1_say', 'G5:ASK 恢复链首句例外——锁内单击不跳(首播必完整,F4)'); assert(vibrateLog.slice(vib0).filter((v) => JSON.stringify(v.pattern) === BEAT_PATTERN).length === 1, 'G4:F4 锁内空点单击播 beat 轻震(可区分于真确认的 [20])'); await advance(() => game.currentVoiceId === 'voice/w1_sayo'); // 首句播完,锁释放 await g(game, 'down'); // 呼出菜单 await advance(() => !game.voicePlaying); await g(game, 'down'); // 关菜单 → 局面复述(重播 w1_say 组 = 复播) assert(game.currentVoiceId === 'voice/w1_say', 'G5 前置:关菜单局面复述回放首句 w1_say'); await g(game, 'tap'); // 复播的首句单击 → 可跳(锁不随复播重新武装) await flush(); assert(game.currentVoiceId === 'voice/w1_sayo', 'G5:复播可即掐——F4 锁只锁首播一句,复播(局面复述)首句单击照常跳过'); await advance(() => game.currentVoiceId === 'voice/w1_saye'); await g(game, 'tap'); // F5 链末句(交接 p7 前)单击不可跳 assert(game.currentVoiceId === 'voice/w1_saye' && game.voicePlaying, 'G5:首播必完整——链末关键句(交接抉择提示前)单击不可跳(F5)'); assert(vibrateLog.slice(vib0).filter((v) => JSON.stringify(v.pattern) === BEAT_PATTERN).length === 2, 'G4:F5 尾句空点单击播 beat 轻震(R2 P3-4「p9 后空点」证据位)'); await advance(() => !game.voicePlaying); // 尾句完整播完 → 链末等待 await g(game, 'tap'); // 链末单击继续 → p7 提示句起播 const vibP = vibrateLog.length; await g(game, 'tap'); // p* 播放中单击不可跳(PROMPT_RE 门) assert(game.currentVoiceId === 'voice/p7' && game.promptPlaying, 'G5:首播必完整——p* 首播单击不跳(PROMPT_RE 门)'); assert(vibrateLog.slice(vibP).some((v) => JSON.stringify(v.pattern) === BEAT_PATTERN), 'G4:p* 不可跳句空点单击播 beat 轻震(R1 P3-1 证据位)'); teardown(game); } // ---- G3:male 设置下 k1_a/k1_b(糖糖 NPC 平铺组)回退行为——性别目录 404 后 // 平铺版照常播出(非静默),回退链工作正常 → 确认 + 断言 + 文档注记 clearSave(); resetSettings('off'); settings.set({ voice: 'male' }); storageData.set(SAVE_KEY, JSON.stringify({ schema: 1, updatedAt: new Date().toISOString(), summary: '第3通·失眠的孩子 · 口碑+2 · 结局0/4', phase: 'ONAIR', node: 'k1_in', call: 3, ending: null, rep: 2, knot: false, heardArgument: false, bond: { zhou: 1, deer: 1, kid: 0, wife: 0, han: 0 }, endings: {}, nights: 3, lastCloser: null, })); { const { audio, playLog, game } = makeRig(); const errBefore = consoleErrs.length; await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'ASK' && !game.voicePlaying); await g(game, 'tap'); // 恢复 → k1_in(链末等待)→ 单击续进 k1_a → k1_b await tapUntil(game, () => game.currentVoiceId === 'voice/k1_b', 6); await advance(() => !game.voicePlaying); assert(urlHits('audio/voice/male/k1_a') >= 1 && urlHits('audio/voice/male/k1_b') >= 1, 'G3 前置:male 设置先尝试性别目录 male/k1_a、male/k1_b(无资产属设计——NPC 平铺组不做双性别)'); assert(voicePlays(playLog, 'voice/k1_a') === 1 && voicePlays(playLog, 'voice/k1_b') === 1, 'G3:male/k1_a、k1_b 404 后回退平铺版播出(非静默,引擎「性别目录→平铺」回退链正常)'); assert(!consoleErrs.slice(errBefore).some((e) => e.includes('k1_a') || e.includes('k1_b')), 'G3:回退命中平铺版 → 零「资产缺失」console.error(若真静默则修,实测确认无需修)'); settings.set({ voice: 'female' }); teardown(game); } } // ================================================================ 静态审计 // (数字规范:分档整句字面量、无运行时 id 拼接;存档 summary 格式;资产盘点) realLog('== 静态审计 =='); { const src = fs.readFileSync(path.join(GAME_DIR, 'game.js'), 'utf8'); for (const id of ['voice/prog_c1', 'voice/prog_c2', 'voice/prog_c3', 'voice/prog_c4', 'voice/prog_c5', 'voice/rep_high', 'voice/rep_mid', 'voice/rep_low', 'voice/gd_ring1', 'voice/gd_ring2']) { 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'); // 资产盘点:voice/bgm 全量齐备(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))); // Stable Audio 3 批次已全部补产落地(2026-09-02,research/new8/batch1-assets-report.md // §8:含 R2 F3 phone_ring,共 12 件);保留清单作为守卫——若未来出现新缺失必须仍属该组。 const STABLE_AUDIO_PENDING = new Set(['sfx/onair_sting', 'sfx/line_connect', 'sfx/line_drop', 'sfx/amb_rain_city', 'sfx/amb_station', 'sfx/amb_kid_room', 'sfx/arg_muffled', 'sfx/amb_street_rain', 'sfx/amb_cab', 'sfx/brake_screech', 'sfx/tape_static']); 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,ogg+m4a 双格式,含性别目录/平铺回退)`); assert(both('sfx/ui_confirm') && both('sfx/ui_menu') && both('sfx/phone_ring'), '程序合成 sfx ui_confirm/ui_menu/phone_ring 齐备(R2 F3 铃声循环垫落盘,ogg+m4a)'); assert(missingSfx.every((id) => STABLE_AUDIO_PENDING.has(id)), `sfx 缺失仅限 Stable Audio 未产出批次(实际: ${missingSfx.join(', ') || '无'})`); // 双性别 / NPC 平铺抽查(§5.3) assert(both('voice/male/p1') && both('voice/female/p1'), '抉择提示 p1 双性别齐备'); assert(both('voice/z1_a') && !fs.existsSync(path.join(AUDIO_DIR, 'voice/male/z1_a.ogg')), 'NPC 老周 z1_a 仅平铺目录(不做双性别,电话滤波质感)'); assert(both('voice/male/gd_ring1') && both('voice/female/gd_ring2'), 'RING 两级提示 gd_ring1/gd_ring2 双性别齐备'); // 存档 summary 人话格式(RING 进入时机) clearSave(); resetSettings('off'); const { audio, game } = makeRig(); await audio.unlock(); game.start(); await flush(); await advance(() => game.phase === 'RING'); const sd = saveData(); assert(sd && sd.schema === 1, '存档 schema=1'); assert(sd && sd.summary === '第1通·走丢的老人 · 口碑+0 · 结局0/4', `summary 格式「第N通·来电者 · 口碑±X · 结局K/4」(实际: ${sd && sd.summary})`); assert(sd && Number.isFinite(Date.parse(sd.updatedAt)), 'updatedAt 为 ISO 时间戳'); teardown(game); } // ================================================================ 汇总 realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`); if (failures.length > 0) { realLog('失败清单:'); for (const f of failures) realLog(` - ${f}`); process.exit(1); } realLog('全部断言通过。');