Files
xsl ee5d4d2544 feat(playbook): 26 号交互手册全量落地——剩余 12 款深度重构 + 语音全量重制
storyline/escape/simon 首批之后的推广收官:15 款全部按 26 号模式运行
(发言权/回执/三档提示/迷茫接管/手势阶梯/断点复述),旧「双击重听/格挡/
菜单确认」等反模式口径全线废除,开场零规则念白(首句即动手)。

游戏层(12 款):rpg(格挡=前摇窗口内再单击)/ rhythm / space / radio /
dungeon / abyss / detective / cipher / teahouse / keeper / bridge /
hunt(联机切片):
- 每款 game.js 接引擎六模块 + makeInputPipeline(doubleTapWindow:0);
  四向玩法类 swipeCore + 长按帮助;双击是玩法的(space 护盾 / hunt
  旋风斩)按手势阶梯阶段 3 分档恢复双击窗口(doubletapCore)
- 每款 playtest 重写:玩法断言 + §6.2 乱点机器人(无声操作 / 被动
  原句重复 / 打断失败 = 0);15 款 3900+ 断言全绿
- 12 份 docs/design/26-apply-*.md 施工单 + tools/voice_lines_26/
  台词清单 + voice-ids 文档同步 + 转写会话剧本按新口径重写

引擎层(web/engine/,6 处修复):pipeline 增 doubletapCore;novice
teachLine 带 voice/ 前缀(此前教学句被静默丢弃);confusion.record
前置到新手门(锁定手势的乱点也计信号);InvalidHints 播完恢复解释前
状态(根治孤儿 BUSY);WaitPoint takeover(2) 台账去重;台账只在
speak 真正播出时登记。遗留引擎债登记 roadmap §3.1。

语音资产:台词表合并(新增 ~250 系统句 / 改 ~50 旧口径 / 删 ~110
废止句;合并器 tools/merge_voice_lines_26.py + 引用校验器
tools/validate_voice_refs.py),GPU 重录 1536 wav → 4204 ogg/m4a
落地,check_all_assets + validate_voice_refs 双绿;键盘变体同步扩表。

大厅/文档:15 款统一新交互口径(「新交互」徽标退役);README /
HANDOVER / rpg README 旧口径修正;roadmap P0-P3 置完成。
2026-10-02 12:33:20 +08:00

