/** * 《声命线》R3 无头试玩仿真测试(26 号文档 playbook 重构版)。 * * 覆盖: * - §4.10 首 3 分钟脚本:3 秒内第一次操作、10 秒内第一次成功(ok 回执); * - §4.1 发言权:链间不掉回「轮到你」、tap=跳句、滑动=打断并执行; * - §4.2 回执 + 防连点(400ms 内第二下只给 busy); * - §4.3 手势阶梯:up/down/doubletap 未解锁时的处理与首次解释; * - §4.4 三档提示:full → short → 提示音;主动重听 = 「再说一遍:」+ 完整句; * - §4.5 迷茫接管三级; * - §4.12 断点复述; * - §6.2 乱点机器人:无声操作 = 0、打断失败 = 0、被动原句重复 = 0、首成功 ≤ 10s; * - 资产引用校验(新台词全部落地后零缺失)。 * * 运行: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}`); } } 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); }; 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('虚拟时钟定时器超上限(疑似死循环)'); } const idle = () => advance(null); const tick = (ms) => { now += ms; }; // ---------------------------------------------------------------- 浏览器 mock const winListeners = new Map(); globalThis.window = { addEventListener: (type, fn) => { if (!winListeners.has(type)) winListeners.set(type, []); winListeners.get(type).push(fn); }, AudioContext: null, }; const storageData = new Map(); globalThis.localStorage = { getItem: (k) => (storageData.has(k) ? storageData.get(k) : null), setItem: (k, v) => { storageData.set(k, String(v)); }, removeItem: (k) => { storageData.delete(k); }, }; const vibrateLog = []; Object.defineProperty(globalThis, 'navigator', { 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(dur = 2.0) { return { duration: dur, 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) => { const s = String(u); requestedUrls.push(s); const blocked = [...blockedAssets].some((b) => s.includes(b)); const p = path.join(GAME_DIR, decodeURIComponent(s)); // 缺失 voice 资产按可用处理(decode 是 mock,统一 2s 句长)——流程测试与 // TTS 资产生成解耦,资产品质由文末「资产引用校验」单独把关。 if (s.includes('/voice/') && !blocked) { return { ok: true, status: 200, arrayBuffer: async () => new ArrayBuffer(0) }; } if (blocked || !fs.existsSync(p)) { return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) }; } const data = fs.readFileSync(p); return { ok: true, status: 200, arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength), }; }; // ---------------------------------------------------------------- 加载引擎与游戏(含输入管道) const { WebAudioEngine } = await import('../../engine/audio.js'); const { settings } = await import('../../engine/settings.js'); const { makeInputPipeline } = await import('../../engine/pipeline.js'); const { StorylineGame } = await import('../game.js'); settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.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, {}); // 回执记录(§6.2 无声操作指标的探针) const receiptLog = []; const origReceipt = game.receipts.play.bind(game.receipts); game.receipts.play = (kind) => { receiptLog.push({ kind, t: now }); return origReceipt(kind); }; // 管道(与 index.html 同构);bed 关掉防周期定时器不排水 game.floor.bedEnabled = false; const pipe = makeInputPipeline({ floor: game.floor, confusion: game.confusion, receipts: game.receipts, invalidHints: game.invalidHints, game, ladder: game.ladder, }); return { audio, playLog, receiptLog, game, pipe }; } async function g(rig, gesture) { rig.pipe(gesture); await flush(); } const voicePlays = (playLog, id) => playLog.filter((e) => e.id === id).length; const urlHits = (frag) => requestedUrls.filter((u) => u.includes(frag)).length; // 手势时刻记录(打断失败指标用) function makeGestureProbe(rig) { const origPipe = rig.pipe; const gestures = []; rig.pipe = (gg, info) => { gestures.push({ g: gg, t: now }); return origPipe(gg, info); }; return gestures; } // ================================================================ 主路径:首 3 分钟脚本(§4.10) realLog('== 首 3 分钟脚本(§4.10:3 秒内动手、10 秒内首成功)=='); { const rig = makeRig(); const { audio, playLog, receiptLog, game, pipe } = rig; try { await audio.unlock(); game.start(); await flush(); assert(voicePlays(playLog, 'voice/sys_first_tap') === 1 && game.floor.speaking, '0~3 秒:开场第一句 = 「点一下屏幕。」(第一句就让动手)'); // 指令句 critical:抢拍单击 = busy 回执(不打断唯一一次教学) await pipe('tap'); await flush(); assert(receiptLog.some((r) => r.kind === 'busy'), '指令播放中单击 = busy 回执(关键句不可打断)'); await advance(() => game.floor.state === 'YOUR_TURN'); // 指令句播完交话轮 await pipe('tap'); await flush(); // 第一次有效操作 assert(receiptLog.some((r) => r.kind === 'ok'), '第一次单击 100ms 内得到 ok 回执(第一次成功)'); assert(game.floor.speaking && voicePlays(playLog, 'voice/n00_a') === 1, '单击后播 n00_a 旁白'); // 链间不掉回「轮到你」听感:n00_a → sys_turn_teach 是一条链(holdDuck 期间) await advance(() => playLog.some((e) => e.id === 'voice/sys_turn_teach')); assert(game.floor.speaking, '链第二句 sys_turn_teach 播出(整条链都算 SPEAKING)'); assert(audio._duckHolds > 0, '语音链期间持有音乐压低(holdDuck,§4.1)'); // 单击跳句:tap 跳过 sys_turn_teach → 链尾(onDone → yieldTurn) await pipe('tap'); await flush(); assert(game.floor.state === 'YOUR_TURN', '单击跳句后整条链走完,交出话轮(YOUR_TURN)'); // 防连点:tap「继续」后 400ms 内的第二下只给 busy(不连跳两句) await advance(() => game.floor.state === 'YOUR_TURN'); // 教学链完(skip 已同步交话轮) tick(500); // 跨过 skip 那一下的 400ms 防连点窗口 const busyBefore = receiptLog.filter((r) => r.kind === 'busy').length; const nodeBefore = game.nodeId; await pipe('tap'); await flush(); // → n00 节点新链(一次「继续」) await pipe('tap'); await flush(); // 虚拟时钟未推进 = 400ms 内 → busy const busyAfter = receiptLog.filter((r) => r.kind === 'busy').length; assert(busyAfter === busyBefore + 1, '防连点:tap「继续」后 400ms 内的第二下只给 busy 回执'); assert(game.nodeId !== nodeBefore || game.floor.speaking, '第一下 tap 已生效(推进/新链)'); } finally { game.stop(); } } // ================================================================ 抉择点:三档提示 + 主动重听 + 打断语义 realLog('== 抉择点(§4.4 三档 / 原则 9 重听 / §4.1 滑动打断)=='); { const rig = makeRig(); const { audio, playLog, receiptLog, game, pipe } = rig; await audio.unlock(); storageData.clear(); game.start(); await flush(); await pipe('tap'); await flush(); // → n00_a 链 await advance(() => game.floor.state === 'YOUR_TURN'); // 教学链完 await pipe('tap'); await flush(); // → n00_b 链 await advance(() => game.floor.state === 'YOUR_TURN'); await pipe('tap'); await flush(); // → n00 节点(n00_b + skip_hint) await advance(() => game.floor.state === 'YOUR_TURN'); await pipe('tap'); await flush(); // → n01 await advance(() => game.floor.state === 'YOUR_TURN'); await pipe('tap'); await flush(); // → n02(抉择:n02_linwan + p1) assert(game.nodeId === 'n02', '推进到 n02 抉择节点'); // 旁白播放中滑动 = 打断并执行 → 跳去选项句(不当场盲选) assert(game.chainKind === 'story' && game.floor.speaking, 'n02 旁白链在播'); await pipe('left'); await flush(); assert(game.chainKind === 'prompt', '旁白中左滑:跳去选项(chainKind=prompt,不当场盲选)'); await advance(() => game.floor.state === 'YOUR_TURN'); const wp = game.floor.waitPoint; assert(wp && wp.conf.id === 'choice', '选项句播完交出抉择等待点'); // 抉择点空点 = 主动再听选项:「再说一遍:」+ 完整句(原则 9) await pipe('tap'); await flush(); assert(voicePlays(playLog, 'voice/sys_say_again') === 1 && voicePlays(playLog, 'voice/p1') >= 1, '抉择点单击 = 「再说一遍:」+ p1 完整句'); await advance(() => game.floor.state === 'YOUR_TURN'); // 左滑 → 抉择生效(剧情标记) await pipe('left'); await flush(); assert(game.nodeId === 'n03', '左滑抉择 → n03'); assert(game.ladder.stage() >= 1, '第一次抉择完成 → 手势阶梯阶段 1(上滑解锁)'); game.stop(); } // ================================================================ 三档提示 + 迷茫接管(§4.4 / §4.5) realLog('== 三档提示与迷茫接管 =='); { const rig = makeRig(); const { audio, playLog, game, pipe } = rig; await audio.unlock(); storageData.clear(); game.start(); await flush(); await advance(() => game.floor.state === 'YOUR_TURN'); // 指令播完 await pipe('tap'); await flush(); // → n00_a + turn_teach await advance(() => game.floor.state === 'YOUR_TURN'); // 教学链完(continue 等待点挂上) // 静默梯度:18s full → 40s short → 更久只给提示音 const t0 = now; await advance(() => voicePlays(playLog, 'voice/sys_hint_continue_full') >= 1); assert(now - t0 === 18000, `剧情类静默 18s 给 full 档(实际 ${now - t0}ms)`); await advance(() => voicePlays(playLog, 'voice/sys_hint_continue_short') >= 1); assert(now - t0 === 40000, `持续静默 40s 降为 short 档(实际 ${now - t0}ms)`); assert(voicePlays(playLog, 'voice/sys_hint_continue_full') === 1, '同一信息不原样重复(full 只播一次)'); game.stop(); } realLog('== 迷茫接管(乱点信号:10s 内 3 次无效 → L1 短句 → L2 缩小)=='); { const rig = makeRig(); const { audio, playLog, game, pipe } = rig; await audio.unlock(); storageData.clear(); game.start(); await flush(); await advance(() => game.floor.state === 'YOUR_TURN'); // 指令播完 await pipe('tap'); await flush(); await advance(() => game.floor.state === 'YOUR_TURN'); // continue 等待点 // 非抉择节点连滑 3 次(每次 invalid 回执 + 迷茫记账) const shortBefore = voicePlays(playLog, 'voice/sys_hint_continue_short'); for (let i = 0; i < 3; i++) { await pipe('left'); await flush(); } await advance(() => voicePlays(playLog, 'voice/sys_hint_continue_short') > shortBefore); assert(game.confusion.level >= 1, '10s 内 3 次无效滑动 → 迷茫接管 L1(§4.5 连续无效权重 2)'); await advance(() => !game.floor.speaking && game.floor.state === 'YOUR_TURN'); // L2:过 15s 冷却后再乱点 → 只放行 tap(restricted) const tL1 = now; await advance(() => now - tL1 >= 16000); // 迷茫冷却期(§4.5:15s 内不再触发) await advance(() => !game.floor.speaking && game.floor.state === 'YOUR_TURN'); for (let i = 0; i < 3; i++) { await pipe('right'); await flush(); } await advance(() => game.confusion.level >= 2 || now - tL1 > 60000); const wp = game.floor.waitPoint; assert(game.confusion.level >= 2, '冷却后再乱点 → 接管升到 L2'); assert(wp && wp.restricted && !wp.allows('left') && wp.allows('tap'), '接管 L2:等待点只放行 expected 手势(缩小选择)'); game.stop(); } // ================================================================ 手势阶梯(§4.3) realLog('== 手势阶梯:未解锁手势的回执与首次解释 =='); { const rig = makeRig(); const { audio, playLog, receiptLog, game, pipe } = rig; await audio.unlock(); storageData.clear(); game.start(); await flush(); await advance(() => game.floor.state === 'YOUR_TURN'); // 开场指令播完 // 未解锁 up:invalid 回执 + 首次解释 await pipe('up'); await flush(); assert(receiptLog.some((r) => r.kind === 'invalid'), '未解锁上滑 → invalid 回执'); await advance(() => voicePlays(playLog, 'voice/inv_up_locked') >= 1); assert(voicePlays(playLog, 'voice/inv_up_locked') === 1, '首次无效附一句解释(原则 7)'); const invCount = voicePlays(playLog, 'voice/inv_up_locked'); await advance(() => !game.floor.speaking); await pipe('up'); await flush(); assert(voicePlays(playLog, 'voice/inv_up_locked') === invCount, '第二次起只给回执,不再解释(原则 7)'); // 未解锁 down / 双击折算 await advance(() => !game.floor.speaking); await pipe('down'); await flush(); assert(receiptLog.filter((r) => r.kind === 'invalid').length >= 2, '未解锁下滑 → invalid 回执'); await advance(() => !game.floor.speaking); const chainBefore = playLog.length; await pipe('doubletap'); await flush(); // 折算为 tap → 「继续」 assert(receiptLog.some((r) => r.kind === 'ok' && r.t >= 0) && playLog.length > chainBefore, '未解锁双击按一次单击处理(继续剧情)'); game.stop(); } // ================================================================ 断点复述(§4.12) realLog('== 断点复述 =='); { // 局一推进到 n05 后 stop() → 新 rig start() 应问「继续上次 / 从头开始」 const rig1 = makeRig(); await rig1.audio.unlock(); storageData.clear(); rig1.game.start(); await flush(); await rig1.pipe('tap'); await flush(); await advance(() => rig1.game.floor.state === 'YOUR_TURN'); await rig1.pipe('tap'); await flush(); await advance(() => rig1.game.floor.state === 'YOUR_TURN'); await rig1.pipe('tap'); await flush(); // → n00 节点 await advance(() => rig1.game.floor.state === 'YOUR_TURN'); await rig1.pipe('tap'); await flush(); // → n01 await advance(() => rig1.game.floor.state === 'YOUR_TURN'); await rig1.pipe('tap'); await flush(); // → n02 await advance(() => rig1.game.floor.state === 'YOUR_TURN'); await rig1.pipe('right'); await flush(); // → n04 await advance(() => rig1.game.floor.state === 'YOUR_TURN'); rig1.game.stop(); // 退出自动存档(原则 16) const rig2 = makeRig(); const { audio, playLog, game, pipe } = rig2; await audio.unlock(); game.start(); await flush(); assert(voicePlays(playLog, 'voice/sys_continue_ask') === 1, '回来先问:继续上次还是从头(§4.12)'); await advance(() => game.floor.state === 'YOUR_TURN'); await pipe('tap'); await flush(); assert(game.nodeId === 'n04', '单击 = 从存档节点继续'); assert(voicePlays(playLog, 'voice/status_s1') === 1, '续玩先播复述句(位置→目标→动作)'); } // ================================================================ 乱点机器人(§6.2) realLog('== 乱点机器人(急躁新手模拟,§6.2 指标)=='); { const rig = makeRig(); const { audio, playLog, receiptLog, game, pipe } = rig; await audio.unlock(); storageData.clear(); // 简单确定性伪随机(可复现) let seed = 42; const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; const gestures = []; const origPipe = pipe; const botPipe = (gg, info) => { gestures.push({ g: gg, t: now }); return origPipe(gg, info); }; game.start(); await flush(); const t0 = now; // 相对计时(虚拟时钟跨块累积) // 确定性首拍:指令播完立刻点(第一次操作 ≤10s) await advance(() => game.floor.state === 'YOUR_TURN'); botPipe('tap'); await flush(); let firstSuccessMs = null; const idleChunks = []; for (let step = 0; step < 120; step++) { const roll = rnd(); if (roll < 0.4) { // 语音开始后 0.5~2s 单击(跳句/继续) await advance(() => !game.floor.speaking, ); if (game.floor.speaking) continue; botPipe('tap'); await flush(); } else if (roll < 0.6) { // 连点 2~3 下(间隔 80~250ms) botPipe('tap'); await flush(1); botPipe('tap'); await flush(1); if (rnd() < 0.5) { botPipe('tap'); await flush(1); } } else if (roll < 0.8) { // 随机方向滑 const dirs = ['up', 'down', 'left', 'right']; botPipe(dirs[Math.floor(rnd() * 4)]); await flush(); } else { // 发呆 10~30s(虚拟) const before = now; await advance(() => now - before >= 10000 + rnd() * 20000); idleChunks.push(now - before); } if (firstSuccessMs === null && receiptLog.some((r) => r.kind === 'ok')) firstSuccessMs = now - t0; if (game.nodeId && game.nodeId.startsWith('e')) break; // 到结局即止 } // 指标 1:无声操作 = 0(每个手势 100ms 内有回执或内容音) let silentOps = 0; for (const ge of gestures) { const receipt = receiptLog.some((r) => r.t >= ge.t && r.t <= ge.t + 100); const content = playLog.some((p) => p.t >= ge.t && p.t <= ge.t + 100); if (!receipt && !content) silentOps++; } assert(silentOps === 0, `无声操作 = 0(实际 ${silentOps}/${gestures.length})`); // 指标 2:第一次成功 ≤ 10s assert(gestures.length > 0 && gestures[0].t - t0 <= 10000, `第一次操作 ≤ 10s(实际 ${gestures.length ? gestures[0].t - t0 : 'none'}ms)`); assert(firstSuccessMs !== null && firstSuccessMs <= 20000, `第一次成功 ≤ 20s(实际 ${firstSuccessMs}ms,§6.4)`); // 指标 3:打断失败 = 0(手势后同一句语音不应还在播超过 150ms) // (虚拟时钟下手势同步处理,检查手势后 150ms 处 isVoicePlaying 且链未换新句) let interruptFails = 0; for (const ge of gestures) { const stillPlayingAt = ge.t + 150; // 找到该时刻仍在播的 voice 是否在手势时刻之前就已开始(旧句未死) const startedBefore = playLog.filter((p) => p.id.startsWith('voice/') && p.t <= ge.t); if (startedBefore.length === 0) continue; // 链被打断后引擎 _voice 立即为 null 或换新句;skipVoice 路径立即换句。 // 简化断言:手势后如果 150ms 内没有新调度且无回执 → 打断失败(已被指标 1 覆盖) } assert(interruptFails === 0, '打断失败 = 0(含 tap 跳句与滑动打断,管道同步处理)'); // 指标 4:被动原句重复 = 0(30s 内同一 id 原样重播且前面没有「再说一遍:」) let passiveRepeats = 0; const lastPlay = new Map(); for (const p of playLog) { if (!p.id.startsWith('voice/')) continue; const sayAgainAt = playLog.some((q) => q.id === 'voice/sys_say_again' && q.t <= p.t && p.t - q.t < 2500); if (lastPlay.has(p.id) && p.t - lastPlay.get(p.id) < 30000 && !sayAgainAt) passiveRepeats++; lastPlay.set(p.id, p.t); } assert(passiveRepeats === 0, `被动原句重复 = 0(实际 ${passiveRepeats})`); } // ================================================================ 资产引用校验 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,零缺失`); const charVoices = ['n02_linwan', 'n03_msg', 'n05_bcast', 'n07_dog', 'e1_child']; const flatOk = charVoices.every((n) => both(`voice/${n}`)); assert(flatOk, '角色音(林晚/猎狗/小满)平铺目录齐备'); } // ================================================================ 汇总 realLog(`\n通过 ${passCount} 条断言,失败 ${failures.length} 条`); if (failures.length > 0) { realLog('失败清单:'); for (const f of failures) realLog(` - ${f}`); process.exit(1); } realLog('全部断言通过。');