- 设计: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端到端冒烟通过
117 lines
5.3 KiB
JavaScript
117 lines
5.3 KiB
JavaScript
/**
|
||
* 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));
|
||
}
|