feat(hunt): 《围猎》2-4人实时联机纯音频协作守卫——纯音频MMORPG第一阶段切片

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

验证:hunt-sim 16/16 + api-smoke 12/12 + hunt playtest 211/211 + 既有10游戏与引擎回归零回退 + 真服务器WS端到端冒烟通过
This commit is contained in:
xsl
2026-09-13 11:08:01 +08:00
parent 31372615b2
commit 005851d01a
392 changed files with 6826 additions and 19 deletions
+116
View File
@@ -0,0 +1,116 @@
/**
* hunt-balance-scan.mjs — 《围猎》bot 难度平衡扫描(开发工具,非断言测试)。
*
* 目的(23-hunt.md §3.4 / §15 裁决 10):固定多种子仿真整局,校准 bot 强度口径——
* - 真人全程挂机(2 bot 独守) → 大概率失守,或胜利但耐久仅 1-3(C)
* - 真人以 bot 水准游玩(3 bot 局)→ 余耐久 6-9(A/B)为主
*
* 场景:
* idle 真人不出手(挂机下限)
* bot 真人槽直接复用 room.botDecide(与 bot 完全同一决策管线 = "bot 水准")
* optimal 真人最优策略(内圈即打、≥3 放旋风斩;人类上限参考)
*
* 运行:
* tools/node/bin/node web/server/test/hunt-balance-scan.mjs [种子数=24]
* tools/node/bin/node web/server/test/hunt-balance-scan.mjs grid [种子数=16] # 参数网格扫描
*/
import { EventEmitter } from 'node:events';
import hunt from '../hunt-room.js';
const { createManager, BOT_TUNING_DEFAULT } = hunt;
class FakeConn extends EventEmitter {
constructor() { super(); this.sent = []; }
send(str) { this.sent.push(JSON.parse(str)); }
close() { queueMicrotask(() => this.emit('close')); }
}
function runGame(seed, mode, botTuning) {
const mgr = createManager({ rateLimit: Infinity, botTuning });
mgr.createRoom({ seed });
const conn = new FakeConn();
mgr.attach(conn);
conn.emit('message', JSON.stringify({ t: 'join', ver: 1, room: 'quick', name: 'scan' }));
conn.emit('message', JSON.stringify({ t: 'act', kind: 'start', seq: 1 }));
const room = [...mgr.rooms.values()][0];
let guard = 0;
while (room.phase !== 'end' && guard++ < 4000) {
mgr.tick(100);
const slot = room.slots[0];
if (!slot || room.phase !== 'wave') continue;
if (mode === 'bot') {
if (room.now % 500 === 0) room.botDecide(slot); // 与 bot 同决策频率、同规则
} else if (mode === 'optimal') {
const mobs = [...room.mobs.values()];
if (room.now >= slot.skillCdUntil && mobs.filter((m) => m.eta <= 3).length >= 3) {
room.adjudicateSkill(slot);
} else if (room.now >= slot.cdUntil) {
const inner = mobs.filter((m) => m.eta <= 4).sort((a, b) => a.eta - b.eta);
if (inner.length) room.adjudicateAttack(slot, inner[0].dir);
}
}
}
const end = conn.sent.filter((m) => m.t === 'ev' && m.kind === 'end').pop();
room.destroy();
return end ? { win: end.win, dur: end.dur, grade: end.grade } : { win: null, dur: null, grade: '超时' };
}
function sweep(mode, seeds, botTuning) {
return seeds.map((s) => runGame(s, mode, botTuning));
}
function line(results) {
const wins = results.filter((r) => r.win === true);
const durs = wins.map((r) => r.dur);
const hist = {};
for (const d of durs) hist[d] = (hist[d] || 0) + 1;
const mean = durs.length ? (durs.reduce((a, b) => a + b, 0) / durs.length).toFixed(1) : '—';
return `胜 ${wins.length}/${results.length} 耐久[${Object.keys(hist).sort((a, b) => b - a).map((d) => `${d}×${hist[d]}`).join(' ') || '—'}] mean=${mean}`;
}
function summarize(mode, results) {
const wins = results.filter((r) => r.win === true);
const durs = wins.map((r) => r.dur);
const inBand = wins.filter((r) => r.dur >= 6 && r.dur <= 9).length; // 3bot 目标带
const badIdle = results.filter((r) => r.win === false || r.dur <= 3).length; // 挂机目标带
const grades = {};
for (const r of wins) grades[r.grade] = (grades[r.grade] || 0) + 1;
console.log(`\n[${mode}] ${line(results)}`);
console.log(` 评级: ${Object.entries(grades).sort().map(([g, n]) => `${g}×${n}`).join(' ') || '—'}`
+ ` | 目标命中: 挂机(负或≤3)=${badIdle}/${results.length},3bot(6-9)=${inBand}/${results.length}`);
console.log(' 逐局:', results.map((r) => (r.win ? `${r.dur}` : '负')).join(' '));
}
const arg0 = process.argv[2] || '24';
if (arg0 === 'grid') {
// 网格扫描:找同时满足两口径的参数(小样本快筛,选出后再用默认模式 24 seeds 确认)
const n = Number(process.argv[3]) || 16;
const seeds = Array.from({ length: n }, (_, i) => i + 1);
console.log(`网格扫描 ${n} seeds/点:`);
console.log('参数(犹豫/反应带/技能想起) → idle 局 | 3bot 局');
for (const hesitateP of [0.5, 0.6, 0.7, 0.75, 0.8]) {
for (const [reactBase, reactSpan] of [[2.2, 1.2], [1.8, 1.0], [1.5, 0.8]]) {
for (const skillP of [0.6, 0.3]) {
const tuning = { ...BOT_TUNING_DEFAULT, hesitateP, reactBase, reactSpan, skillP };
const idle = sweep('idle', seeds, tuning);
const bot = sweep('bot', seeds, tuning);
const idleHit = idle.filter((r) => r.win === false || r.dur <= 3).length;
const botHit = bot.filter((r) => r.win && r.dur >= 6 && r.dur <= 9).length;
console.log(
`p=${hesitateP} band=${reactBase}+${reactSpan}r skillP=${skillP}`
+ ` | idle: ${line(idle)} 目标${idleHit}/${n}`
+ ` | 3bot: ${line(bot)} 目标${botHit}/${n}`,
);
}
}
}
} else {
const n = Number(arg0) || 24;
const seeds = Array.from({ length: n }, (_, i) => i + 1);
console.log(`《围猎》bot 平衡扫描:${n} seeds × 3 场景,默认参数 ${JSON.stringify(BOT_TUNING_DEFAULT)}`);
summarize('idle(真人挂机,2 bot 独守)', sweep('idle', seeds));
summarize('bot(3 bot 水准局)', sweep('bot', seeds));
summarize('optimal(人类上限参考)', sweep('optimal', seeds));
}