Files
SoundGame/web/hunt/test/playtest.mjs
T
xsl 005851d01a feat(hunt): 《围猎》2-4人实时联机纯音频协作守卫——纯音频MMORPG第一阶段切片
- 设计:docs/design/23-hunt.md + voice-ids(84 id,166条)+ 13号断言登记,§15记录10+1条设计裁决
- 服务器:零依赖WebSocket(ws.js)+ 房间管理/服务端权威战斗/bot AI(hunt-room.js),断线30s宽限resume回槽、超时转bot;修复package.json模块类型误判的既有破坏
- 客户端:web/hunt/ 全套(教学T0-T4/听觉视野裁剪R1-R8/结算排行榜/guide接入),playtest 211断言
- 引擎:audio.js 后向兼容 opts.pan(StereoPannerNode),web-engine-api.md 同步
- 资产:166条TTS语音(QC无离群)+ 13件合成sfx(真峰值≤-3dBFS)+ 2首Mureka BGM,终检全绿
- 平衡:bot调参至挂机局71%失守/C、3bot局71% A/B(hunt-balance-scan.mjs 24种子扫描)
- 接线:大厅卡片、profiles.js hunt桶、README/roadmap MMO路线条目

验证:hunt-sim 16/16 + api-smoke 12/12 + hunt playtest 211/211 + 既有10游戏与引擎回归零回退 + 真服务器WS端到端冒烟通过
2026-09-13 11:08:01 +08:00

1433 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 《围猎》无头试玩仿真测试(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 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();
// 无 S 弧目标时下滑 = 菜单(三件套保留):sys_menu + 首项 + settings_hint
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 且菜单不收
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 + 跳转大厅
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/<gender>/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('全部断言通过。');