/** * 《围猎》无头试玩仿真测试(26 号 playbook 重构版;设计单 docs/design/26-apply-hunt.md, * 交互宪法 docs/design/26-audio-interaction-playbook.md,玩法契约 23-hunt.md 附录 C)。 * * 方案: * - mock 浏览器环境:window/localStorage/navigator/fetch/AudioContext * (createStereoPanner 有记录,供 opts.pan 引擎回归断言); * - 使用**真实** engine(audio/settings/floor/receipts/hints/confusion/novice/pipeline); * - 手势走 makeInputPipeline 管道(与 index.html 同一接线:双击窗口按 ladder 分档); * - 虚拟时钟: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(资产缺失兜底断言用)。 * * 覆盖:§4.10 教学波首 3 分钟(首句动手、10s 内首杀)、四向出击/冷却/呼喊节流、 * 双击分档(阶段 3=重听威胁档、阶段 4=旋风斩)、菜单(左右换项+单击确认+回大厅 * 二次确认)、结算与 ladder.onGameDone、断线复述 net_back/sys_recap、snap 收敛、 * 拥挤控制 R2/R4、设置边界、资产缺失兜底、§6.2 乱点机器人(无声操作 0 / * 被动原句重复 0 / 打断失败 0)。 * * 运行:tools/node/bin/node web/hunt/test/playtest.mjs */ const SAVE_KEY = 'soundgame.progress.hunt'; const AUTH_KEY = 'soundgame.auth'; const LADDER_KEY = 'soundgame.gestures'; // ---------------------------------------------------------------- 断言收集 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); step(); 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 < 200000; n++) { await flush(2); if (cond && cond()) return 'cond'; let nextId = -1; let nextAt = Infinity; for (const [id, t] of timers) if (t.fireAt < nextAt) { nextAt = t.fireAt; nextId = id; } if (nextId < 0) return 'idle'; now = nextAt; const t = timers.get(nextId); timers.delete(nextId); t.cb(...t.args); } throw new Error('虚拟时钟定时器超上限(疑似死循环)'); } /** 固定步长推进(常驻 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; } createOscillator() { const o = new MockNode(); o.type = 'sine'; o.frequency = new MockParam(440); o.start = () => {}; o.stop = () => {}; return o; } createBiquadFilter() { const f = new MockNode(); f.type = 'lowpass'; f.frequency = new MockParam(750); f.Q = new MockParam(1); return f; } 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 { makeInputPipeline } = await import('../../engine/pipeline.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(); /** 双击窗口分档前的 ladder 现值设置(必须在 makeRig 之前:DT_CORE 是快照)。 */ function setLadder(stage = 0, games = 0) { storageData.set(LADDER_KEY, JSON.stringify({ stage, games })); } function getLadder() { try { return JSON.parse(storageData.get(LADDER_KEY) || '{"stage":0,"games":0}'); } catch (_) { return { stage: 0, games: 0 }; } } 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 stopLog = []; // stopVoice / skipVoice 时刻(打断失败指标) const origStop = audio.stopVoice.bind(audio); audio.stopVoice = () => { stopLog.push({ t: now }); return origStop(); }; const origSkip = audio.skipVoice.bind(audio); audio.skipVoice = () => { stopLog.push({ t: now }); return origSkip(); }; const ws = makeWsMock(); const game = new HuntGame(audio, {}, { transport: ws.transport, wsUrl: 'ws://test.local/ws/hunt' }); game.floor.bedEnabled = false; // 虚拟时钟:不起等待声定时器 const receiptLog = []; const origR = game.receipts.play.bind(game.receipts); game.receipts.play = (kind) => { receiptLog.push({ kind, t: now }); return origR(kind); }; const chimeLog = []; const origChime = game.floor.chime.bind(game.floor); game.floor.chime = () => { chimeLog.push({ t: now }); return origChime(); }; // 管道接线与 index.html 同一逻辑(双击窗口按 ladder 分档快照) const dtCore = game.DT_CORE; const pipe = makeInputPipeline({ floor: game.floor, confusion: game.confusion, receipts: game.receipts, invalidHints: game.invalidHints, game, ladder: game.ladder, opts: dtCore ? { ...game.PIPELINE_OPTS, doubletapCore: true } : game.PIPELINE_OPTS, }); return { audio, playLog, stopLog, receiptLog, chimeLog, game, ws, pipe, dtCore }; } let gestureCount = 0; /** 注入一次手势(经管道);返回手势发生时语音链是否 critical(打断失败豁免)。 */ async function g(rig, gesture) { gestureCount++; const crit = !!(rig.game.floor._chain && rig.game.floor._chain.critical); rig.pipe(gesture); await flush(); return crit; } function setProgress(p) { storageData.set(SAVE_KEY, JSON.stringify({ best: null, runs: 0, totalKills: 0, tutDone: false, skillUnlocked: 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)→ 等待交出话轮。 */ async function driveToLobby(rig, joinedOver = {}) { const { game, ws } = rig; await g(rig, 'tap'); const conn = ws.last(); ws.open(conn); await flush(); // 等开场链播完再进房(真实网络有延迟;瞬进会顶掉 intro 链,属预期行为但此处不验) await advance(() => !rig.audio.isVoicePlaying()); ws.inject(conn, joinedMsg(joinedOver)); await advance(() => game.floor.state === 'YOUR_TURN'); return conn; } /** lobby → 单击开局 → ev wave 1 → WAVE(播报链播完回 ACTION)。 */ async function driveToWave(rig, conn) { const { game, ws } = rig; await g(rig, 'tap'); // LOBBY 单击 = 立即开局 await advance(() => !rig.audio.isVoicePlaying()); ws.inject(conn, evWave(1)); await advance(() => game.phase === 'wave' && !rig.audio.isVoicePlaying() && rig.game.floor.state === 'ACTION'); } /** 手势时刻仍在播的语音句(估长 1.8s,被 stop/skip 或自然播完则不算)。 * 严格 p.t < t:手势自身触发的句子(同毫秒起播)不算「已在播」。 */ function activeVoiceAt(rig, t) { const plays = rig.playLog.filter((e) => e.id.startsWith('voice/') && e.t < t); const last = plays[plays.length - 1]; if (!last) return null; if (rig.stopLog.some((s) => s.t >= last.t && s.t <= t)) return null; // 已被打断/跳过(含起播同毫秒即被跳) if (t - last.t >= 1800) return null; // 已自然播完 return last; } // ================================================================ 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 · 教学波 §4.10(首句动手 / 10s 内首杀) realLog('== Block 1 · 教学波 T0-T3(26 号 §4.10,guide=off)=='); { resetSettings('off'); storageData.delete(SAVE_KEY); // 无存档 → tutDone=false storageData.delete(LADDER_KEY); // 全新手(stage 0:双击折算单击) const rig = makeRig(); assert(rig.dtCore === false, '新手(stage 0)双击窗口分档:DT_CORE=false(双击不存在)'); const { audio, playLog, receiptLog, 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' && game.floor.state === 'ACTION', '启动进 TITLE(BGM 即状态声,ACTION)'); assert(playLog.some((e) => e.id === 'bgm/menu'), 'TITLE 播放 bgm/menu'); // 无效手势(左右滑):invalid 回执 + 首次附一句解释(原则 7);再滑只给回执 await g(rig, 'left'); assert(receiptLog.some((r) => r.kind === 'invalid'), 'TITLE 左右滑:invalid 回执(禁止静默)'); assert(playsOf(playLog, 'voice/gd_invalid') === 1, 'TITLE 左右滑首次:gd_invalid 解释一次'); await g(rig, 'right'); assert(playsOf(playLog, 'voice/gd_invalid') === 1 && receiptLog.filter((r) => r.kind === 'invalid').length >= 2, 'TITLE 再次左右滑:只给回执,不再解释(原则 7)'); // 无存档单击 → 快速匹配,join 必带 teach:true(首局=教学房)+ ver:1 + room:"quick" await g(rig, '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 取自档案'); await advance(() => !audio.isVoicePlaying()); // joined(phase:teach) → TEACH:第一句就让玩家动手(critical,不念规则) const teachAt = now; ws.inject(conn, joinedMsg({ state: { phase: 'teach', wave: 0, dur: 10 } })); await flush(); // critical 链在播时点按 → busy 回执(不打断,§4.1/§4.6) await g(rig, 'tap'); assert(game._tut === 0, 'T0 指令句 critical:播完前点按不打断(仍 T0)'); assert(receiptLog.some((r) => r.kind === 'busy'), 'critical 期点按:busy 回执(请稍等)'); await advance(() => game.floor.state === 'YOUR_TURN' && game._tut === 0); 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(篝火即氛围)'); assert(playsOf(playLog, 'voice/sys_first_tap') === 1, 'T0 第一句 = sys_first_tap「点一下屏幕。」(首句动手)'); assert(playsOf(playLog, 'voice/intro_1') === 0 && playsOf(playLog, 'voice/tut_rule') === 0, '教学开场不念规则(intro/tut_rule 不在 T0,反模式 6)'); // T0 作答:点一下 → T1(滑砍) await g(rig, 'tap'); assert(game._tut === 1 && playsOf(playLog, 'voice/tut_hit_full') === 1, 'T0 单击 → T1 播 tut_hit_full「朝叫声,滑。」'); await advance(() => game.floor.state === 'YOUR_TURN' && game._tut === 1); // 指令句播完再作答 // 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 flush(); // _onSpawn 已同步重估渲染集 assert(game._loops.size === 0, '听觉裁剪:eta>6s 怪零 loop'); // 先错向滑 W → act attack dir:W → ev miss → atk_swish + miss_side 归因(30s 去重) await g(rig, '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/miss_side') >= 1); assert(playsOf(playLog, 'voice/miss_side') === 1, 'T1 错向 → miss_side「在另一边。」归因一次'); await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN'); // 冷却 2.5s 内再滑(miss 反馈 ~1.8s 仍在窗内)→ invalid 回执、零 act const errVib1 = vibCount(ERR_PATTERN); await g(rig, 'up'); assert(ws.acts(conn, 'attack').length === 1, '出击冷却 2.5s 内滑:零 act 发出'); assert(playsOf(playLog, 'voice/atk_cd') === 1, '冷却首次:atk_cd「出刀还在冷却。」解释一次'); assert(vibCount(ERR_PATTERN) === errVib1 + 1, '冷却首拦:error 震(每冷却窗一次)'); await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN'); // T1 期点按 → invalid + inv_swipe(附录 C §6 做错列「先滑」) const invBefore = receiptLog.filter((r) => r.kind === 'invalid').length; await g(rig, 'tap'); assert(receiptLog.filter((r) => r.kind === 'invalid').length === invBefore + 1 && playsOf(playLog, 'voice/inv_swipe') === 1, 'T1 点按:invalid 回执 + inv_swipe「先朝声音滑。」解释一次'); await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN'); // 向 N 滑 → act attack dir:N → ev kill → tut_kill_ok → T2(§4.10:10s 内首杀) await g(rig, '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'); assert(now - teachAt <= 10000, `首杀 ≤10s(§4.10,实际 ${now - teachAt}ms)`); await advance(() => game.floor.state === 'YOUR_TURN' && game._tut === 2); assert(chainOrderOk(playLog, ['tut_kill_ok', 'tut_call_full']), 'T1 首杀 → tut_kill_ok → T2 tut_call_full「点一下,喊人。」'); // ladder §4.3 阶段 1:第一次击杀 = 第一个小目标(hunt 不播 sys_unlock_up:上滑=出击北弧) assert(getLadder().stage === 1, '第一次击杀 → ladder.onGoal():全局手势阶梯 stage=1'); // 再放一只教学狼(稍后验证突入零惩罚) ws.inject(conn, evSpawn('w9', 'wolf', 'N', 12)); await flush(); // T2:单击 → act callout 发出;本机播自己座位角色 help_call(中央 pan=0) await advanceMs(4600); // 过呼喊节流(T2 前无呼喊,防御性推进) await g(rig, '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 → T3 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(() => game.floor.state === 'YOUR_TURN'); assert(chainOrderOk(playLog, ['tut_callout_ok', 'tut_help_full']), 'T2 bot 应答播完 → tut_callout_ok(呼喊-响应闭环点明)→ T3 tut_help_full「按住屏幕,听营地。」'); // 教学狼突入零惩罚: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 震'); // T3 作答:长按 → 营地状态一句话 + tut_done → tutDone 写入进度 await g(rig, 'longpress'); await advance(() => playsOf(playLog, 'voice/tut_done') === 1); assert(chainOrderOk(playLog, ['hp_band1', 'lp_act', 'tut_done']), 'T3 长按 → 状态一句话(耐久档+一个动作)→ 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(game.floor.state === 'SPEAKING' || game.floor.state === 'ACTION', 'WAVE 对局 = ACTION(播报链播完回 ACTION)'); 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()); assert(game.floor.state === 'ACTION', '波次播报播完回 ACTION(不留安静态)'); // 全程零「双击=旋风斩」教学(附录 C §3:旋风斩口头不出现) assert(playsOf(playLog, 'voice/tut_skill') === 0 && playsOf(playLog, 'voice/tut_skill_cd') === 0, '教学波零旋风斩口播(tut_skill/tut_skill_cd 停用)'); // 手势预算:教学路径 ≤150 次手势(§24 关键路径预算) assert(gestureCount - g0 <= 150, `教学关键路径手势预算 ≤150(实际 ${gestureCount - g0})`); game.stop(); await flush(); } // ================================================================ Block 2 · WAVE 战斗(新手档:双击=呼喊) realLog('== Block 2 · WAVE 战斗:听觉裁剪 / 冷却 / 呼喊 / 突入 / 长按状态 =='); { resetSettings('off'); storageData.delete(LADDER_KEY); // stage 0 setProgress({ tutDone: true }); const rig = makeRig(); assert(rig.dtCore === false, 'stage 0:双击按单击处理(doubletapCore 不传)'); const { audio, playLog, receiptLog, game, ws } = rig; await audio.unlock(); game.start(); await flush(); // 非首局:join 不带 teach const conn = await driveToLobby(rig); const join = ws.msgs(conn)[0]; assert(join && !('teach' in join), '二周目(tutDone=true)join 不带 teach'); assert(game.phase === 'lobby' && game.floor.state === 'YOUR_TURN', 'joined(phase:lobby) → LOBBY(交出话轮)'); assert(playsOf(playLog, 'voice/room_wait') === 1, 'LOBBY 进入播 room_wait'); // LOBBY 长按=在场猎人播报;重复问 → 「再说一遍:」前缀(§4.4 主动重听) await g(rig, 'longpress'); assert(playsOf(playLog, 'voice/team_3') === 1, 'LOBBY 长按播 team_3(在场猎人)'); await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN'); await g(rig, 'longpress'); await advance(() => playsOf(playLog, 'voice/team_3') === 2); assert(playsOf(playLog, 'voice/sys_say_again') === 1 && playsOf(playLog, 'voice/team_3') === 2, 'LOBBY 重复长按:sys_say_again 前缀 + 完整句(主动查询允许原句)'); await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN'); // 单击发 act start;开局 roster ev:3 猎人、bot 槽 char 齐全;新队友 join_chime await g(rig, '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 advance(() => game.phase === '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 节点就位)'); } // ---- 出击冷却 2.5s:冷却内滑仅 invalid 回执 + atk_cd 一次、零 act await g(rig, 'right'); const atkActs = ws.acts(conn, 'attack'); assert(atkActs.length === 1 && atkActs[0].dir === 'E', 'WAVE 右滑 → act attack dir:E'); assert(receiptLog.some((r) => r.kind === 'ok'), '出击接受:ok 回执(禁止静默)'); await g(rig, 'left'); assert(ws.acts(conn, 'attack').length === 1, '出击冷却内滑:零 act'); assert(playsOf(playLog, 'voice/atk_cd') === 1, '冷却首次滑:atk_cd 解释一次(其后仅回执)'); const errVib0 = vibCount(ERR_PATTERN); await g(rig, 'left'); await g(rig, 'up'); assert(ws.acts(conn, 'attack').length === 1 && vibCount(ERR_PATTERN) === errVib0, '冷却内连滑:每下 invalid 回执(禁止静默),error 震仅每冷却窗首拦一次'); await advanceMs(2600); await g(rig, 'left'); assert(ws.acts(conn, 'attack').length === 2 && ws.acts(conn, 'attack')[1].dir === 'W', '冷却后滑正常发出 act attack dir:W'); // 击杀归属 + 挥空真话(呼喊节流拒绝/挥空必须出声) ws.inject(conn, evKill('m2', 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(真话出声)'); assert(playsOf(playLog, 'voice/miss_side') === 1, 'WAVE 挥空 miss_side 归因(30s 台账一次)'); await advance(() => !audio.isVoicePlaying() && game.floor.state === 'ACTION'); // 归因句播完再继续 // ---- 呼喊:单击 → act callout + 本机座位角色 help_call;节流 5s(invalid 一次解释) await g(rig, '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(rig, 'tap'); assert(ws.acts(conn, 'callout').length === 1, '呼喊节流 5s:连点只出一条 callout act'); assert(playsOf(playLog, 'voice/call_cd') === 1, '呼喊节流首次:call_cd 解释一次'); assert(receiptLog.filter((r) => r.kind === 'invalid').length >= 1, '呼喊节流内单击:invalid 回执(出声)'); // 新手(stage<3)双击不存在:折算成单击=呼喊(附录 C §4 WAVE 行) const callActs0 = ws.acts(conn, 'callout').length; await advanceMs(5100); // 过呼喊节流(5s) await g(rig, 'doubletap'); assert(ws.acts(conn, 'callout').length === callActs0 + 1, '新手双击按单击处理 → 呼喊(act callout)'); assert(ws.acts(conn, 'skill').length === 0, '新手零 act skill(阶段 4 前不放技能)'); // 他人呼喊:该座角色 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('m1', '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()); assert(game.floor.state === 'ACTION', '警报播完回 ACTION'); // 3.5s 节流窗内再突入 → 不重复播报 ws.inject(conn, evBreach('m3', 'wolf', 'E', 8)); await flush(); assert(game.dur === 8, '窗内狼突入耐久照扣(dur=8)'); assert(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', 6)); 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()); // ---- 长按 = 我在哪(耐久档 + 波次档 + 一个动作,≤20 字;重复问更短) await g(rig, 'longpress'); await advance(() => playsOf(playLog, 'voice/lp_act') >= 1); assert(chainOrderOk(playLog, ['hp_band2', 'st_wave_a', 'lp_act']), 'WAVE 长按 → hp_band → st_wave → lp_act(状态一句话)'); assert(playsOf(playLog, 'voice/team_3') === 2, 'WAVE 帮助不含在场人数(人数在 LOBBY 长按)'); await advance(() => !audio.isVoicePlaying()); assert(game.floor.state === 'ACTION', '帮助播完回 ACTION'); await g(rig, 'longpress'); // 同状态重复问 → 只剩动作句(原则 9) await advance(() => playsOf(playLog, 'voice/lp_act') === 2); await advance(() => !audio.isVoicePlaying()); assert(playsOf(playLog, 'voice/lp_act') === 2 && playsOf(playLog, 'voice/hp_band2') === 1, '同状态重复长按:只播 lp_act(更短档),不原句重播'); assert(game.floor.state === 'ACTION', '帮助播完仍回 ACTION'); // ---- 耐久首次 ≤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 → 号角 + 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(波间休整)'); await advance(() => game.floor.state === 'YOUR_TURN'); assert(game._inRest(), '波间休整窗 8s 开启(等待点交出话轮)'); // 休整窗:上滑/单击 invalid(无可打目标)+ inv_rest 一次;下滑=菜单(Block 3 详测) await g(rig, 'up'); assert(playsOf(playLog, 'voice/inv_rest') === 1, '休整窗滑动作 invalid:inv_rest「下一波马上来。」一次'); assert(ws.acts(conn, 'attack').length === 2, '休整窗滑不发出击(怪已清)'); 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()); assert(game.floor.state === 'ACTION' && !game._inRest(), '第 2 波开始:回 ACTION、休整窗关闭'); // ---- 本波未出手 8s → 只给交接提示音(不念说明书;出手后永不催) const chime0 = rig.chimeLog.length; await advanceMs(8200); assert(rig.chimeLog.length === chime0 + 1, 'wave 等待点:本波未出手 8s 仅 earcon(零语音催促)'); await g(rig, 'down'); // 出手(南弧空挥也算在场证明) const chime1 = rig.chimeLog.length; await advanceMs(15000); assert(rig.chimeLog.length === chime1, '本波已出手:等待声提示永不触发(等待是合法玩法)'); game.stop(); await flush(); } // ================================================================ Block 2b · 阶段 3/4:双击=重听威胁档 / 旋风斩 realLog('== Block 2b · 双击分档:阶段 3 重听威胁 / 阶段 4 旋风斩 =='); { resetSettings('off'); setLadder(3, 5); // 阶段 3(累计 3 局):双击已识别 setProgress({ tutDone: true, skillUnlocked: false }); const rig = makeRig(); assert(rig.dtCore === true, 'stage>=3:默认双击窗口 + doubletapCore(双击直达游戏)'); const { audio, playLog, game, ws } = rig; await audio.unlock(); game.start(); await flush(); const conn = await driveToLobby(rig); // 阶段 4 专属教学(附录 C §3):大厅进入即教旋风斩一句并解锁 assert(playsOf(playLog, 'voice/skill_teach') === 1, '阶段 3 首入大厅:skill_teach「双击,放出旋风斩。」'); assert(getProgress().skillUnlocked === true, 'skillUnlocked 落盘(此后双击=旋风斩)'); await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN'); // 大厅双击 = 重听等待点(再说一遍 + room_wait) await g(rig, 'doubletap'); await advance(() => playsOf(playLog, 'voice/room_wait') === 2); assert(playsOf(playLog, 'voice/sys_say_again') >= 1 && playsOf(playLog, 'voice/room_wait') === 2, '阶段 3 大厅双击:重听等待点(sys_say_again + room_wait)'); await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); // start ws.inject(conn, evWave(1)); await advance(() => game.phase === 'wave' && !audio.isVoicePlaying()); // ---- 阶段 4:双击(就绪)→ act skill;ev skill 清内圈 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 }, ])); await flush(); await g(rig, 'doubletap'); assert(ws.acts(conn, 'skill').length === 1, '阶段 4 双击(就绪)→ 发出 act skill'); ws.inject(conn, evSkill(0, ['n1', 'n2', 'n3'])); await flush(); assert(playLog.some((e) => e.id === 'sfx/skill_spin'), '旋风斩放出播 skill_spin(中央)'); assert(vibCount(SUCCESS_PATTERN) >= 1, '旋风斩 success 震'); assert(game.mobs.size === 0, '旋风斩清全场内圈'); await advanceMs(600); assert(game._loops.size === 0, '清场后 loop 全停'); // 冷却中双击 = 重听威胁:再造 3 只内圈 → threat_many + say_again 前缀,零 act ws.inject(conn, snapMsg([ { id: 'p1', mkind: 'wolf', dir: 'N', eta: 2.0 }, { id: 'p2', mkind: 'wolf', dir: 'W', eta: 3.0 }, { id: 'p3', 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(rig, 'doubletap'); assert(ws.acts(conn, 'skill').length === skillActs0, '旋风斩冷却中双击:零 act(本地先拦)'); await advance(() => playsOf(playLog, 'voice/threat_many') === 1); assert(playsOf(playLog, 'voice/threat_many') === 1 && playsOf(playLog, 'voice/sys_say_again') >= 2, '冷却中双击=重听威胁:内圈 3 只 → threat_many(带「再说一遍:」前缀)'); await advance(() => !audio.isVoicePlaying()); // 8s 冷却完成 → skill_ready(hint 通道:只在空闲时播) await advanceMs(8100); assert(playsOf(playLog, 'voice/skill_ready') === 1, '旋风斩冷却完成播 skill_ready(每轮冷却 1 次)'); // ---- 终局:结算链 + 阶段 3 双击重听结算等待点 ws.inject(conn, evEnd({ win: true, dur: 8, grade: 'A', kills: { '0': 14, '1': 9, '2': 6 } })); await advance(() => game.floor.state === 'YOUR_TURN'); assert(game.phase === 'end', 'ev end → END 结算态'); assert(chainOrderOk(playLog, ['end_win', 'grade_a', 'kill_band2', 'end_rank', 'end_again']), '胜利结算链有序(end_win→grade_a→kill_band2→end_rank→end_again)'); await g(rig, 'doubletap'); await advance(() => playsOf(playLog, 'voice/end_again') >= 2); assert(playsOf(playLog, 'voice/end_again') >= 2 && playsOf(playLog, 'voice/sys_say_again') >= 3, '阶段 3 END 双击:重听结算等待点(say_again + end_again)'); await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN'); game.stop(); await flush(); } // ================================================================ Block 3 · 菜单 E3(不暂停)+ WAVE 下滑恒出击 realLog('== Block 3 · 菜单不暂停(E3)+ WAVE 下滑恒出击 =='); { resetSettings('off'); storageData.delete(LADDER_KEY); 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); // WAVE 内下滑恒出击(附录 C §3:WAVE 内=出击南方,不弹菜单) ws.inject(conn, snapMsg([{ id: 's1', mkind: 'wolf', dir: 'S', eta: 3.0 }])); await flush(); await g(rig, 'down'); assert(ws.acts(conn, 'attack').length === 1 && ws.acts(conn, 'attack')[0].dir === 'S', 'WAVE 下滑 → 出击(act attack dir:S),不开菜单'); assert(game._menuOpen === false, 'WAVE 下滑出击时菜单不开'); ws.inject(conn, evKill('s1', 2, 'S')); // bot 补刀清场(kills 归 slot 2) await flush(); // 无怪下滑 = 空挥出击(真话),仍不弹菜单 const cdT0 = now; await advance(() => now - cdT0 >= 2600); // 过 2.5s 出击冷却 const atkBefore = ws.acts(conn, 'attack').length; await g(rig, 'down'); assert(ws.acts(conn, 'attack').length === atkBefore + 1 && ws.acts(conn, 'attack')[atkBefore].dir === 'S', 'WAVE 无怪下滑 = 空挥出击(act attack dir:S)'); assert(game._menuOpen === false, 'WAVE 内下滑绝不打开菜单'); // 菜单只在波间休整窗(waveclear 后 8s)开放 ws.inject(conn, evWaveclear(1)); await advance(() => game._inRest() && game.floor.state === 'YOUR_TURN'); await g(rig, 'down'); assert(game._menuOpen === true, '波间休整窗下滑 → 模态菜单打开'); await advance(() => game.floor.state === 'YOUR_TURN'); assert(chainOrderOk(playLog, ['sys_menu', 'sys_menu_resume']), '菜单打开即播 sys_menu + 首项(左右滑换项,单击确认)'); assert(playsOf(playLog, 'voice/settings_hint') === 0, 'settings_hint 废止(设置只在大厅改,00 §1.2)'); // 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 菜单不收'); assert(playsOf(playLog, 'voice/breach') === 1, 'E3:菜单开着警报照播(可顶菜单链,R1)'); await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN'); // 菜单 3 项固定、右滑循环(resume → help → home → 回绕 resume) await g(rig, 'right'); await g(rig, 'right'); await g(rig, 'right'); const menuIdx = playLog.filter((e) => e.id.startsWith('voice/sys_menu_') && e.id !== '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(rig, 'longpress'); await advance(() => playsOf(playLog, 'voice/sys_menu') >= 2); assert(true, '菜单内长按重听菜单说明'); await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN'); // 单击 = 确认当前项(resume)→ 关闭回休整等待(菜单交互 >8s,先静默续窗: // 菜单开着时 waveclear 只延长休整窗,不顶菜单链) ws.inject(conn, evWaveclear(1)); await flush(); await g(rig, 'tap'); assert(game._menuOpen === false, '单击确认「回到猎场」:菜单关闭'); assert(game._inRest() && game.floor.state === 'YOUR_TURN', '菜单关闭回波间休整等待点(不留安静态)'); // 重新打开:选中"重新听帮助"→ 单击确认播 help_main 且菜单不收 ws.inject(conn, evWaveclear(1)); // 重置 8s 休整窗(菜单已关,照常播 wave_clear 会被 // 后续菜单链顶掉——此处只需休整窗在场) await advance(() => game._inRest()); await g(rig, 'down'); await advance(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'right'); // idx 1 = sys_menu_help await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); await advance(() => playsOf(playLog, 'voice/help_main') === 1); assert(game._menuOpen === true, '单击确认"重新听帮助":播帮助且菜单不收'); await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN'); // 退出房间回大厅:破坏性操作二次确认(原则 6)→ 确认后发 leave + 跳转 await g(rig, 'right'); // idx 2 = sys_menu_home await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); // 第一次:sys_home_check(armed) await advance(() => playsOf(playLog, 'voice/sys_home_check') === 1); assert(game._menuOpen === true && !ws.msgs(conn).some((m) => m && m.t === 'leave'), '回大厅首次单击:只播 sys_home_check 二次确认,不发 leave'); await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); // 第二次:确认退出 await flush(); assert(ws.msgs(conn).some((m) => m && m.t === 'leave'), '回大厅二次确认 → 发出 leave'); assert(game._navigatedHome === '../' && window.location.href === '../', '回大厅跳转(leaving 收尾,不留尸体页面)'); } // ================================================================ Block 4 · 终局结算 + 排行榜 + ladder.onGameDone realLog('== Block 4 · 胜利/失守结算链 + PUT progress + 解锁教学 =='); { resetSettings('off'); storageData.delete(LADDER_KEY); // 全新阶梯:本局 END → games=1 → 解锁下滑菜单 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 → 只播 lp_title(不播评级段) await g(rig, 'longpress'); assert(playsOf(playLog, 'voice/lp_title') === 1 && playsOf(playLog, 'voice/grade_a') === 0, 'TITLE 长按无最佳档:lp_title 一句话(不播评级段)'); await advance(() => !audio.isVoicePlaying()); const conn = await driveToLobby(rig); assert(playsOf(playLog, 'voice/intro_1') === 1 && playsOf(playLog, 'voice/intro_2') === 1, '二周目匹配等待播 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(rig, 'tap'); // 立即开局 ws.inject(conn, evWave(1)); await flush(); assert(game.phase === 'wave', 'wave 1 进 WAVE'); await advance(() => !audio.isVoicePlaying()); // 终局(胜利,A,击杀 14)→ 死窗内 ghost tap 不重开(critical 尾句锁住重开入口) 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(rig, '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/sys_unlock_down') === 1); assert(chainOrderOk(playLog, ['end_win', 'grade_a', 'kill_band2', 'end_rank', 'end_again']), '胜利结算链有序(end_win→grade_a→kill_band2→end_rank→end_again)'); assert(playsOf(playLog, 'voice/sys_unlock_down') === 1, '一局 END → ladder.onGameDone():第 1 局解锁下滑菜单(sys_unlock_down 教学)'); assert(getLadder().stage === 2 && getLadder().games === 1, '阶梯落盘:stage=2 / games=1'); await advance(() => game.floor.state === 'YOUR_TURN'); // 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:长按=重听个人最佳段(刚播过 → say_again 前缀,允许原句重播) await g(rig, 'longpress'); await advance(() => playsOf(playLog, 'voice/grade_a') >= 2); await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN'); assert(playsOf(playLog, 'voice/grade_a') >= 2, 'END 长按重听个人最佳段(评级+击杀档)'); // 死窗/链完毕后单击 = 再来一局(重新进匹配) await g(rig, '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 advance(() => game.floor.state === 'YOUR_TURN'); await g(rig, '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 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'); storageData.delete(LADDER_KEY); 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(rig, '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 · 断线重连 / 房灭 / 不可达 + 断点复述 realLog('== Block 5 · 断线 resume 回槽 / room_gone / 断点复述 =='); { resetSettings('off'); storageData.delete(LADDER_KEY); 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(§4.12 复述句「你回来了。朝叫声滑。」)'); 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 advance(() => game.phase === 'lost' && game.floor.state === 'YOUR_TURN'); assert(playsOf(playLog, 'voice/net_gone') === 1, '房灭后迟到 resume:err room_gone → net_gone'); assert(game.phase === 'lost', '重连失败 → LOST(明示失败,不静默死屏)'); // 断点复述(§4.12):一局内被迫退出 → 回 TITLE 一句话复述,summary 保留 await g(rig, 'tap'); assert(game.phase === 'title', 'LOST 单击 → 回大厅(TITLE)'); await advance(() => playsOf(playLog, 'voice/sys_recap') === 1); assert(playsOf(playLog, 'voice/sys_recap') === 1, '一局内退出回 TITLE:sys_recap 一句话复述(进度 summary 保留)'); assert(getProgress().unfinished !== true, '复述后 unfinished 标记清除(不重复播)'); game.stop(); await flush(); } // ---- 30s 宽限耗尽 → net_gone(战斗态断线不恢复) realLog('== Block 5b · 30s 宽限耗尽 → LOST =='); { resetSettings('off'); storageData.delete(LADDER_KEY); 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'); storageData.delete(LADDER_KEY); setProgress({ tutDone: true }); const rig = makeRig(); const { audio, playLog, game, ws } = rig; await audio.unlock(); game.start(); await flush(); await g(rig, '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(rig, 'tap'); assert(game.phase === 'title', 'LOST 单击回大厅'); game.stop(); await flush(); } // ================================================================ Block 6 · snap 兜底(随机丢 ev 后收敛) realLog('== Block 6 · snap 兜底收敛 =='); { resetSettings('off'); storageData.delete(LADDER_KEY); setProgress({ tutDone: true }); const rig = makeRig(); const { audio, 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'); storageData.delete(LADDER_KEY); setProgress({ tutDone: true }); const rig = makeRig(); const { audio, 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.floor.speak(['voice/threat_few']); // 占住 voice(1.8s) 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); game.floor.speak(['voice/hp_band1', 'voice/st_wave_a', 'voice/team_3', 'voice/kill_band1']); // ~7.2s 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'); storageData.delete(LADDER_KEY); // 设置边界(§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_hit_full'); await flush(); assert(requestedUrls.slice(url1).some((u) => u.includes('voice/female/key/tut_hit_full')), '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);4 人局长按播 team_4 const rig = makeRig(); const { playLog, game, ws } = rig; await rig.audio.unlock(); game.start(); await flush(); await g(rig, '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 advance(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'longpress'); // LOBBY 长按 = 在场人数 await advance(() => playsOf(playLog, 'voice/team_4') === 1); assert(playsOf(playLog, 'voice/team_4') === 1, '4 人局 LOBBY 长播音 team_4'); await advance(() => !rig.audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); // start ws.inject(conn, evWave(1)); await flush(); await advance(() => !rig.audio.isVoicePlaying()); await g(rig, '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)'); await advance(() => !rig.audio.isVoicePlaying()); game.stop(); await flush(); } // ================================================================ Block 9 · 资产缺失兜底(Floor 看门狗推进不卡死) realLog('== Block 9 · 屏蔽 voice 资产:流程不卡死 =='); { resetSettings('off'); storageData.delete(SAVE_KEY); storageData.delete(LADDER_KEY); blockedAssets.add('tut_hit_full'); // 屏蔽 T1 教学句 const rig = makeRig(); const { audio, playLog, game, ws } = rig; await audio.unlock(); game.start(); await flush(); await g(rig, 'tap'); const conn = ws.last(); ws.open(conn); await flush(); ws.inject(conn, joinedMsg({ state: { phase: 'teach', wave: 0, dur: 10 } })); await advance(() => game.floor.state === 'YOUR_TURN'); await g(rig, 'tap'); // T0 → T1(tut_hit_full 缺失 → Floor 单句看门狗兜底推进) ws.inject(conn, evSpawn('w1', 'wolf', 'N', 12)); await flush(); await advance(() => game.floor.state === 'YOUR_TURN'); // 看门狗推进后仍交出话轮 await g(rig, '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, '屏蔽教学句资产:Floor 看门狗兜底推进,流程不卡死(T1 → T2)'); blockedAssets.clear(); game.stop(); await flush(); } // ================================================================ Block 10 · 乱点机器人(§6.2) realLog('== Block 10 · 乱点机器人(§6.2 指标:无声 0 / 重复 0 / 打断失败 0)=='); { resetSettings('off'); storageData.delete(SAVE_KEY); storageData.delete(LADDER_KEY); // 新手:stage 0(双击折算单击) setProgress({ tutDone: true, runs: 0 }); const rig = makeRig(); assert(rig.dtCore === false, '机器人档:新手双击窗口关闭(折算单击)'); const { audio, playLog, receiptLog, chimeLog, game, ws } = rig; await audio.unlock(); let seed = 13; const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; const gestures = []; // { g, t, crit } const botG = (gg) => { gestures.push({ g: gg, t: now, crit: !!(game.floor._chain && game.floor._chain.critical) }); rig.pipe(gg); }; game.start(); await flush(); const t0 = now; botG('tap'); // TITLE 单击 → 匹配 await flush(); const conn = ws.last(); ws.open(conn); await flush(); ws.inject(conn, joinedMsg()); // LOBBY // ---- 迷你服务器:响应 act(教学契约由 23 §5 服务端承担,此处按协议模拟) const ARCS_BY_BASE = { N: ['NW', 'N', 'NE'], E: ['NE', 'E', 'SE'], S: ['SE', 'S', 'SW'], W: ['SW', 'W', 'NW'] }; const srv = { mobs: new Map(), nextId: 1, kills: 0, started: false }; const serve = () => { for (const a of ws.acts(conn)) { if (a.__served) continue; a.__served = true; if (a.kind === 'start' && !srv.started) { srv.started = true; setTimeout(() => ws.inject(conn, evWave(1)), 400); } else if (a.kind === 'attack') { const hit = [...srv.mobs.values()].find((m) => (ARCS_BY_BASE[a.dir] || []).includes(m.dir)); if (hit) { srv.mobs.delete(hit.id); srv.kills++; setTimeout(() => ws.inject(conn, evKill(hit.id, 0, hit.dir)), 120); } else { setTimeout(() => ws.inject(conn, evMiss(0, a.dir)), 120); } } else if (a.kind === 'callout') { // bot 补位应答(真人呼喊 → 空闲 bot help_ack,§5) setTimeout(() => ws.inject(conn, evBotline(2, 'help_ack')), 1500); } } }; const DIRS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; let dirIdx = 0; let waveStartAt = null; let waveclearAt = null; let wave2At = null; let breached = false; let firstKillAt = null; let ended = false; for (let step = 0; step < 240; step++) { const roll = rnd(); if (roll < 0.35) { botG('tap'); await flush(); } else if (roll < 0.55) { botG('tap'); await flush(1); botG('tap'); await flush(1); // 连点两下(80~250ms 内) } else if (roll < 0.62) { botG('doubletap'); await flush(); // 新手折算单击 } else if (roll < 0.88) { const dirs = ['up', 'down', 'left', 'right']; botG(dirs[Math.floor(rnd() * 4)]); await flush(); } else { const before = now; await advance(() => now - before >= 1500 + rnd() * 3500); // 发呆 1.5~5s } serve(); if (game._myKills >= 1 && firstKillAt === null) firstKillAt = now; // ---- 事件编排(模拟服务器节奏) if (game.phase === 'wave' && waveStartAt === null) waveStartAt = now; if (game.phase === 'wave' && !game._inRest() && waveStartAt !== null && !ended) { const waveT = now - (wave2At ?? waveStartAt); if (srv.mobs.size < 3 && waveT > 2000 && rnd() < 0.5) { const id = 'b' + srv.nextId++; const dir = DIRS[dirIdx++ % DIRS.length]; srv.mobs.set(id, { id, mkind: rnd() < 0.2 ? 'bear' : 'wolf', dir }); ws.inject(conn, evSpawn(id, srv.mobs.get(id).mkind, dir, 9)); } if (!breached && waveT > 12000 && srv.mobs.size) { const [m] = [...srv.mobs.values()]; srv.mobs.delete(m.id); breached = true; ws.inject(conn, evBreach(m.id, m.mkind, m.dir, 9)); // dur 10→9 } if (waveclearAt === null && waveT > 42000) { waveclearAt = now; srv.mobs.clear(); ws.inject(conn, { t: 'ev', kind: 'waveclear', ts: now, wave: game.wave }); } } if (waveclearAt !== null && wave2At === null && now - waveclearAt > 8500) { wave2At = now; breached = false; ws.inject(conn, evWave(2)); } if (wave2At !== null && !ended && now - wave2At > 42000) { ended = true; srv.mobs.clear(); ws.inject(conn, evEnd({ win: true, dur: 9, grade: 'B', kills: { '0': srv.kills, '1': 5, '2': 4 } })); } if (game._goingHome) break; // 机器人碰巧确认了"回大厅":也算合法退场 if (ended && game.phase === 'matching') break; // 结算后单击 → 再来一局(收工) await advance(() => true) && 0; await advanceMs(200 + Math.floor(rnd() * 600)); } // ---- 指标 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); const chimed = chimeLog.some((c) => c.t >= ge.t && c.t <= ge.t + 100); if (!receipt && !content && !chimed) silentOps++; } assert(silentOps === 0, `无声操作 = 0(实际 ${silentOps}/${gestures.length})`); // ---- 指标 2:被动原句重复 = 0(30s 内同 id 两次且无「再说一遍:」前缀) let passiveRepeats = 0; const repeatIds = new Map(); const lastPlay = new Map(); for (const p of playLog) { if (!p.id.startsWith('voice/')) continue; const sayAgainAt = playLog.some((q) => q.id === 'voice/sys_say_again' && q.t <= p.t && p.t - q.t < 2500); if (lastPlay.has(p.id) && p.t - lastPlay.get(p.id) < 30000 && !sayAgainAt) { passiveRepeats++; repeatIds.set(p.id, (repeatIds.get(p.id) || 0) + 1); } lastPlay.set(p.id, p.t); } if (passiveRepeats) realLog(` 重复明细: ${[...repeatIds.entries()].map(([k, v]) => `${k}×${v}`).join(' ')}`); assert(passiveRepeats === 0, `被动原句重复 = 0(实际 ${passiveRepeats})`); // ---- 指标 3:打断失败 = 0(非 critical 句播中操作后 150ms 内停/被顶/自然结束) let interruptFails = 0; const failDetail = []; for (const ge of gestures) { const act = activeVoiceAt(rig, ge.t); if (!act || ge.crit) continue; // 防连点吸收的 400ms 内快速重点(busy 回执,含折算成 tap 的双击)= // 引擎设计内的单次逻辑交互,豁免 const tapGuarded = receiptLog.some((r) => r.kind === 'busy' && r.t === ge.t); if (tapGuarded) continue; const cleared = rig.stopLog.some((s) => s.t >= ge.t && s.t <= ge.t + 150) || playLog.some((p) => p.id.startsWith('voice/') && p.t > ge.t && p.t <= ge.t + 150) || (ge.t + 150) - act.t >= 1800; // 自然播完 if (!cleared) { interruptFails++; failDetail.push(`${ge.g}@${ge.t}(${act.id},+${ge.t - act.t}ms)`); } } if (failDetail.length) realLog(' 打断失败明细: ' + failDetail.slice(0, 10).join(' | ')); assert(interruptFails === 0, `打断失败 = 0(实际 ${interruptFails})`); assert(gestures.length >= 80, `机器人手势量足够(实际 ${gestures.length})`); assert(game.phase === 'wave' || game.phase === 'end' || game.phase === 'matching' || game._goingHome, `机器人推进到对局/结算(phase=${game.phase}${game._goingHome ? ',退场' : ''})`); assert(ended || game._goingHome, '编排到达终局(ev end 已注入)'); realLog(` 机器人轨迹:手势 ${gestures.length},服务器账面击杀 ${srv.kills},两波 + 终局,用时 ${now - t0}ms`); game.stop(); await flush(); } // ================================================================ Block 11 · 静态审计(台词表 id 一致) realLog('== Block 11 · 静态审计:只引用台词表内 id(23-hunt-voice-ids.md + 26 号清单)=='); { const VOICE_IDS = new Set([ // A 组(4) 'intro_1', 'intro_2', 'room_wait', 'room_start', // B 组 · 教学(26 号重排:T0 点一下 → T1 滑砍 → T2 呼喊 → T3 长按) 'sys_first_tap', 'sys_first_tap_short', 'tut_hit_full', 'tut_hit_short', 'tut_kill_ok', 'tut_call_full', 'tut_call_short', 'tut_callout_ok', 'tut_help_full', 'tut_help_short', 'tut_done', // 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 组(2) 'skill_ready', 'skill_teach', // H 组(9) 'end_win', 'end_lose', 'grade_s', 'grade_a', 'grade_b', 'grade_c', 'end_rank', 'end_again', 'end_again_short', // I 组(4) 'net_lost', 'net_back', 'net_to_bot', 'net_gone', // J 组 · 菜单/帮助(26 号:左右换项单击确认) 'sys_menu', 'sys_menu_short', 'sys_menu_resume', 'sys_menu_help', 'sys_menu_home', 'sys_home_check', 'help_main', // K 组 · 向导/纠错 'gd_invalid', 'gd_idle', // L 组 · 26 号新增(系统句 / 等待点三档 / 长按帮助 / 归因 / 接管 / 复述) 'sys_say_again', 'sys_unlock_down', 'sys_unlock_dt', 'sys_recap', 'room_wait_short', 'lp_act', 'lp_title', 'atk_cd', 'call_cd', 'inv_swipe', 'inv_rest', 'miss_side', 'wave_only', ]); 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.warn(Floor 看门狗)不崩 const unexpected = consoleErrs.filter((e) => !e.includes('资产缺失')); assert(unexpected.length === 0, '全程无未捕获异常/非预期 console.error'); } // ================================================================ 汇总 realLog('\n========================================'); realLog(`断言通过 ${passCount} 条,失败 ${failures.length} 条`); if (failures.length) { realLog('失败明细:'); for (const f of failures) realLog(` - ${f}`); process.exit(1); } realLog('全部断言通过。');