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