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 置完成。
1833 lines
88 KiB
JavaScript
1833 lines
88 KiB
JavaScript
/**
|
||
* 《围猎》无头试玩仿真测试(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('全部断言通过。');
|