/** * 《围猎》无头试玩仿真测试(docs/design/13-playtest-criteria.md §24 客户端侧断言)。 * * 方案: * - mock 浏览器环境:window/localStorage/navigator/fetch/AudioContext * (createStereoPanner 有记录,供 opts.pan 引擎回归断言); * - 使用**真实** engine/audio.js + settings.js + guide.js; * - 手势直接注入 game.onGesture(与 attachGestures 回调同路径); * - 虚拟时钟:patch setTimeout/setInterval/clearTimeout/performance.now; * - WS 用内存回路 mock(23 §6 协议字段注入 joined/ev/snap/err;发送帧逐条记录); * 不依赖 web/server 实现(并行开发中),协议字段以 23 §6 为唯一契约; * - 音频资产并行生成中:fetch 对 audio/* 一律合成假 buffer(按 url 估时长: * voice 1.8s / npc 1.5s / loop 4s / bgm 90s / 其余 sfx 0.5s),语音链靠引擎 * 自然 onComplete 推进;blockedAssets 中的片段强制 404(资产缺失兜底断言用)。 * * 运行:tools/node/bin/node web/hunt/test/playtest.mjs */ const SAVE_KEY = 'soundgame.progress.hunt'; const AUTH_KEY = 'soundgame.auth'; // ---------------------------------------------------------------- 断言收集 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 intervals = new Set(); 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 }); }; intervals.add(id); timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step, args }); return id; }; globalThis.clearInterval = (id) => { intervals.delete(id); timers.delete(id); }; Object.defineProperty(globalThis, 'performance', { value: { now: () => now }, configurable: true, }); async function flush(rounds = 8) { for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r)); } /** 推进虚拟时钟到 cond() 满足或空闲。 */ async function advance(cond = null) { for (let n = 0; n < 100000; 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('虚拟时钟定时器超上限(疑似死循环)'); } /** 固定步长推进(常驻 interval 存在时不可用它等空闲)。 */ async function advanceMs(ms) { const until = now + ms; while (now < until) { await flush(2); let nextId = -1; let nextAt = Infinity; for (const [id, t] of timers) if (t.fireAt < nextAt) { nextAt = t.fireAt; nextId = id; } if (nextId < 0 || nextAt > until) { now = until; break; } now = nextAt; const t = timers.get(nextId); timers.delete(nextId); t.cb(...t.args); } await flush(); } // ---------------------------------------------------------------- 浏览器 mock const winListeners = new Map(); globalThis.window = { addEventListener: (type, fn) => { if (!winListeners.has(type)) winListeners.set(type, []); winListeners.get(type).push(fn); }, location: { protocol: 'http:', host: 'test.local', 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', { // maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,voice 走主资产 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 MockNode { constructor() { this.connectedTo = null; } connect(t) { this.connectedTo = t; } disconnect() {} } class MockGainNode extends MockNode { constructor() { super(); this.gain = new MockParam(); } } class MockPannerNode extends MockNode { constructor() { super(); this.pan = new MockParam(0); } } class MockAudioContext { constructor() { this.state = 'running'; this.destination = {}; this.sampleRate = 44100; this.panners = []; // createStereoPanner 创建记录(opts.pan 回归断言) this.gains = []; // createGain 创建记录 this.sources = []; // createBufferSource 创建记录 } get currentTime() { return now / 1000; } createGain() { const g = new MockGainNode(); this.gains.push(g); return g; } createStereoPanner() { const p = new MockPannerNode(); this.panners.push(p); return p; } createDynamicsCompressor() { const n = new MockGainNode(); n.threshold = new MockParam(); n.ratio = new MockParam(); n.attack = new MockParam(); n.release = new MockParam(); return n; } createBufferSource() { const s = new MockNode(); s.buffer = null; s.loop = false; s.playbackRate = new MockParam(); s.start = () => {}; s.stop = () => {}; this.sources.push(s); return s; } createScriptProcessor() { return new MockNode(); } decodeAudioData(ab) { return Promise.resolve({ duration: durationOfUrl(ab && ab.__url), numberOfChannels: 1, sampleRate: 44100, getChannelData: () => new Float32Array(16), }); } resume() { this.state = 'running'; return Promise.resolve(); } } globalThis.window.AudioContext = MockAudioContext; /** 按 url 估假 buffer 时长(语音链自然推进 + npc 在播时长账目)。 */ function durationOfUrl(u) { const s = String(u || ''); if (s.includes('/bgm/')) return 90; if (s.includes('/voice/')) return 1.8; if (s.includes('npc_')) return 1.5; if (s.includes('loop_')) return 4.0; return 0.5; } // fetch:audio/* 一律合成假资产(并行生成中不依赖真实文件);blockedAssets 强制 404; // /api/progress GET/PUT 走内存服务器桶(PUT 全量记录供结算断言) const requestedUrls = []; const blockedAssets = new Set(); const apiPuts = []; const serverBuckets = new Map(); // game → data(登录态进度桶) globalThis.fetch = async (u, opts = {}) => { const s = String(u); requestedUrls.push(s); if (s.startsWith('/api/')) { if (s.startsWith('/api/progress') && (opts.method || 'GET') === 'PUT') { let body = null; try { body = JSON.parse(opts.body); } catch (_) { /* 静默 */ } apiPuts.push({ body, t: now }); if (body && body.game) serverBuckets.set(body.game, body.data); return { ok: true, status: 200, json: async () => ({ ok: true, game: body && body.game }) }; } if (s.startsWith('/api/progress')) { const gname = new URLSearchParams(s.split('?')[1] || '').get('game'); const data = serverBuckets.get(gname) || null; return { ok: true, status: 200, json: async () => ({ game: gname, data }) }; } return { ok: false, status: 404, json: async () => ({}) }; } const blocked = [...blockedAssets].some((b) => s.includes(b)); if (blocked) return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) }; const ab = new ArrayBuffer(8); ab.__url = s; return { ok: true, status: 200, arrayBuffer: async () => ab }; }; // ---------------------------------------------------------------- WS 内存回路 mock function makeWsMock() { const mock = { conns: [], sent: [], // { conn, msg } transport(u, handlers) { const conn = { url: u, handlers, opened: false, closed: false, send(s) { let m = null; try { m = JSON.parse(s); } catch (_) { /* 坏帧 */ } mock.sent.push({ conn, msg: m }); }, close() { if (!conn.closed) { conn.closed = true; handlers.onclose(); } }, }; mock.conns.push(conn); return conn; }, last() { return mock.conns[mock.conns.length - 1]; }, open(conn) { conn.opened = true; conn.handlers.onopen(); }, inject(conn, msg) { conn.handlers.onmessage(msg); }, drop(conn) { if (!conn.closed) { conn.closed = true; conn.handlers.onclose(); } }, msgs(conn) { return mock.sent.filter((x) => x.conn === conn).map((x) => x.msg); }, acts(conn, kind) { return mock.msgs(conn).filter((m) => m && m.t === 'act' && (!kind || m.kind === kind)); }, }; return mock; } // ---------------------------------------------------------------- 加载引擎与游戏 const { WebAudioEngine } = await import('../../engine/audio.js'); const { settings } = await import('../../engine/settings.js'); const { HuntGame } = await import('../game.js'); function resetSettings(guide = 'off') { settings.set({ guide, voice: 'female', speed: 1.0, bgm: 1.0, input: 'touch' }); } resetSettings('off'); storageData.set(AUTH_KEY, JSON.stringify({ username: '试炼猎人', token: 'tok1' })); // ---------------------------------------------------------------- 协议夹具(23 §6 字段) const SECTORS3 = [['N', 'NE', 'E'], ['SE', 'S', 'SW'], ['W', 'NW']]; function joinedMsg(over = {}) { return { t: 'joined', room: 'ABCD', slot: 0, resume: 'r0', char: 'yan', sector: SECTORS3[0], seed: 42, state: { phase: 'lobby', wave: 0, dur: 10 }, roster: [ { slot: 0, name: '试炼猎人', bot: false, char: 'yan', sector: SECTORS3[0] }, { slot: 1, name: '小筝', bot: true, char: 'zheng', sector: SECTORS3[1] }, { slot: 2, name: '老周', bot: true, char: 'zhou', sector: SECTORS3[2] }, ], ...over, }; } const evSpawn = (id, mkind, dir, eta, ts = now) => ({ t: 'ev', kind: 'spawn', ts, id, mkind, dir, eta }); const evKill = (id, slot, dir, mkind = 'wolf', ts = now) => ({ t: 'ev', kind: 'kill', ts, id, slot, dir, mkind }); const evMiss = (slot, dir, ts = now) => ({ t: 'ev', kind: 'miss', ts, slot, dir }); const evSkill = (slot, cleared, ts = now) => ({ t: 'ev', kind: 'skill', ts, slot, cleared }); const evBreach = (id, mkind, dir, dur, ts = now) => ({ t: 'ev', kind: 'breach', ts, id, mkind, dir, dur }); const evWave = (wave, ts = now) => ({ t: 'ev', kind: 'wave', ts, wave }); const evWaveclear = (wave, ts = now) => ({ t: 'ev', kind: 'waveclear', ts, wave }); const evCallout = (slot, ts = now) => ({ t: 'ev', kind: 'callout', ts, slot }); const evBotline = (slot, line, ts = now) => ({ t: 'ev', kind: 'botline', ts, slot, line }); const evRoster = (roster, ts = now) => ({ t: 'ev', kind: 'roster', ts, roster }); const evEnd = (over, ts = now) => ({ t: 'ev', kind: 'end', ts, ...over }); const snapMsg = (mobs, over = {}, ts = now) => ({ t: 'snap', ts, phase: 'wave', wave: 1, dur: 10, mobs, slots: [{ slot: 0, alive: true, bot: false, cd: 0, skillCd: 0 }], ...over, }); // ---------------------------------------------------------------- 测试工具 const ERR_PATTERN = JSON.stringify([60, 40, 60]); const WARN_PATTERN = JSON.stringify([30, 50, 30, 50, 30]); const DANGER_PATTERN = JSON.stringify([25, 40, 25, 40, 25]); const HIT_PATTERN = JSON.stringify([80]); const PICKUP_PATTERN = JSON.stringify([15]); const SUCCESS_PATTERN = JSON.stringify([100]); const HEARTBEAT_PATTERN = JSON.stringify([40, 100, 40]); const vibCount = (pattern, since = 0) => vibrateLog.filter((v) => JSON.stringify(v.pattern) === pattern && v.t >= since).length; const playsOf = (playLog, id) => playLog.filter((e) => e.id === id).length; /** 全量播放 id 记录(静态审计:只引用台词表内 id)。 */ const allPlayedIds = new Set(); function makeRig() { const audio = new WebAudioEngine('audio/'); const playLog = []; const origPlay = audio.play.bind(audio); audio.play = (id, opts) => { playLog.push({ id, opts: opts || {}, t: now }); allPlayedIds.add(id); return origPlay(id, opts); }; const ws = makeWsMock(); const game = new HuntGame(audio, {}, { transport: ws.transport, wsUrl: 'ws://test.local/ws/hunt' }); return { audio, playLog, game, ws }; } let gestureCount = 0; async function g(game, gesture) { gestureCount++; game.onGesture(gesture); await flush(); } function setProgress(p) { storageData.set(SAVE_KEY, JSON.stringify({ best: null, runs: 0, totalKills: 0, tutDone: false, ...p, })); } function getProgress() { try { return JSON.parse(storageData.get(SAVE_KEY) || 'null'); } catch (_) { return null; } } /** 链序断言:ids 按序出现在 playLog 的 voice 流中(子序列匹配)。 */ function chainOrderOk(playLog, ids) { const seq = playLog.filter((e) => e.id.startsWith('voice/')).map((e) => e.id); let at = -1; for (const id of ids) { const i = seq.indexOf('voice/' + id, at + 1); if (i < 0) return false; at = i; } return true; } /** TITLE 单击 → 匹配 → 注入 joined(lobby)。返回 { conn, join }。 */ async function driveToLobby(rig, joinedOver = {}) { const { game, ws } = rig; await g(game, 'tap'); const conn = ws.last(); ws.open(conn); await flush(); const join = ws.msgs(conn)[0]; // 等开场链播完再进房(真实网络有延迟;瞬进会顶掉 intro 链,属预期行为但此处不验) await advance(() => !rig.audio.isVoicePlaying()); ws.inject(conn, joinedMsg(joinedOver)); await flush(); return { conn, join }; } /** lobby → 单击开局 → ev wave 1 → WAVE。 */ async function driveToWave(rig, conn) { const { game, ws } = rig; await g(game, 'tap'); // LOBBY 单击 = 立即开局 await advance(() => !rig.audio.isVoicePlaying()); ws.inject(conn, evWave(1)); await advance(() => !rig.audio.isVoicePlaying()); } // ================================================================ Block 0 · 引擎 opts.pan 回归(23 §9) realLog('== Block 0 · 引擎 opts.pan 后向兼容 =='); { resetSettings('off'); const audio = new WebAudioEngine('audio/'); await audio.unlock(); const ctx = audio.ctx; const busGainCount = ctx.gains.length; // master/bgmPref/bgm/voice/sfx 五条总线 // 无 pan 调用路径:零 panner 节点,原链路 source → gain → sfxBus audio.play('sfx/atk_whoosh'); await flush(); assert(ctx.panners.length === 0, 'opts.pan 回归:无 pan 调用路径零 panner 节点(引擎行为不变)'); assert(ctx.gains.length === busGainCount + 1, '无 pan sfx:只多一个播放 gain'); assert(ctx.gains[busGainCount].connectedTo === audio._sfxBus, '无 pan sfx:gain 直连 sfxBus(原路径逐字节不变)'); // pan=0 与缺省等价 audio.play('sfx/atk_whoosh', { pan: 0 }); await flush(); assert(ctx.panners.length === 0, 'opts.pan 回归:pan=0 与缺省等价(零 panner)'); // 非法 pan 静默忽略 audio.play('sfx/atk_whoosh', { pan: NaN }); audio.play('sfx/atk_whoosh', { pan: 'x' }); await flush(); assert(ctx.panners.length === 0, 'opts.pan 回归:NaN/非数字 pan 静默忽略'); // pan=-1 / 0.7 / 1:panner 值正确,链路 source → gain → panner → sfxBus for (const [pan, expect] of [[-1, -1], [0.7, 0.7], [1, 1]]) { const n0 = ctx.panners.length; audio.play('sfx/kill_wolf', { pan }); await flush(); assert(ctx.panners.length === n0 + 1, `pan=${pan}:创建 panner`); const p = ctx.panners[ctx.panners.length - 1]; assert(Math.abs(p.pan.value - expect) < 1e-9, `pan=${pan}:panner.pan.value === ${expect}`); assert(p.connectedTo === audio._sfxBus, `pan=${pan}:panner 接 sfxBus`); const gNode = ctx.gains[ctx.gains.length - 1]; assert(gNode.connectedTo === p, `pan=${pan}:gain 接 panner(链路 source→gain→panner→bus)`); } // clamp:越界钳到 [-1,1] audio.play('sfx/kill_wolf', { pan: 2 }); await flush(); assert(ctx.panners[ctx.panners.length - 1].pan.value === 1, 'pan=2 钳到 1'); audio.play('sfx/kill_wolf', { pan: -3 }); await flush(); assert(ctx.panners[ctx.panners.length - 1].pan.value === -1, 'pan=-3 钳到 -1'); // voice 层同款能力(本作旁白不使用,对称能力一次给足) { const n0 = ctx.panners.length; audio.play('voice/intro_1', { pan: -0.5 }); await flush(); assert(ctx.panners.length === n0 + 1, 'voice opts.pan:创建 panner'); const p = ctx.panners[ctx.panners.length - 1]; assert(Math.abs(p.pan.value - (-0.5)) < 1e-9, 'voice pan=-0.5:panner 值正确'); assert(p.connectedTo === audio._voiceBus, 'voice panner 接 voiceBus'); } // voice 无 pan:零 panner(回归) { const n0 = ctx.panners.length; audio.play('voice/intro_2'); await advance(() => !audio.isVoicePlaying()); assert(ctx.panners.length === n0, 'voice 无 pan:零 panner(既有行为不变)'); } // 老 Safari:无 createStereoPanner → 静默忽略 pan(不炸、照常出声) ctx.createStereoPanner = undefined; const srcBefore = ctx.sources.length; audio.play('sfx/atk_whoosh', { pan: 1 }); audio.play('voice/room_wait', { pan: 0.8 }); await flush(); assert(ctx.sources.length === srcBefore + 1, '老 Safari 降级:无 createStereoPanner 时 sfx 照常播放'); assert(ctx.gains[ctx.gains.length - 1].connectedTo === audio._voiceBus, '老 Safari 降级:voice gain 直连 voiceBus(pan 静默忽略)'); await advance(() => !audio.isVoicePlaying()); } // ================================================================ Block 1 · 关键路径:首局教学房(§24 关键路径 1-2) realLog('== Block 1 · 首局教学房 T0-T4(guide=off)=='); { resetSettings('off'); storageData.delete(SAVE_KEY); // 无存档 → tutDone=false const rig = makeRig(); const { audio, playLog, game, ws } = rig; const g0 = gestureCount; // §7 全局:解锁前 play 为 no-op audio.play('voice/intro_1'); await flush(); assert(audio._voice === null && consoleWarns.some((w) => w.includes('尚未解锁')), '启动遮罩点按前无音频播放(play 为 no-op)'); await audio.unlock(); playLog.length = 0; game.start(); await flush(); assert(game.phase === 'title', '启动进 TITLE'); assert(playLog.some((e) => e.id === 'bgm/menu'), 'TITLE 播放 bgm/menu'); // 无效手势(左右滑):error 震 + guide=off 零 gd_invalid 语音 await g(game, 'left'); assert(vibCount(ERR_PATTERN) >= 1, 'TITLE 左右滑:error 震'); assert(playsOf(playLog, 'voice/gd_invalid') === 0, 'guide=off:无效手势零向导语音'); // 无存档单击 → 快速匹配,join 必带 teach:true(首局=教学房)+ ver:1 + room:"quick" await g(game, 'tap'); assert(game.phase === 'matching', 'TITLE 单击 → MATCHING'); const conn = ws.last(); ws.open(conn); await flush(); const join = ws.msgs(conn)[0]; assert(join && join.t === 'join' && join.ver === 1 && join.room === 'quick' && join.teach === true, '首局单击发出 join { room:"quick", teach:true, ver:1 }'); assert(join.name === '试炼猎人', 'join.name 取自档案'); // 注入 joined(教学房:slot 0 + 2 bot,seed=42)→ TEACH ws.inject(conn, joinedMsg({ state: { phase: 'teach', wave: 0, dur: 10 } })); await flush(); assert(game.phase === 'teach', 'joined(phase:teach) → TEACH'); assert(game.slot === 0 && game._myChar === 'yan' && game.seed === 42, 'joined 字段落位(slot/char/seed)'); assert(playLog.some((e) => e.id === 'bgm/hunt'), 'TEACH BGM 切 bgm/hunt'); await advance(() => !audio.isVoicePlaying()); assert(chainOrderOk(playLog, ['intro_1', 'intro_2', 'tut_rule']), 'T0:intro_1→intro_2→tut_rule 有序'); // T0 单击 → T1(tut_attack) await g(game, 'tap'); assert(game._tut === 1 && playsOf(playLog, 'voice/tut_attack') === 1, 'T0 单击 → T1 播 tut_attack'); await advance(() => !audio.isVoicePlaying()); // T1:服务器放教学狼 1(N,eta 12)→ 生成呼啸一声;eta>6 零 loop ws.inject(conn, evSpawn('w1', 'wolf', 'N', 12)); await flush(); assert(playLog.some((e) => e.id === 'sfx/howl_wolf' && e.opts.pan === 0 && Math.abs(e.opts.volume - 0.4) < 1e-9), 'T1 教学狼生成呼啸:howl_wolf,pan=0(N),vol=0.4(外圈一声)'); await advanceMs(600); // 渲染器重估一轮 assert(game._loops.size === 0, '听觉裁剪:eta>6s 怪零 loop'); // 先错向滑 W → act attack dir:W → ev miss → atk_swish + error 震 + gd_idle 重教,怪物不受伤 await g(game, 'left'); let acts = ws.acts(conn, 'attack'); assert(acts.length === 1 && acts[0].dir === 'W', 'T1 错向滑 W → 发出 act attack dir:W'); ws.inject(conn, evMiss(0, 'W')); await advanceMs(400); // atk_swish 与 atk_whoosh 同类间隔 ≥0.15s,走 one-shot 队列延迟播出 assert(playLog.some((e) => e.id === 'sfx/atk_swish'), 'T1 挥空播 atk_swish(归因真话)'); assert(vibCount(ERR_PATTERN) >= 1, 'T1 挥空 error 震'); assert(game.mobs.has('w1'), 'T1 挥空怪物不受伤'); await advance(() => playsOf(playLog, 'voice/gd_idle') >= 1); assert(playsOf(playLog, 'voice/gd_idle') === 1, 'T1 错向 → gd_idle 重教'); await advance(() => !audio.isVoicePlaying()); // 冷却 2.5s 内再滑 → 仅 error 震、零 act(冷却门控在 T1 同样生效) await g(game, 'up'); assert(ws.acts(conn, 'attack').length === 1, '出击冷却 2.5s 内滑:零 act 发出'); await advanceMs(2600); // 向 N 滑 → act attack dir:N → ev kill → tut_kill_ok await g(game, 'up'); acts = ws.acts(conn, 'attack'); assert(acts.length === 2 && acts[1].dir === 'N', 'T1 向 N 滑 → 发出 act attack dir:N'); ws.inject(conn, evKill('w1', 0, 'N')); await flush(); assert(playLog.some((e) => e.id === 'sfx/kill_wolf'), 'T1 首杀播 kill_wolf'); assert(vibCount(PICKUP_PATTERN) >= 1, 'T1 首杀 pickup 震'); assert(game._myKills === 1, '击杀归属:kills 账 slot 0 = 1'); assert(game._tut === 2, 'T1 首杀 → T2'); await advance(() => !audio.isVoicePlaying()); assert(chainOrderOk(playLog, ['tut_kill_ok', 'tut_callout']), 'T1 首杀 → tut_kill_ok → T2 tut_callout'); // 再放一只教学狼(稍后验证突入零惩罚) ws.inject(conn, evSpawn('w9', 'wolf', 'N', 12)); await flush(); // T2:单击 → act callout 发出;本机播自己座位角色 help_call(中央 pan=0) await g(game, 'tap'); assert(ws.acts(conn, 'callout').length === 1, 'T2 单击 → 发出 act callout'); assert(game._npcLog.some((r) => r.id === 'sfx/npc_yan_help_call' && r.pan === 0), 'T2 本机播自己座位角色 help_call(npc_yan_help_call,中央)'); // 自己的 callout ev 回环不重复播 ws.inject(conn, evCallout(0)); await flush(); assert(game._npcLog.filter((r) => r.id === 'sfx/npc_yan_help_call').length === 1, '自己呼喊的 ev 回环不重复播'); // 注入 bot help_ack(座 2 老周,防区中心 WNW=292.5°,pan≈-0.92)→ 播完 → tut_callout_ok ws.inject(conn, evBotline(2, 'help_ack')); await advance(() => game._npcLog.some((r) => r.id === 'sfx/npc_zhou_help_ack')); const ackRec = game._npcLog.find((r) => r.id === 'sfx/npc_zhou_help_ack'); assert(Math.abs(ackRec.pan - Math.sin(292.5 * Math.PI / 180)) < 1e-6, 'T2 bot help_ack 带 pan=该 bot 防区中心(座 2 WNW,pan≈-0.92)'); await advance(() => game._tut === 3); await advance(() => !audio.isVoicePlaying()); assert(chainOrderOk(playLog, ['tut_callout_ok', 'tut_skill']), 'T2 bot 应答播完 → tut_callout_ok(呼喊-响应闭环点明)→ T3 tut_skill'); // T3:服务器放教学狼 2、3(贴近 eta 5s)→ 双击旋风斩 → ev skill cleared=2 ws.inject(conn, evSpawn('w2', 'wolf', 'NE', 5)); ws.inject(conn, evSpawn('w3', 'wolf', 'SE', 5)); await flush(); await g(game, 'doubletap'); assert(ws.acts(conn, 'skill').length === 1, 'T3 双击 → 发出 act skill'); ws.inject(conn, evSkill(0, ['w2', 'w3'])); await flush(); assert(playLog.some((e) => e.id === 'sfx/skill_spin'), 'T3 旋风斩放出播 skill_spin'); assert(vibCount(SUCCESS_PATTERN) >= 1, 'T3 旋风斩 success 震'); assert(!game.mobs.has('w2') && !game.mobs.has('w3'), 'T3 旋风斩清掉教学狼 2、3'); assert(game._tut === 4, 'T3 放出 → T4'); await advance(() => !audio.isVoicePlaying()); assert(chainOrderOk(playLog, ['tut_skill_cd', 'tut_status']), 'T3 放出 → tut_skill_cd(讲冷却与冷却中双击=重新听)→ T4 tut_status'); // 教学狼突入零惩罚:ev breach 不扣耐久、不播 breach 警报、无 hit 震 const hitVib0 = vibCount(HIT_PATTERN); ws.inject(conn, evBreach('w9', 'wolf', 'N', 10)); await flush(); assert(game.dur === 10, '学期零惩罚:教学狼突入不扣耐久'); assert(playsOf(playLog, 'voice/breach') === 0 && playsOf(playLog, 'voice/breach_heavy') === 0, '学期零惩罚:教学段突入不播 breach 警报(明说规则前不漏真话)'); assert(vibCount(HIT_PATTERN) === hitVib0, '学期零惩罚:教学段突入无 hit 震'); // T4:上滑 → 状态链 4 段有序 → tut_breach → tut_done → tutDone 写入进度 await g(game, 'up'); await advance(() => game._tutDoneWaiting); assert(chainOrderOk(playLog, ['hp_band1', 'st_wave_a', 'team_3', 'kill_band1', 'tut_breach', 'tut_done']), 'T4 上滑 → 状态链 4 段有序(hp_band→st_wave→team_3→kill_band)→ tut_breach → tut_done'); assert(getProgress() && getProgress().tutDone === true, '教学完成 tutDone 写入进度'); // 注入 ev wave 1 → WAVE(教学房同场接续第 1 波;先号角后播报;无 room_start) const vibHb0 = vibCount(HEARTBEAT_PATTERN); ws.inject(conn, evWave(1)); await flush(); assert(game.phase === 'wave', '教学完成 → ev wave 1 → WAVE'); assert(playLog.some((e) => e.id === 'sfx/horn_wave'), 'ev wave 1:先号角 horn_wave'); assert(vibCount(HEARTBEAT_PATTERN) > vibHb0, 'ev wave:heartbeat 震'); const iHorn = playLog.findIndex((e) => e.id === 'sfx/horn_wave'); const iBand = playLog.findIndex((e) => e.id === 'voice/wave_band1'); assert(iBand > iHorn, '先号角后播报(wave_band1 在 horn_wave 后)'); assert(playsOf(playLog, 'voice/room_start') === 0, '教学房接续第 1 波不播 room_start(tut_done 已衔接)'); await advance(() => !audio.isVoicePlaying()); // guide=off:WAVE 30s 空闲零向导播放、零看门狗定时器 await advanceMs(30000); assert(playsOf(playLog, 'voice/gd_idle') === 1, 'guide=off:WAVE 30s 空闲零 gd_idle(仍是 T1 那次)'); assert(game.guide._wd === null, 'guide=off:零看门狗定时器'); // 手势预算:教学路径 ≤150 次手势(§24 关键路径预算) assert(gestureCount - g0 <= 150, `教学关键路径手势预算 ≤150(实际 ${gestureCount - g0})`); game.stop(); await flush(); } // ================================================================ Block 2 · 战斗态:听觉裁剪/冷却/威胁/呼喊/突入(接教学房同一实例) // (为块独立性另起实例,从 WAVE 开始) realLog('== Block 2 · WAVE 战斗:听觉裁剪 / 冷却 / 威胁 / 呼喊 / 突入 =='); { resetSettings('off'); setProgress({ tutDone: true }); const rig = makeRig(); const { audio, playLog, game, ws } = rig; await audio.unlock(); game.start(); await flush(); // 非首局:join 不带 teach const { conn, join } = await driveToLobby(rig); assert(join && !('teach' in join), '二周目(tutDone=true)join 不带 teach'); assert(game.phase === 'lobby', 'joined(phase:lobby) → LOBBY'); assert(playsOf(playLog, 'voice/room_wait') === 1, 'LOBBY 进入播 room_wait'); // LOBBY 上滑=在场猎人播报;双击=重听 room_wait await g(game, 'up'); assert(playsOf(playLog, 'voice/team_3') === 1, 'LOBBY 上滑播 team_3(在场猎人)'); await advance(() => !audio.isVoicePlaying()); await g(game, 'doubletap'); assert(playsOf(playLog, 'voice/room_wait') === 2, 'LOBBY 双击重听 room_wait'); await advance(() => !audio.isVoicePlaying()); // 单击发 act start;开局 roster ev:3 猎人、bot 槽 char 齐全;新队友 join_chime await g(game, 'tap'); assert(ws.acts(conn, 'start').length === 1, 'LOBBY 单击 → 发出 act start'); ws.inject(conn, evRoster([ { slot: 0, name: '试炼猎人', bot: false, char: 'yan', sector: SECTORS3[0] }, { slot: 1, name: '小筝', bot: true, char: 'zheng', sector: SECTORS3[1] }, { slot: 2, name: '老周', bot: true, char: 'zhou', sector: SECTORS3[2] }, ])); await flush(); assert(game._roster.size === 3, '开局 roster ev:猎人总数 = max(3, 人类数) = 3'); assert(game._charOf(1) === 'zheng' && game._charOf(2) === 'zhou', 'bot 槽 char 齐全'); // ev wave 1 → room_start → wave_band1(从 LOBBY 开局) ws.inject(conn, evWave(1)); await flush(); assert(game.phase === 'wave', 'ev wave 1 → WAVE'); await advance(() => !audio.isVoicePlaying()); assert(chainOrderOk(playLog, ['room_start', 'wave_band1']), '开局链:room_start → wave_band1'); // ---- 听觉视野裁剪:外圈呼啸一声(pan=1),eta>6 零 loop;进中圈起 loop 0.30 ws.inject(conn, evSpawn('e1', 'wolf', 'E', 9)); await flush(); const e1Howl = playLog.find((e) => e.id === 'sfx/howl_wolf' && Math.abs(e.opts.pan - 1) < 1e-9); assert(e1Howl && Math.abs(e1Howl.opts.volume - 0.4) < 1e-9, '外圈生成呼啸:pan=sin(90°)=1(E),vol 0.4'); await advanceMs(600); assert(game._loops.size === 0, 'eta>6s:零 loop(外圈静默)'); await advanceMs(3200); // eta 9 → ~5.8,跨进中圈 await advance(() => game._loops.size === 1); { const h = game._loops.get('e1'); assert(h && Math.abs(h.vol - 0.3) < 1e-9, '中圈(4-6s)起 loop,音量档 0.30'); assert(h.mkind === 'wolf', 'loop 资产按 mkind=loop_wolf'); } // ---- R3:注入 5 只内圈怪 → loop ≤3 路且为 eta 最小 3 只 ws.inject(conn, snapMsg([ { id: 'm1', mkind: 'wolf', dir: 'N', eta: 1.0 }, { id: 'm2', mkind: 'wolf', dir: 'NE', eta: 2.0 }, { id: 'm3', mkind: 'wolf', dir: 'E', eta: 3.0 }, { id: 'm4', mkind: 'bear', dir: 'SE', eta: 3.5 }, { id: 'm5', mkind: 'wolf', dir: 'S', eta: 3.9 }, ])); await flush(); assert(game._loops.size === 3, 'R3:5 只内圈怪 → loop 恰 3 路'); assert(game._loops.has('m1') && game._loops.has('m2') && game._loops.has('m3'), 'R3:loop 渲染 eta 最小 3 只(m1/m2/m3)'); assert(Math.abs(game._loops.get('m1').vol - 0.8) < 1e-9, '内圈 <2.5s:loop 音量档 0.80'); assert(Math.abs(game._loops.get('m3').vol - 0.55) < 1e-9, '内圈 2.5-4s:loop 音量档 0.55'); // snap 把 m4/m5/e1 全清出本地(不在 snap 的 id 删除) assert(!game.mobs.has('e1'), 'snap 对账:本地多出/缺席的 id 被清理'); await flush(); { const h2 = game._loops.get('m2'); assert(h2 && h2.panner && Math.abs(h2.panner.pan.value - Math.sin(45 * Math.PI / 180)) < 1e-6, 'loop 声像 pan=sin(az)(NE≈0.7,自建 panner 句柄)'); assert(game._loops.get('m1').gain, 'loop 句柄建立(gain 节点就位)'); } // ---- 双击:旋风斩就绪 → 放出;冷却中 → 重听威胁(threat_many) await g(game, 'doubletap'); assert(ws.acts(conn, 'skill').length === 1, 'WAVE 双击(就绪)→ 发出 act skill'); ws.inject(conn, evSkill(0, ['m1', 'm2', 'm3', 'm4', 'm5'])); await flush(); assert(playLog.some((e) => e.id === 'sfx/skill_spin'), '旋风斩放出播 skill_spin(中央)'); assert(game.mobs.size === 0, '旋风斩清全场内圈'); await advanceMs(600); assert(game._loops.size === 0, '清场后 loop 全停'); // 再造 3 只内圈 → 冷却中双击 = 重听威胁(≥3 只 → threat_many),零 act // (snap slots 携带服务端冷却真值:旋风斩刚放过,skillCd≈7.4) ws.inject(conn, snapMsg([ { id: 'n1', mkind: 'wolf', dir: 'N', eta: 2.0 }, { id: 'n2', mkind: 'wolf', dir: 'W', eta: 3.0 }, { id: 'n3', mkind: 'bear', dir: 'NW', eta: 1.5 }, ], { slots: [{ slot: 0, alive: true, bot: false, cd: 0, skillCd: 7.4 }] })); await flush(); const skillActs0 = ws.acts(conn, 'skill').length; await g(game, 'doubletap'); assert(ws.acts(conn, 'skill').length === skillActs0, '旋风斩冷却中双击:零 act(本地先拦)'); assert(playsOf(playLog, 'voice/threat_many') === 1, '冷却中双击=重听威胁:内圈 3 只 → threat_many'); await advance(() => !audio.isVoicePlaying()); // 8s 冷却完成 → skill_ready(queue:true 不顶句;每轮 1 次) await advanceMs(8100); assert(playsOf(playLog, 'voice/skill_ready') === 1, '旋风斩冷却完成播 skill_ready(每轮冷却 1 次)'); // ---- 出击冷却 2.5s:冷却内滑仅 error 震、零 act await g(game, 'right'); const atkActs = ws.acts(conn, 'attack'); assert(atkActs.length === 1 && atkActs[0].dir === 'E', 'WAVE 右滑 → act attack dir:E'); await g(game, 'left'); assert(ws.acts(conn, 'attack').length === 1, '出击冷却内滑:零 act'); assert(vibCount(ERR_PATTERN) >= 1, '出击冷却内滑:仅 error 震'); await advanceMs(2600); await g(game, 'left'); assert(ws.acts(conn, 'attack').length === 2 && ws.acts(conn, 'attack')[1].dir === 'W', '冷却后滑正常发出 act attack dir:W'); // 击杀归属 + 挥空真话 ws.inject(conn, evKill('n2', 0, 'W')); await flush(); assert(playLog.some((e) => e.id === 'sfx/kill_wolf' && Math.abs(e.opts.pan - (-1)) < 1e-9), '击杀音带 pan=出击方位(W,pan=-1)'); assert(vibCount(PICKUP_PATTERN) >= 1, '命中 pickup 震'); ws.inject(conn, evMiss(0, 'E')); await advanceMs(400); // one-shot 同类间隔排队 assert(playLog.some((e) => e.id === 'sfx/atk_swish'), 'WAVE 挥空播 atk_swish + error 震'); // ---- 呼喊:单击 → act callout + 本机座位角色 help_call;节流 5s await g(game, 'tap'); assert(ws.acts(conn, 'callout').length === 1, 'WAVE 单击 → act callout'); assert(game._npcLog.some((r) => r.id === 'sfx/npc_yan_help_call'), '本机播 npc_yan_help_call'); await g(game, 'tap'); assert(ws.acts(conn, 'callout').length === 1, '呼喊节流 5s:连点只出一条 callout act'); assert(vibCount(ERR_PATTERN) >= 1, '呼喊节流内单击:error 震'); // 他人呼喊:该座角色 help_call 带防区中心 pan + warning 震 const warn0 = vibCount(WARN_PATTERN); ws.inject(conn, evCallout(2)); await advance(() => game._npcLog.some((r) => r.id === 'sfx/npc_zhou_help_call')); const callRec = game._npcLog.find((r) => r.id === 'sfx/npc_zhou_help_call'); assert(Math.abs(callRec.pan - Math.sin(292.5 * Math.PI / 180)) < 1e-6, '收到呼喊:座 2 防区中心 pan(WNW≈-0.92)'); assert(vibCount(WARN_PATTERN) > warn0, '收到呼喊 warning 震'); // ---- 突入:breach_thud + hit 震 + breach 播报(节流 3.5s);熊 -2 → breach_heavy const hit0 = vibCount(HIT_PATTERN); ws.inject(conn, evBreach('n1', 'wolf', 'N', 9)); await flush(); assert(game.dur === 9, '狼突入耐久 -1(dur=9)'); assert(playLog.some((e) => e.id === 'sfx/breach_thud'), '突入播 breach_thud(中央)'); assert(vibCount(HIT_PATTERN) > hit0, '突入 hit 震'); assert(playsOf(playLog, 'voice/breach') === 1, '狼突入播 breach 警报'); await advance(() => !audio.isVoicePlaying()); // 3.5s 节流窗内再突入 → 不重复播报 ws.inject(conn, evBreach('n3', 'bear', 'NW', 7)); await flush(); assert(game.dur === 7, '熊突入耐久 -2(dur=7)'); assert(playsOf(playLog, 'voice/breach_heavy') === 0 && playsOf(playLog, 'voice/breach') === 1, 'breach 播报节流 ≥3.5s:窗内突入不重复播报'); await advanceMs(3600); ws.inject(conn, evSpawn('b2', 'bear', 'SW', 11)); await flush(); ws.inject(conn, evBreach('b2', 'bear', 'SW', 5)); await flush(); assert(playsOf(playLog, 'voice/breach_heavy') === 1, '节流窗后熊突入播 breach_heavy'); // 后半平面呼啸音量 ×0.6(SW=225°,身后发闷) const b2Howl = playLog.filter((e) => e.id === 'sfx/howl_bear').pop(); assert(b2Howl && Math.abs(b2Howl.opts.volume - 0.4 * 0.6) < 1e-9 && Math.abs(b2Howl.opts.pan - Math.sin(225 * Math.PI / 180)) < 1e-6, '后半平面呼啸:音量 ×0.6("身后发闷"),pan=sin(225°)≈-0.7'); await advance(() => !audio.isVoicePlaying()); // ---- 耐久首次 ≤2 → danger_low(每局 1 次)+ danger 震 const danger0 = vibCount(DANGER_PATTERN); ws.inject(conn, evSpawn('d1', 'wolf', 'N', 8)); ws.inject(conn, evBreach('d1', 'wolf', 'N', 2)); await advance(() => playsOf(playLog, 'voice/danger_low') === 1); assert(playsOf(playLog, 'voice/danger_low') === 1, '耐久首次 ≤2 播 danger_low'); assert(vibCount(DANGER_PATTERN) > danger0, '耐久 ≤2 danger 震'); ws.inject(conn, evSpawn('d2', 'wolf', 'N', 8)); await advanceMs(3600); ws.inject(conn, evBreach('d2', 'wolf', 'N', 1)); await flush(); assert(playsOf(playLog, 'voice/danger_low') === 1, 'danger_low 每局只播 1 次'); // ---- 波次推进:waveclear → wave_clear;ev wave 2 → horn + wave_band2 ws.inject(conn, { t: 'ev', kind: 'waveclear', ts: now, wave: 1 }); await advance(() => playsOf(playLog, 'voice/wave_clear') === 1); assert(playsOf(playLog, 'voice/wave_clear') === 1, 'ev waveclear 播 wave_clear(波间休整)'); ws.inject(conn, evWave(2)); await flush(); assert(chainOrderOk(playLog, ['wave_clear', 'wave_band2']), '第 2 波:号角 + wave_band2'); assert(game.wave === 2, 'wave=2'); await advance(() => !audio.isVoicePlaying()); game.stop(); await flush(); } // ================================================================ Block 3 · 菜单 E3(不暂停)+ 上/下滑语境折中 realLog('== Block 3 · 菜单不暂停(E3)+ 语境折中 =='); { resetSettings('off'); setProgress({ tutDone: true }); const rig = makeRig(); const { audio, playLog, game, ws } = rig; await audio.unlock(); game.start(); await flush(); const { conn } = await driveToLobby(rig); await driveToWave(rig, conn); // 语境折中(文件头裁决):S 弧有可打怪时下滑 = 出击(不开菜单) ws.inject(conn, snapMsg([{ id: 's1', mkind: 'wolf', dir: 'S', eta: 3.0 }])); await flush(); await g(game, 'down'); assert(ws.acts(conn, 'attack').length === 1 && ws.acts(conn, 'attack')[0].dir === 'S', '下滑弧内有可打怪 → 出击(act attack dir:S),不开菜单'); assert(game._menuOpen === false, '下滑出击时菜单不开'); ws.inject(conn, evKill('s1', 2, 'S')); // bot 补刀清场(kills 归 slot 2) await flush(); // v2 S1:战斗中下滑恒出击(无怪 = 空挥真话 atk_swish),不再弹菜单 const cdT0 = performance.now(); await advance(() => performance.now() - cdT0 >= 2600); // 过 2.5s 出击冷却 const atkBefore = ws.acts(conn, 'attack').length; await g(game, 'down'); assert(ws.acts(conn, 'attack').length === atkBefore + 1 && ws.acts(conn, 'attack')[atkBefore].dir === 'S', 'v2 S1:战斗中无怪下滑 = 空挥出击(act attack dir:S)'); assert(game._menuOpen === false, 'v2 S1:战斗中下滑绝不打开菜单'); // v2 S1:菜单只在波间休整窗(waveclear 后 8s)开放 ws.inject(conn, evWaveclear(1)); await flush(); await g(game, 'down'); assert(game._menuOpen === true, '波间休整窗下滑 → 模态菜单打开'); await advance(() => playsOf(playLog, 'voice/settings_hint') === 1); assert(chainOrderOk(playLog, ['sys_menu', 'sys_menu_resume', 'settings_hint']), '菜单打开即播 sys_menu + 首项 + settings_hint(设置只读、回大厅修改)'); // E3:菜单打开期间战斗照常——注入 breach,耐久照扣、菜单不收 ws.inject(conn, evSpawn('x1', 'wolf', 'N', 3)); await flush(); ws.inject(conn, evBreach('x1', 'wolf', 'N', 4)); await flush(); assert(game.dur === 4, 'E3:菜单开着 breach 照扣耐久(联机不可单方暂停)'); assert(game._menuOpen === true, 'E3:菜单开着 breach 菜单不收'); await advance(() => !audio.isVoicePlaying()); // 菜单 3 项固定、单击循环(回绕) await g(game, 'tap'); await g(game, 'tap'); await g(game, 'tap'); const menuIdx = playLog.filter((e) => e.id.startsWith('voice/sys_menu_')).map((e) => e.id); const tail = menuIdx.slice(-3); assert(tail[0] === 'voice/sys_menu_help' && tail[1] === 'voice/sys_menu_home' && tail[2] === 'voice/sys_menu_resume', '菜单固定 3 项,单击循环(help → home → 回绕 resume)'); // 上滑 = 帮助 await g(game, 'up'); assert(playsOf(playLog, 'voice/help_main') === 1, '菜单内上滑播 help_main(帮助)'); await advance(() => !audio.isVoicePlaying()); // 下滑 = 关闭 await g(game, 'down'); assert(game._menuOpen === false, '菜单内下滑 = 关闭'); // 双击确认:选中"重新听帮助"→ 播 help_main 且菜单不收 ws.inject(conn, evWaveclear(1)); // 重置 8s 休整窗 await flush(); await g(game, 'down'); assert(game._menuOpen === true, '菜单再次打开(休整窗内)'); await advance(() => !audio.isVoicePlaying()); await g(game, 'tap'); // idx 1 = sys_menu_help await g(game, 'doubletap'); await advance(() => playsOf(playLog, 'voice/help_main') === 2); assert(game._menuOpen === true, '双击确认"重新听帮助":播帮助且菜单不收'); await advance(() => !audio.isVoicePlaying()); await g(game, 'down'); // 关闭 assert(game._menuOpen === false, '菜单再次关闭'); // 双击确认"退出房间回大厅":发 leave + 跳转大厅 ws.inject(conn, evWaveclear(1)); // 重置 8s 休整窗 await flush(); await g(game, 'down'); await advance(() => !audio.isVoicePlaying()); await g(game, 'tap'); await g(game, 'tap'); // idx 2 = sys_menu_home await g(game, 'doubletap'); await flush(); assert(ws.msgs(conn).some((m) => m && m.t === 'leave'), '菜单"回大厅"确认 → 发出 leave'); assert(game._navigatedHome === '../' && window.location.href === '../', '回大厅跳转(leaving 收尾,不留尸体页面)'); } // ================================================================ Block 4 · 终局结算 + 排行榜(§12) realLog('== Block 4 · 胜利/失守结算链 + PUT progress(summary 生成规则)=='); { resetSettings('off'); setProgress({ tutDone: true }); serverBuckets.delete('hunt'); const putCount0 = apiPuts.length; const rig = makeRig(); const { audio, playLog, game, ws } = rig; await audio.unlock(); game.start(); await flush(); // TITLE 上滑无 best → error 震(不播评级段) await g(game, 'up'); assert(vibCount(ERR_PATTERN) >= 1 && playsOf(playLog, 'voice/grade_a') === 0, 'TITLE 上滑无最佳档:error 震,不播评级段'); const { conn } = await driveToLobby(rig); assert(playsOf(playLog, 'voice/intro_1') === 1 && playsOf(playLog, 'voice/intro_2') === 1, '二周目 TITLE 单击后播开场 intro_1/intro_2(A 组触发口径)'); // 第 4 位队友加入 → join_chime ws.inject(conn, evRoster([ { slot: 0, name: '试炼猎人', bot: false, char: 'yan', sector: SECTORS3[0] }, { slot: 1, name: '小筝', bot: true, char: 'zheng', sector: SECTORS3[1] }, { slot: 2, name: '老周', bot: true, char: 'zhou', sector: SECTORS3[2] }, { slot: 3, name: '路人甲', bot: false, char: null, sector: ['W', 'NW'] }, ])); await flush(); assert(playLog.some((e) => e.id === 'sfx/join_chime'), '队友加入播 join_chime'); await g(game, 'tap'); // 立即开局 ws.inject(conn, evWave(1)); await flush(); assert(game.phase === 'wave', 'wave 1 进 WAVE'); await advance(() => !audio.isVoicePlaying()); // 终局(胜利,A,击杀 14)→ 800ms 手势死窗 ws.inject(conn, evEnd({ win: true, dur: 8, grade: 'A', kills: { '0': 14, '1': 9, '2': 6 } })); await flush(); assert(game.phase === 'end', 'ev end → END 结算态'); const connsAtEnd = ws.conns.length; await advanceMs(400); await g(game, 'tap'); // 死窗内 ghost tap → 被吞 assert(game.phase === 'end' && ws.conns.length === connsAtEnd, '结算死窗:end 到达后 800ms 内 tap 被吞(不重开)'); // 结算链有序:end_win → grade_a → kill_band2 → end_rank → end_again await advance(() => playsOf(playLog, 'voice/end_again') === 1); assert(chainOrderOk(playLog, ['end_win', 'grade_a', 'kill_band2', 'end_rank', 'end_again']), '胜利结算链有序(end_win→grade_a→kill_band2→end_rank→end_again)'); // PUT progress:summary 命中生成规则;best 按"评级>耐久>击杀"写入 const winPut = apiPuts.slice(putCount0).find((p) => p.body && p.body.game === 'hunt'); assert(winPut, '终局 PUT /api/progress { game:"hunt" }'); assert(winPut.body.data.summary === '最佳评级A · 击杀14 · 营地余8 · 共1局', 'summary 命中生成规则(最佳评级A · 击杀14 · 营地余8 · 共1局)'); assert(winPut.body.data.best && winPut.body.data.best.grade === 'A' && winPut.body.data.best.kills === 14 && winPut.body.data.best.durLeft === 8, 'best 写入(胜利局:评级/击杀/营地余)'); assert(winPut.body.data.runs === 1 && winPut.body.data.tutDone === true, 'runs/tutDone 账正确'); assert(getProgress().summary === '最佳评级A · 击杀14 · 营地余8 · 共1局', 'localStorage 离线缓存同步写入'); // END:双击=重听结算链;上滑=重听个人最佳段 await g(game, 'doubletap'); await advance(() => playsOf(playLog, 'voice/end_win') === 2); assert(playsOf(playLog, 'voice/end_win') === 2, 'END 双击重听结算链'); await advance(() => !audio.isVoicePlaying()); await g(game, 'up'); const gradeABefore = playsOf(playLog, 'voice/grade_a'); await advance(() => playsOf(playLog, 'voice/grade_a') > gradeABefore || !audio.isVoicePlaying()); assert(playsOf(playLog, 'voice/grade_a') >= gradeABefore, 'END 上滑重听个人最佳段(评级+击杀档)'); await advance(() => !audio.isVoicePlaying()); // 死窗后单击 = 再来一局(重新进匹配) await g(game, 'tap'); assert(game.phase === 'matching' && ws.conns.length === connsAtEnd + 1, '死窗后单击再来一局 → 重新进匹配'); // ---- 第二局:失守路径(dur 归零,无评级、不入榜) const conn2 = ws.last(); ws.open(conn2); await flush(); ws.inject(conn2, joinedMsg()); await flush(); await g(game, 'tap'); // start ws.inject(conn2, evWave(1)); await flush(); await advance(() => !audio.isVoicePlaying()); const gradeTotal0 = playLog.filter((e) => e.id.startsWith('voice/grade_')).length; const endRank0 = playsOf(playLog, 'voice/end_rank'); const endAgain0 = playsOf(playLog, 'voice/end_again'); ws.inject(conn2, evEnd({ win: false, dur: 0, grade: null, kills: { '0': 3, '1': 8, '2': 5 } })); await flush(); await advance(() => playsOf(playLog, 'voice/end_again') > endAgain0); assert(chainOrderOk(playLog, ['end_lose', 'kill_band1', 'end_again']), '失守结算链:end_lose → kill_band → end_again'); assert(playLog.filter((e) => e.id.startsWith('voice/grade_')).length === gradeTotal0, '失守不播评级(无 grade_*)'); assert(playsOf(playLog, 'voice/end_rank') === endRank0, '失守不播 end_rank'); const losePut = apiPuts.slice(putCount0).filter((p) => p.body && p.body.game === 'hunt').pop(); assert(losePut.body.data.best && losePut.body.data.best.grade === 'A' && losePut.body.data.best.kills === 14, '失守局不写 best(排行榜不被败局污染)'); assert(losePut.body.data.runs === 2 && losePut.body.data.totalKills === 14 + 3, '失守照记 runs/totalKills(runs=2,totalKills=14+3=17)'); assert(losePut.body.data.summary === '最佳评级A · 击杀14 · 营地余8 · 共2局', '失守后 summary 仍按 best 生成(共2局)'); game.stop(); await flush(); } // ---- 全败 summary:无 best 时「共N局 · 还没守到天亮过」 realLog('== Block 4b · 全败 summary =='); { resetSettings('off'); setProgress({ tutDone: true }); serverBuckets.delete('hunt'); const putCount0 = apiPuts.length; const rig = makeRig(); const { audio, playLog, game, ws } = rig; await audio.unlock(); game.start(); await flush(); const { conn } = await driveToLobby(rig); await g(game, 'tap'); ws.inject(conn, evWave(1)); await flush(); await advance(() => !audio.isVoicePlaying()); ws.inject(conn, evEnd({ win: false, dur: 0, grade: null, kills: { '0': 2 } })); await advance(() => playsOf(playLog, 'voice/end_again') === 1); const put = apiPuts.slice(putCount0).filter((p) => p.body && p.body.game === 'hunt').pop(); assert(put.body.data.summary === '共1局 · 还没守到天亮过', '无 best(全败)summary:共1局 · 还没守到天亮过'); game.stop(); await flush(); } // ================================================================ Block 5 · 断线重连 / 房灭 / 服务器不可达(§6.6) realLog('== Block 5 · 断线 resume 回槽 / room_gone / 不可达 =='); { resetSettings('off'); setProgress({ tutDone: true }); const rig = makeRig(); const { audio, playLog, game, ws } = rig; await audio.unlock(); game.start(); await flush(); const { conn } = await driveToLobby(rig); await driveToWave(rig, conn); ws.inject(conn, evSpawn('x1', 'wolf', 'N', 8)); await flush(); // 断线 → net_lost 播报 + 本地静默重连(怪物照爬:eta 继续衰减) ws.drop(conn); await advance(() => playsOf(playLog, 'voice/net_lost') === 1); assert(playsOf(playLog, 'voice/net_lost') === 1, 'WS 断开播 net_lost(正在重连)'); const etaAtDrop = game._mobEta(game.mobs.get('x1')); await advanceMs(1500); assert(game._mobEta(game.mobs.get('x1')) < etaAtDrop, '断网期间怪物按最后已知 eta 继续衰减渲染(戏剧张力保留)'); // 宽限内带 resume 重连 → 回槽:joined 同 slot/seed/char + 紧随 snap 全量 → net_back await advanceMs(700); // 越过 2s 重连节奏 const conn2 = ws.last(); assert(conn2 !== conn, '断线后自动发起新连接(静默重连)'); ws.open(conn2); await flush(); const rejoin = ws.msgs(conn2)[0]; assert(rejoin && rejoin.resume === 'r0' && rejoin.room === 'ABCD', '重连 join 携带 resume 凭证回原房'); ws.inject(conn2, joinedMsg({ state: { phase: 'wave', wave: 1, dur: 10 } })); await flush(); ws.inject(conn2, snapMsg([{ id: 'x1', mkind: 'wolf', dir: 'N', eta: 7.5 }])); await flush(); assert(playsOf(playLog, 'voice/net_back') === 1, 'resume 回槽成功播 net_back(接着打)'); assert(game.slot === 0 && game._myChar === 'yan' && game.seed === 42, '回槽:同 slot/seed/char'); assert(game.mobs.has('x1') && Math.abs(game._mobEta(game.mobs.get('x1')) - 7.5) < 0.2, '紧随 snap 全量:怪表与快照一致(eta 按 snap 校正)'); assert(game.phase === 'wave', '回槽后战斗续接(WAVE)'); // 再断 → 重连 → 房已灭:err room_gone → net_gone → LOST ws.drop(conn2); await advanceMs(2200); const conn3 = ws.last(); ws.open(conn3); await flush(); ws.inject(conn3, { t: 'err', code: 'room_gone', msg: '房间已销毁' }); await flush(); assert(playsOf(playLog, 'voice/net_gone') === 1, '房灭后迟到 resume:err room_gone → net_gone'); assert(game.phase === 'lost', '重连失败 → LOST(明示失败,不静默死屏)'); await g(game, 'tap'); assert(game.phase === 'title', 'LOST 单击 → 回大厅(TITLE)'); game.stop(); await flush(); } // ---- 30s 宽限耗尽 → net_gone(战斗态断线不恢复) realLog('== Block 5b · 30s 宽限耗尽 → LOST =='); { resetSettings('off'); setProgress({ tutDone: true }); const rig = makeRig(); const { audio, playLog, game, ws } = rig; await audio.unlock(); game.start(); await flush(); const { conn } = await driveToLobby(rig); await driveToWave(rig, conn); ws.drop(conn); await flush(); const connsAtDrop = ws.conns.length; await advanceMs(32000); // 每 2s 一次重连尝试,对端均不响应 assert(ws.conns.length >= connsAtDrop + 3, '30s 宽限内持续静默重连(≥3 次尝试)'); assert(playsOf(playLog, 'voice/net_lost') === 1, 'net_lost 每次断线只播一次'); assert(playsOf(playLog, 'voice/net_gone') === 1 && game.phase === 'lost', '30s 宽限耗尽 → net_gone → LOST'); game.stop(); await flush(); } // ---- 服务器不可达:TITLE 单击 → net_lost → net_gone → LOST(明示失败) realLog('== Block 5c · 服务器不可达 =='); { resetSettings('off'); setProgress({ tutDone: true }); const rig = makeRig(); const { audio, playLog, game, ws } = rig; await audio.unlock(); game.start(); await flush(); await g(game, 'tap'); // TITLE 单击 = 快速匹配;连接永不建立 assert(game.phase === 'matching', 'TITLE 单击 → MATCHING'); await advanceMs(36000); // 2s 建立看守 + 2s 重连节奏,30s 宽限后放弃 assert(playsOf(playLog, 'voice/net_lost') === 1 && playsOf(playLog, 'voice/net_gone') === 1, '服务器不可达:net_lost → net_gone(明示失败)'); assert(game.phase === 'lost', '服务器不可达 → LOST(不静默死屏)'); await g(game, 'tap'); assert(game.phase === 'title', 'LOST 单击回大厅'); game.stop(); await flush(); } // ================================================================ Block 6 · snap 兜底(随机丢 ev 后收敛) realLog('== Block 6 · snap 兜底收敛 =='); { resetSettings('off'); setProgress({ tutDone: true }); const rig = makeRig(); const { audio, playLog, game, ws } = rig; await audio.unlock(); game.start(); await flush(); const { conn } = await driveToLobby(rig); await driveToWave(rig, conn); // 注入 4 只生成(s3 的 spawn ev "丢了"不注入 = 丢弃 25%+ 事件)+ 1 只幽灵怪 s9 ws.inject(conn, evSpawn('s1', 'wolf', 'N', 8)); ws.inject(conn, evSpawn('s2', 'wolf', 'E', 9)); ws.inject(conn, evSpawn('s4', 'bear', 'S', 10)); ws.inject(conn, evSpawn('s9', 'wolf', 'W', 8)); // 幽灵:snap 里不存在 await flush(); await advanceMs(2000); // 仅凭 snap(2Hz 兜底)收敛:s3 补上、s9 删除、s1 eta 偏差 >0.5s 校正 ws.inject(conn, snapMsg([ { id: 's1', mkind: 'wolf', dir: 'N', eta: 5.4 }, { id: 's2', mkind: 'wolf', dir: 'E', eta: 7.0 }, { id: 's3', mkind: 'wolf', dir: 'NE', eta: 6.5 }, { id: 's4', mkind: 'bear', dir: 'S', eta: 8.0 }, ])); await flush(); const ids = [...game.mobs.keys()].sort(); assert(JSON.stringify(ids) === JSON.stringify(['s1', 's2', 's3', 's4']), 'snap 对账:缺失的 s3 补上、幽灵 s9 删除(id 集合收敛一致)'); assert(Math.abs(game._mobEta(game.mobs.get('s1')) - 5.4) < 0.05, 'snap 对账:s1 eta 偏差 0.6s > 0.5s → 按 snap 校正'); assert(Math.abs(game._mobEta(game.mobs.get('s2')) - 7.0) < 0.05, 's2 eta 未偏差则不动'); // 渲染收敛:仅 s1(5.4s)进听觉视野 await advanceMs(600); assert(game._loops.size === 1 && game._loops.has('s1'), '听觉视野按 snap 真值渲染(仅 eta≤6 的 s1)'); game.stop(); await flush(); } // ================================================================ Block 7 · 拥挤控制 R2/R4(§8) realLog('== Block 7 · 拥挤控制:R2 喊话调度 / R4 one-shot 上限 =='); { resetSettings('off'); setProgress({ tutDone: true }); const rig = makeRig(); const { audio, playLog, game, ws } = rig; await audio.unlock(); game.start(); await flush(); const { conn } = await driveToLobby(rig); await driveToWave(rig, conn); // ---- R4:波次生成潮 4 只同刻生成 → 呼啸并发 ≤2、同类间隔 ≥0.15s、队列 ≤2(超出丢弃) const posLog0 = game._posLog.length; ws.inject(conn, evSpawn('h1', 'wolf', 'N', 9)); ws.inject(conn, evSpawn('h2', 'wolf', 'NE', 9)); ws.inject(conn, evSpawn('h3', 'wolf', 'E', 9)); ws.inject(conn, evSpawn('h4', 'wolf', 'W', 9)); await flush(); await advanceMs(2500); // 等队列消化完 const howls = game._posLog.slice(posLog0).filter((r) => r.id === 'sfx/howl_wolf'); assert(howls.length === 3, 'R4:生成潮呼啸队列深 ≤2,超出丢弃(4 只 → 播 3 声)'); for (let i = 1; i < howls.length; i++) { assert(howls[i].t - howls[i - 1].t >= 150, `R4:同类 one-shot 间隔 ≥0.15s(第 ${i} 间隔 ${howls[i].t - howls[i - 1].t}ms)`); } // one-shot 并发核算(SFX_DUR 账面):任意时刻 ≤2 const DUR = { atk_whoosh: 0.3, atk_swish: 0.3, kill_wolf: 0.5, kill_bear: 0.7, skill_spin: 0.8, breach_thud: 0.6, horn_wave: 0.9, join_chime: 0.4, call_whistle: 0.6, howl_wolf: 0.8, howl_bear: 1.0 }; const base = (id) => id.slice(id.lastIndexOf('/') + 1); const posAll = game._posLog.slice(posLog0); const pts = []; for (const r of posAll) { pts.push({ t: r.t, d: 1 }); pts.push({ t: r.t + (DUR[base(r.id)] || 0.5) * 1000, d: -1 }); } pts.sort((a, b) => a.t - b.t || a.d - b.d); let cur = 0, maxConcur = 0; for (const p of pts) { cur += p.d; maxConcur = Math.max(maxConcur, cur); } assert(maxConcur <= 2, `R4:定位 one-shot 同刻 ≤2 并发(峰值实测 ${maxConcur})`); // ---- R2:旁白在播时喊话延迟投递 ≤2s game._playChain(['threat_few']); // 占住 voice(1.92s) await flush(); assert(audio.isVoicePlaying(), 'R2 前置:旁白在播'); const npcLog0 = game._npcLog.length; ws.inject(conn, evBotline(1, 'greet')); await flush(); assert(game._npcLog.length === npcLog0, 'R2:旁白在播时喊话不即时出声(延迟投递)'); await advance(() => game._npcLog.length > npcLog0); const greetRec = game._npcLog[npcLog0]; assert(greetRec.id === 'sfx/npc_zheng_greet' && greetRec.t <= now, 'R2:旁白播完后喊话延迟投递成功(≤2s 内)'); // ---- R2:同刻在播 ≤1;待播队列深 ≤1(新到顶掉未播的过期句) ws.inject(conn, evBotline(2, 'kill')); ws.inject(conn, evBotline(1, 'cover')); await flush(); await advance(() => game._npcLog.length > npcLog0 + 1); assert(game._npcLog.some((r) => r.id === 'sfx/npc_zheng_cover'), 'R2:待播位被新句顶掉后播出最新句'); assert(!game._npcLog.some((r) => r.id === 'sfx/npc_zhou_kill'), 'R2:被顶掉的过期句不再播出(队列深 ≤1)'); // 同刻 ≤1:逐条账目两两不重叠 const npcs = game._npcLog.map((r) => ({ t: r.t, until: r.t + r.dur * 1000 })); let overlap = false; for (let i = 1; i < npcs.length; i++) if (npcs[i].t < npcs[i - 1].until - 1) overlap = true; assert(!overlap, 'R2:bot 喊话同刻在播 ≤1(账目两两不重叠)'); // ---- R2:长旁白链期间待播超时丢弃(≤2s 延迟上限) ws.inject(conn, snapMsg([])); // 清空怪表(否则内圈怪会让上滑变成出击) await flush(); await advanceMs(600); await g(game, 'up'); // 状态链 4 段(~7.7s voice 占用) await flush(); assert(audio.isVoicePlaying(), 'R2 前置:状态链在播'); const npcLog1 = game._npcLog.length; ws.inject(conn, evBotline(2, 'wave')); await advanceMs(9000); assert(!game._npcLog.slice(npcLog1).some((r) => r.id === 'sfx/npc_zhou_wave'), 'R2:旁白在播延迟 >2s → 喊话超时丢弃(氛围句可弃)'); await advance(() => !audio.isVoicePlaying()); game.stop(); await flush(); } // ================================================================ Block 8 · 设置边界 + 座 3 哨音 realLog('== Block 8 · 设置边界 / 座 3 呼喊哨音 =='); { resetSettings('off'); // 设置边界(§7 全局断言):voice 切换下一句用对应目录 const audio = new WebAudioEngine('audio/'); await audio.unlock(); settings.set({ voice: 'male' }); const url0 = requestedUrls.length; audio.play('voice/intro_1'); await flush(); assert(requestedUrls.slice(url0).some((u) => u.includes('voice/male/intro_1')), 'settings.voice 切换后下一句用 male 目录'); await advance(() => !audio.isVoicePlaying()); // 键盘变体:input=key 时候选链前置 voice//key/ settings.set({ voice: 'female', input: 'key' }); const url1 = requestedUrls.length; audio.play('voice/tut_rule'); await flush(); assert(requestedUrls.slice(url1).some((u) => u.includes('voice/female/key/tut_rule')), 'input=key:候选链前置 key 变体目录(缺失回退主资产)'); await advance(() => !audio.isVoicePlaying()); settings.set({ input: 'touch' }); // bgm=0:不加载不播放 settings.set({ bgm: 0 }); const url2 = requestedUrls.length; audio.play('bgm/hunt'); await flush(); assert(!requestedUrls.slice(url2).some((u) => u.includes('/bgm/')), 'settings.bgm=0:无 bgm 层加载'); settings.set({ bgm: 1.0 }); resetSettings('off'); // 座 3 呼喊 = call_whistle 哨音(带 pan,§15 裁决 6) const rig = makeRig(); const { playLog, game, ws } = rig; await rig.audio.unlock(); game.start(); await flush(); await g(game, 'tap'); const conn = ws.last(); ws.open(conn); await flush(); ws.inject(conn, joinedMsg({ slot: 3, char: null, sector: ['W', 'NW'], roster: [ { slot: 0, name: '甲', bot: false, char: 'yan', sector: ['N', 'NE'] }, { slot: 1, name: '乙', bot: false, char: 'zheng', sector: ['E', 'SE'] }, { slot: 2, name: '丙', bot: false, char: 'zhou', sector: ['S', 'SW'] }, { slot: 3, name: '试炼猎人', bot: false, char: null, sector: ['W', 'NW'] }, ], })); await flush(); await g(game, 'tap'); // start ws.inject(conn, evWave(1)); await flush(); await advance(() => !rig.audio.isVoicePlaying()); await g(game, 'tap'); // 呼喊 assert(game._npcLog.length === 0, '座 3 无角色声:不播 npc_*'); const whistle = playLog.find((e) => e.id === 'sfx/call_whistle'); assert(whistle && Math.abs(whistle.opts.pan - Math.sin(292.5 * Math.PI / 180)) < 1e-6, '座 3 呼喊 = call_whistle 哨音(带防区中心 pan≈-0.92)'); assert(playsOf(playLog, 'voice/team_4') === 0, '(状态链 4 人局另行验证)'); await g(game, 'up'); // 无 N 弧目标 → 状态链 await advance(() => playsOf(playLog, 'voice/team_4') === 1); assert(playsOf(playLog, 'voice/team_4') === 1, '4 人局状态链播 team_4'); await advance(() => !rig.audio.isVoicePlaying()); game.stop(); await flush(); } // ================================================================ Block 9 · 资产缺失兜底(看门狗推进不卡死) realLog('== Block 9 · 屏蔽 voice 资产:流程不卡死 =='); { resetSettings('off'); storageData.delete(SAVE_KEY); blockedAssets.add('tut_attack'); // 屏蔽 T1 教学句 const rig = makeRig(); const { audio, playLog, game, ws } = rig; await audio.unlock(); game.start(); await flush(); await g(game, 'tap'); const conn = ws.last(); ws.open(conn); await flush(); ws.inject(conn, joinedMsg({ state: { phase: 'teach', wave: 0, dur: 10 } })); await advance(() => !audio.isVoicePlaying()); await g(game, 'tap'); // T0 → T1(tut_attack 缺失 → 单句看门狗兜底推进) ws.inject(conn, evSpawn('w1', 'wolf', 'N', 12)); await flush(); await g(game, 'up'); ws.inject(conn, evKill('w1', 0, 'N')); await advance(() => playsOf(playLog, 'voice/tut_kill_ok') === 1); assert(playsOf(playLog, 'voice/tut_kill_ok') === 1 && game._tut === 2, '屏蔽教学句资产:看门狗兜底推进,流程不卡死(T1 → T2)'); blockedAssets.clear(); game.stop(); await flush(); } // ================================================================ Block 10 · 静态审计(台词表 id 一致) realLog('== Block 10 · 静态审计:只引用台词表内 id(23-hunt-voice-ids.md)=='); { const VOICE_IDS = new Set([ // A 组(4) 'intro_1', 'intro_2', 'room_wait', 'room_start', // B 组(11) 'tut_rule', 'tut_attack', 'tut_kill_ok', 'tut_callout', 'tut_callout_ok', 'tut_skill', 'tut_skill_cd', 'tut_status', 'tut_breach', 'tut_done', 'tut_skip', // C 组(6) 'wave_band1', 'wave_band2', 'wave_band3', 'wave_band4', 'wave_band5', 'wave_clear', // D 组(12) 'hp_band1', 'hp_band2', 'hp_band3', 'hp_band4', 'st_wave_a', 'st_wave_b', 'st_wave_c', 'team_3', 'team_4', 'kill_band0', 'kill_band1', 'kill_band2', // E 组(3) 'threat_none', 'threat_few', 'threat_many', // F 组(3) 'breach', 'breach_heavy', 'danger_low', // G 组(1) 'skill_ready', // H 组(9) 'end_win', 'end_lose', 'grade_s', 'grade_a', 'grade_b', 'grade_c', 'end_rank', 'end_again', // I 组(4) 'net_lost', 'net_back', 'net_to_bot', 'net_gone', // J 组(6) 'sys_menu', 'sys_menu_resume', 'sys_menu_help', 'sys_menu_home', 'help_main', 'settings_hint', // K 组(2) 'gd_invalid', 'gd_idle', ]); const SFX_IDS = new Set([ 'atk_whoosh', 'atk_swish', 'kill_wolf', 'kill_bear', 'skill_spin', 'breach_thud', 'horn_wave', 'join_chime', 'call_whistle', 'howl_wolf', 'howl_bear', 'loop_wolf', 'loop_bear', ]); for (const char of ['yan', 'zheng', 'zhou']) { for (const line of ['greet', 'help_call', 'help_ack', 'cover', 'kill', 'breach', 'skill', 'wave']) { SFX_IDS.add(`npc_${char}_${line}`); } } const BGM_IDS = new Set(['menu', 'hunt']); let bad = 0; for (const id of allPlayedIds) { const slash = id.indexOf('/'); const layer = id.slice(0, slash); const name = id.slice(slash + 1); if (layer === 'voice') { if (!VOICE_IDS.has(name)) { bad++; realLog(` ✗ 表外 voice id:${id}`); } } else if (layer === 'sfx') { if (!SFX_IDS.has(name)) { bad++; realLog(` ✗ 表外 sfx id:${id}`); } } else if (layer === 'bgm') { if (!BGM_IDS.has(name)) { bad++; realLog(` ✗ 表外 bgm id:${id}`); } } } assert(bad === 0, `全部播放 id 均在台词表/资产表内(共审计 ${allPlayedIds.size} 个不同 id)`); // §7 全局:全程无未捕获异常;资产缺失仅 console.error 不崩 const unexpected = consoleErrs.filter((e) => !e.includes('资产缺失')); assert(unexpected.length === 0, '全程无未捕获异常/非预期 console.error(资产缺失仅 console.error 不崩)'); } // ================================================================ 汇总 realLog('\n========================================'); realLog(`断言通过 ${passCount} 条,失败 ${failures.length} 条`); if (failures.length) { realLog('失败明细:'); for (const f of failures) realLog(` - ${f}`); process.exit(1); } realLog('全部断言通过。');