/** * 《声命线》无头试玩仿真测试(docs/design/13-playtest-criteria.md §1 + §7)。 * * 方案: * - mock 浏览器环境:window/localStorage/navigator/fetch/AudioContext * (decodeAudioData 返回固定 duration 的假 buffer;fetch 直接读真实资产目录, * 缺失资产走引擎 console.error 静默路径); * - 使用**真实** engine/audio.js + settings.js(voice 链靠引擎 endTimer + * onComplete 推进,虚拟时钟驱动,不真实等待); * - 手势直接注入 game.onGesture(与 attachGestures 回调同路径); * - 虚拟时钟:patch setTimeout/clearTimeout/performance.now。 * * 运行:tools/node/bin/node web/storyline/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 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); }; 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('虚拟时钟定时器超上限(疑似死循环)'); } const idle = () => advance(null); // ---------------------------------------------------------------- 浏览器 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,语音走触屏措辞主资产 // (键盘变体路由的桌面路径由 harness/engine-input.test.mjs K9 专测) value: { maxTouchPoints: 1, vibrate: (...a) => { vibrateLog.push(a[0]); 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 运行时合成所需(guide=standard/full 时 guide.state/nudge 会创建) 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) => { const s = String(u); requestedUrls.push(s); 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 { StorylineGame } = await import('../game.js'); // 向导层关闭:回归测试只验证纯游戏逻辑(guide='full' 的行为由 harness/guide.test.mjs 覆盖) settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 }); let gestureCount = 0; function makeRig() { const audio = new WebAudioEngine('audio/'); const playLog = []; const origPlay = audio.play.bind(audio); audio.play = (id, opts) => { playLog.push({ id, t: now }); return origPlay(id, opts); }; const game = new StorylineGame(audio, {}); return { audio, playLog, game }; } async function g(game, gesture) { gestureCount++; game.onGesture(gesture); await flush(); if (process.env.TRACE) { realLog(` [gesture ${gesture}] node=${game.nodeId} mode=${game.mode} playing=${game.voicePlaying} cur=${game.currentVoiceId} armed=${game.choiceArmed}`); } } const voicePlays = (playLog, id) => playLog.filter((e) => e.id === id).length; const urlHits = (frag) => requestedUrls.filter((u) => u.includes(frag)).length; // ================================================================ 主路径试玩 realLog('== 主路径试玩(关键路径 + 边界断言)=='); { const { audio, playLog, game } = makeRig(); // §7.1 解锁前 play 为 no-op audio.play('voice/n00_intro'); await flush(); assert(audio._voice === null && consoleWarns.some((w) => w.includes('尚未解锁')), '启动遮罩点按前无音频播放(play 为 no-op)'); await audio.unlock(); // 1. start() → BGM + n00 旁白 game.start(); await flush(); assert(playLog.some((e) => e.id === 'bgm/dark_drone'), '开局播放 bgm/dark_drone'); assert(game.nodeId === 'n00' && game.voicePlaying && game.currentVoiceId === 'voice/n00_intro', 'n00 旁白开始(voice 层有播放记录)'); // 长按快进(引擎内置):临时 1.7× 不变调,松手恢复 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'); // ② 单击跳过本句:n00_intro → skip_hint(链继续,不断) await g(game, 'tap'); assert(game.voicePlaying && game.currentVoiceId === 'voice/skip_hint', 'voice 播放中单击 = 跳过本句(skipVoice 续链到 skip_hint)'); await idle(); assert(!game.voicePlaying && game.nodeId === 'n00', 'n00 链播完,等待单击继续'); assert(voicePlays(playLog, 'voice/skip_hint') === 1, '③ 开场 skip_hint 提示播了一次'); // 切男声:下一句起用 male 目录 settings.set({ voice: 'male' }); await g(game, 'tap'); // → n01 await flush(); assert(urlHits('audio/voice/male/n01_wake.') > 0, 'settings.voice=male 后旁白走 male 目录'); // 双击重听当前节点 const beforeReplay = voicePlays(playLog, 'voice/n01_wake'); await g(game, 'doubletap'); assert(game.currentVoiceId === 'voice/n01_wake' && voicePlays(playLog, 'voice/n01_wake') === beforeReplay + 1, '语音播放中双击 = 重听当前节点(同一 voice id 重播)'); // 菜单:BGM 切 menu pad,返回剧情节点不丢、BGM 淡回 await g(game, 'down'); assert(game.mode === 'MENU', '下滑呼出菜单'); await flush(); assert(audio._bgm && audio._bgm.id === 'bgm/menu', '② 进菜单 BGM 切 bgm/menu'); await idle(); // voice/menu 播完 await g(game, 'tap'); // 关菜单 assert(game.mode === 'STORY' && game.nodeId === 'n01', '关菜单返回剧情,节点不丢(不重头)'); await flush(); assert(audio._bgm && audio._bgm.id === 'bgm/dark_drone', '② 关菜单 BGM 淡回 dark_drone'); await idle(); // 重播 n01 播完 // 上滑:帮助 + 进度,随后自动重播当前节点 await g(game, 'up'); assert(game.currentVoiceId === 'voice/help', '上滑播放操作说明'); await idle(); assert(voicePlays(playLog, 'voice/status1') === 1, '帮助后播当前进度(第一幕 status1)'); // settings.bgm = 0 → 立即静默;调回 → 补播最后状态 BGM settings.set({ bgm: 0 }); await flush(); assert(audio._bgm === null, 'settings.bgm=0 时无 bgm 层播放'); settings.set({ bgm: 1 }); await flush(); assert(audio._bgm && audio._bgm.id === 'bgm/dark_drone', 'bgm 调回后自动补播最后状态 BGM'); // → n02(抉择点)。n02_linwan 是角色音(林晚):male 目录无,平铺回退命中 settings.set({ speed: 1.7 }); requestedUrls.length = 0; await g(game, 'tap'); await flush(); assert(game.nodeId === 'n02', '进入 n02 抉择节点'); assert(audio._voice && audio._voice.st.tempo === 1.7, 'settings.speed=1.7 下一句起生效'); assert(urlHits('audio/voice/male/n02_linwan.') > 0 && urlHits('audio/voice/n02_linwan.ogg') > 0, '④ 角色音 n02_linwan 走平铺目录回退(不随性别设置变化)'); settings.set({ speed: 1.0 }); // 抉择提示 p1 播放中:单击不可跳;滑动缓存 pendingChoice await advance(() => game.currentVoiceId === 'voice/p1'); assert(game.promptPlaying, '抉择提示 p1 播放中(promptPlaying=true)'); await g(game, 'tap'); assert(game.currentVoiceId === 'voice/p1', '① 抉择提示播放中单击不可跳'); await g(game, 'right'); assert(game.pendingChoice === 'right', '提示音播放中右滑缓存 pendingChoice'); await idle(); assert(game.nodeId === 'n04', '链尾 pendingChoice 生效 → n04'); assert(!game.companion && !game.knowTrap && !game.betrayed, '剧情标记初始为 false'); // n04 → n05 → n06 → n07(无语音时单击维持原义:继续剧情) settings.set({ voice: 'female' }); // 切回女声,验证女性设置下角色音同样平铺 for (const next of ['n05', 'n06', 'n07']) { await idle(); await g(game, 'tap'); assert(game.nodeId === next, `单击继续剧情 → ${next}`); } await idle(); assert(game.choiceArmed, 'n07 提示播完 choiceArmed=true(等待抉择)'); await g(game, 'right'); // 装死 → n09 assert(game.nodeId === 'n09' && game.knowTrap === true, 'n09 右滑 → knowTrap === true'); requestedUrls.length = 0; await idle(); await g(game, 'tap'); // → n10 await idle(); await g(game, 'left'); // 闻声寻去 → n11 assert(game.nodeId === 'n11', 'n10 左滑 → n11'); await idle(); await g(game, 'left'); // 带上小满 → n12 assert(game.nodeId === 'n12' && game.companion === true, 'n11 左滑 → companion === true'); await idle(); await g(game, 'tap'); // → n15 断桥 await idle(); await g(game, 'left'); // 涵洞(带小满 → 虚拟节点解析 n16b) assert(game.nodeId === 'n16b', '虚拟节点 resolve:带小满时 v_culvert → n16b'); await idle(); await g(game, 'tap'); // → n18 await idle(); assert(urlHits('audio/voice/n18_dog.ogg') > 0 || urlHits('audio/voice/female/n18_dog.') > 0, 'n18 旁白正常加载'); await g(game, 'left'); // 交出坐标 → n19 assert(game.nodeId === 'n19' && game.betrayed === true, 'n18 左滑 → betrayed === true'); await idle(); await g(game, 'tap'); // → n20 await idle(); await g(game, 'left'); // 赎罪 → n21 assert(game.nodeId === 'n21', 'n20 左滑 → n21'); await idle(); await g(game, 'tap'); // → e2 结局:代价 assert(game.nodeId === 'e2', '到达结局节点 e2(4 结局之一)'); await idle(); assert(voicePlays(playLog, 'voice/e2_price') === 1, '结局语音 e2_price 播出'); assert(game.awaitingRestart === true, '结局链播完进入结算态(awaitingRestart)'); assert(audio._bgm && audio._bgm.id === 'bgm/dark_drone', '② 结局结算保持 dark_drone 不切(10 号文档 §2)'); // 结算态单击重开回 n00 await g(game, 'tap'); assert(game.nodeId === 'n00' && game.mode === 'STORY' && !game.companion && !game.knowTrap && !game.betrayed, '结算态单击重开回 n00,剧情标记重置'); await idle(); assert(voicePlays(playLog, 'voice/skip_hint') === 1, '③ 重开后 skip_hint 不重播(每局一次)'); assert(gestureCount <= 40, `关键路径手势预算 ≤40(实际 ${gestureCount})`); } // ================================================================ 看门狗路径 realLog('== 看门狗路径(屏蔽 voice/n05_signal)=='); { settings.set({ voice: 'female', speed: 1.0, bgm: 1.0 }); blockedAssets.add('n05_signal'); const errBefore = consoleErrs.length; const { audio, game } = makeRig(); await audio.unlock(); game.start(); await idle(); await g(game, 'tap'); // n01 await idle(); await g(game, 'tap'); // n02 await idle(); // n02_linwan + p1 播完 await g(game, 'right'); // → n04 await idle(); await g(game, 'tap'); // → n05(第一句 n05_signal 被屏蔽) await idle(); // 看门狗 30s 超时续链 → n05_bcast 播完 assert(consoleErrs.slice(errBefore).some((e) => e.includes('n05_signal')), '屏蔽资产仅 console.error,不抛异常'); assert(consoleWarns.some((w) => w.includes('看门狗')), '语音看门狗超时自动续链'); assert(game.nodeId === 'n05' && !game.voicePlaying, '缺失 voice 不卡死:n05 链走完'); await g(game, 'tap'); // n06 await idle(); await g(game, 'tap'); // n07(非抉择点 n06 之后继续剧情) await idle(); await g(game, 'right'); // n07 装死 → n09 await idle(); await g(game, 'tap'); // n10 await idle(); await g(game, 'right'); // n14 await idle(); await g(game, 'tap'); // n15 await idle(); await g(game, 'right'); // n17(无小满 → v_cable → n17) assert(game.nodeId === 'n17', '虚拟节点 resolve:无小满时 v_cable → n17'); await idle(); await g(game, 'tap'); // n18 await idle(); await g(game, 'right'); // n22 await idle(); await g(game, 'tap'); // n23 await idle(); await g(game, 'left'); // n23 开闸 → n24 await idle(); await g(game, 'tap'); // n24 播完单击 → e4 assert(game.nodeId === 'e4', '屏蔽资产后仍可推进到结局 e4'); await idle(); assert(game.awaitingRestart === true, '看门狗路径到达结算态'); blockedAssets.clear(); } // ================================================================ live round2 N1 // (docs/design/13-playtest-criteria.md §16:真实在播的长语音不被 30s 看门狗掐断) realLog('== live round2 N1:长语音看门狗续约不掐(§16)=='); { settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 }); const { audio, playLog, game } = makeRig(); await audio.unlock(); // 预植 40s 长语音缓存(模拟真实 intro 71.7s 超 30s 阈值的情形;虚拟时钟不真实等待) // 缓存键格式(P1 起): ||(见 engine/audio.js _load) audio._cache.set('main|female|voice/n00_intro', Promise.resolve({ duration: 40.0, numberOfChannels: 1, sampleRate: 44100, getChannelData: () => new Float32Array(16), })); const warnBefore = consoleWarns.length; const t0 = now; game.start(); await flush(); assert(game.currentVoiceId === 'voice/n00_intro', 'N1:40s 长 intro 开播'); // 越过 30s 看门狗阈值:voice 真在播 → 不掐、不续链、无告警 await advance(() => now - t0 >= 30000); assert(game.currentVoiceId === 'voice/n00_intro' && audio.isVoicePlaying() && game.nodeId === 'n00', 'N1:30s 看门狗触发时 voice 真在播 → 不续链(仍停在 intro)'); assert(!consoleWarns.slice(warnBefore).some((w) => w.includes('看门狗')), 'N1:在播长语音不触发看门狗告警'); // 自然播完才续链下一句(skip_hint 不再顶掉 intro 末句——冷启动末句被吃的根因) await advance(() => game.currentVoiceId === 'voice/skip_hint'); assert(now - t0 >= 40000, `N1:intro 自然播完才续链 skip_hint(实际 ${now - t0}ms)`); assert(voicePlays(playLog, 'voice/skip_hint') === 1, 'N1:skip_hint 在 intro 播完后播出'); await idle(); assert(!game.voicePlaying && game.nodeId === 'n00', 'N1:长链正常走完进入等待态'); } // ================================================================ live round1 修复断言 // (docs/design/13-playtest-criteria.md §10:结局双击重听 / 结局状态播报; // §10 分支台词口径经 §16 N2 翻案——钢索路径恢复播 n18_dog) realLog('== live round1 修复断言(§10+§16:结局重听 / n18 两路径 / 结局状态)=='); { settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 }); const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await idle(); // n00 链播完 // 钢索路径推进到 n18:n01→n02(right→n04)→n05→n06→n07(right→n09)→n10(right→n14)→n15 await g(game, 'tap'); await idle(); // → n01 await g(game, 'tap'); await idle(); // → n02(抉择) await g(game, 'right'); await idle(); // → n04 await g(game, 'tap'); await idle(); // → n05 await g(game, 'tap'); await idle(); // → n06 await g(game, 'tap'); await idle(); // → n07(抉择) await g(game, 'right'); await idle(); // → n09 await g(game, 'tap'); await idle(); // → n10(抉择) await g(game, 'right'); await idle(); // → n14 await g(game, 'tap'); await idle(); // → n15(抉择) await g(game, 'right'); await idle(); // 钢索 → n17(无涵洞经历) assert(game.nodeId === 'n17' && game.viaCulvert === false, 'n15 右滑 → 钢索 n17(viaCulvert === false)'); // round2 N2(§10 P2-2 矫枉过正翻案):钢索路径恢复播 n18_dog 对峙戏 + p6 await g(game, 'tap'); await idle(); // → n18 assert(game.nodeId === 'n18' && voicePlays(playLog, 'voice/n18_dog') === 1 && voicePlays(playLog, 'voice/p6') === 1, 'N2:钢索路径 n18 恢复播 n18_dog(对峙戏)+ 抉择提示 p6(known-issue:涵洞句轻微穿帮待 TTS 分支句)'); // 推进到结局 e2:n19→n20(left→n21)→e2 await g(game, 'left'); await idle(); // n18 交坐标 → n19 await g(game, 'tap'); await idle(); // → n20(抉择) await g(game, 'left'); await idle(); // → n21 await g(game, 'tap'); await idle(); // → e2 assert(game.nodeId === 'e2' && game.awaitingRestart === true, '到达结局 e2 进入结算态'); // P2-1 结局双击重听:结算态双击 → 结局语音重播;重播中单击 = 跳句不重开 const e2before = voicePlays(playLog, 'voice/e2_price'); await g(game, 'doubletap'); assert(game.voicePlaying && game.currentVoiceId === 'voice/e2_price' && voicePlays(playLog, 'voice/e2_price') === e2before + 1, 'P2-1:结局结算态双击 = 重播结局语音'); assert(game.awaitingRestart === false, 'P2-1:结局重播期间离开结算态(单击回到跳句语义)'); await g(game, 'tap'); // 重播中单击 = 跳过本句 assert(game.nodeId === 'e2', 'P2-1:结局重播中单击跳过本句,不触发重开'); await idle(); assert(game.awaitingRestart === true && !game.voicePlaying, 'P2-1:结局重播链播完回到结算态'); // P3-2 结局后状态播报:help 后播当前结局语音(不再报 status3),播完回结算态 const e2again = voicePlays(playLog, 'voice/e2_price'); await g(game, 'up'); assert(game.currentVoiceId === 'voice/help', 'P3-2:结局后上滑先播 help'); await idle(); assert(voicePlays(playLog, 'voice/e2_price') === e2again + 1 && voicePlays(playLog, 'voice/status3') === 0, 'P3-2:结局后状态播报 = 当前结局语音(不再报 status3)'); assert(game.awaitingRestart === true && !game.voicePlaying, 'P3-2:状态播完直接回结算态(不二次重播节点)'); } // ================================================================ 静默兜底(standard 档) // (docs/design/13-playtest-criteria.md §10:13s gd_continue / 30s gd_again / 手势重置) realLog('== 静默兜底(guide=standard,§10 P2-3)=='); { settings.set({ guide: 'standard', voice: 'female', speed: 1.0, bgm: 1.0 }); const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await advance(() => !game.voicePlaying); // n00 链播完(精确停链尾,不越过看门狗) assert(game.nodeId === 'n00' && !game.voicePlaying, 'standard:n00 链播完进入等待态'); const tArm = now; await advance(() => voicePlays(playLog, 'voice/gd_continue') >= 1); assert(now - tArm === 13000, `standard:13s 无操作播 gd_continue(实际 ${now - tArm}ms)`); await advance(() => voicePlays(playLog, 'voice/gd_again') >= 1); assert(now - tArm === 30000, `standard:30s 升级播 gd_again(实际 ${now - tArm}ms)`); await idle(); // 两级用尽彻底静默(无更多定时器) // 玩家手势后重置:新节点播完重新挂载,13s 再次提示 await g(game, 'tap'); // → n01 await advance(() => !game.voicePlaying); assert(game.nodeId === 'n01' && !game.voicePlaying, 'standard:手势后进入 n01 等待态'); const gdBefore = voicePlays(playLog, 'voice/gd_continue'); const tWait = now; await advance(() => voicePlays(playLog, 'voice/gd_continue') > gdBefore); assert(now - tWait === 13000, `standard:手势重置后新等待点 13s 再提示(实际 ${now - tWait}ms)`); settings.set({ guide: 'off' }); } // ================================================================ 抉择节点静默兜底 // (docs/design/13-playtest-criteria.md §16 N3:25s/50s 重播 p* 提示、手势复位) realLog('== 抉择节点静默兜底(guide=standard,§16 N3)=='); { settings.set({ guide: 'standard', voice: 'female', speed: 1.0, bgm: 1.0 }); const { audio, playLog, game } = makeRig(); await audio.unlock(); game.start(); await advance(() => !game.voicePlaying); // n00 播完 await g(game, 'tap'); // → n01 await advance(() => !game.voicePlaying); await g(game, 'tap'); // → n02(抉择:n02_linwan + p1) await advance(() => !game.voicePlaying); assert(game.nodeId === 'n02' && game.choiceArmed, 'N3:n02 抉择链播完,等待抉择'); const p1first = voicePlays(playLog, 'voice/p1'); const tArm = now; await advance(() => voicePlays(playLog, 'voice/p1') > p1first); assert(now - tArm === 25000, `N3:抉择播完 25s 无操作重播 p1(实际 ${now - tArm}ms)`); await advance(() => voicePlays(playLog, 'voice/p1') > p1first + 1); assert(now - tArm === 50000, `N3:50s 再复述一次 p1(实际 ${now - tArm}ms)`); await idle(); // 两级用尽彻底静默 assert(game.nodeId === 'n02' && game.choiceArmed, 'N3:复述期间剧情不推进、仍待抉择'); // 空点复位:抉择等待态单击(无进展手势)重置计时,再 25s 复述 await g(game, 'tap'); const p1again = voicePlays(playLog, 'voice/p1'); const tTap = now; await advance(() => voicePlays(playLog, 'voice/p1') > p1again); assert(now - tTap === 25000, `N3:空点后计时重置,25s 再复述(实际 ${now - tTap}ms)`); // 玩家抉择后兜底终止:右滑 → n04,此后不再复述 p1 await g(game, 'right'); await advance(() => !game.voicePlaying); assert(game.nodeId === 'n04', 'N3:右滑抉择 → n04 剧情推进'); const p1final = voicePlays(playLog, 'voice/p1'); await idle(); assert(voicePlays(playLog, 'voice/p1') === p1final, 'N3:抉择后兜底终止(不再复述)'); settings.set({ guide: 'off' }); } // ================================================================ 资产引用校验 realLog('== 资产引用校验 =='); { const src = fs.readFileSync(path.join(GAME_DIR, 'game.js'), 'utf8'); 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))); let missing = 0; for (const id of ids) { if (id.startsWith('voice/')) { const name = id.slice(6); const ok = both(`voice/male/${name}`) || both(`voice/female/${name}`) || both(`voice/${name}`); if (!ok) { missing++; realLog(` ✗ 资产缺失: ${id}`); } } else if (!both(id)) { missing++; realLog(` ✗ 资产缺失: ${id}`); } } assert(missing === 0, `全部 ${ids.length} 个引用 id 均有 ogg+m4a(含性别目录/平铺回退),零缺失`); // ③ skip_hint 双性别齐备 assert(both('voice/male/skip_hint') && both('voice/female/skip_hint'), 'voice/skip_hint 双性别 ogg+m4a 齐备'); // ④ 角色音只在平铺目录(不随性别设置变化,12 号文档 §4 回归) const charVoices = ['n02_linwan', 'n03_msg', 'n05_bcast', 'n07_dog', 'e1_child']; const flatOk = charVoices.every((n) => both(`voice/${n}`)); const notGendered = charVoices.every((n) => !fs.existsSync(path.join(AUDIO_DIR, `voice/male/${n}.ogg`)) && !fs.existsSync(path.join(AUDIO_DIR, `voice/female/${n}.ogg`))); assert(flatOk && notGendered, '④ 角色音(林晚/猎狗/小满等)仅存平铺目录,性别目录无副本'); } // ================================================================ 汇总 realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`); if (failures.length > 0) { realLog('失败清单:'); for (const f of failures) realLog(` - ${f}`); process.exit(1); } realLog('全部断言通过。');