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

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

117 lines
5.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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));
}