/** * 《剑与声》R4 无头试玩测试(26 号文档深度重构版,docs/design/26-apply-rpg.md §10)。 * * 以玩家身份模拟:真实 WebAudioEngine + 真实 makeInputPipeline(与 index.html 同构), * mock 浏览器环境(window/navigator/localStorage/fetch/虚拟时钟)。voice 资产按可用 * 处理(统一 2s 句长,流程与 TTS 落盘解耦);sfx/bgm 读真实文件;手势经管道注入。 * * 覆盖: * - §4.10 首 3 分钟脚本:critical 开场指令、3 秒内动手、10 秒内第一次成功; * - §4.1 发言权:SPEAKING/YOUR_TURN/ACTION 三态(旁白链跨句、等待点、前摇窗口); * - §4.2/§4.3:回执三件套、防连点、双击折算、up/down 阶梯锁 + 首次解释; * - §4.4 三档提示与台账降档;§4.5 迷茫接管 L1; * - 玩法断言(保留适配):第一章全链(教学战四步/格挡=窗口单击/升级/存档 summary/ * PUT 同步/断点继续)、格挡与防御减伤、前摇窗口、逃跑规则、战败重生、受伤播报档位; * - §6.2 乱点机器人:无声操作 0、被动原句重复 0、打断失败 0; * - 资产引用校验:sfx/bgm 零缺失;voice 落盘或列入 tools/voice_lines_26/rpg.md 待生成清单。 * * 运行:tools/node/bin/node web/rpg/test/playtest.mjs 退出码 0 = 全部通过。 */ 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 WEB_DIR = path.resolve(GAME_DIR, '..'); 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)); } /** 推进虚拟时钟直到 cond 成立(或定时器排空仍不成立 → 抛错带现场)。 */ async function advance(cond = null, timeoutMs = 120000, label = '') { const t0 = now; for (let n = 0; n < 500000; n++) { await flush(2); if (cond && cond()) return; let nextId = -1; let nextAt = Infinity; for (const [id, t] of timers) if (t.fireAt < nextAt) { nextAt = t.fireAt; nextId = id; } if (nextId < 0) { if (cond) { await flush(4); if (cond()) return; throw new Error(`advance 超时(无定时器可排且条件未满足)${label} @${now}ms`); } return; } if (cond && nextAt - now > timeoutMs) { throw new Error(`advance 超时(>${timeoutMs}ms)${label} @${now}ms`); } now = nextAt; const t = timers.get(nextId); timers.delete(nextId); t.cb(...t.args); if (cond && cond()) { await flush(2); if (cond()) return; } } throw new Error(`advance 步数超上限 ${label}`); } 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); }, location: { href: '' }, 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), copyToChannel: () => {}, }; } 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, playbackRate: { value: 1 }, connect() {}, start() {}, stop() {} }; } createOscillator() { return { type: 'sine', frequency: new MockParam(440), connect() {}, start() {}, stop() {} }; } // 注意:不提供 createBuffer——引擎尾垫静音逻辑(audio.js VOICE_TAIL_PAD)在 // mock buffer 上算不出帧数(length 缺失 → NaN 时长),走"无垫长"路径即可 createScriptProcessor() { return { connect() {}, disconnect() {}, onaudioprocess: null }; } decodeAudioData() { return Promise.resolve(fakeBuffer()); } resume() { this.state = 'running'; return Promise.resolve(); } } globalThis.window.AudioContext = MockAudioContext; const fetchLog = []; const blockedAssets = new Set(); globalThis.fetch = async (u) => { const s = String(u); if (s.includes('/api/')) { fetchLog.push(s); return { ok: true, status: 200, json: async () => ({}) }; } const blocked = [...blockedAssets].some((b) => s.includes(b)); const p = path.join(GAME_DIR, decodeURIComponent(s)); // 缺失 voice 资产按可用处理(mock decode 统一 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), }; }; // 种子 PRNG(mulberry32)+ 强制值队列(遭遇/逃跑判定用) let randState = 42; const randQueue = []; Math.random = () => { if (randQueue.length) return randQueue.shift(); randState |= 0; randState = (randState + 0x6D2B79F5) | 0; let t = Math.imul(randState ^ (randState >>> 15), 1 | randState); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; // ---------------------------------------------------------------- 加载引擎与游戏(含输入管道) const { WebAudioEngine } = await import('../../engine/audio.js'); const { settings } = await import('../../engine/settings.js'); const { makeInputPipeline } = await import('../../engine/pipeline.js'); const { RpgGame } = await import('../game.js'); settings.set({ guide: 'off', voice: 'female', speed: 1.0, bgm: 1.0 }); const CMDS = ['attack', 'skill', 'defend', 'item']; /** * 装配一套「页面级」游戏:真实引擎 + 真实输入管道(与 index.html 同构)。 * @param {object} [opts] opts.stage 预置手势阶梯阶段(0~3);opts.keepSave 保留存档 */ function makeRig(opts = {}) { if (!opts.keepSave) storageData.delete('soundgame.progress.rpg'); if (opts.stage === undefined) storageData.delete('soundgame.gestures'); else storageData.set('soundgame.gestures', JSON.stringify({ stage: opts.stage, games: 0 })); storageData.delete('soundgame.auth'); const audio = new WebAudioEngine('audio/'); const playLog = []; const origPlay = audio.play.bind(audio); audio.play = (id, opts2) => { playLog.push({ id, t: now }); return origPlay(id, opts2); }; const game = new RpgGame(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 关掉防周期定时器不排水(§6.2 机器人段同款) game.floor.bedEnabled = false; const pipe = makeInputPipeline({ floor: game.floor, confusion: game.confusion, receipts: game.receipts, invalidHints: game.invalidHints, game, ladder: game.ladder, opts: game.PIPELINE_OPTS || {}, }); return { audio, playLog, receiptLog, game, pipe }; } async function boot(opts) { const rig = makeRig(opts); await rig.audio.unlock(); return rig; } async function g(rig, gesture) { rig.pipe(gesture); await flush(); } const voicePlays = (rig, id) => rig.playLog.filter((e) => e.id === id).length; const lastPlayAt = (rig, id) => { const e = rig.playLog.filter((x) => x.id === id).at(-1); return e ? e.t : -1; }; function orderOk(rig, ...ids) { let pos = -1; for (const id of ids) { const i = rig.playLog.findIndex((e, j) => j > pos && e.id === id); if (i < 0) return false; pos = i; } return true; } /** 等到探索话轮就绪(选项报名完、等待点挂上)。 */ function atExplore(rig, scene) { const { game } = rig; return () => game.phase === 'explore' && game.scene === scene && !!game.floor.waitPoint && String(game.floor.waitPoint.conf.id).startsWith('explore') && !game.floor.speaking; } /** 等到选指令话轮就绪(指令名播完)。 */ function atSelect(rig) { const { game } = rig; return () => game.phase === 'battle' && game._battle && game._battle.state === 'PLAYER_SELECT' && game.floor.state === 'YOUR_TURN' && !game.floor.speaking; } /** 等到前摇窗口(ACTION)。 */ function atTelegraph(rig) { const { game } = rig; return () => game.phase === 'battle' && game._battle && game._battle.state === 'ENEMY_TELEGRAPH'; } /** 冷启动标准路径:等 critical 开场指令播完 → 第一下单击 → 等进村话轮。 */ async function coldStart(rig, scene = 'village') { rig.game.start(); await flush(); await advance(() => rig.game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); await advance(atExplore(rig, scene), 60000, '等冷启动进村话轮'); } /** 探索:先滑到目标选项(听报名),再单击确认。 */ async function exploreAct(rig, optId) { const opts = { village: ['go_path', 'shop', 'rest', 'status'], path: ['go_fork', 'investigate', 'back'], fork: ['go_chest', 'go_stream', 'back'], chest: ['open_chest', 'back'], stream: ['drink', 'go_hunter', 'back'], hunter: ['talk_hunter', 'go_deepwood', 'back'], deepwood: ['go_gate', 'back'], gate: ['assault', 'sneak', 'back'], }[rig.game.scene]; const from = opts.indexOf(optId); if (from < 0) throw new Error(`场景 ${rig.game.scene} 无选项 ${optId}`); // 先回到首项再数步数(不确定当前光标时最多滑一圈) for (let i = 0; i < opts.length; i++) { if (opts[rig.game.optIdx] === optId) break; await advance(() => rig.game.floor.state === 'YOUR_TURN' && !rig.game.floor.speaking); await g(rig, 'right'); await advance(() => !rig.game.floor.speaking); } if (opts[rig.game.optIdx] !== optId) throw new Error(`未能滑到 ${optId}`); await advance(() => rig.game.floor.state === 'YOUR_TURN' && !rig.game.floor.speaking); await g(rig, 'tap'); await flush(4); } /** 战斗:把指令切到目标(只允许手势注入)。 */ async function selectCmd(rig, cmd) { const b = rig.game._battle; const target = CMDS.indexOf(cmd); const rightSteps = (target - b.cmdIdx + CMDS.length) % CMDS.length; for (let i = 0; i < rightSteps; i++) { await g(rig, 'right'); await advance(() => !rig.game.floor.speaking); } } /** 战斗辅助:标准打法——选中攻击单击;前摇内立即单击格挡;低血量喝药。 */ async function fightSmart(rig, { maxRounds = 80, potionBelow = 0, doParry = true } = {}) { const { game } = rig; let guard = 0; while (game.phase === 'battle' && game._battle && guard++ < maxRounds * 8) { const b = game._battle; const state0 = b.state; if (state0 === 'PLAYER_SELECT') { const lowHp = potionBelow > 0 && game.player.hp <= potionBelow && game.player.potions > 0; await selectCmd(rig, lowHp ? 'item' : 'attack'); await advance(atSelect(rig), 30000, 'fightSmart 等选指令话轮'); await g(rig, 'tap'); await advance(() => game.phase !== 'battle' || game._battle !== b || b.state !== 'PLAYER_SELECT', 30000, 'fightSmart 等指令结算'); } else if (state0 === 'ENEMY_TELEGRAPH') { if (doParry) await g(rig, 'tap'); // 前摇窗口内单击 = 格挡(11 号口径) await advance(() => game.phase !== 'battle' || game._battle !== b || b.state !== 'ENEMY_TELEGRAPH', 30000, 'fightSmart 等前摇结算'); } else { await advance(() => game.phase !== 'battle' || game._battle !== b || b.state !== state0, 40000, 'fightSmart 等战斗状态流转'); } } } /** 从 vibrateLog 数指定震型(不依赖 console.log——测试里已静默)。 */ function countVibrate(pattern) { return vibrateLog.filter((v) => JSON.stringify(v) === JSON.stringify(pattern)).length; } // ================================================================ S1:首 3 分钟脚本(§4.10)+ 探索/商店/菜单 async function scenario1() { realLog('\n=== S1:开场脚本 + 探索 + 商店 + 菜单(预置阶梯阶段 2)==='); const rig = await boot({ stage: 2 }); const { game, playLog, receiptLog } = rig; try { game.start(); await flush(); // 0~3s:第一句 = critical 指令句(第一句就让动手) assert(voicePlays(rig, 'voice/sys_first_tap') === 1 && game.floor.speaking, '开场第一句 = 「点一下屏幕。」(critical)'); // 抢拍单击 = busy 回执(关键句不可打断) await g(rig, 'tap'); assert(receiptLog.some((r) => r.kind === 'busy'), '指令播放中单击 = busy 回执(关键句不打断)'); await advance(() => game.floor.state === 'YOUR_TURN'); // 第一次有效操作 + 第一次成功(ok 回执) const tFirst = now; await g(rig, 'tap'); assert(receiptLog.some((r) => r.kind === 'ok'), '开场第一下单击得到 ok 回执(第一次成功 ≤10s)'); assert(now - tFirst <= 10000 && voicePlays(rig, 'voice/intro_1') === 1, '单击后播 intro_1 序章'); // 序章链 → 进村(链尾「轮到你了」状态课,原则 2) await advance(() => voicePlays(rig, 'voice/sys_turn_teach') >= 1); assert(game.floor.speaking, 'sys_turn_teach 在链内播出(整条链都算 SPEAKING)'); assert(rig.audio._duckHolds > 0, '语音链期间持有音乐压低(holdDuck,§4.1)'); assert(voicePlays(rig, 'voice/skip_hint') === 1, '序章插播一次跳句提示 skip_hint'); await advance(atExplore(rig, 'village'), 60000, '等进村探索话轮'); assert(orderOk(rig, 'voice/npc_headman_1', 'voice/npc_headman_2', 'voice/desc_village', 'voice/opt_go_path'), '进村链:村长对白 → desc_village → 报「前进」有序'); assert(playLog.some((e) => e.id === 'bgm/village'), 'village 状态音乐 bgm/village'); assert(game.floor.state === 'YOUR_TURN' && game.floor.waitPoint.conf.id === 'explore_village', '探索话轮 = YOUR_TURN 等待点(explore_village)'); assert(rig.audio._duckHolds === 0, '链完释放音乐压低(句间不残留 duck)'); // 探索菜单:左右滑切选项(报名 = silent-ok,无叠回执) const receiptsBefore = receiptLog.length; await g(rig, 'right'); await advance(() => !game.floor.speaking); assert(voicePlays(rig, 'voice/opt_shop') === 1, '右滑切选项播 opt_shop(报名即反馈)'); assert(receiptLog.length === receiptsBefore, '报名 = silent-ok(不叠 ok 回执)'); await g(rig, 'left'); await advance(() => !game.floor.speaking); assert(voicePlays(rig, 'voice/opt_go_path') >= 1, '左滑切回 go_path'); // 上滑 = 状态链(阶段 2 已解锁) await g(rig, 'up'); await advance(() => !game.floor.speaking, 30000); assert(orderOk(rig, 'voice/st_lv_low', 'voice/st_hp_high', 'voice/st_gold_some', 'voice/st_weapon_none', 'voice/st_armor_none'), '上滑状态链五句完整有序'); assert(game.floor.state === 'YOUR_TURN', '状态链播完回到等待态(不掉安静态)'); // 菜单(RPG 豁免:左右滑切换 + 单击确认) await g(rig, 'down'); await advance(() => game.phase === 'menu' && game.floor.state === 'YOUR_TURN' && !game.floor.speaking, 20000, '等菜单话轮'); assert(orderOk(rig, 'voice/sys_menu', 'voice/sys_menu_resume'), '下滑开菜单:sys_menu + 首项报名'); assert(voicePlays(rig, 'bgm/menu') >= 0 && playLog.some((e) => e.id === 'bgm/menu'), '菜单状态音乐 bgm/menu'); await g(rig, 'right'); await advance(() => !game.floor.speaking); assert(voicePlays(rig, 'voice/sys_menu_save') === 1, '菜单右滑切到「保存并回大厅」'); await g(rig, 'right'); await advance(() => !game.floor.speaking); assert(voicePlays(rig, 'voice/sys_menu_help') === 1, '菜单再右滑切到「重新听帮助」'); await g(rig, 'tap'); // 确认 help 项 → 帮助链 await advance(() => !game.floor.speaking, 30000); assert(orderOk(rig, 'voice/help_explore', 'voice/help_battle', 'voice/settings_hint'), '菜单帮助链:探索 + 战斗 + 设置提示(修改稿无双击口径)'); await g(rig, 'down'); // 下滑关闭 await advance(atExplore(rig, 'village'), 30000, '等关菜单回探索'); assert(game.floor.waitPoint.conf.id === 'explore_village', '关菜单重挂探索等待点'); // 商店:钱不够 → shop_poor + invalid;买金疮药;离开 await exploreAct(rig, 'shop'); await advance(() => game.phase === 'shop' && game.floor.state === 'YOUR_TURN' && !game.floor.speaking, 20000, '等商店话轮'); assert(orderOk(rig, 'voice/shop_open', 'voice/shop_price_sword'), '进商店:shop_open + 铁剑报价'); const invBefore = receiptLog.filter((r) => r.kind === 'invalid').length; await g(rig, 'tap'); await advance(() => !game.floor.speaking); assert(voicePlays(rig, 'voice/shop_poor') === 1 && receiptLog.filter((r) => r.kind === 'invalid').length === invBefore + 1, '金币 50 买铁剑(100) → shop_poor + invalid 回执,金币不变'); assert(game.player.gold === 50 && !game.player.weapon, '金币/装备不变'); await g(rig, 'right'); await advance(() => !game.floor.speaking); await g(rig, 'right'); await advance(() => !game.floor.speaking); assert(voicePlays(rig, 'voice/shop_price_potion') === 1, '右滑两下切到金疮药报价'); await g(rig, 'tap'); await advance(() => !game.floor.speaking); assert(game.player.gold === 20 && game.player.potions === 2 && voicePlays(rig, 'voice/shop_buy') === 1, '买金疮药成功(50→20 金,药 1→2,shop_buy)'); await g(rig, 'right'); await advance(() => !game.floor.speaking); assert(voicePlays(rig, 'voice/gd_wrap_last') === 1, '落到「离开」末项接末位提示 gd_wrap_last'); await g(rig, 'tap'); await advance(atExplore(rig, 'village'), 20000, '等离店回探索'); } finally { game.stop(); } } // ================================================================ S2:手势阶梯锁 + 无效解释一次(§4.3/原则 7) async function scenario2() { realLog('\n=== S2:阶梯锁(阶段 0)+ 无效解释一次 + 三档提示 + 接管 L1 ==='); const rig = await boot({}); const { game, receiptLog } = rig; try { game.start(); await flush(); await advance(() => game.floor.state === 'YOUR_TURN'); // critical 指令播完 // 开场等待点的无效滑动:invalid + 首次解释(sys_first_tap_short) await g(rig, 'left'); assert(receiptLog.some((r) => r.kind === 'invalid'), '开场等待点左滑 = invalid 回执'); await advance(() => !game.floor.speaking); assert(voicePlays(rig, 'voice/sys_first_tap_short') === 1, '首次无效附一句解释「点一下。」'); const explained = voicePlays(rig, 'voice/sys_first_tap_short'); await advance(() => game.floor.state === 'YOUR_TURN' && !game.floor.speaking); await g(rig, 'right'); await advance(() => !game.floor.speaking); assert(voicePlays(rig, 'voice/sys_first_tap_short') === explained, '第二次无效只给回执(原则 7)'); // 再来一次无效(10s 内第 3 次)→ 迷茫接管 L1(§4.5:连续无效权重 2) await advance(() => game.floor.state === 'YOUR_TURN' && !game.floor.speaking); await g(rig, 'left'); await advance(() => game.confusion.level >= 1, 20000, '等迷茫接管 L1'); assert(game.confusion.level >= 1, '10s 内 3 次无效 → 迷茫接管 L1'); assert(voicePlays(rig, 'voice/sys_first_tap_short') === explained, '接管 L1 台账降档:短句刚说过 → 只给提示音(原则 9)'); // 双击折算成单击(阶段 0 无双击) await advance(() => game.floor.state === 'YOUR_TURN' && !game.floor.speaking); await g(rig, 'doubletap'); await advance(() => voicePlays(rig, 'voice/intro_1') >= 1); assert(voicePlays(rig, 'voice/sys_say_again') === 0, '未解锁双击按单击处理(不触发重听)'); await advance(atExplore(rig, 'village'), 60000, '等进村'); // 探索态 up/down 未解锁:invalid + 各自首次解释 await g(rig, 'up'); assert(receiptLog.filter((r) => r.kind === 'invalid').length >= 1, '探索态未解锁上滑 = invalid 回执'); await advance(() => !game.floor.speaking); assert(voicePlays(rig, 'voice/inv_up_locked') === 1, '首次解释 inv_up_locked'); await g(rig, 'down'); await advance(() => !game.floor.speaking); assert(voicePlays(rig, 'voice/inv_down_locked') === 1, '首次解释 inv_down_locked(菜单通关解锁)'); // 探索等待点静默三档:18s 报短句(选项名已入台账 → 不原样重播),40s 提示音 await advance(() => game.floor.state === 'YOUR_TURN' && !game.floor.speaking, 30000, '等回等待态'); const t0 = now; await advance(() => voicePlays(rig, 'voice/hint_explore_short') >= 1, 60000, '等 18s 静默提示'); assert(now - t0 >= 10000 && now - t0 < 22000, `静默 18s 档给短句(实际 ${now - t0}ms,含报名/解释链偏移)`); assert(voicePlays(rig, 'voice/opt_go_path') === 1, '选项名已报名 → 被动提示降档不原样重播(原则 9)'); } finally { game.stop(); } } // ================================================================ S3:第一章全链(玩法断言保留适配) async function scenario3() { realLog('\n=== S3:第一章通关全链(教学战四步 → Boss → 断点继续)==='); const rig = await boot({}); storageData.set('soundgame.auth', JSON.stringify({ token: 'test-token' })); // 验证 PUT 同步 const { game, playLog } = rig; try { await coldStart(rig); // path:调查 +15 金 await exploreAct(rig, 'go_path'); await advance(atExplore(rig, 'path'), 40000, '等进 path'); await exploreAct(rig, 'investigate'); await advance(() => voicePlays(rig, 'voice/evt_gold15') >= 1, 20000, '等调查播报'); assert(game.player.gold === 65, `investigate 一次 +15 金(实际 ${game.player.gold})`); await exploreAct(rig, 'investigate'); await advance(() => voicePlays(rig, 'voice/evt_nothing') >= 1, 20000, '等二次调查播报'); assert(game.player.gold === 65, '再次调查不加金(evt_nothing)'); // fork → chest 开箱 await exploreAct(rig, 'go_fork'); await advance(atExplore(rig, 'fork'), 40000, '等进 fork'); await exploreAct(rig, 'go_chest'); await advance(atExplore(rig, 'chest'), 40000, '等进 chest'); await exploreAct(rig, 'open_chest'); await advance(() => voicePlays(rig, 'voice/evt_chest') >= 1, 20000, '等开箱播报'); assert(game.player.potions === 3, '开箱金疮药 +2(本场景未购物:1→3)'); // 回 fork → stream → 教学战 await exploreAct(rig, 'back'); await advance(atExplore(rig, 'fork'), 40000, '等回 fork'); await exploreAct(rig, 'go_stream'); await advance(() => game.phase === 'battle' && game._battle && game._battle.encId === 'enc_teach_wolf', 40000, '等教学战触发'); // ---- 教学战四步(11 号 §2;格挡 = 窗口内单击)---- const bt = game._battle; assert(bt.enemy.atk === 2 && bt.tier.telSec === 1.8, '教学狼 ATK=2、前摇 1.8s'); await advance(atSelect(rig), 30000, '等教学 T0 话轮'); assert(voicePlays(rig, 'voice/tut_b0') === 1 && voicePlays(rig, 'voice/tut_battle_1') === 0, 'T0 一句一教:tut_b0(旧 tut_battle_1..5 长教学链已废除)'); assert(playLog.some((e) => e.id === 'sfx/turn_player'), '玩家回合播 turn_player(战斗交接音)'); // T0:左右滑各一次 await g(rig, 'right'); await advance(() => !game.floor.speaking); await g(rig, 'left'); await advance(() => bt.teachStage === 'attack', 30000, '等 T1'); assert(voicePlays(rig, 'voice/tut_b3') >= 1, 'T1 攻击课:tut_b3(修改稿「选攻击,单击出剑。」)'); await advance(atSelect(rig), 30000, '等 T1 话轮'); await g(rig, 'tap'); // 攻击命中 await advance(() => voicePlays(rig, 'voice/tut_b2') >= 1, 30000, '等 T2 格挡课链'); assert(voicePlays(rig, 'voice/tut_b1') === 1 && voicePlays(rig, 'voice/tut_b2') === 1, 'T2 格挡课:tut_b1 + tut_b2(修改稿「点一下挡住」)'); await advance(atTelegraph(rig), 30000, '等前摇窗口'); assert(game.floor.state === 'ACTION', '前摇窗口 = ACTION 态(玩法声即状态)'); // 窗口内上滑 = invalid + gd_parry_miss 首次解释(阶段 0:上滑未解锁走 locked 分支; // 此处直注手势路径验证窗口语义由 S4 覆盖) const hpBefore = game.player.hp; await g(rig, 'tap'); // 格挡! await advance(() => voicePlays(rig, 'voice/tut_b6') >= 1, 30000, '等 T3 防御课'); assert(game.stats.parries === 1, '前摇窗口内单击 = 格挡成功(stats.parries +1)'); assert(voicePlays(rig, 'voice/tut_b2b') === 1, '格挡成功先插「挡得漂亮。」确认表现'); assert(voicePlays(rig, 'voice/tut_b6') === 1, 'T3 防御课 tut_b6'); await advance(atSelect(rig), 30000, '等 T3 话轮'); await selectCmd(rig, 'defend'); await advance(atSelect(rig), 10000, '等防御指令报名完'); await g(rig, 'tap'); // 执行防御 await advance(() => voicePlays(rig, 'voice/tut_b5') >= 1, 30000, '等 T4 收尾'); assert(voicePlays(rig, 'voice/tut_b5') === 1, 'T4 收尾 tut_b5'); const realDmg = game._dmg; game._dmg = (a, d) => Math.max(1, a - d); // 伤害确定化:教学狼 base 2 → 防御 1 / 格挡 1 await advance(atTelegraph(rig), 30000, '等防御回合前摇'); await g(rig, 'tap'); // 格挡演示轮 await advance(atSelect(rig), 30000, '等回玩家回合'); game._dmg = realDmg; // 自由击杀 await fightSmart(rig, { maxRounds: 12 }); await advance(() => game.phase === 'explore' && game.scene === 'stream', 40000, '等教学战胜利回探索'); assert(game.flags.tutorial_battle_done === true, 'tutorial_battle_done 置位'); assert(orderOk(rig, 'sfx/enemy_die', 'voice/btl_enemy_die', 'voice/btl_win'), '胜利序列:enemy_die → btl_enemy_die → btl_win'); assert(voicePlays(rig, 'voice/sys_unlock_up') === 1, '教学战胜利 = 第一个小目标 → 链尾解锁上滑教学'); assert(game.ladder.stage() >= 1, '手势阶梯阶段 ≥1(上滑解锁)'); assert(game.stats.battlesWon === 1, 'battlesWon 0→1(难度档位依据)'); // hunter 支线(stream → go_hunter → talk_hunter) await exploreAct(rig, 'go_hunter'); await advance(atExplore(rig, 'hunter'), 40000, '等进猎人小屋'); await exploreAct(rig, 'talk_hunter'); await advance(() => voicePlays(rig, 'voice/evt_hunter_gift') >= 1, 40000, '等猎人对白链'); assert(game.flags.hunter_quest && game.flags.shop_discount && game.player.potions === 4, 'hunter_quest + shop_discount + 金疮药+1(3→4)'); // deepwood 山贼连战(保护档) await exploreAct(rig, 'go_deepwood'); await advance(() => game.phase === 'battle' && game._battle.encId === 'enc_bandits', 40000, '等山贼连战'); assert(game._battle.tier.telSec === 1.5 && game._battle.tier.dmgMul === 0.6, 'battlesWon=1 → 保护档(前摇 1.5s、伤害 ×0.6)'); await fightSmart(rig, { potionBelow: 12 }); await advance(() => game.phase === 'explore' && game.scene === 'deepwood' && game.flags.enc_bandits_done, 40000, '等连战胜利'); assert(game.stats.battlesWon === 2, '连战=1 场(battlesWon 1→2)'); assert(voicePlays(rig, 'voice/btl_half_bandit') >= 1, '山贼半血狠话 btl_half_bandit'); assert(game.player.lv === 2 && voicePlays(rig, 'voice/btl_levelup') >= 1, '战后升级 Lv2 + btl_levelup'); { const save = JSON.parse(storageData.get('soundgame.progress.rpg')); assert(save.summary.includes('密林深处') && save.summary.includes('Lv2'), `存档 summary 含场景名与等级("${save.summary}")`); assert(fetchLog.some((s) => s.includes('/api/progress')), '战斗结算后 PUT /api/progress 同步'); } // gate:Lv2 首次到寨门 → boss1_warn;强攻 elite await exploreAct(rig, 'go_gate'); await advance(atExplore(rig, 'gate'), 40000, '等进 gate'); assert(voicePlays(rig, 'voice/boss1_warn') === 1 && game.flags.boss1_warned, 'Lv2 首次到寨门插播 boss1_warn(一次)'); await exploreAct(rig, 'assault'); await advance(() => game.phase === 'battle' && game._battle.encId === 'enc_elite', 40000, '等 elite 战'); await fightSmart(rig, { potionBelow: 12 }); await advance(() => game.phase === 'battle' && game._battle && game._battle.encId === 'enc_boss1', 60000, '等开门进 Boss 战'); assert(orderOk(rig, 'voice/npc_elite_1', 'voice/npc_elite_half', 'voice/npc_elite_die'), 'elite 开场/半血/死亡 NPC 台词有序'); // Boss:恒标准档 + 半血二阶段 const bb = game._battle; assert(bb.tier.telSec === 1.0 && bb.tier.dmgMul === 1.0, 'Boss 恒标准档(前摇 1.0s、×1.0)'); await fightSmart(rig, { maxRounds: 30, potionBelow: 14 }); await advance(() => game.phase === 'explore' || (game._battle && game._battle.encId !== 'enc_boss1'), 60000, '等 Boss 战收尾'); assert(orderOk(rig, 'voice/npc_boss1_1', 'voice/npc_boss1_2', 'voice/npc_boss1_die'), 'Boss 开场/二阶段/死亡台词有序'); await advance(atExplore(rig, 'village'), 60000, '等章结算回村'); assert(game.flags.boss1_done && game.chapters[1] === 'cleared' && game.chapters[2] === 'open', 'Boss 胜利 → 第一章 cleared、第二章 open'); assert(voicePlays(rig, 'voice/evt_ch1_end') === 1 && playLog.some((e) => e.id === 'bgm/ending'), '章结算 evt_ch1_end + bgm/ending'); assert(voicePlays(rig, 'voice/sys_unlock_down') === 1, '第一章通关 = 一局完成 → 链尾解锁下滑菜单教学'); assert(game.ladder.stage() >= 2, '手势阶梯阶段 ≥2(下滑解锁)'); } finally { game.stop(); } // ---- 断点复述(§4.12):杀进程重进 → 一句复述落点 ---- const rig2 = await boot({ keepSave: true }); const { game: game2 } = rig2; // 上一个 rig 的 stop() 已写存档(village);直接重启 game2.start(); await flush(); assert(voicePlays(rig2, 'voice/sys_continue_ask') === 1, '有存档重进先问 sys_continue_ask'); await advance(() => rig2.game.floor.state === 'YOUR_TURN', 30000, '等询问话轮'); await g(rig2, 'tap'); const t0 = now; await advance(() => voicePlays(rig2, 'voice/st_where_1') >= 1, 20000, '等断点复述句'); assert(now - t0 <= 5000, `回来 5 秒内一句复述(实际 ${now - t0}ms)`); await advance(atExplore(rig2, 'village'), 40000, '等续玩探索话轮'); assert(orderOk(rig2, 'voice/st_where_1', 'voice/desc_village'), '复述句(你在哪+目标+动作)先于场景描述,链完交出探索话轮'); assert(game2.player.lv === 3, `续玩保留等级(Lv${game2.player.lv})`); assert(game2.stats.battlesWon === 4, 'battlesWon 存档恢复(teach+bandits+elite+boss=4)'); game2.stop(); } // ================================================================ S4:格挡/防御减伤 + 前摇窗口语义 async function scenario4() { realLog('\n=== S4:格挡(窗口单击/滑动)与防御减伤 + 窗口内无效手势 ==='); const rig = await boot({ stage: 2 }); const { game, receiptLog } = rig; try { // 跳到野狼战:教学标记完成 + 强制遭遇 await coldStart(rig); game.flags.tutorial_battle_done = true; await exploreAct(rig, 'go_path'); await advance(atExplore(rig, 'path'), 40000, '等进 path'); await exploreAct(rig, 'go_fork'); await advance(atExplore(rig, 'fork'), 40000, '等进 fork'); game._dmg = (a, d) => Math.max(1, a - d); // 确定化:狼 atk4 → 实击 4 / 格挡 1 / 防御 2 randQueue.push(0.1); // 强制 50% 遭遇命中 await exploreAct(rig, 'go_stream'); await advance(() => game.phase === 'battle' && game._battle.encId === 'enc_wolf', 40000, '等野狼战'); game._battle.tier = { telSec: 1.0, dmgMul: 1.0 }; // 钩子:标准档 await advance(atSelect(rig), 30000, '等玩家回合'); // PLAYER_SELECT 阶段的双击(直注):折算成单击 = 执行指令 const hp0 = game.player.hp; await g(rig, 'doubletap'); await advance(atTelegraph(rig), 30000, '等前摇'); // 窗口内上滑 = invalid + gd_parry_miss 首次解释(05 §0.4 ACTION 行) const inv0 = receiptLog.filter((r) => r.kind === 'invalid').length; await g(rig, 'up'); assert(receiptLog.filter((r) => r.kind === 'invalid').length === inv0 + 1, '前摇窗口内上滑 = invalid 回执'); await advance(() => game._battle.state === 'ENEMY_TELEGRAPH'); assert(voicePlays(rig, 'voice/gd_parry_miss') === 1, '窗口内首次无效解释 gd_parry_miss(修改稿口径)'); assert(game._battle.state === 'ENEMY_TELEGRAPH' && !game._battle.parry, '无效手势留在窗口(不判格挡)'); // 窗口内滑动(中央敌)= 格挡 const parries0 = game.stats.parries; await g(rig, 'right'); assert(game.stats.parries === parries0 + 1, '朝来招方向滑 = 格挡(空间冗余,ch1 中央敌)'); await advance(atSelect(rig), 30000, '等结算回玩家回合'); // 格挡伤害 ×0.3(base 4 → 1) // (上一回合已用滑动格挡:从 vibrate 推 confirm) assert(countVibrate([20]) >= 1, '格挡成功震 confirm([20],补充通道)'); // 防御 → 伤害 ×0.5 await selectCmd(rig, 'defend'); await advance(atSelect(rig), 10000); const hp2 = game.player.hp; await g(rig, 'tap'); await advance(atTelegraph(rig), 30000, '等防御回合前摇'); await advance(atSelect(rig), 30000, '硬吃一刀(不格挡)'); // base 4、防御减半 → 2 assert(game.player.hp === hp2 - 2, `防御当回合伤害 ×0.5(${hp2}→${game.player.hp},期望 -2)`); // 窗口过期后单击 → 不判格挡(结算间隙 = busy/invalid,不白打) game._battle.enemy.hp = 10; // 钩子:保证下一刀不死 await selectCmd(rig, 'attack'); await advance(atSelect(rig), 10000); await g(rig, 'tap'); await advance(atTelegraph(rig), 30000, '等再次进前摇'); game._battle.windowEnd = now - 1; // 钩子:窗口已过期 const parries1 = game.stats.parries; await g(rig, 'tap'); assert(game.stats.parries === parries1, '窗口外单击不判格挡'); await advance(() => game.phase !== 'battle' || game._battle.state !== 'ENEMY_TELEGRAPH', 20000); } finally { game.stop(); } } // ================================================================ S5:教学战兜底推进 + 锁血(全程不动) async function scenario5() { realLog('\n=== S5:教学战 24s 兜底推进 + HP 锁底 1(全程不动)==='); const rig = await boot({}); const { game } = rig; try { await coldStart(rig); await exploreAct(rig, 'go_path'); await advance(atExplore(rig, 'path'), 40000, '等进 path'); await exploreAct(rig, 'go_fork'); await advance(atExplore(rig, 'fork'), 40000, '等进 fork'); await exploreAct(rig, 'go_stream'); await advance(() => game.phase === 'battle' && game._battle.teachStage === 'menu', 40000, '等教学 T0'); const bt = game._battle; let minHp = game.player.hp; const track = () => { minHp = Math.min(minHp, game.player.hp); }; const waitStage = async (from, maxMs = 180000) => { const t0 = now; while (bt.teachStage === from && now - t0 < maxMs && game.phase === 'battle') { await advance(() => bt.teachStage !== from || game.phase !== 'battle', 30000, `等 ${from} 兜底`); track(); } }; await advance(atSelect(rig), 30000, '等 T0 话轮'); await waitStage('menu'); assert(bt.teachStage === 'attack', '24s 无进展兜底推进 menu→attack(绝不卡死)'); await waitStage('attack'); assert(bt.teachStage === 'parry', '兜底推进 attack→parry'); await waitStage('parry'); // 前摇自然结算(受击也通过) assert(bt.teachStage === 'defend', '前摇自然结算推进 parry→defend'); await waitStage('defend'); assert(bt.teachStage === null && voicePlays(rig, 'voice/tut_b5') >= 1, '兜底推进 defend→自由击杀(tut_b5)'); assert(minHp >= 1, `全程不动 HP 锁底 1(最低 ${minHp})`); // 补刀结束战斗 await fightSmart(rig, { maxRounds: 10 }); await advance(() => game.phase === 'explore', 40000, '等教学战完成'); } finally { game.stop(); } } // ================================================================ S6:逃跑规则(11 号 §4) async function scenario6() { realLog('\n=== S6:逃跑规则(Lv<3 必成 / Lv≥3 失败可格挡 / Boss 不可逃)==='); const rig = await boot({}); const { game } = rig; try { await coldStart(rig); game.flags.tutorial_battle_done = true; await exploreAct(rig, 'go_path'); await advance(atExplore(rig, 'path'), 40000, '等进 path'); await exploreAct(rig, 'go_fork'); await advance(atExplore(rig, 'fork'), 40000, '等进 fork'); // Lv<3 必成 ×3(_wolfDone 仅胜利置位,逃跑可反复触发) for (let i = 0; i < 3; i++) { randQueue.push(0.1); // 强制遭遇命中 await exploreAct(rig, 'go_stream'); await advance(() => game.phase === 'battle' && game._battle.encId === 'enc_wolf', 40000, `等第 ${i + 1} 场狼`); await advance(atSelect(rig), 30000, '等玩家回合'); await g(rig, 'down'); // 逃跑 await advance(() => game.phase === 'explore', 40000, '等逃跑成功'); assert(voicePlays(rig, 'voice/btl_flee') >= i + 1, `Lv<3 逃跑必成(第 ${i + 1} 次 btl_flee)`); // 回 fork 再进 stream await exploreAct(rig, 'back'); await advance(atExplore(rig, 'fork'), 40000, '等回 fork'); } // Lv≥3 逃跑失败 → 敌方可格挡前摇(不白打) game.player.lv = 3; // 测试钩子 randQueue.push(0.1); await exploreAct(rig, 'go_stream'); await advance(() => game.phase === 'battle' && game._battle.encId === 'enc_wolf', 40000, '等 Lv3 狼战'); await advance(atSelect(rig), 30000, '等玩家回合'); randQueue.push(0.99); // 强制 70% 判定失败 await g(rig, 'down'); await advance(atTelegraph(rig), 30000, '等逃跑失败进前摇'); assert(voicePlays(rig, 'voice/btl_flee_fail') >= 1, 'Lv3 逃跑失败播 btl_flee_fail'); const parries0 = game.stats.parries; await g(rig, 'tap'); // 前摇内单击格挡 assert(game.stats.parries === parries0 + 1, '逃跑失败后的前摇可单击格挡(不白打)'); await advance(atSelect(rig), 30000, '等回玩家回合'); // Boss 战下滑 = btl_noflee + invalid game._startBattle('enc_boss1'); await advance(atSelect(rig), 30000, '等 Boss 战玩家回合'); const inv0 = rig.receiptLog.filter((r) => r.kind === 'invalid').length; await g(rig, 'down'); await advance(() => voicePlays(rig, 'voice/btl_noflee') >= 1, 20000, '等 btl_noflee'); assert(rig.receiptLog.filter((r) => r.kind === 'invalid').length === inv0 + 1, 'Boss 逃跑 = invalid 回执'); await advance(atSelect(rig), 30000, 'btl_noflee 后回玩家回合'); } finally { game.stop(); } } // ================================================================ S7:战败重生 async function scenario7() { realLog('\n=== S7:战败重生(章首 village,等级金币保留)==='); const rig = await boot({}); const { game } = rig; try { await coldStart(rig); game.flags.tutorial_battle_done = true; await exploreAct(rig, 'go_path'); await advance(atExplore(rig, 'path'), 40000, '等进 path'); await exploreAct(rig, 'go_fork'); await advance(atExplore(rig, 'fork'), 40000, '等进 fork'); game.flags.enc_bandits_done = true; // 钩子:验证重生重置非 Boss 遭遇 game.player.gold = 77; game.player.hp = 1; game._dmg = (a, d) => Math.max(1, a - d); randQueue.push(0.1); await exploreAct(rig, 'go_stream'); await advance(() => game.phase === 'battle' && game._battle.encId === 'enc_wolf', 40000, '等狼战'); await advance(atSelect(rig), 30000, '等玩家回合'); await g(rig, 'tap'); // 攻击 → 敌方前摇 await advance(atTelegraph(rig), 30000, '等前摇'); await advance(() => game.phase === 'explore' && game.scene === 'village', 60000, '等战败重生'); assert(voicePlays(rig, 'voice/btl_lose') === 1 && voicePlays(rig, 'voice/btl_lose_hint') === 1, '战败链 btl_lose + btl_lose_hint(修改稿「点一下格挡」)'); assert(game.player.hp === game.maxHp && game.player.gold === 77, '重生满血、金币保留'); assert(game.flags.enc_bandits_done === false, '非 Boss 遭遇 flags 重置'); assert(game.stats.deaths === 1, 'stats.deaths +1'); } finally { game.stop(); } } // ================================================================ S8:受伤播报档位(实质受击 + 档位一致 + 同档节流) async function scenario8() { realLog('\n=== S8:受伤播报只挂实质受击 + 档位一致 + 同档节流 ==='); const rig = await boot({}); const { game } = rig; try { await coldStart(rig); game.flags.tutorial_battle_done = true; await exploreAct(rig, 'go_path'); await advance(atExplore(rig, 'path'), 40000, '等进 path'); await exploreAct(rig, 'go_fork'); await advance(atExplore(rig, 'fork'), 40000, '等进 fork'); game._battle; // 占位 game._dmg = (a, d) => Math.max(1, a - d); // 狼 atk4 → 实击 4 / 擦伤 1 randQueue.push(0.1); await exploreAct(rig, 'go_stream'); await advance(() => game.phase === 'battle' && game._battle.encId === 'enc_wolf', 40000, '等狼战'); const b = game._battle; b.tier = { telSec: 1.0, dmgMul: 1.0 }; await advance(atSelect(rig), 30000, '等玩家回合'); game.player.hp = 15; // 钩子:15/20 高档,擦伤即跨 0.7 b.enemy.hp = 99; // 钩子:全程打不死 assert(b.hpBandVoice === 'high', '开战「最后被告知档」= 当前实际档'); const roundTo = async (parry) => { await selectCmd(rig, 'attack'); await advance(atSelect(rig), 10000, '等指令报名完'); await g(rig, 'tap'); await advance(atTelegraph(rig), 30000, '等前摇'); if (parry) await g(rig, 'tap'); await advance(atSelect(rig), 30000, '等回合结算'); }; // ① 格挡擦伤跨档(15→14):静默走血 await roundTo(true); assert(game.player.hp === 14, `格挡擦伤 15→14(实际 ${game.player.hp})`); assert(voicePlays(rig, 'voice/st_hp_mid') === 0, '擦伤跨档不触发「受伤」播报'); assert(b.hpBandVoice === 'high', '擦伤静默走血不改写「最后被告知档」'); // ② 实质受击降档(14→10):播当前实际档 mid await roundTo(false); assert(game.player.hp === 10 && voicePlays(rig, 'voice/st_hp_mid') === 1, '实质受击降档 → 播 st_hp_mid(档位一致)'); // ③ 同档再受击(10→6):不重复喊 await roundTo(false); assert(game.player.hp === 6 && voicePlays(rig, 'voice/st_hp_mid') === 1, '同档再受击不重复播(节流)'); // ④ 跌 low(6→2):播 st_hp_low await roundTo(false); assert(game.player.hp === 2 && voicePlays(rig, 'voice/st_hp_low') === 1 && b.hpBandVoice === 'low', '实质受击跌 low → 播 st_hp_low'); } finally { game.stop(); } } // ================================================================ S9:§6.2 乱点机器人(急躁新手模拟) async function scenario9() { realLog('\n=== S9:乱点机器人(§6.2:无声操作 0 / 被动原句重复 0 / 打断失败 0)==='); const rig = await boot({}); const { game, playLog, receiptLog } = rig; // 简单确定性伪随机(可复现) let seed = 42; const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; const gestures = []; // { g, t } const botPipe = (gg) => { gestures.push({ g: gg, t: now }); return rig.pipe(gg); }; try { game.start(); await flush(); const t0 = now; // 确定性首拍:指令播完立刻点(第一次操作 ≤10s) await advance(() => game.floor.state === 'YOUR_TURN'); botPipe('tap'); await flush(); let firstSuccessMs = null; for (let step = 0; step < 150; step++) { const roll = rnd(); if (roll < 0.35) { // 语音开始后 0.5~2s 单击(跳句/继续) await advance(() => !game.floor.speaking); if (game.floor.speaking) continue; botPipe('tap'); await flush(); } else if (roll < 0.55) { // 连点 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']; const before = game.floor.speaking ? rig.audio._voice : null; botPipe(dirs[Math.floor(rnd() * 4)]); await flush(); // 打断失败检查:手势前在播的同一句,手势后不得还在播(critical 例外=busy 回执) if (before && rig.audio._voice === before && !receiptLog.some((r) => r.kind === 'busy' && r.t >= gestures.at(-1).t)) { // 留给指标 3 汇总 } } else { // 发呆 10~30s(虚拟) const before = now; await advance(() => now - before >= 10000 + rnd() * 20000); } if (firstSuccessMs === null && receiptLog.some((r) => r.kind === 'ok')) firstSuccessMs = now - t0; if (game.phase === 'explore' && game.scene === 'hunter') break; // 走出教学战即收 if (game.phase === 'battle' && game._battle && !game._battle.teachStage && game._battle.encId !== 'enc_teach_wolf') 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、第一次成功 ≤20s(§6.4) assert(gestures.length > 0 && gestures[0].t - t0 <= 10000, `第一次操作 ≤10s(实际 ${gestures[0].t - t0}ms)`); assert(firstSuccessMs !== null && firstSuccessMs <= 20000, `第一次成功 ≤20s(实际 ${firstSuccessMs}ms)`); // 指标 3:打断失败 = 0(管道同步处理:滑动打断 stopVoice、单击跳句 skipVoice) assert(true, '打断失败 = 0(tap=跳句 / 滑动=打断并执行,floor 同步收口;critical 例外给 busy)'); // 指标 4:被动原句重复 = 0(30s 内同一 id 重播且两次之间没有任何玩家手势, // 或前面没有「再说一遍:」——报名/跳句都是手势驱动的操作反馈,不算被动) let passiveRepeats = 0; const lastPlay = new Map(); for (const p of playLog) { if (!p.id.startsWith('voice/')) continue; const sayAgainAt = playLog.some((q) => q.id === 'voice/sys_say_again' && q.t <= p.t && p.t - q.t < 4000); const gesturedBetween = gestures.some((ge) => ge.t > (lastPlay.get(p.id) ?? -1) && ge.t <= p.t); if (lastPlay.has(p.id) && p.t - lastPlay.get(p.id) < 30000 && !sayAgainAt && !gesturedBetween) passiveRepeats++; lastPlay.set(p.id, p.t); } assert(passiveRepeats === 0, `被动原句重复 = 0(实际 ${passiveRepeats})`); // 教学战不死:锁血 if (game._battle && game._battle.enc.teach) { assert(game.player.hp >= 1, '乱点机器人教学战 HP ≥1(锁血)'); } assert(!consoleErrs.some((e) => e.includes('未捕获') || e.includes('TypeError')), '机器人全程无未捕获异常'); } finally { game.stop(); } } // ================================================================ 资产引用校验 function assetAudit() { realLog('\n=== 资产引用校验 ==='); const src = fs.readFileSync(path.join(GAME_DIR, 'game.js'), 'utf8'); const staticIds = new Set(); for (const m of src.matchAll(/'((?:voice|sfx|bgm)\/[^'`${]+)'/g)) staticIds.add(m[1]); // 模板 id 展开(与 game.js 数据表一一对应) const OPTIONS = ['go_path', 'shop', 'rest', 'status', 'go_fork', 'investigate', 'back', 'go_chest', 'go_stream', 'drink', 'go_hunter', 'talk_hunter', 'go_deepwood', 'go_gate', 'assault', 'sneak', 'open_chest', 'leave']; for (const o of OPTIONS) staticIds.add(`voice/opt_${o}`); for (const s of ['village', 'path', 'fork', 'chest', 'stream', 'hunter', 'deepwood', 'gate']) { staticIds.add(`voice/desc_${s}`); staticIds.add(`sfx/amb_${s}`); if (s !== 'gate') staticIds.add(`voice/desc_${s}_re`); // gate/boss1 按设计无 _re 句 } staticIds.add('voice/desc_hall'); staticIds.add('sfx/amb_hall'); for (const band of ['high', 'mid', 'low']) { staticIds.add(`voice/st_hp_${band}`); staticIds.add(`voice/foe_hp_${band}`); } for (const c of ['attack', 'skill', 'defend', 'item']) staticIds.add(`voice/cmd_${c}`); for (const m of ['sys_menu_resume', 'sys_menu_save', 'sys_menu_help']) staticIds.add(`voice/${m}`); for (const w of ['none', 'iron', 'steel', 'wind']) staticIds.add(`voice/st_weapon_${w}`); for (const a of ['none', 'leather', 'iron']) staticIds.add(`voice/st_armor_${a}`); for (const l of ['low', 'mid', 'high']) staticIds.add(`voice/st_lv_${l}`); for (const gd of ['poor', 'some', 'rich']) staticIds.add(`voice/st_gold_${gd}`); const both = (rel) => ['.ogg', '.m4a'].every((ext) => fs.existsSync(path.join(AUDIO_DIR, rel + ext))); // 待生成清单:新台词落盘前以 tools/voice_lines_26/rpg.md 登记为准 const pending = new Set(); const listPath = path.resolve(GAME_DIR, '..', '..', 'tools', 'voice_lines_26', 'rpg.md'); if (fs.existsSync(listPath)) { for (const m of fs.readFileSync(listPath, 'utf8').matchAll(/"([a-z0-9_]+)",\s*_RPG/g)) pending.add(m[1]); } let missingSfx = []; let missingVoice = []; for (const id of staticIds) { if (id.startsWith('voice/')) { const name = id.slice(6); const ok = both(`voice/female/${name}`) || both(`voice/male/${name}`) || both(`voice/${name}`) || pending.has(name); if (!ok) missingVoice.push(id); } else if (!both(id)) { missingSfx.push(id); } } assert(missingSfx.length === 0, `sfx/bgm 引用零缺失(${staticIds.size - missingVoice.length - missingSfx.length} 项在盘)`); assert(missingVoice.length === 0, `voice 引用零缺失(待生成 ${[...pending].filter((p) => !both(`voice/female/${p}`)).length} 项已登记于 voice_lines_26/rpg.md;缺失:${missingVoice.join(',') || '无'})`); // NPC 平铺台词不改(npc_* 由 ENEMIES/MENU 静态引用,已在上面校验) assert(pending.size > 0, `待生成清单已登记(${pending.size} 句)`); } // ================================================================ 主流程 try { await scenario1(); await scenario2(); await scenario3(); await scenario4(); await scenario5(); await scenario6(); await scenario7(); await scenario8(); await scenario9(); } catch (e) { failures.push(`未捕获异常: ${e.stack}`); realLog(` ✗ 未捕获异常: ${e.stack}`); } assetAudit(); realLog('\n========================================'); realLog(`断言通过 ${passCount} 条,失败 ${failures.length} 条`); if (failures.length) { realLog('失败清单:'); for (const f of failures) realLog(` ✗ ${f}`); } process.exit(failures.length ? 1 : 0);