1833 lines
88 KiB
JavaScript
Raw Permalink 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.
/**
* 《围猎》无头试玩仿真测试(26 号 playbook 重构版;设计单 docs/design/26-apply-hunt.md,
* 交互宪法 docs/design/26-audio-interaction-playbook.md,玩法契约 23-hunt.md 附录 C)。
*
* 方案:
* - mock 浏览器环境:window/localStorage/navigator/fetch/AudioContext
* (createStereoPanner 有记录,供 opts.pan 引擎回归断言);
* - 使用**真实** engine(audio/settings/floor/receipts/hints/confusion/novice/pipeline);
* - 手势走 makeInputPipeline 管道(与 index.html 同一接线:双击窗口按 ladder 分档);
* - 虚拟时钟:patch setTimeout/setInterval/clearTimeout/performance.now;
* - WS 用内存回路 mock(23 §6 协议字段注入 joined/ev/snap/err;发送帧逐条记录);
* 不依赖 web/server 实现(并行开发中),协议字段以 23 §6 为唯一契约;
* - 音频资产并行生成中:fetch 对 audio/* 一律合成假 buffer(按 url 估时长:
* voice 1.8s / npc 1.5s / loop 4s / bgm 90s / 其余 sfx 0.5s),语音链靠引擎
* 自然 onComplete 推进;blockedAssets 中的片段强制 404(资产缺失兜底断言用)。
*
* 覆盖:§4.10 教学波首 3 分钟(首句动手、10s 内首杀)、四向出击/冷却/呼喊节流、
* 双击分档(阶段 3=重听威胁档、阶段 4=旋风斩)、菜单(左右换项+单击确认+回大厅
* 二次确认)、结算与 ladder.onGameDone、断线复述 net_back/sys_recap、snap 收敛、
* 拥挤控制 R2/R4、设置边界、资产缺失兜底、§6.2 乱点机器人(无声操作 0 /
* 被动原句重复 0 / 打断失败 0)。
*
* 运行:tools/node/bin/node web/hunt/test/playtest.mjs
*/
const SAVE_KEY = 'soundgame.progress.hunt';
const AUTH_KEY = 'soundgame.auth';
const LADDER_KEY = 'soundgame.gestures';
// ---------------------------------------------------------------- 断言收集
const realLog = console.log.bind(console);
let passCount = 0;
const failures = [];
function assert(cond, msg) {
if (cond) { passCount++; realLog(` ✓ ${msg}`); }
else { failures.push(msg); realLog(` ✗ ${msg}`); }
}
const consoleErrs = [];
const consoleWarns = [];
console.error = (...a) => { consoleErrs.push(a.join(' ')); };
console.warn = (...a) => { consoleWarns.push(a.join(' ')); };
console.log = () => {};
// ---------------------------------------------------------------- 虚拟时钟
let now = 0;
let timerSeq = 1;
const timers = new Map();
const intervals = new Set();
const realSetImmediate = setImmediate;
globalThis.setTimeout = (cb, ms = 0, ...args) => {
const id = timerSeq++;
timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb, args });
return id;
};
globalThis.clearTimeout = (id) => { timers.delete(id); };
globalThis.setInterval = (cb, ms = 0, ...args) => {
const id = timerSeq++;
const step = (...a) => {
if (!intervals.has(id)) return;
cb(...a);
if (intervals.has(id)) timers.set(id, { fireAt: now + Math.max(0, Number(ms) || 0), cb: step, args });
};
intervals.add(id);
step();
return id;
};
globalThis.clearInterval = (id) => { intervals.delete(id); timers.delete(id); };
Object.defineProperty(globalThis, 'performance', {
value: { now: () => now }, configurable: true,
});
async function flush(rounds = 8) {
for (let i = 0; i < rounds; i++) await new Promise((r) => realSetImmediate(r));
}
/** 推进虚拟时钟到 cond() 满足或空闲。 */
async function advance(cond = null) {
for (let n = 0; n < 200000; n++) {
await flush(2);
if (cond && cond()) return 'cond';
let nextId = -1; let nextAt = Infinity;
for (const [id, t] of timers) if (t.fireAt < nextAt) { nextAt = t.fireAt; nextId = id; }
if (nextId < 0) return 'idle';
now = nextAt;
const t = timers.get(nextId);
timers.delete(nextId);
t.cb(...t.args);
}
throw new Error('虚拟时钟定时器超上限(疑似死循环)');
}
/** 固定步长推进(常驻 interval 存在时不可用它等空闲)。 */
async function advanceMs(ms) {
const until = now + ms;
while (now < until) {
await flush(2);
let nextId = -1; let nextAt = Infinity;
for (const [id, t] of timers) if (t.fireAt < nextAt) { nextAt = t.fireAt; nextId = id; }
if (nextId < 0 || nextAt > until) { now = until; break; }
now = nextAt;
const t = timers.get(nextId);
timers.delete(nextId);
t.cb(...t.args);
}
await flush();
}
// ---------------------------------------------------------------- 浏览器 mock
const winListeners = new Map();
globalThis.window = {
addEventListener: (type, fn) => {
if (!winListeners.has(type)) winListeners.set(type, []);
winListeners.get(type).push(fn);
},
location: { protocol: 'http:', host: 'test.local', href: '' },
AudioContext: null, // 下方赋值
};
const storageData = new Map();
globalThis.localStorage = {
getItem: (k) => (storageData.has(k) ? storageData.get(k) : null),
setItem: (k, v) => { storageData.set(k, String(v)); },
removeItem: (k) => { storageData.delete(k); },
};
const vibrateLog = [];
Object.defineProperty(globalThis, 'navigator', {
// maxTouchPoints: 1 —— 手机 persona:inputMode() 判 touch,voice 走主资产
value: { maxTouchPoints: 1, vibrate: (...a) => { vibrateLog.push({ pattern: a[0], t: now }); return true; } },
configurable: true,
});
class MockParam {
constructor(v = 1) { this.value = v; }
setValueAtTime() {}
linearRampToValueAtTime() {}
cancelScheduledValues() {}
}
class MockNode {
constructor() { this.connectedTo = null; }
connect(t) { this.connectedTo = t; }
disconnect() {}
}
class MockGainNode extends MockNode {
constructor() { super(); this.gain = new MockParam(); }
}
class MockPannerNode extends MockNode {
constructor() { super(); this.pan = new MockParam(0); }
}
class MockAudioContext {
constructor() {
this.state = 'running'; this.destination = {}; this.sampleRate = 44100;
this.panners = []; // createStereoPanner 创建记录(opts.pan 回归断言)
this.gains = []; // createGain 创建记录
this.sources = []; // createBufferSource 创建记录
}
get currentTime() { return now / 1000; }
createGain() { const g = new MockGainNode(); this.gains.push(g); return g; }
createStereoPanner() { const p = new MockPannerNode(); this.panners.push(p); return p; }
createDynamicsCompressor() {
const n = new MockGainNode();
n.threshold = new MockParam(); n.ratio = new MockParam();
n.attack = new MockParam(); n.release = new MockParam();
return n;
}
createBufferSource() {
const s = new MockNode();
s.buffer = null; s.loop = false; s.playbackRate = new MockParam();
s.start = () => {}; s.stop = () => {};
this.sources.push(s);
return s;
}
createOscillator() {
const o = new MockNode();
o.type = 'sine'; o.frequency = new MockParam(440);
o.start = () => {}; o.stop = () => {};
return o;
}
createBiquadFilter() {
const f = new MockNode();
f.type = 'lowpass'; f.frequency = new MockParam(750); f.Q = new MockParam(1);
return f;
}
createScriptProcessor() { return new MockNode(); }
decodeAudioData(ab) {
return Promise.resolve({
duration: durationOfUrl(ab && ab.__url),
numberOfChannels: 1, sampleRate: 44100,
getChannelData: () => new Float32Array(16),
});
}
resume() { this.state = 'running'; return Promise.resolve(); }
}
globalThis.window.AudioContext = MockAudioContext;
/** 按 url 估假 buffer 时长(语音链自然推进 + npc 在播时长账目)。 */
function durationOfUrl(u) {
const s = String(u || '');
if (s.includes('/bgm/')) return 90;
if (s.includes('/voice/')) return 1.8;
if (s.includes('npc_')) return 1.5;
if (s.includes('loop_')) return 4.0;
return 0.5;
}
// fetch:audio/* 一律合成假资产(并行生成中不依赖真实文件);blockedAssets 强制 404;
// /api/progress GET/PUT 走内存服务器桶(PUT 全量记录供结算断言)
const requestedUrls = [];
const blockedAssets = new Set();
const apiPuts = [];
const serverBuckets = new Map(); // game → data(登录态进度桶)
globalThis.fetch = async (u, opts = {}) => {
const s = String(u);
requestedUrls.push(s);
if (s.startsWith('/api/')) {
if (s.startsWith('/api/progress') && (opts.method || 'GET') === 'PUT') {
let body = null;
try { body = JSON.parse(opts.body); } catch (_) { /* 静默 */ }
apiPuts.push({ body, t: now });
if (body && body.game) serverBuckets.set(body.game, body.data);
return { ok: true, status: 200, json: async () => ({ ok: true, game: body && body.game }) };
}
if (s.startsWith('/api/progress')) {
const gname = new URLSearchParams(s.split('?')[1] || '').get('game');
const data = serverBuckets.get(gname) || null;
return { ok: true, status: 200, json: async () => ({ game: gname, data }) };
}
return { ok: false, status: 404, json: async () => ({}) };
}
const blocked = [...blockedAssets].some((b) => s.includes(b));
if (blocked) return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
const ab = new ArrayBuffer(8);
ab.__url = s;
return { ok: true, status: 200, arrayBuffer: async () => ab };
};
// ---------------------------------------------------------------- WS 内存回路 mock
function makeWsMock() {
const mock = {
conns: [],
sent: [], // { conn, msg }
transport(u, handlers) {
const conn = {
url: u, handlers, opened: false, closed: false,
send(s) { let m = null; try { m = JSON.parse(s); } catch (_) { /* 坏帧 */ } mock.sent.push({ conn, msg: m }); },
close() { if (!conn.closed) { conn.closed = true; handlers.onclose(); } },
};
mock.conns.push(conn);
return conn;
},
last() { return mock.conns[mock.conns.length - 1]; },
open(conn) { conn.opened = true; conn.handlers.onopen(); },
inject(conn, msg) { conn.handlers.onmessage(msg); },
drop(conn) { if (!conn.closed) { conn.closed = true; conn.handlers.onclose(); } },
msgs(conn) { return mock.sent.filter((x) => x.conn === conn).map((x) => x.msg); },
acts(conn, kind) { return mock.msgs(conn).filter((m) => m && m.t === 'act' && (!kind || m.kind === kind)); },
};
return mock;
}
// ---------------------------------------------------------------- 加载引擎与游戏
const { WebAudioEngine } = await import('../../engine/audio.js');
const { settings } = await import('../../engine/settings.js');
const { makeInputPipeline } = await import('../../engine/pipeline.js');
const { HuntGame } = await import('../game.js');
function resetSettings(guide = 'off') {
settings.set({ guide, voice: 'female', speed: 1.0, bgm: 1.0, input: 'touch' });
}
resetSettings('off');
storageData.set(AUTH_KEY, JSON.stringify({ username: '试炼猎人', token: 'tok1' }));
// ---------------------------------------------------------------- 协议夹具(23 §6 字段)
const SECTORS3 = [['N', 'NE', 'E'], ['SE', 'S', 'SW'], ['W', 'NW']];
function joinedMsg(over = {}) {
return {
t: 'joined', room: 'ABCD', slot: 0, resume: 'r0', char: 'yan', sector: SECTORS3[0], seed: 42,
state: { phase: 'lobby', wave: 0, dur: 10 },
roster: [
{ slot: 0, name: '试炼猎人', bot: false, char: 'yan', sector: SECTORS3[0] },
{ slot: 1, name: '小筝', bot: true, char: 'zheng', sector: SECTORS3[1] },
{ slot: 2, name: '老周', bot: true, char: 'zhou', sector: SECTORS3[2] },
],
...over,
};
}
const evSpawn = (id, mkind, dir, eta, ts = now) => ({ t: 'ev', kind: 'spawn', ts, id, mkind, dir, eta });
const evKill = (id, slot, dir, mkind = 'wolf', ts = now) => ({ t: 'ev', kind: 'kill', ts, id, slot, dir, mkind });
const evMiss = (slot, dir, ts = now) => ({ t: 'ev', kind: 'miss', ts, slot, dir });
const evSkill = (slot, cleared, ts = now) => ({ t: 'ev', kind: 'skill', ts, slot, cleared });
const evBreach = (id, mkind, dir, dur, ts = now) => ({ t: 'ev', kind: 'breach', ts, id, mkind, dir, dur });
const evWave = (wave, ts = now) => ({ t: 'ev', kind: 'wave', ts, wave });
const evWaveclear = (wave, ts = now) => ({ t: 'ev', kind: 'waveclear', ts, wave });
const evCallout = (slot, ts = now) => ({ t: 'ev', kind: 'callout', ts, slot });
const evBotline = (slot, line, ts = now) => ({ t: 'ev', kind: 'botline', ts, slot, line });
const evRoster = (roster, ts = now) => ({ t: 'ev', kind: 'roster', ts, roster });
const evEnd = (over, ts = now) => ({ t: 'ev', kind: 'end', ts, ...over });
const snapMsg = (mobs, over = {}, ts = now) => ({
t: 'snap', ts, phase: 'wave', wave: 1, dur: 10,
mobs, slots: [{ slot: 0, alive: true, bot: false, cd: 0, skillCd: 0 }], ...over,
});
// ---------------------------------------------------------------- 测试工具
const ERR_PATTERN = JSON.stringify([60, 40, 60]);
const WARN_PATTERN = JSON.stringify([30, 50, 30, 50, 30]);
const DANGER_PATTERN = JSON.stringify([25, 40, 25, 40, 25]);
const HIT_PATTERN = JSON.stringify([80]);
const PICKUP_PATTERN = JSON.stringify([15]);
const SUCCESS_PATTERN = JSON.stringify([100]);
const HEARTBEAT_PATTERN = JSON.stringify([40, 100, 40]);
const vibCount = (pattern, since = 0) => vibrateLog.filter((v) => JSON.stringify(v.pattern) === pattern && v.t >= since).length;
const playsOf = (playLog, id) => playLog.filter((e) => e.id === id).length;
/** 全量播放 id 记录(静态审计:只引用台词表内 id)。 */
const allPlayedIds = new Set();
/** 双击窗口分档前的 ladder 现值设置(必须在 makeRig 之前:DT_CORE 是快照)。 */
function setLadder(stage = 0, games = 0) {
storageData.set(LADDER_KEY, JSON.stringify({ stage, games }));
}
function getLadder() {
try { return JSON.parse(storageData.get(LADDER_KEY) || '{"stage":0,"games":0}'); } catch (_) { return { stage: 0, games: 0 }; }
}
function makeRig() {
const audio = new WebAudioEngine('audio/');
const playLog = [];
const origPlay = audio.play.bind(audio);
audio.play = (id, opts) => {
playLog.push({ id, opts: opts || {}, t: now });
allPlayedIds.add(id);
return origPlay(id, opts);
};
const stopLog = []; // stopVoice / skipVoice 时刻(打断失败指标)
const origStop = audio.stopVoice.bind(audio);
audio.stopVoice = () => { stopLog.push({ t: now }); return origStop(); };
const origSkip = audio.skipVoice.bind(audio);
audio.skipVoice = () => { stopLog.push({ t: now }); return origSkip(); };
const ws = makeWsMock();
const game = new HuntGame(audio, {}, { transport: ws.transport, wsUrl: 'ws://test.local/ws/hunt' });
game.floor.bedEnabled = false; // 虚拟时钟:不起等待声定时器
const receiptLog = [];
const origR = game.receipts.play.bind(game.receipts);
game.receipts.play = (kind) => { receiptLog.push({ kind, t: now }); return origR(kind); };
const chimeLog = [];
const origChime = game.floor.chime.bind(game.floor);
game.floor.chime = () => { chimeLog.push({ t: now }); return origChime(); };
// 管道接线与 index.html 同一逻辑(双击窗口按 ladder 分档快照)
const dtCore = game.DT_CORE;
const pipe = makeInputPipeline({
floor: game.floor,
confusion: game.confusion,
receipts: game.receipts,
invalidHints: game.invalidHints,
game,
ladder: game.ladder,
opts: dtCore ? { ...game.PIPELINE_OPTS, doubletapCore: true } : game.PIPELINE_OPTS,
});
return { audio, playLog, stopLog, receiptLog, chimeLog, game, ws, pipe, dtCore };
}
let gestureCount = 0;
/** 注入一次手势(经管道);返回手势发生时语音链是否 critical(打断失败豁免)。 */
async function g(rig, gesture) {
gestureCount++;
const crit = !!(rig.game.floor._chain && rig.game.floor._chain.critical);
rig.pipe(gesture);
await flush();
return crit;
}
function setProgress(p) {
storageData.set(SAVE_KEY, JSON.stringify({
best: null, runs: 0, totalKills: 0, tutDone: false, skillUnlocked: false, ...p,
}));
}
function getProgress() {
try { return JSON.parse(storageData.get(SAVE_KEY) || 'null'); } catch (_) { return null; }
}
/** 链序断言:ids 按序出现在 playLog 的 voice 流中(子序列匹配)。 */
function chainOrderOk(playLog, ids) {
const seq = playLog.filter((e) => e.id.startsWith('voice/')).map((e) => e.id);
let at = -1;
for (const id of ids) {
const i = seq.indexOf('voice/' + id, at + 1);
if (i < 0) return false;
at = i;
}
return true;
}
/** TITLE 单击 → 匹配 → 注入 joined(lobby)→ 等待交出话轮。 */
async function driveToLobby(rig, joinedOver = {}) {
const { game, ws } = rig;
await g(rig, 'tap');
const conn = ws.last();
ws.open(conn);
await flush();
// 等开场链播完再进房(真实网络有延迟;瞬进会顶掉 intro 链,属预期行为但此处不验)
await advance(() => !rig.audio.isVoicePlaying());
ws.inject(conn, joinedMsg(joinedOver));
await advance(() => game.floor.state === 'YOUR_TURN');
return conn;
}
/** lobby → 单击开局 → ev wave 1 → WAVE(播报链播完回 ACTION)。 */
async function driveToWave(rig, conn) {
const { game, ws } = rig;
await g(rig, 'tap'); // LOBBY 单击 = 立即开局
await advance(() => !rig.audio.isVoicePlaying());
ws.inject(conn, evWave(1));
await advance(() => game.phase === 'wave' && !rig.audio.isVoicePlaying() && rig.game.floor.state === 'ACTION');
}
/** 手势时刻仍在播的语音句(估长 1.8s,被 stop/skip 或自然播完则不算)。
* 严格 p.t < t:手势自身触发的句子(同毫秒起播)不算「已在播」。 */
function activeVoiceAt(rig, t) {
const plays = rig.playLog.filter((e) => e.id.startsWith('voice/') && e.t < t);
const last = plays[plays.length - 1];
if (!last) return null;
if (rig.stopLog.some((s) => s.t >= last.t && s.t <= t)) return null; // 已被打断/跳过(含起播同毫秒即被跳)
if (t - last.t >= 1800) return null; // 已自然播完
return last;
}
// ================================================================ Block 0 · 引擎 opts.pan 回归(23 §9)
realLog('== Block 0 · 引擎 opts.pan 后向兼容 ==');
{
resetSettings('off');
const audio = new WebAudioEngine('audio/');
await audio.unlock();
const ctx = audio.ctx;
const busGainCount = ctx.gains.length; // master/bgmPref/bgm/voice/sfx 五条总线
// 无 pan 调用路径:零 panner 节点,原链路 source → gain → sfxBus
audio.play('sfx/atk_whoosh');
await flush();
assert(ctx.panners.length === 0, 'opts.pan 回归:无 pan 调用路径零 panner 节点(引擎行为不变)');
assert(ctx.gains.length === busGainCount + 1, '无 pan sfx:只多一个播放 gain');
assert(ctx.gains[busGainCount].connectedTo === audio._sfxBus, '无 pan sfx:gain 直连 sfxBus(原路径逐字节不变)');
// pan=0 与缺省等价
audio.play('sfx/atk_whoosh', { pan: 0 });
await flush();
assert(ctx.panners.length === 0, 'opts.pan 回归:pan=0 与缺省等价(零 panner)');
// 非法 pan 静默忽略
audio.play('sfx/atk_whoosh', { pan: NaN });
audio.play('sfx/atk_whoosh', { pan: 'x' });
await flush();
assert(ctx.panners.length === 0, 'opts.pan 回归:NaN/非数字 pan 静默忽略');
// pan=-1 / 0.7 / 1:panner 值正确,链路 source → gain → panner → sfxBus
for (const [pan, expect] of [[-1, -1], [0.7, 0.7], [1, 1]]) {
const n0 = ctx.panners.length;
audio.play('sfx/kill_wolf', { pan });
await flush();
assert(ctx.panners.length === n0 + 1, `pan=${pan}:创建 panner`);
const p = ctx.panners[ctx.panners.length - 1];
assert(Math.abs(p.pan.value - expect) < 1e-9, `pan=${pan}:panner.pan.value === ${expect}`);
assert(p.connectedTo === audio._sfxBus, `pan=${pan}:panner 接 sfxBus`);
const gNode = ctx.gains[ctx.gains.length - 1];
assert(gNode.connectedTo === p, `pan=${pan}:gain 接 panner(链路 source→gain→panner→bus)`);
}
// clamp:越界钳到 [-1,1]
audio.play('sfx/kill_wolf', { pan: 2 });
await flush();
assert(ctx.panners[ctx.panners.length - 1].pan.value === 1, 'pan=2 钳到 1');
audio.play('sfx/kill_wolf', { pan: -3 });
await flush();
assert(ctx.panners[ctx.panners.length - 1].pan.value === -1, 'pan=-3 钳到 -1');
// voice 层同款能力(本作旁白不使用,对称能力一次给足)
{
const n0 = ctx.panners.length;
audio.play('voice/intro_1', { pan: -0.5 });
await flush();
assert(ctx.panners.length === n0 + 1, 'voice opts.pan:创建 panner');
const p = ctx.panners[ctx.panners.length - 1];
assert(Math.abs(p.pan.value - (-0.5)) < 1e-9, 'voice pan=-0.5:panner 值正确');
assert(p.connectedTo === audio._voiceBus, 'voice panner 接 voiceBus');
}
// voice 无 pan:零 panner(回归)
{
const n0 = ctx.panners.length;
audio.play('voice/intro_2');
await advance(() => !audio.isVoicePlaying());
assert(ctx.panners.length === n0, 'voice 无 pan:零 panner(既有行为不变)');
}
// 老 Safari:无 createStereoPanner → 静默忽略 pan(不炸、照常出声)
ctx.createStereoPanner = undefined;
const srcBefore = ctx.sources.length;
audio.play('sfx/atk_whoosh', { pan: 1 });
audio.play('voice/room_wait', { pan: 0.8 });
await flush();
assert(ctx.sources.length === srcBefore + 1, '老 Safari 降级:无 createStereoPanner 时 sfx 照常播放');
assert(ctx.gains[ctx.gains.length - 1].connectedTo === audio._voiceBus, '老 Safari 降级:voice gain 直连 voiceBus(pan 静默忽略)');
await advance(() => !audio.isVoicePlaying());
}
// ================================================================ Block 1 · 教学波 §4.10(首句动手 / 10s 内首杀)
realLog('== Block 1 · 教学波 T0-T3(26 号 §4.10,guide=off)==');
{
resetSettings('off');
storageData.delete(SAVE_KEY); // 无存档 → tutDone=false
storageData.delete(LADDER_KEY); // 全新手(stage 0:双击折算单击)
const rig = makeRig();
assert(rig.dtCore === false, '新手(stage 0)双击窗口分档:DT_CORE=false(双击不存在)');
const { audio, playLog, receiptLog, game, ws } = rig;
const g0 = gestureCount;
// §7 全局:解锁前 play 为 no-op
audio.play('voice/intro_1');
await flush();
assert(audio._voice === null && consoleWarns.some((w) => w.includes('尚未解锁')),
'启动遮罩点按前无音频播放(play 为 no-op)');
await audio.unlock();
playLog.length = 0;
game.start();
await flush();
assert(game.phase === 'title' && game.floor.state === 'ACTION', '启动进 TITLE(BGM 即状态声,ACTION)');
assert(playLog.some((e) => e.id === 'bgm/menu'), 'TITLE 播放 bgm/menu');
// 无效手势(左右滑):invalid 回执 + 首次附一句解释(原则 7);再滑只给回执
await g(rig, 'left');
assert(receiptLog.some((r) => r.kind === 'invalid'), 'TITLE 左右滑:invalid 回执(禁止静默)');
assert(playsOf(playLog, 'voice/gd_invalid') === 1, 'TITLE 左右滑首次:gd_invalid 解释一次');
await g(rig, 'right');
assert(playsOf(playLog, 'voice/gd_invalid') === 1 && receiptLog.filter((r) => r.kind === 'invalid').length >= 2,
'TITLE 再次左右滑:只给回执,不再解释(原则 7)');
// 无存档单击 → 快速匹配,join 必带 teach:true(首局=教学房)+ ver:1 + room:"quick"
await g(rig, 'tap');
assert(game.phase === 'matching', 'TITLE 单击 → MATCHING');
const conn = ws.last();
ws.open(conn);
await flush();
const join = ws.msgs(conn)[0];
assert(join && join.t === 'join' && join.ver === 1 && join.room === 'quick' && join.teach === true,
'首局单击发出 join { room:"quick", teach:true, ver:1 }');
assert(join.name === '试炼猎人', 'join.name 取自档案');
await advance(() => !audio.isVoicePlaying());
// joined(phase:teach) → TEACH:第一句就让玩家动手(critical,不念规则)
const teachAt = now;
ws.inject(conn, joinedMsg({ state: { phase: 'teach', wave: 0, dur: 10 } }));
await flush();
// critical 链在播时点按 → busy 回执(不打断,§4.1/§4.6)
await g(rig, 'tap');
assert(game._tut === 0, 'T0 指令句 critical:播完前点按不打断(仍 T0)');
assert(receiptLog.some((r) => r.kind === 'busy'), 'critical 期点按:busy 回执(请稍等)');
await advance(() => game.floor.state === 'YOUR_TURN' && game._tut === 0);
assert(game.phase === 'teach', 'joined(phase:teach) → TEACH');
assert(game.slot === 0 && game._myChar === 'yan' && game.seed === 42, 'joined 字段落位(slot/char/seed)');
assert(playLog.some((e) => e.id === 'bgm/hunt'), 'TEACH BGM 切 bgm/hunt(篝火即氛围)');
assert(playsOf(playLog, 'voice/sys_first_tap') === 1, 'T0 第一句 = sys_first_tap「点一下屏幕。」(首句动手)');
assert(playsOf(playLog, 'voice/intro_1') === 0 && playsOf(playLog, 'voice/tut_rule') === 0,
'教学开场不念规则(intro/tut_rule 不在 T0,反模式 6)');
// T0 作答:点一下 → T1(滑砍)
await g(rig, 'tap');
assert(game._tut === 1 && playsOf(playLog, 'voice/tut_hit_full') === 1, 'T0 单击 → T1 播 tut_hit_full「朝叫声,滑。」');
await advance(() => game.floor.state === 'YOUR_TURN' && game._tut === 1); // 指令句播完再作答
// T1:服务器放教学狼 1(N,eta 12)→ 生成呼啸一声;eta>6 零 loop
ws.inject(conn, evSpawn('w1', 'wolf', 'N', 12));
await flush();
assert(playLog.some((e) => e.id === 'sfx/howl_wolf' && e.opts.pan === 0 && Math.abs(e.opts.volume - 0.4) < 1e-9),
'T1 教学狼生成呼啸:howl_wolf,pan=0(N),vol=0.4(外圈一声)');
await flush(); // _onSpawn 已同步重估渲染集
assert(game._loops.size === 0, '听觉裁剪:eta>6s 怪零 loop');
// 先错向滑 W → act attack dir:W → ev miss → atk_swish + miss_side 归因(30s 去重)
await g(rig, 'left');
let acts = ws.acts(conn, 'attack');
assert(acts.length === 1 && acts[0].dir === 'W', 'T1 错向滑 W → 发出 act attack dir:W');
ws.inject(conn, evMiss(0, 'W'));
await advanceMs(400); // atk_swish 与 atk_whoosh 同类间隔 ≥0.15s,走 one-shot 队列延迟播出
assert(playLog.some((e) => e.id === 'sfx/atk_swish'), 'T1 挥空播 atk_swish(归因真话)');
assert(vibCount(ERR_PATTERN) >= 1, 'T1 挥空 error 震');
assert(game.mobs.has('w1'), 'T1 挥空怪物不受伤');
await advance(() => playsOf(playLog, 'voice/miss_side') >= 1);
assert(playsOf(playLog, 'voice/miss_side') === 1, 'T1 错向 → miss_side「在另一边。」归因一次');
await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN');
// 冷却 2.5s 内再滑(miss 反馈 ~1.8s 仍在窗内)→ invalid 回执、零 act
const errVib1 = vibCount(ERR_PATTERN);
await g(rig, 'up');
assert(ws.acts(conn, 'attack').length === 1, '出击冷却 2.5s 内滑:零 act 发出');
assert(playsOf(playLog, 'voice/atk_cd') === 1, '冷却首次:atk_cd「出刀还在冷却。」解释一次');
assert(vibCount(ERR_PATTERN) === errVib1 + 1, '冷却首拦:error 震(每冷却窗一次)');
await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN');
// T1 期点按 → invalid + inv_swipe(附录 C §6 做错列「先滑」)
const invBefore = receiptLog.filter((r) => r.kind === 'invalid').length;
await g(rig, 'tap');
assert(receiptLog.filter((r) => r.kind === 'invalid').length === invBefore + 1
&& playsOf(playLog, 'voice/inv_swipe') === 1,
'T1 点按:invalid 回执 + inv_swipe「先朝声音滑。」解释一次');
await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN');
// 向 N 滑 → act attack dir:N → ev kill → tut_kill_ok → T2(§4.10:10s 内首杀)
await g(rig, 'up');
acts = ws.acts(conn, 'attack');
assert(acts.length === 2 && acts[1].dir === 'N', 'T1 向 N 滑 → 发出 act attack dir:N');
ws.inject(conn, evKill('w1', 0, 'N'));
await flush();
assert(playLog.some((e) => e.id === 'sfx/kill_wolf'), 'T1 首杀播 kill_wolf');
assert(vibCount(PICKUP_PATTERN) >= 1, 'T1 首杀 pickup 震');
assert(game._myKills === 1, '击杀归属:kills 账 slot 0 = 1');
assert(game._tut === 2, 'T1 首杀 → T2');
assert(now - teachAt <= 10000, `首杀 ≤10s(§4.10,实际 ${now - teachAt}ms)`);
await advance(() => game.floor.state === 'YOUR_TURN' && game._tut === 2);
assert(chainOrderOk(playLog, ['tut_kill_ok', 'tut_call_full']), 'T1 首杀 → tut_kill_ok → T2 tut_call_full「点一下,喊人。」');
// ladder §4.3 阶段 1:第一次击杀 = 第一个小目标(hunt 不播 sys_unlock_up:上滑=出击北弧)
assert(getLadder().stage === 1, '第一次击杀 → ladder.onGoal():全局手势阶梯 stage=1');
// 再放一只教学狼(稍后验证突入零惩罚)
ws.inject(conn, evSpawn('w9', 'wolf', 'N', 12));
await flush();
// T2:单击 → act callout 发出;本机播自己座位角色 help_call(中央 pan=0)
await advanceMs(4600); // 过呼喊节流(T2 前无呼喊,防御性推进)
await g(rig, 'tap');
assert(ws.acts(conn, 'callout').length === 1, 'T2 单击 → 发出 act callout');
assert(game._npcLog.some((r) => r.id === 'sfx/npc_yan_help_call' && r.pan === 0),
'T2 本机播自己座位角色 help_call(npc_yan_help_call,中央)');
// 自己的 callout ev 回环不重复播
ws.inject(conn, evCallout(0));
await flush();
assert(game._npcLog.filter((r) => r.id === 'sfx/npc_yan_help_call').length === 1, '自己呼喊的 ev 回环不重复播');
// 注入 bot help_ack(座 2 老周,防区中心 WNW=292.5°,pan≈-0.92)→ 播完 → tut_callout_ok → T3
ws.inject(conn, evBotline(2, 'help_ack'));
await advance(() => game._npcLog.some((r) => r.id === 'sfx/npc_zhou_help_ack'));
const ackRec = game._npcLog.find((r) => r.id === 'sfx/npc_zhou_help_ack');
assert(Math.abs(ackRec.pan - Math.sin(292.5 * Math.PI / 180)) < 1e-6,
'T2 bot help_ack 带 pan=该 bot 防区中心(座 2 WNW,pan≈-0.92)');
await advance(() => game._tut === 3);
await advance(() => game.floor.state === 'YOUR_TURN');
assert(chainOrderOk(playLog, ['tut_callout_ok', 'tut_help_full']),
'T2 bot 应答播完 → tut_callout_ok(呼喊-响应闭环点明)→ T3 tut_help_full「按住屏幕,听营地。」');
// 教学狼突入零惩罚:ev breach 不扣耐久、不播 breach 警报、无 hit 震
const hitVib0 = vibCount(HIT_PATTERN);
ws.inject(conn, evBreach('w9', 'wolf', 'N', 10));
await flush();
assert(game.dur === 10, '学期零惩罚:教学狼突入不扣耐久');
assert(playsOf(playLog, 'voice/breach') === 0 && playsOf(playLog, 'voice/breach_heavy') === 0,
'学期零惩罚:教学段突入不播 breach 警报(明说规则前不漏真话)');
assert(vibCount(HIT_PATTERN) === hitVib0, '学期零惩罚:教学段突入无 hit 震');
// T3 作答:长按 → 营地状态一句话 + tut_done → tutDone 写入进度
await g(rig, 'longpress');
await advance(() => playsOf(playLog, 'voice/tut_done') === 1);
assert(chainOrderOk(playLog, ['hp_band1', 'lp_act', 'tut_done']),
'T3 长按 → 状态一句话(耐久档+一个动作)→ tut_done');
assert(getProgress() && getProgress().tutDone === true, '教学完成 tutDone 写入进度');
// 注入 ev wave 1 → WAVE(教学房同场接续第 1 波;先号角后播报;无 room_start)
const vibHb0 = vibCount(HEARTBEAT_PATTERN);
ws.inject(conn, evWave(1));
await flush();
assert(game.phase === 'wave', '教学完成 → ev wave 1 → WAVE');
assert(game.floor.state === 'SPEAKING' || game.floor.state === 'ACTION', 'WAVE 对局 = ACTION(播报链播完回 ACTION)');
assert(playLog.some((e) => e.id === 'sfx/horn_wave'), 'ev wave 1:先号角 horn_wave');
assert(vibCount(HEARTBEAT_PATTERN) > vibHb0, 'ev wave:heartbeat 震');
const iHorn = playLog.findIndex((e) => e.id === 'sfx/horn_wave');
const iBand = playLog.findIndex((e) => e.id === 'voice/wave_band1');
assert(iBand > iHorn, '先号角后播报(wave_band1 在 horn_wave 后)');
assert(playsOf(playLog, 'voice/room_start') === 0, '教学房接续第 1 波不播 room_start(tut_done 已衔接)');
await advance(() => !audio.isVoicePlaying());
assert(game.floor.state === 'ACTION', '波次播报播完回 ACTION(不留安静态)');
// 全程零「双击=旋风斩」教学(附录 C §3:旋风斩口头不出现)
assert(playsOf(playLog, 'voice/tut_skill') === 0 && playsOf(playLog, 'voice/tut_skill_cd') === 0,
'教学波零旋风斩口播(tut_skill/tut_skill_cd 停用)');
// 手势预算:教学路径 ≤150 次手势(§24 关键路径预算)
assert(gestureCount - g0 <= 150, `教学关键路径手势预算 ≤150(实际 ${gestureCount - g0})`);
game.stop();
await flush();
}
// ================================================================ Block 2 · WAVE 战斗(新手档:双击=呼喊)
realLog('== Block 2 · WAVE 战斗:听觉裁剪 / 冷却 / 呼喊 / 突入 / 长按状态 ==');
{
resetSettings('off');
storageData.delete(LADDER_KEY); // stage 0
setProgress({ tutDone: true });
const rig = makeRig();
assert(rig.dtCore === false, 'stage 0:双击按单击处理(doubletapCore 不传)');
const { audio, playLog, receiptLog, game, ws } = rig;
await audio.unlock();
game.start();
await flush();
// 非首局:join 不带 teach
const conn = await driveToLobby(rig);
const join = ws.msgs(conn)[0];
assert(join && !('teach' in join), '二周目(tutDone=true)join 不带 teach');
assert(game.phase === 'lobby' && game.floor.state === 'YOUR_TURN', 'joined(phase:lobby) → LOBBY(交出话轮)');
assert(playsOf(playLog, 'voice/room_wait') === 1, 'LOBBY 进入播 room_wait');
// LOBBY 长按=在场猎人播报;重复问 → 「再说一遍:」前缀(§4.4 主动重听)
await g(rig, 'longpress');
assert(playsOf(playLog, 'voice/team_3') === 1, 'LOBBY 长按播 team_3(在场猎人)');
await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN');
await g(rig, 'longpress');
await advance(() => playsOf(playLog, 'voice/team_3') === 2);
assert(playsOf(playLog, 'voice/sys_say_again') === 1 && playsOf(playLog, 'voice/team_3') === 2,
'LOBBY 重复长按:sys_say_again 前缀 + 完整句(主动查询允许原句)');
await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN');
// 单击发 act start;开局 roster ev:3 猎人、bot 槽 char 齐全;新队友 join_chime
await g(rig, 'tap');
assert(ws.acts(conn, 'start').length === 1, 'LOBBY 单击 → 发出 act start');
ws.inject(conn, evRoster([
{ slot: 0, name: '试炼猎人', bot: false, char: 'yan', sector: SECTORS3[0] },
{ slot: 1, name: '小筝', bot: true, char: 'zheng', sector: SECTORS3[1] },
{ slot: 2, name: '老周', bot: true, char: 'zhou', sector: SECTORS3[2] },
]));
await flush();
assert(game._roster.size === 3, '开局 roster ev:猎人总数 = max(3, 人类数) = 3');
assert(game._charOf(1) === 'zheng' && game._charOf(2) === 'zhou', 'bot 槽 char 齐全');
// ev wave 1 → room_start → wave_band1(从 LOBBY 开局)
ws.inject(conn, evWave(1));
await advance(() => game.phase === 'wave');
await advance(() => !audio.isVoicePlaying());
assert(chainOrderOk(playLog, ['room_start', 'wave_band1']), '开局链:room_start → wave_band1');
// ---- 听觉视野裁剪:外圈呼啸一声(pan=1),eta>6 零 loop;进中圈起 loop 0.30
ws.inject(conn, evSpawn('e1', 'wolf', 'E', 9));
await flush();
const e1Howl = playLog.find((e) => e.id === 'sfx/howl_wolf' && Math.abs(e.opts.pan - 1) < 1e-9);
assert(e1Howl && Math.abs(e1Howl.opts.volume - 0.4) < 1e-9, '外圈生成呼啸:pan=sin(90°)=1(E),vol 0.4');
await advanceMs(600);
assert(game._loops.size === 0, 'eta>6s:零 loop(外圈静默)');
await advanceMs(3200); // eta 9 → ~5.8,跨进中圈
await advance(() => game._loops.size === 1);
{
const h = game._loops.get('e1');
assert(h && Math.abs(h.vol - 0.3) < 1e-9, '中圈(4-6s)起 loop,音量档 0.30');
assert(h.mkind === 'wolf', 'loop 资产按 mkind=loop_wolf');
}
// ---- R3:注入 5 只内圈怪 → loop ≤3 路且为 eta 最小 3 只
ws.inject(conn, snapMsg([
{ id: 'm1', mkind: 'wolf', dir: 'N', eta: 1.0 },
{ id: 'm2', mkind: 'wolf', dir: 'NE', eta: 2.0 },
{ id: 'm3', mkind: 'wolf', dir: 'E', eta: 3.0 },
{ id: 'm4', mkind: 'bear', dir: 'SE', eta: 3.5 },
{ id: 'm5', mkind: 'wolf', dir: 'S', eta: 3.9 },
]));
await flush();
assert(game._loops.size === 3, 'R3:5 只内圈怪 → loop 恰 3 路');
assert(game._loops.has('m1') && game._loops.has('m2') && game._loops.has('m3'),
'R3:loop 渲染 eta 最小 3 只(m1/m2/m3)');
assert(Math.abs(game._loops.get('m1').vol - 0.8) < 1e-9, '内圈 <2.5s:loop 音量档 0.80');
assert(Math.abs(game._loops.get('m3').vol - 0.55) < 1e-9, '内圈 2.5-4s:loop 音量档 0.55');
// snap 把 m4/m5/e1 全清出本地(不在 snap 的 id 删除)
assert(!game.mobs.has('e1'), 'snap 对账:本地多出/缺席的 id 被清理');
await flush();
{
const h2 = game._loops.get('m2');
assert(h2 && h2.panner && Math.abs(h2.panner.pan.value - Math.sin(45 * Math.PI / 180)) < 1e-6,
'loop 声像 pan=sin(az)(NE≈0.7,自建 panner 句柄)');
assert(game._loops.get('m1').gain, 'loop 句柄建立(gain 节点就位)');
}
// ---- 出击冷却 2.5s:冷却内滑仅 invalid 回执 + atk_cd 一次、零 act
await g(rig, 'right');
const atkActs = ws.acts(conn, 'attack');
assert(atkActs.length === 1 && atkActs[0].dir === 'E', 'WAVE 右滑 → act attack dir:E');
assert(receiptLog.some((r) => r.kind === 'ok'), '出击接受:ok 回执(禁止静默)');
await g(rig, 'left');
assert(ws.acts(conn, 'attack').length === 1, '出击冷却内滑:零 act');
assert(playsOf(playLog, 'voice/atk_cd') === 1, '冷却首次滑:atk_cd 解释一次(其后仅回执)');
const errVib0 = vibCount(ERR_PATTERN);
await g(rig, 'left');
await g(rig, 'up');
assert(ws.acts(conn, 'attack').length === 1
&& vibCount(ERR_PATTERN) === errVib0,
'冷却内连滑:每下 invalid 回执(禁止静默),error 震仅每冷却窗首拦一次');
await advanceMs(2600);
await g(rig, 'left');
assert(ws.acts(conn, 'attack').length === 2 && ws.acts(conn, 'attack')[1].dir === 'W',
'冷却后滑正常发出 act attack dir:W');
// 击杀归属 + 挥空真话(呼喊节流拒绝/挥空必须出声)
ws.inject(conn, evKill('m2', 0, 'W'));
await flush();
assert(playLog.some((e) => e.id === 'sfx/kill_wolf' && Math.abs(e.opts.pan - (-1)) < 1e-9),
'击杀音带 pan=出击方位(W,pan=-1)');
assert(vibCount(PICKUP_PATTERN) >= 1, '命中 pickup 震');
ws.inject(conn, evMiss(0, 'E'));
await advanceMs(400); // one-shot 同类间隔排队
assert(playLog.some((e) => e.id === 'sfx/atk_swish'), 'WAVE 挥空播 atk_swish(真话出声)');
assert(playsOf(playLog, 'voice/miss_side') === 1, 'WAVE 挥空 miss_side 归因(30s 台账一次)');
await advance(() => !audio.isVoicePlaying() && game.floor.state === 'ACTION'); // 归因句播完再继续
// ---- 呼喊:单击 → act callout + 本机座位角色 help_call;节流 5s(invalid 一次解释)
await g(rig, 'tap');
assert(ws.acts(conn, 'callout').length === 1, 'WAVE 单击 → act callout');
assert(game._npcLog.some((r) => r.id === 'sfx/npc_yan_help_call'), '本机播 npc_yan_help_call');
await g(rig, 'tap');
assert(ws.acts(conn, 'callout').length === 1, '呼喊节流 5s:连点只出一条 callout act');
assert(playsOf(playLog, 'voice/call_cd') === 1, '呼喊节流首次:call_cd 解释一次');
assert(receiptLog.filter((r) => r.kind === 'invalid').length >= 1, '呼喊节流内单击:invalid 回执(出声)');
// 新手(stage<3)双击不存在:折算成单击=呼喊(附录 C §4 WAVE 行)
const callActs0 = ws.acts(conn, 'callout').length;
await advanceMs(5100); // 过呼喊节流(5s)
await g(rig, 'doubletap');
assert(ws.acts(conn, 'callout').length === callActs0 + 1, '新手双击按单击处理 → 呼喊(act callout)');
assert(ws.acts(conn, 'skill').length === 0, '新手零 act skill(阶段 4 前不放技能)');
// 他人呼喊:该座角色 help_call 带防区中心 pan + warning 震
const warn0 = vibCount(WARN_PATTERN);
ws.inject(conn, evCallout(2));
await advance(() => game._npcLog.some((r) => r.id === 'sfx/npc_zhou_help_call'));
const callRec = game._npcLog.find((r) => r.id === 'sfx/npc_zhou_help_call');
assert(Math.abs(callRec.pan - Math.sin(292.5 * Math.PI / 180)) < 1e-6, '收到呼喊:座 2 防区中心 pan(WNW≈-0.92)');
assert(vibCount(WARN_PATTERN) > warn0, '收到呼喊 warning 震');
// ---- 突入:breach_thud + hit 震 + breach 播报(节流 3.5s);熊 -2 → breach_heavy
const hit0 = vibCount(HIT_PATTERN);
ws.inject(conn, evBreach('m1', 'wolf', 'N', 9));
await flush();
assert(game.dur === 9, '狼突入耐久 -1(dur=9)');
assert(playLog.some((e) => e.id === 'sfx/breach_thud'), '突入播 breach_thud(中央)');
assert(vibCount(HIT_PATTERN) > hit0, '突入 hit 震');
assert(playsOf(playLog, 'voice/breach') === 1, '狼突入播 breach 警报');
await advance(() => !audio.isVoicePlaying());
assert(game.floor.state === 'ACTION', '警报播完回 ACTION');
// 3.5s 节流窗内再突入 → 不重复播报
ws.inject(conn, evBreach('m3', 'wolf', 'E', 8));
await flush();
assert(game.dur === 8, '窗内狼突入耐久照扣(dur=8)');
assert(playsOf(playLog, 'voice/breach') === 1, 'breach 播报节流 ≥3.5s:窗内突入不重复播报');
await advanceMs(3600);
ws.inject(conn, evSpawn('b2', 'bear', 'SW', 11));
await flush();
ws.inject(conn, evBreach('b2', 'bear', 'SW', 6));
await flush();
assert(playsOf(playLog, 'voice/breach_heavy') === 1, '节流窗后熊突入播 breach_heavy');
// 后半平面呼啸音量 ×0.6(SW=225°,身后发闷)
const b2Howl = playLog.filter((e) => e.id === 'sfx/howl_bear').pop();
assert(b2Howl && Math.abs(b2Howl.opts.volume - 0.4 * 0.6) < 1e-9 && Math.abs(b2Howl.opts.pan - Math.sin(225 * Math.PI / 180)) < 1e-6,
'后半平面呼啸:音量 ×0.6("身后发闷"),pan=sin(225°)≈-0.7');
await advance(() => !audio.isVoicePlaying());
// ---- 长按 = 我在哪(耐久档 + 波次档 + 一个动作,≤20 字;重复问更短)
await g(rig, 'longpress');
await advance(() => playsOf(playLog, 'voice/lp_act') >= 1);
assert(chainOrderOk(playLog, ['hp_band2', 'st_wave_a', 'lp_act']), 'WAVE 长按 → hp_band → st_wave → lp_act(状态一句话)');
assert(playsOf(playLog, 'voice/team_3') === 2, 'WAVE 帮助不含在场人数(人数在 LOBBY 长按)');
await advance(() => !audio.isVoicePlaying());
assert(game.floor.state === 'ACTION', '帮助播完回 ACTION');
await g(rig, 'longpress'); // 同状态重复问 → 只剩动作句(原则 9)
await advance(() => playsOf(playLog, 'voice/lp_act') === 2);
await advance(() => !audio.isVoicePlaying());
assert(playsOf(playLog, 'voice/lp_act') === 2 && playsOf(playLog, 'voice/hp_band2') === 1,
'同状态重复长按:只播 lp_act(更短档),不原句重播');
assert(game.floor.state === 'ACTION', '帮助播完仍回 ACTION');
// ---- 耐久首次 ≤2 → danger_low(每局 1 次)+ danger 震
const danger0 = vibCount(DANGER_PATTERN);
ws.inject(conn, evSpawn('d1', 'wolf', 'N', 8));
ws.inject(conn, evBreach('d1', 'wolf', 'N', 2));
await advance(() => playsOf(playLog, 'voice/danger_low') === 1);
assert(playsOf(playLog, 'voice/danger_low') === 1, '耐久首次 ≤2 播 danger_low');
assert(vibCount(DANGER_PATTERN) > danger0, '耐久 ≤2 danger 震');
ws.inject(conn, evSpawn('d2', 'wolf', 'N', 8));
await advanceMs(3600);
ws.inject(conn, evBreach('d2', 'wolf', 'N', 1));
await flush();
assert(playsOf(playLog, 'voice/danger_low') === 1, 'danger_low 每局只播 1 次');
// ---- 波次推进:waveclear → wave_clear → 波间休整交出话轮;ev wave 2 → 号角 + band2
ws.inject(conn, { t: 'ev', kind: 'waveclear', ts: now, wave: 1 });
await advance(() => playsOf(playLog, 'voice/wave_clear') === 1);
assert(playsOf(playLog, 'voice/wave_clear') === 1, 'ev waveclear 播 wave_clear(波间休整)');
await advance(() => game.floor.state === 'YOUR_TURN');
assert(game._inRest(), '波间休整窗 8s 开启(等待点交出话轮)');
// 休整窗:上滑/单击 invalid(无可打目标)+ inv_rest 一次;下滑=菜单(Block 3 详测)
await g(rig, 'up');
assert(playsOf(playLog, 'voice/inv_rest') === 1, '休整窗滑动作 invalid:inv_rest「下一波马上来。」一次');
assert(ws.acts(conn, 'attack').length === 2, '休整窗滑不发出击(怪已清)');
ws.inject(conn, evWave(2));
await flush();
assert(chainOrderOk(playLog, ['wave_clear', 'wave_band2']), '第 2 波:号角 + wave_band2');
assert(game.wave === 2, 'wave=2');
await advance(() => !audio.isVoicePlaying());
assert(game.floor.state === 'ACTION' && !game._inRest(), '第 2 波开始:回 ACTION、休整窗关闭');
// ---- 本波未出手 8s → 只给交接提示音(不念说明书;出手后永不催)
const chime0 = rig.chimeLog.length;
await advanceMs(8200);
assert(rig.chimeLog.length === chime0 + 1, 'wave 等待点:本波未出手 8s 仅 earcon(零语音催促)');
await g(rig, 'down'); // 出手(南弧空挥也算在场证明)
const chime1 = rig.chimeLog.length;
await advanceMs(15000);
assert(rig.chimeLog.length === chime1, '本波已出手:等待声提示永不触发(等待是合法玩法)');
game.stop();
await flush();
}
// ================================================================ Block 2b · 阶段 3/4:双击=重听威胁档 / 旋风斩
realLog('== Block 2b · 双击分档:阶段 3 重听威胁 / 阶段 4 旋风斩 ==');
{
resetSettings('off');
setLadder(3, 5); // 阶段 3(累计 3 局):双击已识别
setProgress({ tutDone: true, skillUnlocked: false });
const rig = makeRig();
assert(rig.dtCore === true, 'stage>=3:默认双击窗口 + doubletapCore(双击直达游戏)');
const { audio, playLog, game, ws } = rig;
await audio.unlock();
game.start();
await flush();
const conn = await driveToLobby(rig);
// 阶段 4 专属教学(附录 C §3):大厅进入即教旋风斩一句并解锁
assert(playsOf(playLog, 'voice/skill_teach') === 1, '阶段 3 首入大厅:skill_teach「双击,放出旋风斩。」');
assert(getProgress().skillUnlocked === true, 'skillUnlocked 落盘(此后双击=旋风斩)');
await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN');
// 大厅双击 = 重听等待点(再说一遍 + room_wait)
await g(rig, 'doubletap');
await advance(() => playsOf(playLog, 'voice/room_wait') === 2);
assert(playsOf(playLog, 'voice/sys_say_again') >= 1 && playsOf(playLog, 'voice/room_wait') === 2,
'阶段 3 大厅双击:重听等待点(sys_say_again + room_wait)');
await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN');
await g(rig, 'tap'); // start
ws.inject(conn, evWave(1));
await advance(() => game.phase === 'wave' && !audio.isVoicePlaying());
// ---- 阶段 4:双击(就绪)→ act skill;ev skill 清内圈
ws.inject(conn, snapMsg([
{ id: 'n1', mkind: 'wolf', dir: 'N', eta: 2.0 },
{ id: 'n2', mkind: 'wolf', dir: 'W', eta: 3.0 },
{ id: 'n3', mkind: 'bear', dir: 'NW', eta: 1.5 },
]));
await flush();
await g(rig, 'doubletap');
assert(ws.acts(conn, 'skill').length === 1, '阶段 4 双击(就绪)→ 发出 act skill');
ws.inject(conn, evSkill(0, ['n1', 'n2', 'n3']));
await flush();
assert(playLog.some((e) => e.id === 'sfx/skill_spin'), '旋风斩放出播 skill_spin(中央)');
assert(vibCount(SUCCESS_PATTERN) >= 1, '旋风斩 success 震');
assert(game.mobs.size === 0, '旋风斩清全场内圈');
await advanceMs(600);
assert(game._loops.size === 0, '清场后 loop 全停');
// 冷却中双击 = 重听威胁:再造 3 只内圈 → threat_many + say_again 前缀,零 act
ws.inject(conn, snapMsg([
{ id: 'p1', mkind: 'wolf', dir: 'N', eta: 2.0 },
{ id: 'p2', mkind: 'wolf', dir: 'W', eta: 3.0 },
{ id: 'p3', mkind: 'bear', dir: 'NW', eta: 1.5 },
], { slots: [{ slot: 0, alive: true, bot: false, cd: 0, skillCd: 7.4 }] }));
await flush();
const skillActs0 = ws.acts(conn, 'skill').length;
await g(rig, 'doubletap');
assert(ws.acts(conn, 'skill').length === skillActs0, '旋风斩冷却中双击:零 act(本地先拦)');
await advance(() => playsOf(playLog, 'voice/threat_many') === 1);
assert(playsOf(playLog, 'voice/threat_many') === 1 && playsOf(playLog, 'voice/sys_say_again') >= 2,
'冷却中双击=重听威胁:内圈 3 只 → threat_many(带「再说一遍:」前缀)');
await advance(() => !audio.isVoicePlaying());
// 8s 冷却完成 → skill_ready(hint 通道:只在空闲时播)
await advanceMs(8100);
assert(playsOf(playLog, 'voice/skill_ready') === 1, '旋风斩冷却完成播 skill_ready(每轮冷却 1 次)');
// ---- 终局:结算链 + 阶段 3 双击重听结算等待点
ws.inject(conn, evEnd({ win: true, dur: 8, grade: 'A', kills: { '0': 14, '1': 9, '2': 6 } }));
await advance(() => game.floor.state === 'YOUR_TURN');
assert(game.phase === 'end', 'ev end → END 结算态');
assert(chainOrderOk(playLog, ['end_win', 'grade_a', 'kill_band2', 'end_rank', 'end_again']),
'胜利结算链有序(end_win→grade_a→kill_band2→end_rank→end_again)');
await g(rig, 'doubletap');
await advance(() => playsOf(playLog, 'voice/end_again') >= 2);
assert(playsOf(playLog, 'voice/end_again') >= 2 && playsOf(playLog, 'voice/sys_say_again') >= 3, '阶段 3 END 双击:重听结算等待点(say_again + end_again)');
await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN');
game.stop();
await flush();
}
// ================================================================ Block 3 · 菜单 E3(不暂停)+ WAVE 下滑恒出击
realLog('== Block 3 · 菜单不暂停(E3)+ WAVE 下滑恒出击 ==');
{
resetSettings('off');
storageData.delete(LADDER_KEY);
setProgress({ tutDone: true });
const rig = makeRig();
const { audio, playLog, game, ws } = rig;
await audio.unlock();
game.start();
await flush();
const conn = await driveToLobby(rig);
await driveToWave(rig, conn);
// WAVE 内下滑恒出击(附录 C §3:WAVE 内=出击南方,不弹菜单)
ws.inject(conn, snapMsg([{ id: 's1', mkind: 'wolf', dir: 'S', eta: 3.0 }]));
await flush();
await g(rig, 'down');
assert(ws.acts(conn, 'attack').length === 1 && ws.acts(conn, 'attack')[0].dir === 'S',
'WAVE 下滑 → 出击(act attack dir:S),不开菜单');
assert(game._menuOpen === false, 'WAVE 下滑出击时菜单不开');
ws.inject(conn, evKill('s1', 2, 'S')); // bot 补刀清场(kills 归 slot 2)
await flush();
// 无怪下滑 = 空挥出击(真话),仍不弹菜单
const cdT0 = now;
await advance(() => now - cdT0 >= 2600); // 过 2.5s 出击冷却
const atkBefore = ws.acts(conn, 'attack').length;
await g(rig, 'down');
assert(ws.acts(conn, 'attack').length === atkBefore + 1
&& ws.acts(conn, 'attack')[atkBefore].dir === 'S',
'WAVE 无怪下滑 = 空挥出击(act attack dir:S)');
assert(game._menuOpen === false, 'WAVE 内下滑绝不打开菜单');
// 菜单只在波间休整窗(waveclear 后 8s)开放
ws.inject(conn, evWaveclear(1));
await advance(() => game._inRest() && game.floor.state === 'YOUR_TURN');
await g(rig, 'down');
assert(game._menuOpen === true, '波间休整窗下滑 → 模态菜单打开');
await advance(() => game.floor.state === 'YOUR_TURN');
assert(chainOrderOk(playLog, ['sys_menu', 'sys_menu_resume']),
'菜单打开即播 sys_menu + 首项(左右滑换项,单击确认)');
assert(playsOf(playLog, 'voice/settings_hint') === 0, 'settings_hint 废止(设置只在大厅改,00 §1.2)');
// E3:菜单打开期间战斗照常——注入 breach,耐久照扣、菜单不收、警报顶菜单链后重建
ws.inject(conn, evSpawn('x1', 'wolf', 'N', 3));
await flush();
ws.inject(conn, evBreach('x1', 'wolf', 'N', 4));
await flush();
assert(game.dur === 4, 'E3:菜单开着 breach 照扣耐久(联机不可单方暂停)');
assert(game._menuOpen === true, 'E3:菜单开着 breach 菜单不收');
assert(playsOf(playLog, 'voice/breach') === 1, 'E3:菜单开着警报照播(可顶菜单链,R1)');
await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN');
// 菜单 3 项固定、右滑循环(resume → help → home → 回绕 resume)
await g(rig, 'right');
await g(rig, 'right');
await g(rig, 'right');
const menuIdx = playLog.filter((e) => e.id.startsWith('voice/sys_menu_') && e.id !== 'voice/sys_menu').map((e) => e.id);
const tail = menuIdx.slice(-3);
assert(tail[0] === 'voice/sys_menu_help' && tail[1] === 'voice/sys_menu_home' && tail[2] === 'voice/sys_menu_resume',
'菜单固定 3 项,右滑循环(help → home → 回绕 resume)');
// 长按 = 重听菜单说明
await g(rig, 'longpress');
await advance(() => playsOf(playLog, 'voice/sys_menu') >= 2);
assert(true, '菜单内长按重听菜单说明');
await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN');
// 单击 = 确认当前项(resume)→ 关闭回休整等待(菜单交互 >8s,先静默续窗:
// 菜单开着时 waveclear 只延长休整窗,不顶菜单链)
ws.inject(conn, evWaveclear(1));
await flush();
await g(rig, 'tap');
assert(game._menuOpen === false, '单击确认「回到猎场」:菜单关闭');
assert(game._inRest() && game.floor.state === 'YOUR_TURN', '菜单关闭回波间休整等待点(不留安静态)');
// 重新打开:选中"重新听帮助"→ 单击确认播 help_main 且菜单不收
ws.inject(conn, evWaveclear(1)); // 重置 8s 休整窗(菜单已关,照常播 wave_clear 会被
// 后续菜单链顶掉——此处只需休整窗在场)
await advance(() => game._inRest());
await g(rig, 'down');
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'right'); // idx 1 = sys_menu_help
await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN');
await g(rig, 'tap');
await advance(() => playsOf(playLog, 'voice/help_main') === 1);
assert(game._menuOpen === true, '单击确认"重新听帮助":播帮助且菜单不收');
await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN');
// 退出房间回大厅:破坏性操作二次确认(原则 6)→ 确认后发 leave + 跳转
await g(rig, 'right'); // idx 2 = sys_menu_home
await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN');
await g(rig, 'tap'); // 第一次:sys_home_check(armed)
await advance(() => playsOf(playLog, 'voice/sys_home_check') === 1);
assert(game._menuOpen === true && !ws.msgs(conn).some((m) => m && m.t === 'leave'),
'回大厅首次单击:只播 sys_home_check 二次确认,不发 leave');
await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN');
await g(rig, 'tap'); // 第二次:确认退出
await flush();
assert(ws.msgs(conn).some((m) => m && m.t === 'leave'), '回大厅二次确认 → 发出 leave');
assert(game._navigatedHome === '../' && window.location.href === '../', '回大厅跳转(leaving 收尾,不留尸体页面)');
}
// ================================================================ Block 4 · 终局结算 + 排行榜 + ladder.onGameDone
realLog('== Block 4 · 胜利/失守结算链 + PUT progress + 解锁教学 ==');
{
resetSettings('off');
storageData.delete(LADDER_KEY); // 全新阶梯:本局 END → games=1 → 解锁下滑菜单
setProgress({ tutDone: true });
serverBuckets.delete('hunt');
const putCount0 = apiPuts.length;
const rig = makeRig();
const { audio, playLog, game, ws } = rig;
await audio.unlock();
game.start();
await flush();
// TITLE 长按无 best → 只播 lp_title(不播评级段)
await g(rig, 'longpress');
assert(playsOf(playLog, 'voice/lp_title') === 1 && playsOf(playLog, 'voice/grade_a') === 0,
'TITLE 长按无最佳档:lp_title 一句话(不播评级段)');
await advance(() => !audio.isVoicePlaying());
const conn = await driveToLobby(rig);
assert(playsOf(playLog, 'voice/intro_1') === 1 && playsOf(playLog, 'voice/intro_2') === 1,
'二周目匹配等待播 intro_1/intro_2(A 组触发口径,等待态填充可跳句)');
// 第 4 位队友加入 → join_chime
ws.inject(conn, evRoster([
{ slot: 0, name: '试炼猎人', bot: false, char: 'yan', sector: SECTORS3[0] },
{ slot: 1, name: '小筝', bot: true, char: 'zheng', sector: SECTORS3[1] },
{ slot: 2, name: '老周', bot: true, char: 'zhou', sector: SECTORS3[2] },
{ slot: 3, name: '路人甲', bot: false, char: null, sector: ['W', 'NW'] },
]));
await flush();
assert(playLog.some((e) => e.id === 'sfx/join_chime'), '队友加入播 join_chime');
await g(rig, 'tap'); // 立即开局
ws.inject(conn, evWave(1));
await flush();
assert(game.phase === 'wave', 'wave 1 进 WAVE');
await advance(() => !audio.isVoicePlaying());
// 终局(胜利,A,击杀 14)→ 死窗内 ghost tap 不重开(critical 尾句锁住重开入口)
ws.inject(conn, evEnd({ win: true, dur: 8, grade: 'A', kills: { '0': 14, '1': 9, '2': 6 } }));
await flush();
assert(game.phase === 'end', 'ev end → END 结算态');
const connsAtEnd = ws.conns.length;
await advanceMs(400);
await g(rig, 'tap'); // 死窗内 ghost tap → 跳句/回执,不重开
assert(game.phase === 'end' && ws.conns.length === connsAtEnd,
'结算死窗:end 到达后 800ms 内 tap 不重开(无新连接)');
// 结算链有序:end_win → grade_a → kill_band2 → end_rank → end_again(+解锁教学)
await advance(() => playsOf(playLog, 'voice/sys_unlock_down') === 1);
assert(chainOrderOk(playLog, ['end_win', 'grade_a', 'kill_band2', 'end_rank', 'end_again']),
'胜利结算链有序(end_win→grade_a→kill_band2→end_rank→end_again)');
assert(playsOf(playLog, 'voice/sys_unlock_down') === 1,
'一局 END → ladder.onGameDone():第 1 局解锁下滑菜单(sys_unlock_down 教学)');
assert(getLadder().stage === 2 && getLadder().games === 1, '阶梯落盘:stage=2 / games=1');
await advance(() => game.floor.state === 'YOUR_TURN');
// PUT progress:summary 命中生成规则;best 按"评级>耐久>击杀"写入
const winPut = apiPuts.slice(putCount0).find((p) => p.body && p.body.game === 'hunt');
assert(winPut, '终局 PUT /api/progress { game:"hunt" }');
assert(winPut.body.data.summary === '最佳评级A · 击杀14 · 营地余8 · 共1局',
'summary 命中生成规则(最佳评级A · 击杀14 · 营地余8 · 共1局)');
assert(winPut.body.data.best && winPut.body.data.best.grade === 'A' && winPut.body.data.best.kills === 14
&& winPut.body.data.best.durLeft === 8, 'best 写入(胜利局:评级/击杀/营地余)');
assert(winPut.body.data.runs === 1 && winPut.body.data.tutDone === true, 'runs/tutDone 账正确');
assert(getProgress().summary === '最佳评级A · 击杀14 · 营地余8 · 共1局', 'localStorage 离线缓存同步写入');
// END:长按=重听个人最佳段(刚播过 → say_again 前缀,允许原句重播)
await g(rig, 'longpress');
await advance(() => playsOf(playLog, 'voice/grade_a') >= 2);
await advance(() => !audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN');
assert(playsOf(playLog, 'voice/grade_a') >= 2, 'END 长按重听个人最佳段(评级+击杀档)');
// 死窗/链完毕后单击 = 再来一局(重新进匹配)
await g(rig, 'tap');
assert(game.phase === 'matching' && ws.conns.length === connsAtEnd + 1, '结算完单击再来一局 → 重新进匹配');
// ---- 第二局:失守路径(dur 归零,无评级、不入榜)
const conn2 = ws.last();
ws.open(conn2);
await flush();
ws.inject(conn2, joinedMsg());
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap'); // start
ws.inject(conn2, evWave(1));
await flush();
await advance(() => !audio.isVoicePlaying());
const gradeTotal0 = playLog.filter((e) => e.id.startsWith('voice/grade_')).length;
const endRank0 = playsOf(playLog, 'voice/end_rank');
const endAgain0 = playsOf(playLog, 'voice/end_again');
ws.inject(conn2, evEnd({ win: false, dur: 0, grade: null, kills: { '0': 3, '1': 8, '2': 5 } }));
await advance(() => playsOf(playLog, 'voice/end_again') > endAgain0);
assert(chainOrderOk(playLog, ['end_lose', 'kill_band1', 'end_again']),
'失守结算链:end_lose → kill_band → end_again');
assert(playLog.filter((e) => e.id.startsWith('voice/grade_')).length === gradeTotal0,
'失守不播评级(无 grade_*)');
assert(playsOf(playLog, 'voice/end_rank') === endRank0, '失守不播 end_rank');
const losePut = apiPuts.slice(putCount0).filter((p) => p.body && p.body.game === 'hunt').pop();
assert(losePut.body.data.best && losePut.body.data.best.grade === 'A' && losePut.body.data.best.kills === 14,
'失守局不写 best(排行榜不被败局污染)');
assert(losePut.body.data.runs === 2 && losePut.body.data.totalKills === 14 + 3,
'失守照记 runs/totalKills(runs=2,totalKills=14+3=17)');
assert(losePut.body.data.summary === '最佳评级A · 击杀14 · 营地余8 · 共2局',
'失守后 summary 仍按 best 生成(共2局)');
game.stop();
await flush();
}
// ---- 全败 summary:无 best 时「共N局 · 还没守到天亮过」
realLog('== Block 4b · 全败 summary ==');
{
resetSettings('off');
storageData.delete(LADDER_KEY);
setProgress({ tutDone: true });
serverBuckets.delete('hunt');
const putCount0 = apiPuts.length;
const rig = makeRig();
const { audio, playLog, game, ws } = rig;
await audio.unlock();
game.start();
await flush();
const conn = await driveToLobby(rig);
await g(rig, 'tap');
ws.inject(conn, evWave(1));
await flush();
await advance(() => !audio.isVoicePlaying());
ws.inject(conn, evEnd({ win: false, dur: 0, grade: null, kills: { '0': 2 } }));
await advance(() => playsOf(playLog, 'voice/end_again') === 1);
const put = apiPuts.slice(putCount0).filter((p) => p.body && p.body.game === 'hunt').pop();
assert(put.body.data.summary === '共1局 · 还没守到天亮过', '无 best(全败)summary:共1局 · 还没守到天亮过');
game.stop();
await flush();
}
// ================================================================ Block 5 · 断线重连 / 房灭 / 不可达 + 断点复述
realLog('== Block 5 · 断线 resume 回槽 / room_gone / 断点复述 ==');
{
resetSettings('off');
storageData.delete(LADDER_KEY);
setProgress({ tutDone: true });
const rig = makeRig();
const { audio, playLog, game, ws } = rig;
await audio.unlock();
game.start();
await flush();
const conn = await driveToLobby(rig);
await driveToWave(rig, conn);
ws.inject(conn, evSpawn('x1', 'wolf', 'N', 8));
await flush();
// 断线 → net_lost 播报 + 本地静默重连(怪物照爬:eta 继续衰减)
ws.drop(conn);
await advance(() => playsOf(playLog, 'voice/net_lost') === 1);
assert(playsOf(playLog, 'voice/net_lost') === 1, 'WS 断开播 net_lost(正在重连)');
const etaAtDrop = game._mobEta(game.mobs.get('x1'));
await advanceMs(1500);
assert(game._mobEta(game.mobs.get('x1')) < etaAtDrop,
'断网期间怪物按最后已知 eta 继续衰减渲染(戏剧张力保留)');
// 宽限内带 resume 重连 → 回槽:joined 同 slot/seed/char + 紧随 snap 全量 → net_back 复述句
await advanceMs(700); // 越过 2s 重连节奏
const conn2 = ws.last();
assert(conn2 !== conn, '断线后自动发起新连接(静默重连)');
ws.open(conn2);
await flush();
const rejoin = ws.msgs(conn2)[0];
assert(rejoin && rejoin.resume === 'r0' && rejoin.room === 'ABCD', '重连 join 携带 resume 凭证回原房');
ws.inject(conn2, joinedMsg({ state: { phase: 'wave', wave: 1, dur: 10 } }));
await flush();
ws.inject(conn2, snapMsg([{ id: 'x1', mkind: 'wolf', dir: 'N', eta: 7.5 }]));
await flush();
assert(playsOf(playLog, 'voice/net_back') === 1, 'resume 回槽成功播 net_back(§4.12 复述句「你回来了。朝叫声滑。」)');
assert(game.slot === 0 && game._myChar === 'yan' && game.seed === 42, '回槽:同 slot/seed/char');
assert(game.mobs.has('x1') && Math.abs(game._mobEta(game.mobs.get('x1')) - 7.5) < 0.2,
'紧随 snap 全量:怪表与快照一致(eta 按 snap 校正)');
assert(game.phase === 'wave', '回槽后战斗续接(WAVE)');
// 再断 → 重连 → 房已灭:err room_gone → net_gone → LOST
ws.drop(conn2);
await advanceMs(2200);
const conn3 = ws.last();
ws.open(conn3);
await flush();
ws.inject(conn3, { t: 'err', code: 'room_gone', msg: '房间已销毁' });
await advance(() => game.phase === 'lost' && game.floor.state === 'YOUR_TURN');
assert(playsOf(playLog, 'voice/net_gone') === 1, '房灭后迟到 resume:err room_gone → net_gone');
assert(game.phase === 'lost', '重连失败 → LOST(明示失败,不静默死屏)');
// 断点复述(§4.12):一局内被迫退出 → 回 TITLE 一句话复述,summary 保留
await g(rig, 'tap');
assert(game.phase === 'title', 'LOST 单击 → 回大厅(TITLE)');
await advance(() => playsOf(playLog, 'voice/sys_recap') === 1);
assert(playsOf(playLog, 'voice/sys_recap') === 1, '一局内退出回 TITLE:sys_recap 一句话复述(进度 summary 保留)');
assert(getProgress().unfinished !== true, '复述后 unfinished 标记清除(不重复播)');
game.stop();
await flush();
}
// ---- 30s 宽限耗尽 → net_gone(战斗态断线不恢复)
realLog('== Block 5b · 30s 宽限耗尽 → LOST ==');
{
resetSettings('off');
storageData.delete(LADDER_KEY);
setProgress({ tutDone: true });
const rig = makeRig();
const { audio, playLog, game, ws } = rig;
await audio.unlock();
game.start();
await flush();
const conn = await driveToLobby(rig);
await driveToWave(rig, conn);
ws.drop(conn);
await flush();
const connsAtDrop = ws.conns.length;
await advanceMs(32000); // 每 2s 一次重连尝试,对端均不响应
assert(ws.conns.length >= connsAtDrop + 3, '30s 宽限内持续静默重连(≥3 次尝试)');
assert(playsOf(playLog, 'voice/net_lost') === 1, 'net_lost 每次断线只播一次');
assert(playsOf(playLog, 'voice/net_gone') === 1 && game.phase === 'lost',
'30s 宽限耗尽 → net_gone → LOST');
game.stop();
await flush();
}
// ---- 服务器不可达:TITLE 单击 → net_lost → net_gone → LOST(明示失败)
realLog('== Block 5c · 服务器不可达 ==');
{
resetSettings('off');
storageData.delete(LADDER_KEY);
setProgress({ tutDone: true });
const rig = makeRig();
const { audio, playLog, game, ws } = rig;
await audio.unlock();
game.start();
await flush();
await g(rig, 'tap'); // TITLE 单击 = 快速匹配;连接永不建立
assert(game.phase === 'matching', 'TITLE 单击 → MATCHING');
await advanceMs(36000); // 2s 建立看守 + 2s 重连节奏,30s 宽限后放弃
assert(playsOf(playLog, 'voice/net_lost') === 1 && playsOf(playLog, 'voice/net_gone') === 1,
'服务器不可达:net_lost → net_gone(明示失败)');
assert(game.phase === 'lost', '服务器不可达 → LOST(不静默死屏)');
await g(rig, 'tap');
assert(game.phase === 'title', 'LOST 单击回大厅');
game.stop();
await flush();
}
// ================================================================ Block 6 · snap 兜底(随机丢 ev 后收敛)
realLog('== Block 6 · snap 兜底收敛 ==');
{
resetSettings('off');
storageData.delete(LADDER_KEY);
setProgress({ tutDone: true });
const rig = makeRig();
const { audio, game, ws } = rig;
await audio.unlock();
game.start();
await flush();
const conn = await driveToLobby(rig);
await driveToWave(rig, conn);
// 注入 4 只生成(s3 的 spawn ev "丢了"不注入 = 丢弃 25%+ 事件)+ 1 只幽灵怪 s9
ws.inject(conn, evSpawn('s1', 'wolf', 'N', 8));
ws.inject(conn, evSpawn('s2', 'wolf', 'E', 9));
ws.inject(conn, evSpawn('s4', 'bear', 'S', 10));
ws.inject(conn, evSpawn('s9', 'wolf', 'W', 8)); // 幽灵:snap 里不存在
await flush();
await advanceMs(2000);
// 仅凭 snap(2Hz 兜底)收敛:s3 补上、s9 删除、s1 eta 偏差 >0.5s 校正
ws.inject(conn, snapMsg([
{ id: 's1', mkind: 'wolf', dir: 'N', eta: 5.4 },
{ id: 's2', mkind: 'wolf', dir: 'E', eta: 7.0 },
{ id: 's3', mkind: 'wolf', dir: 'NE', eta: 6.5 },
{ id: 's4', mkind: 'bear', dir: 'S', eta: 8.0 },
]));
await flush();
const ids = [...game.mobs.keys()].sort();
assert(JSON.stringify(ids) === JSON.stringify(['s1', 's2', 's3', 's4']),
'snap 对账:缺失的 s3 补上、幽灵 s9 删除(id 集合收敛一致)');
assert(Math.abs(game._mobEta(game.mobs.get('s1')) - 5.4) < 0.05,
'snap 对账:s1 eta 偏差 0.6s > 0.5s → 按 snap 校正');
assert(Math.abs(game._mobEta(game.mobs.get('s2')) - 7.0) < 0.05, 's2 eta 未偏差则不动');
// 渲染收敛:仅 s1(5.4s)进听觉视野
await advanceMs(600);
assert(game._loops.size === 1 && game._loops.has('s1'), '听觉视野按 snap 真值渲染(仅 eta≤6 的 s1)');
game.stop();
await flush();
}
// ================================================================ Block 7 · 拥挤控制 R2/R4(§8)
realLog('== Block 7 · 拥挤控制:R2 喊话调度 / R4 one-shot 上限 ==');
{
resetSettings('off');
storageData.delete(LADDER_KEY);
setProgress({ tutDone: true });
const rig = makeRig();
const { audio, game, ws } = rig;
await audio.unlock();
game.start();
await flush();
const conn = await driveToLobby(rig);
await driveToWave(rig, conn);
// ---- R4:波次生成潮 4 只同刻生成 → 呼啸并发 ≤2、同类间隔 ≥0.15s、队列 ≤2(超出丢弃)
const posLog0 = game._posLog.length;
ws.inject(conn, evSpawn('h1', 'wolf', 'N', 9));
ws.inject(conn, evSpawn('h2', 'wolf', 'NE', 9));
ws.inject(conn, evSpawn('h3', 'wolf', 'E', 9));
ws.inject(conn, evSpawn('h4', 'wolf', 'W', 9));
await flush();
await advanceMs(2500); // 等队列消化完
const howls = game._posLog.slice(posLog0).filter((r) => r.id === 'sfx/howl_wolf');
assert(howls.length === 3, 'R4:生成潮呼啸队列深 ≤2,超出丢弃(4 只 → 播 3 声)');
for (let i = 1; i < howls.length; i++) {
assert(howls[i].t - howls[i - 1].t >= 150, `R4:同类 one-shot 间隔 ≥0.15s(第 ${i} 间隔 ${howls[i].t - howls[i - 1].t}ms)`);
}
// one-shot 并发核算(SFX_DUR 账面):任意时刻 ≤2
const DUR = { atk_whoosh: 0.3, atk_swish: 0.3, kill_wolf: 0.5, kill_bear: 0.7, skill_spin: 0.8, breach_thud: 0.6, horn_wave: 0.9, join_chime: 0.4, call_whistle: 0.6, howl_wolf: 0.8, howl_bear: 1.0 };
const base = (id) => id.slice(id.lastIndexOf('/') + 1);
const posAll = game._posLog.slice(posLog0);
const pts = [];
for (const r of posAll) {
pts.push({ t: r.t, d: 1 });
pts.push({ t: r.t + (DUR[base(r.id)] || 0.5) * 1000, d: -1 });
}
pts.sort((a, b) => a.t - b.t || a.d - b.d);
let cur = 0, maxConcur = 0;
for (const p of pts) { cur += p.d; maxConcur = Math.max(maxConcur, cur); }
assert(maxConcur <= 2, `R4:定位 one-shot 同刻 ≤2 并发(峰值实测 ${maxConcur})`);
// ---- R2:旁白在播时喊话延迟投递 ≤2s
game.floor.speak(['voice/threat_few']); // 占住 voice(1.8s)
await flush();
assert(audio.isVoicePlaying(), 'R2 前置:旁白在播');
const npcLog0 = game._npcLog.length;
ws.inject(conn, evBotline(1, 'greet'));
await flush();
assert(game._npcLog.length === npcLog0, 'R2:旁白在播时喊话不即时出声(延迟投递)');
await advance(() => game._npcLog.length > npcLog0);
const greetRec = game._npcLog[npcLog0];
assert(greetRec.id === 'sfx/npc_zheng_greet' && greetRec.t <= now, 'R2:旁白播完后喊话延迟投递成功(≤2s 内)');
// ---- R2:同刻在播 ≤1;待播队列深 ≤1(新到顶掉未播的过期句)
ws.inject(conn, evBotline(2, 'kill'));
ws.inject(conn, evBotline(1, 'cover'));
await flush();
await advance(() => game._npcLog.length > npcLog0 + 1);
assert(game._npcLog.some((r) => r.id === 'sfx/npc_zheng_cover'), 'R2:待播位被新句顶掉后播出最新句');
assert(!game._npcLog.some((r) => r.id === 'sfx/npc_zhou_kill'), 'R2:被顶掉的过期句不再播出(队列深 ≤1)');
// 同刻 ≤1:逐条账目两两不重叠
const npcs = game._npcLog.map((r) => ({ t: r.t, until: r.t + r.dur * 1000 }));
let overlap = false;
for (let i = 1; i < npcs.length; i++) if (npcs[i].t < npcs[i - 1].until - 1) overlap = true;
assert(!overlap, 'R2:bot 喊话同刻在播 ≤1(账目两两不重叠)');
// ---- R2:长旁白链期间待播超时丢弃(≤2s 延迟上限)
ws.inject(conn, snapMsg([])); // 清空怪表
await flush();
await advanceMs(600);
game.floor.speak(['voice/hp_band1', 'voice/st_wave_a', 'voice/team_3', 'voice/kill_band1']); // ~7.2s
await flush();
assert(audio.isVoicePlaying(), 'R2 前置:旁白链在播');
const npcLog1 = game._npcLog.length;
ws.inject(conn, evBotline(2, 'wave'));
await advanceMs(9000);
assert(!game._npcLog.slice(npcLog1).some((r) => r.id === 'sfx/npc_zhou_wave'),
'R2:旁白在播延迟 >2s → 喊话超时丢弃(氛围句可弃)');
await advance(() => !audio.isVoicePlaying());
game.stop();
await flush();
}
// ================================================================ Block 8 · 设置边界 + 座 3 哨音
realLog('== Block 8 · 设置边界 / 座 3 呼喊哨音 ==');
{
resetSettings('off');
storageData.delete(LADDER_KEY);
// 设置边界(§7 全局断言):voice 切换下一句用对应目录
const audio = new WebAudioEngine('audio/');
await audio.unlock();
settings.set({ voice: 'male' });
const url0 = requestedUrls.length;
audio.play('voice/intro_1');
await flush();
assert(requestedUrls.slice(url0).some((u) => u.includes('voice/male/intro_1')),
'settings.voice 切换后下一句用 male 目录');
await advance(() => !audio.isVoicePlaying());
// 键盘变体:input=key 时候选链前置 voice/<gender>/key/
settings.set({ voice: 'female', input: 'key' });
const url1 = requestedUrls.length;
audio.play('voice/tut_hit_full');
await flush();
assert(requestedUrls.slice(url1).some((u) => u.includes('voice/female/key/tut_hit_full')),
'input=key:候选链前置 key 变体目录(缺失回退主资产)');
await advance(() => !audio.isVoicePlaying());
settings.set({ input: 'touch' });
// bgm=0:不加载不播放
settings.set({ bgm: 0 });
const url2 = requestedUrls.length;
audio.play('bgm/hunt');
await flush();
assert(!requestedUrls.slice(url2).some((u) => u.includes('/bgm/')), 'settings.bgm=0:无 bgm 层加载');
settings.set({ bgm: 1.0 });
resetSettings('off');
// 座 3 呼喊 = call_whistle 哨音(带 pan,§15 裁决 6);4 人局长按播 team_4
const rig = makeRig();
const { playLog, game, ws } = rig;
await rig.audio.unlock();
game.start();
await flush();
await g(rig, 'tap');
const conn = ws.last();
ws.open(conn);
await flush();
ws.inject(conn, joinedMsg({
slot: 3, char: null, sector: ['W', 'NW'],
roster: [
{ slot: 0, name: '甲', bot: false, char: 'yan', sector: ['N', 'NE'] },
{ slot: 1, name: '乙', bot: false, char: 'zheng', sector: ['E', 'SE'] },
{ slot: 2, name: '丙', bot: false, char: 'zhou', sector: ['S', 'SW'] },
{ slot: 3, name: '试炼猎人', bot: false, char: null, sector: ['W', 'NW'] },
],
}));
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'longpress'); // LOBBY 长按 = 在场人数
await advance(() => playsOf(playLog, 'voice/team_4') === 1);
assert(playsOf(playLog, 'voice/team_4') === 1, '4 人局 LOBBY 长播音 team_4');
await advance(() => !rig.audio.isVoicePlaying() && game.floor.state === 'YOUR_TURN');
await g(rig, 'tap'); // start
ws.inject(conn, evWave(1));
await flush();
await advance(() => !rig.audio.isVoicePlaying());
await g(rig, 'tap'); // 呼喊
assert(game._npcLog.length === 0, '座 3 无角色声:不播 npc_*');
const whistle = playLog.find((e) => e.id === 'sfx/call_whistle');
assert(whistle && Math.abs(whistle.opts.pan - Math.sin(292.5 * Math.PI / 180)) < 1e-6,
'座 3 呼喊 = call_whistle 哨音(带防区中心 pan≈-0.92)');
await advance(() => !rig.audio.isVoicePlaying());
game.stop();
await flush();
}
// ================================================================ Block 9 · 资产缺失兜底(Floor 看门狗推进不卡死)
realLog('== Block 9 · 屏蔽 voice 资产:流程不卡死 ==');
{
resetSettings('off');
storageData.delete(SAVE_KEY);
storageData.delete(LADDER_KEY);
blockedAssets.add('tut_hit_full'); // 屏蔽 T1 教学句
const rig = makeRig();
const { audio, playLog, game, ws } = rig;
await audio.unlock();
game.start();
await flush();
await g(rig, 'tap');
const conn = ws.last();
ws.open(conn);
await flush();
ws.inject(conn, joinedMsg({ state: { phase: 'teach', wave: 0, dur: 10 } }));
await advance(() => game.floor.state === 'YOUR_TURN');
await g(rig, 'tap'); // T0 → T1(tut_hit_full 缺失 → Floor 单句看门狗兜底推进)
ws.inject(conn, evSpawn('w1', 'wolf', 'N', 12));
await flush();
await advance(() => game.floor.state === 'YOUR_TURN'); // 看门狗推进后仍交出话轮
await g(rig, 'up');
ws.inject(conn, evKill('w1', 0, 'N'));
await advance(() => playsOf(playLog, 'voice/tut_kill_ok') === 1);
assert(playsOf(playLog, 'voice/tut_kill_ok') === 1 && game._tut === 2,
'屏蔽教学句资产:Floor 看门狗兜底推进,流程不卡死(T1 → T2)');
blockedAssets.clear();
game.stop();
await flush();
}
// ================================================================ Block 10 · 乱点机器人(§6.2)
realLog('== Block 10 · 乱点机器人(§6.2 指标:无声 0 / 重复 0 / 打断失败 0)==');
{
resetSettings('off');
storageData.delete(SAVE_KEY);
storageData.delete(LADDER_KEY); // 新手:stage 0(双击折算单击)
setProgress({ tutDone: true, runs: 0 });
const rig = makeRig();
assert(rig.dtCore === false, '机器人档:新手双击窗口关闭(折算单击)');
const { audio, playLog, receiptLog, chimeLog, game, ws } = rig;
await audio.unlock();
let seed = 13;
const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
const gestures = []; // { g, t, crit }
const botG = (gg) => {
gestures.push({ g: gg, t: now, crit: !!(game.floor._chain && game.floor._chain.critical) });
rig.pipe(gg);
};
game.start();
await flush();
const t0 = now;
botG('tap'); // TITLE 单击 → 匹配
await flush();
const conn = ws.last();
ws.open(conn);
await flush();
ws.inject(conn, joinedMsg()); // LOBBY
// ---- 迷你服务器:响应 act(教学契约由 23 §5 服务端承担,此处按协议模拟)
const ARCS_BY_BASE = { N: ['NW', 'N', 'NE'], E: ['NE', 'E', 'SE'], S: ['SE', 'S', 'SW'], W: ['SW', 'W', 'NW'] };
const srv = { mobs: new Map(), nextId: 1, kills: 0, started: false };
const serve = () => {
for (const a of ws.acts(conn)) {
if (a.__served) continue;
a.__served = true;
if (a.kind === 'start' && !srv.started) {
srv.started = true;
setTimeout(() => ws.inject(conn, evWave(1)), 400);
} else if (a.kind === 'attack') {
const hit = [...srv.mobs.values()].find((m) => (ARCS_BY_BASE[a.dir] || []).includes(m.dir));
if (hit) {
srv.mobs.delete(hit.id);
srv.kills++;
setTimeout(() => ws.inject(conn, evKill(hit.id, 0, hit.dir)), 120);
} else {
setTimeout(() => ws.inject(conn, evMiss(0, a.dir)), 120);
}
} else if (a.kind === 'callout') {
// bot 补位应答(真人呼喊 → 空闲 bot help_ack,§5)
setTimeout(() => ws.inject(conn, evBotline(2, 'help_ack')), 1500);
}
}
};
const DIRS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
let dirIdx = 0;
let waveStartAt = null;
let waveclearAt = null;
let wave2At = null;
let breached = false;
let firstKillAt = null;
let ended = false;
for (let step = 0; step < 240; step++) {
const roll = rnd();
if (roll < 0.35) {
botG('tap'); await flush();
} else if (roll < 0.55) {
botG('tap'); await flush(1); botG('tap'); await flush(1); // 连点两下(80~250ms 内)
} else if (roll < 0.62) {
botG('doubletap'); await flush(); // 新手折算单击
} else if (roll < 0.88) {
const dirs = ['up', 'down', 'left', 'right'];
botG(dirs[Math.floor(rnd() * 4)]); await flush();
} else {
const before = now;
await advance(() => now - before >= 1500 + rnd() * 3500); // 发呆 1.5~5s
}
serve();
if (game._myKills >= 1 && firstKillAt === null) firstKillAt = now;
// ---- 事件编排(模拟服务器节奏)
if (game.phase === 'wave' && waveStartAt === null) waveStartAt = now;
if (game.phase === 'wave' && !game._inRest() && waveStartAt !== null && !ended) {
const waveT = now - (wave2At ?? waveStartAt);
if (srv.mobs.size < 3 && waveT > 2000 && rnd() < 0.5) {
const id = 'b' + srv.nextId++;
const dir = DIRS[dirIdx++ % DIRS.length];
srv.mobs.set(id, { id, mkind: rnd() < 0.2 ? 'bear' : 'wolf', dir });
ws.inject(conn, evSpawn(id, srv.mobs.get(id).mkind, dir, 9));
}
if (!breached && waveT > 12000 && srv.mobs.size) {
const [m] = [...srv.mobs.values()];
srv.mobs.delete(m.id);
breached = true;
ws.inject(conn, evBreach(m.id, m.mkind, m.dir, 9)); // dur 10→9
}
if (waveclearAt === null && waveT > 42000) {
waveclearAt = now;
srv.mobs.clear();
ws.inject(conn, { t: 'ev', kind: 'waveclear', ts: now, wave: game.wave });
}
}
if (waveclearAt !== null && wave2At === null && now - waveclearAt > 8500) {
wave2At = now;
breached = false;
ws.inject(conn, evWave(2));
}
if (wave2At !== null && !ended && now - wave2At > 42000) {
ended = true;
srv.mobs.clear();
ws.inject(conn, evEnd({ win: true, dur: 9, grade: 'B', kills: { '0': srv.kills, '1': 5, '2': 4 } }));
}
if (game._goingHome) break; // 机器人碰巧确认了"回大厅":也算合法退场
if (ended && game.phase === 'matching') break; // 结算后单击 → 再来一局(收工)
await advance(() => true) && 0;
await advanceMs(200 + Math.floor(rnd() * 600));
}
// ---- 指标 1:无声操作 = 0(操作后 100ms 内有回执或内容音)
let silentOps = 0;
for (const ge of gestures) {
const receipt = receiptLog.some((r) => r.t >= ge.t && r.t <= ge.t + 100);
const content = playLog.some((p) => p.t >= ge.t && p.t <= ge.t + 100);
const chimed = chimeLog.some((c) => c.t >= ge.t && c.t <= ge.t + 100);
if (!receipt && !content && !chimed) silentOps++;
}
assert(silentOps === 0, `无声操作 = 0(实际 ${silentOps}/${gestures.length})`);
// ---- 指标 2:被动原句重复 = 0(30s 内同 id 两次且无「再说一遍:」前缀)
let passiveRepeats = 0;
const repeatIds = new Map();
const lastPlay = new Map();
for (const p of playLog) {
if (!p.id.startsWith('voice/')) continue;
const sayAgainAt = playLog.some((q) => q.id === 'voice/sys_say_again' && q.t <= p.t && p.t - q.t < 2500);
if (lastPlay.has(p.id) && p.t - lastPlay.get(p.id) < 30000 && !sayAgainAt) {
passiveRepeats++;
repeatIds.set(p.id, (repeatIds.get(p.id) || 0) + 1);
}
lastPlay.set(p.id, p.t);
}
if (passiveRepeats) realLog(` 重复明细: ${[...repeatIds.entries()].map(([k, v]) => `${k}×${v}`).join(' ')}`);
assert(passiveRepeats === 0, `被动原句重复 = 0(实际 ${passiveRepeats})`);
// ---- 指标 3:打断失败 = 0(非 critical 句播中操作后 150ms 内停/被顶/自然结束)
let interruptFails = 0;
const failDetail = [];
for (const ge of gestures) {
const act = activeVoiceAt(rig, ge.t);
if (!act || ge.crit) continue;
// 防连点吸收的 400ms 内快速重点(busy 回执,含折算成 tap 的双击)=
// 引擎设计内的单次逻辑交互,豁免
const tapGuarded = receiptLog.some((r) => r.kind === 'busy' && r.t === ge.t);
if (tapGuarded) continue;
const cleared = rig.stopLog.some((s) => s.t >= ge.t && s.t <= ge.t + 150)
|| playLog.some((p) => p.id.startsWith('voice/') && p.t > ge.t && p.t <= ge.t + 150)
|| (ge.t + 150) - act.t >= 1800; // 自然播完
if (!cleared) {
interruptFails++;
failDetail.push(`${ge.g}@${ge.t}(${act.id},+${ge.t - act.t}ms)`);
}
}
if (failDetail.length) realLog(' 打断失败明细: ' + failDetail.slice(0, 10).join(' | '));
assert(interruptFails === 0, `打断失败 = 0(实际 ${interruptFails})`);
assert(gestures.length >= 80, `机器人手势量足够(实际 ${gestures.length})`);
assert(game.phase === 'wave' || game.phase === 'end' || game.phase === 'matching' || game._goingHome,
`机器人推进到对局/结算(phase=${game.phase}${game._goingHome ? ',退场' : ''})`);
assert(ended || game._goingHome, '编排到达终局(ev end 已注入)');
realLog(` 机器人轨迹:手势 ${gestures.length},服务器账面击杀 ${srv.kills},两波 + 终局,用时 ${now - t0}ms`);
game.stop();
await flush();
}
// ================================================================ Block 11 · 静态审计(台词表 id 一致)
realLog('== Block 11 · 静态审计:只引用台词表内 id(23-hunt-voice-ids.md + 26 号清单)==');
{
const VOICE_IDS = new Set([
// A 组(4)
'intro_1', 'intro_2', 'room_wait', 'room_start',
// B 组 · 教学(26 号重排:T0 点一下 → T1 滑砍 → T2 呼喊 → T3 长按)
'sys_first_tap', 'sys_first_tap_short',
'tut_hit_full', 'tut_hit_short', 'tut_kill_ok',
'tut_call_full', 'tut_call_short', 'tut_callout_ok',
'tut_help_full', 'tut_help_short', 'tut_done',
// C 组(6)
'wave_band1', 'wave_band2', 'wave_band3', 'wave_band4', 'wave_band5', 'wave_clear',
// D 组(12)
'hp_band1', 'hp_band2', 'hp_band3', 'hp_band4',
'st_wave_a', 'st_wave_b', 'st_wave_c', 'team_3', 'team_4',
'kill_band0', 'kill_band1', 'kill_band2',
// E 组(3)
'threat_none', 'threat_few', 'threat_many',
// F 组(3)
'breach', 'breach_heavy', 'danger_low',
// G 组(2)
'skill_ready', 'skill_teach',
// H 组(9)
'end_win', 'end_lose', 'grade_s', 'grade_a', 'grade_b', 'grade_c', 'end_rank',
'end_again', 'end_again_short',
// I 组(4)
'net_lost', 'net_back', 'net_to_bot', 'net_gone',
// J 组 · 菜单/帮助(26 号:左右换项单击确认)
'sys_menu', 'sys_menu_short', 'sys_menu_resume', 'sys_menu_help', 'sys_menu_home',
'sys_home_check', 'help_main',
// K 组 · 向导/纠错
'gd_invalid', 'gd_idle',
// L 组 · 26 号新增(系统句 / 等待点三档 / 长按帮助 / 归因 / 接管 / 复述)
'sys_say_again', 'sys_unlock_down', 'sys_unlock_dt', 'sys_recap',
'room_wait_short', 'lp_act', 'lp_title',
'atk_cd', 'call_cd', 'inv_swipe', 'inv_rest', 'miss_side', 'wave_only',
]);
const SFX_IDS = new Set([
'atk_whoosh', 'atk_swish', 'kill_wolf', 'kill_bear', 'skill_spin', 'breach_thud',
'horn_wave', 'join_chime', 'call_whistle', 'howl_wolf', 'howl_bear', 'loop_wolf', 'loop_bear',
]);
for (const char of ['yan', 'zheng', 'zhou']) {
for (const line of ['greet', 'help_call', 'help_ack', 'cover', 'kill', 'breach', 'skill', 'wave']) {
SFX_IDS.add(`npc_${char}_${line}`);
}
}
const BGM_IDS = new Set(['menu', 'hunt']);
let bad = 0;
for (const id of allPlayedIds) {
const slash = id.indexOf('/');
const layer = id.slice(0, slash);
const name = id.slice(slash + 1);
if (layer === 'voice') { if (!VOICE_IDS.has(name)) { bad++; realLog(` ✗ 表外 voice id:${id}`); } }
else if (layer === 'sfx') { if (!SFX_IDS.has(name)) { bad++; realLog(` ✗ 表外 sfx id:${id}`); } }
else if (layer === 'bgm') { if (!BGM_IDS.has(name)) { bad++; realLog(` ✗ 表外 bgm id:${id}`); } }
}
assert(bad === 0, `全部播放 id 均在台词表/资产表内(共审计 ${allPlayedIds.size} 个不同 id)`);
// §7 全局:全程无未捕获异常;资产缺失仅 console.warn(Floor 看门狗)不崩
const unexpected = consoleErrs.filter((e) => !e.includes('资产缺失'));
assert(unexpected.length === 0, '全程无未捕获异常/非预期 console.error');
}
// ================================================================ 汇总
realLog('\n========================================');
realLog(`断言通过 ${passCount} 条,失败 ${failures.length} 条`);
if (failures.length) {
realLog('失败明细:');
for (const f of failures) realLog(` - ${f}`);
process.exit(1);
}
realLog('全部断言通过。');