Files
SoundGame/web/server/hunt-room.js
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

1022 lines
37 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-room.js — 《围猎》(web/hunt) 房间管理器 + 服务端权威战斗模拟(零依赖,CommonJS)。
*
* 设计契约:docs/design/23-hunt.md §3/§5/§6/§7(协议字段以 §6 为唯一契约)。
* 职责(§6.4-§6.6、§7 的权威实现):
* - 房间:快速匹配 / 显式 4 位房码、lobby ≤12s(act start / 满 4 人立即开局)、
* 开局 bot 补位至 max(3, 人类数)、断线 30s 宽限 resume 回槽、超时转 bot
* (剩余人类 ≥3 改为槽位移除)、房灭回收、终局保留 30s 后 close 1000。
* - 战斗:波次表(§3.4)刷怪剧本(§3.5 seed 复现)、100ms tick 推进 eta、突入扣耐久
* (狼 -1 / 熊 -2)、出击弧 ±45° + eta≤4 近战窗、冷却 2.5s / 旋风斩 8s / 呼喊节流 5s、
* 评级结算(§3.6,ev end.grade 服务端直发)。教学房(§5):强制单人、教学波 3 狼
* 零惩罚(突入不扣耐久)、教学段 bot AI 挂起(呼喊必答)、教学波完接续第 1 波。
* - bot(§7):与真人同一套裁决管线;500ms AI tick:P(d)=Σ(1/eta) 方向压力 +
* 主防区 ×1.5 偏置 + 反应带(eta ≤ 2.2+rng×1.2,rng 走房间 seed);旋风斩
* (全场 eta≤3 ≥3);压力呼救(主防区 eta≤6 ≥3,20s 节流);补位(§7.3)与
* 喊话调度(§7.4:全局 10s 窗氛围 ≤2 条,callout/help_ack 优先不丢)。
*
* 时钟模型:房间一切时间逻辑由 tick(dtMs) 推进——生产环境 server.js 以 100ms
* setInterval 驱动;测试直接快进 tick 复现(test/hunt-sim.test.mjs)。
* 消息 ts = 房间单调时钟 ms(开局为 0,§6.3);内部一律用 now(建房起单调 ms)。
*
* 连接契约(与 ws.js 的 WsConn 对齐,测试用 FakeConn 替身):
* conn: EventEmitter,'message'(jsonString) / 'close'();方法 send(str, {droppable}) 、
* close(code);管理器在 attach 时挂上 joinedRoom/slotId/限流状态。
*/
'use strict';
const crypto = require('node:crypto');
// ---------------- 常量(§3 数值的权威实现) ----------------
const DIRS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
const DIR_IDX = Object.fromEntries(DIRS.map((d, i) => [d, i]));
const CHARS = ['yan', 'zheng', 'zhou', null]; // 座位角色(§3.3;座 3 恒 null,呼喊用哨音)
const CHAR_NAMES = { yan: '阿岩', zheng: '小筝', zhou: '老周' };
// §3.3 防区表(center 供客户端取呼喊/喊话 pan 方位)
const LAYOUTS = {
3: [
{ sector: ['N', 'NE', 'E'], center: 'NE' },
{ sector: ['SE', 'S', 'SW'], center: 'S' },
{ sector: ['W', 'NW'], center: 'WNW' },
],
4: [
{ sector: ['N', 'NE'], center: 'NNE' },
{ sector: ['E', 'SE'], center: 'ESE' },
{ sector: ['S', 'SW'], center: 'SSW' },
{ sector: ['W', 'NW'], center: 'WNW' },
],
};
// §3.4 波次表
const WAVE_TABLE = [
null,
{ wolf: 4, bear: 0, wolfEta: 10.0, bearEta: 0, gap: 3.0 },
{ wolf: 6, bear: 0, wolfEta: 9.5, bearEta: 0, gap: 2.5 },
{ wolf: 7, bear: 1, wolfEta: 9.0, bearEta: 12.0, gap: 2.2 },
{ wolf: 8, bear: 1, wolfEta: 8.5, bearEta: 11.5, gap: 1.8 },
{ wolf: 10, bear: 2, wolfEta: 8.0, bearEta: 11.0, gap: 1.5 },
];
const LAST_WAVE = 5;
const DUR_MAX = 10;
const MELEE_ETA = 4.0; // 内圈近战窗(§3.1)
const ATTACK_CD_MS = 2500;
const SKILL_CD_MS = 8000;
const CALLOUT_THROTTLE_MS = 5000;
const LOBBY_MS = 12000;
const GRACE_MS = 30000;
const END_KEEP_MS = 30000;
const REST_MS = 8000; // 波间休整
const WAVE1_DELAY_MS = 2000; // 开局到 ev wave 1 的间隔(room_start 播报留白)
const TICK_MS = 100;
const BOT_TICK_MS = 500;
const SNAP_MS = 500; // 2Hz
const ROOM_CODE_CHARS = '23456789ABCDEFGHJKMNPQRSTUVWXYZ'; // §6.2 无歧义字符集
const BOTLINE_WINDOW_MS = 10000; // §7.4 全局节流窗口
const BOTLINE_BUDGET = 2; // 窗口内氛围句上限
const GREET_FIRST_MS = 1000;
const GREET_GAP_MS = 1600; // ≥1.5s(§7.4 greet 间隔)
const COVER_MS = 10000; // §7.3 补位偏置持续 10s
const RATE_PER_SEC = 20; // §6.1 应用消息速率帽
const RATE_STRIKE_MS = 10000; // 再犯判定窗
// §7.2 bot 难度校准(§15 裁决 10 调参;初版 2.2+1.2r 无犹豫实测零泄漏过强):
// 反应带更迟、出手决策带空转概率(500ms tick 内愣神)、旋风斩节制。
// 目标口径见 §3.4:挂机局失守/C、3 bot 水准局 A/B。默认值经
// test/hunt-balance-scan.mjs 多种子扫描选定;改动只涉及服务端 AI 参数,不动协议字段。
const BOT_TUNING_DEFAULT = {
reactBase: 2.0, // 反应带:eta ≤ reactBase + rng×reactSpan 才出手
reactSpan: 1.0,
hesitateP: 0.8, // 决策空转概率(500ms tick 内 80% 愣神——bot 有效出手周期 ~4.5s)
skillEta: 2.5, // 旋风斩触发:全场 eta≤skillEta 的怪 ≥skillMin 且 60% 想起
skillMin: 3,
skillP: 0.6,
};
// ---------------- 确定性随机(§3.5/§7.2) ----------------
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** §3.5:rng(wave) = mulberry32((seed ^ (wave * 0x9E3779B9)) >>> 0) */
function waveRng(seed, wave) {
return mulberry32((seed ^ (wave * 0x9E3779B9)) >>> 0);
}
/**
* §3.5 刷怪剧本:同 seed → 同剧本(可复现断言)。
* 怪物按座位数轮转发牌到各座位防区内的随机方位;熊确定性洗牌混入。
* 返回 [{ at, mkind, dir, seat, eta }](at = 波开始后的 ms;首只 = 1×gap)。
*/
function buildWaveScript(seed, wave, seatCount) {
const spec = WAVE_TABLE[wave];
const rng = waveRng(seed, wave);
const layout = LAYOUTS[seatCount];
const mkinds = [];
for (let i = 0; i < spec.wolf; i++) mkinds.push('wolf');
for (let i = 0; i < spec.bear; i++) mkinds.push('bear');
for (let i = mkinds.length - 1; i > 0; i--) { // Fisher-Yates,走波次 rng
const j = Math.floor(rng() * (i + 1));
[mkinds[i], mkinds[j]] = [mkinds[j], mkinds[i]];
}
return mkinds.map((mkind, i) => {
const seat = i % seatCount;
const sector = layout[seat].sector;
const dir = sector[Math.floor(rng() * sector.length)];
return {
at: (i + 1) * spec.gap * 1000,
mkind,
dir,
seat,
eta: mkind === 'bear' ? spec.bearEta : spec.wolfEta,
};
});
}
/** §3.6 评级(服务端直发,客户端不算分) */
function gradeFor(dur) {
if (dur >= 9) return 'S';
if (dur >= 7) return 'A';
if (dur >= 4) return 'B';
if (dur >= 1) return 'C';
return null;
}
function round2(x) {
return Math.round(x * 100) / 100;
}
function arcOf(dir) { // 出击弧 = dir±45°(3 个罗盘向,§3.2)
const c = DIR_IDX[dir];
return [(c + 7) % 8, c, (c + 1) % 8];
}
// ---------------- 房间 ----------------
class Room {
constructor(manager, { code, teach = false, seed } = {}) {
this.manager = manager;
this.code = code;
this.teach = !!teach;
this.seed = (seed === undefined ? crypto.randomInt(0, 2 ** 32) : seed) >>> 0;
this.phase = 'lobby'; // lobby / teach / wave / end
this.now = 0; // 建房起单调 ms(tick 推进)
this.startedAt = null; // 开局时刻(now 基准);ts = now - startedAt
this.slots = [null, null, null, null];
this.seatCount = 0; // 开局定(3 或 4,§3.3)
this.mobs = new Map(); // id -> { id, mkind, dir, eta, breached? }
this.nextMobId = 1;
this.wave = 0;
this.dur = DUR_MAX;
this.killsBySlot = {}; // 结算个人击杀账(含已离场槽位)
this.script = null; // 当前波剧本(atTs 绝对 ts)
this.scriptIdx = 0;
this.pendingWave = null; // { wave, atTs } 波间休整/开局延迟
this.teachState = null;
this.botRng = mulberry32((this.seed ^ 0xB07A17) >>> 0); // bot 反应带流(§7.2 复现用)
this.botTuning = manager.botTuning; // §15 裁决 10 调参(默认 BOT_TUNING_DEFAULT)
this.lineLog = []; // 喊话/callout 发出时刻(§7.4 全局节流)
this.pendingLines = []; // [{ at, slot, line }](greet 间隔调度,at 为 ts)
this.waveLineIdx = 0;
this.lastBotAt = -Infinity;
this.lastSnapAt = -Infinity;
this.endAt = null;
this.destroyed = false;
}
ts() { return this.startedAt === null ? 0 : this.now - this.startedAt; }
occupiedSlots() { return this.slots.filter(Boolean); }
humanCount() { return this.slots.filter((s) => s && !s.bot).length; }
botSlots() { return this.slots.filter((s) => s && s.bot); }
// ---------------- 槽位 ----------------
_newSlot(slotId, { bot, conn, name }) {
const provisional = slotId <= 2 ? LAYOUTS[3][slotId] : LAYOUTS[4][3];
return {
slot: slotId,
name,
bot,
char: CHARS[slotId],
sector: provisional.sector.slice(), // 开局时按最终布局重排(roster ev 下发)
center: provisional.center,
conn: conn || null,
alive: true, // bot 恒 true;人类=连接存活
resume: bot ? null : crypto.randomBytes(8).toString('hex'), // 16 hex,槽位存活期不变
cdUntil: 0,
skillCdUntil: 0,
calloutUntil: 0,
lastSeq: -1,
graceUntil: 0,
kills: 0,
// bot 状态(§7)
lastActAt: -Infinity,
lastCalloutAt: -Infinity,
lastKillLineAt: -Infinity,
lastBreachLineAt: -Infinity,
recentKills: [],
cover: null, // { callerSlot, callerSector, until, killDone, settled }
};
}
addHuman(slotId, conn, name) {
const slot = this._newSlot(slotId, { bot: false, conn, name });
this.slots[slotId] = slot;
return slot;
}
addBot(slotId) {
const char = CHARS[slotId];
const slot = this._newSlot(slotId, { bot: true, name: CHAR_NAMES[char] || '老猎手' });
this.slots[slotId] = slot;
return slot;
}
removeSlot(slot) {
this.slots[slot.slot] = null;
if (slot.resume) this.manager.resumeIndex.delete(slot.resume);
this.broadcastRoster();
}
/** §6.6 宽限超时/主动 leave 的槽位转 bot(不可逆);char 沿用座角色,播 cover。 */
convertToBot(slot) {
if (slot.resume) this.manager.resumeIndex.delete(slot.resume);
slot.resume = null;
slot.bot = true;
slot.conn = null;
slot.alive = true;
slot.name = CHAR_NAMES[slot.char] || '老猎手';
slot.lastActAt = -Infinity;
slot.lastCalloutAt = -Infinity;
slot.cover = null;
// 冷却不清零:真人用过的冷却延续到 bot(§7.3 公平口径)
this.broadcastRoster();
this.tryBotline(slot, 'cover'); // 转 bot 接管(§7.4 cover 触发源)
}
// ---------------- 消息发送 ----------------
roster() {
return this.occupiedSlots()
.sort((a, b) => a.slot - b.slot)
.map((s) => ({
slot: s.slot, name: s.name, bot: s.bot, char: s.char, sector: s.sector, alive: s.alive,
}));
}
/** 房间内广播(序列化一次);bots/断线槽无 conn 自动跳过。 */
broadcast(obj, opts) {
const str = JSON.stringify(obj);
for (const s of this.occupiedSlots()) {
if (s.conn) s.conn.send(str, opts);
}
}
/** ev 事件流(§6.3;即时下发,不丢) */
broadcastEv(fields) {
this.broadcast({ t: 'ev', ts: this.ts(), ...fields });
}
broadcastRoster() {
this.broadcastEv({ kind: 'roster', roster: this.roster() });
}
sendJoined(slot) {
this.manager.sendMsg(slot.conn, {
t: 'joined',
room: this.code,
slot: slot.slot,
resume: slot.resume,
char: slot.char,
sector: slot.sector,
seed: this.seed,
state: { phase: this.phase, wave: this.wave, dur: this.dur },
roster: this.roster(),
});
}
buildSnap() {
return {
t: 'snap',
ts: this.ts(),
phase: this.phase,
wave: this.wave,
dur: this.dur,
mobs: [...this.mobs.values()].map((m) => ({
id: m.id, mkind: m.mkind, dir: m.dir, eta: round2(m.eta),
})),
slots: this.occupiedSlots().sort((a, b) => a.slot - b.slot).map((s) => ({
slot: s.slot,
alive: s.alive,
bot: s.bot,
cd: round2(Math.max(0, s.cdUntil - this.now) / 1000),
skillCd: round2(Math.max(0, s.skillCdUntil - this.now) / 1000),
})),
};
}
/** snap 可丢(新顶旧,§6.1 背压纪律) */
broadcastSnap() {
this.broadcast(this.buildSnap(), { droppable: true });
}
sendErrTo(slot, code, msg) {
if (slot.conn) this.manager.sendErr(slot.conn, code, msg);
}
// ---------------- 开局 / 波次 ----------------
/** 开局:bot 补位至 max(3, 人类数);防区按在座顺序分配;教学房进 TEACH。 */
startGame() {
if (this.phase !== 'lobby') return;
const humans = this.humanCount();
const target = Math.max(3, humans);
for (let i = 0; i < target; i++) {
if (!this.slots[i]) this.addBot(i);
}
const occupied = this.occupiedSlots().sort((a, b) => a.slot - b.slot);
this.seatCount = occupied.length; // 3 或 4(槽 3 有人类占用时按 4 猎人布局)
const layout = LAYOUTS[this.seatCount];
occupied.forEach((s, i) => {
s.sector = layout[i].sector.slice();
s.center = layout[i].center;
});
this.startedAt = this.now;
this.phase = this.teach ? 'teach' : 'wave';
this.broadcastRoster(); // 补位完成花名册
this.botSlots().sort((a, b) => a.slot - b.slot).forEach((b, i) => { // §7.4 greet 逐只 ≥1.5s
this.pendingLines.push({ at: GREET_FIRST_MS + i * GREET_GAP_MS, slot: b.slot, line: 'greet' });
});
if (this.teach) {
// §5 教学波:3 狼、逼近 12s、突入零惩罚。狼 1 至多补放 2 次(做错重教 ≤2 次)。
this.teachState = { stage: 1, attempts: 0, currentMob: null, nextAt: 6000, spawn23: null, done: false };
} else {
this.pendingWave = { wave: 1, atTs: WAVE1_DELAY_MS };
}
}
startWave(n) {
this.phase = 'wave';
this.wave = n;
this.broadcastEv({ kind: 'wave', wave: n });
const base = this.ts();
this.script = buildWaveScript(this.seed, n, this.seatCount)
.map((e) => ({ ...e, atTs: base + e.at }));
this.scriptIdx = 0;
if (n >= 2) { // §7.4 wave 喊话:第 2 波起轮转一只 bot
const bots = this.botSlots().sort((a, b) => a.slot - b.slot);
if (bots.length) this.tryBotline(bots[this.waveLineIdx++ % bots.length], 'wave');
}
}
/**
* 刷怪(§3.5 防叠声:同方位同刻最多 2 只在场,必要时同防区内换向)。
* 独立成方法供剧本与测试钩子共用。
*/
spawnMob({ mkind, dir, eta, seat }) {
let d = dir;
const countAt = (dd) => [...this.mobs.values()].filter((m) => m.dir === dd).length;
if (countAt(d) >= 2) {
const sector = seat !== undefined && this.seatCount ? LAYOUTS[this.seatCount][seat].sector : DIRS;
const alt = sector.find((dd) => countAt(dd) < 2);
if (alt) d = alt;
}
const id = this.nextMobId++;
const mob = { id, mkind, dir: d, eta };
this.mobs.set(id, mob);
this.broadcastEv({ kind: 'spawn', id, mkind, dir: d, eta });
return mob;
}
breachMob(mob) {
this.mobs.delete(mob.id);
mob.breached = true;
if (this.phase !== 'teach') { // §5 教学波突入零惩罚(不扣耐久)
this.dur = Math.max(0, this.dur - (mob.mkind === 'bear' ? 2 : 1));
}
this.broadcastEv({ kind: 'breach', id: mob.id, mkind: mob.mkind, dir: mob.dir, dur: this.dur });
// §7.4 breach 喊话:本防区漏怪(20s/只节流,氛围预算)
const guard = this.botSlots()
.filter((b) => b.char && b.sector.includes(mob.dir) && this.now - b.lastBreachLineAt >= 20000)
.sort((a, b) => a.slot - b.slot)[0];
if (guard) {
guard.lastBreachLineAt = this.now;
this.tryBotline(guard, 'breach');
}
if (this.dur <= 0 && this.phase !== 'teach') this.endGame(false); // 失守:立即终局
}
// ---------------- 裁决(§3.2 服务端权威) ----------------
adjudicateAttack(slot, dir) {
if (this.now < slot.cdUntil) {
return this.sendErrTo(slot, 'cooldown', '出击冷却中(2.5 秒)');
}
slot.cdUntil = this.now + ATTACK_CD_MS; // 挥空也进冷却(与客户端本地预扣一致)
slot.lastActAt = this.now;
const arc = arcOf(dir);
let best = null;
for (const mob of this.mobs.values()) {
if (!arc.includes(DIR_IDX[mob.dir]) || mob.eta > MELEE_ETA) continue;
if (!best || mob.eta < best.eta) best = mob;
}
if (!best) {
this.broadcastEv({ kind: 'miss', slot: slot.slot, dir });
return;
}
this.mobs.delete(best.id);
this.broadcastEv({ kind: 'kill', id: best.id, slot: slot.slot, dir: best.dir, mkind: best.mkind });
this.creditKill(slot, best);
}
adjudicateSkill(slot) {
if (this.now < slot.skillCdUntil) {
return this.sendErrTo(slot, 'skill_cd', '旋风斩冷却中(8 秒)');
}
slot.skillCdUntil = this.now + SKILL_CD_MS;
slot.lastActAt = this.now;
const clearedMobs = [...this.mobs.values()].filter((m) => m.eta <= MELEE_ETA);
for (const mob of clearedMobs) this.mobs.delete(mob.id);
this.broadcastEv({ kind: 'skill', slot: slot.slot, cleared: clearedMobs.map((m) => m.id) });
for (const mob of clearedMobs) this.creditKill(slot, mob);
if (slot.bot && clearedMobs.length) this.tryBotline(slot, 'skill');
}
adjudicateCallout(slot) {
if (this.now < slot.calloutUntil) {
return this.sendErrTo(slot, 'cooldown', '呼喊太频繁(5 秒节流)');
}
slot.calloutUntil = this.now + CALLOUT_THROTTLE_MS;
slot.lastActAt = this.now;
this._logLine(); // callout 计入喊话窗(氛围句须让路,§7.4)
this.broadcastEv({ kind: 'callout', slot: slot.slot });
// §7.3 补位:空闲 bot(500ms 未出手、座位号小者优先)必答 help_ack + 10s 偏置
const responder = this.botSlots()
.filter((b) => b.slot !== slot.slot && this.now - b.lastActAt >= BOT_TICK_MS)
.sort((a, b) => a.slot - b.slot)[0];
if (responder) {
this.emitBotline(responder, 'help_ack', { priority: true }); // 呼救语义不可被顶掉
responder.cover = {
callerSlot: slot.slot,
callerSector: slot.sector.slice(),
until: this.now + COVER_MS,
killDone: false,
settled: false,
};
}
}
creditKill(slot, mob) {
slot.kills += 1;
this.killsBySlot[String(slot.slot)] = slot.kills;
if (slot.cover && !slot.cover.settled && slot.cover.callerSector.includes(mob.dir)) {
slot.cover.killDone = true;
}
if (slot.bot && slot.char) { // §7.4 kill 喊话:杀熊或三连杀,15s/只节流
slot.recentKills.push(this.now);
slot.recentKills = slot.recentKills.filter((t) => this.now - t <= 6000);
const triple = slot.recentKills.length >= 3;
if ((mob.mkind === 'bear' || triple) && this.now - slot.lastKillLineAt >= 15000) {
slot.lastKillLineAt = this.now;
this.tryBotline(slot, 'kill');
}
}
}
// ---------------- bot 喊话调度(§7.4 + §8 R2 服务端纪律) ----------------
_logLine() {
this.lineLog.push(this.now);
}
/**
* 全局节流:10s 窗氛围 ≤2 条,超出丢弃;priority(callout/help_ack)永远投递——
* 呼救语义不可被氛围句顶掉(其速率已由 5s/座源头节流约束)。
*/
emitBotline(slot, line, { priority = false } = {}) {
if (!slot.char) return; // 座 3 无角色声(§15 裁决 6,客户端用哨音;无 bot 在座 3)
const cutoff = this.now - BOTLINE_WINDOW_MS;
this.lineLog = this.lineLog.filter((t) => t > cutoff);
if (!priority && this.lineLog.length >= BOTLINE_BUDGET) return; // 丢弃氛围句
this._logLine();
this.broadcastEv({ kind: 'botline', slot: slot.slot, line });
}
tryBotline(slot, line) {
this.emitBotline(slot, line, { priority: false });
}
// ---------------- bot AI(§7.2,500ms tick,确定性) ----------------
/** 方向压力 P(d)=Σ(1/eta)(仅计 eta≤4);主防区 ×1.5;补位期呼喊者防区 ×2.0(覆盖主防区偏置)。 */
botPickDir(bot) {
const cov = bot.cover && this.now < bot.cover.until ? bot.cover : null;
let bestDir = null;
let bestP = 0;
for (let d = 0; d < 8; d++) {
let p = 0;
for (const m of this.mobs.values()) {
if (DIR_IDX[m.dir] === d && m.eta <= MELEE_ETA) p += 1 / Math.max(m.eta, 0.1);
}
if (p <= 0) continue;
if (cov ? cov.callerSector.includes(DIRS[d]) : bot.sector.includes(DIRS[d])) {
p *= cov ? 2.0 : 1.5;
}
if (p > bestP) { bestP = p; bestDir = DIRS[d]; }
}
return bestDir;
}
/** §7.2 单 slot 的 500ms 决策(bot 由 botTick 驱动;平衡仿真脚本可对真人槽复用同一口径)。 */
botDecide(bot) {
const tune = this.botTuning;
// 旋风斩:全场 eta≤skillEta ≥skillMin 只 + 技能就绪 + skillP 想起(裁决 10 节制)
if (this.now >= bot.skillCdUntil) {
const close = [...this.mobs.values()].filter((m) => m.eta <= tune.skillEta).length;
if (close >= tune.skillMin && this.botRng() < tune.skillP) this.adjudicateSkill(bot);
}
// 出击:冷却就绪 + max P 方向 + 反应带 + 犹豫空转(裁决 10)
if (this.now >= bot.cdUntil) {
const dir = this.botPickDir(bot);
if (dir) {
const arc = arcOf(dir);
let best = null;
for (const m of this.mobs.values()) {
if (!arc.includes(DIR_IDX[m.dir]) || m.eta > MELEE_ETA) continue;
if (!best || m.eta < best.eta) best = m;
}
if (best
&& best.eta <= tune.reactBase + this.botRng() * tune.reactSpan
&& this.botRng() >= tune.hesitateP) {
this.adjudicateAttack(bot, dir);
}
}
}
// 呼救:主防区压力 ≥3(eta≤6)且 20s 节流
if (this.now - bot.lastCalloutAt >= 20000) {
const pressure = [...this.mobs.values()]
.filter((m) => bot.sector.includes(m.dir) && m.eta <= 6).length;
if (pressure >= 3) {
bot.lastCalloutAt = this.now;
this.adjudicateCallout(bot);
}
}
// §7.3 补位落定:首次在新防区击杀后再无怪 → cover(每次指派 1 次)
const cov = bot.cover;
if (cov && !cov.settled && cov.killDone
&& ![...this.mobs.values()].some((m) => cov.callerSector.includes(m.dir))) {
cov.settled = true;
this.tryBotline(bot, 'cover');
}
}
botTick() {
if (this.phase !== 'wave') return; // §5 教学段 bot AI 挂起(呼喊必答走 callout 路径)
for (const bot of this.botSlots().sort((a, b) => a.slot - b.slot)) this.botDecide(bot);
}
// ---------------- 教学波(§5) ----------------
tickTeach() {
const st = this.teachState;
if (!st || st.done) return;
const ts = this.ts();
if (st.stage === 1) {
if (!st.currentMob && ts >= st.nextAt && st.attempts < 3) {
st.currentMob = this.spawnMob({ mkind: 'wolf', dir: 'N', eta: 12 }); // T1 教学狼,方位 N
st.attempts += 1;
}
if (st.currentMob && !this.mobs.has(st.currentMob.id)) {
if (st.currentMob.breached && st.attempts < 3) { // 突入零惩罚,补放重教
st.currentMob = null;
st.nextAt = ts + 2000;
} else { // 杀掉(或最后一次放过)→ T3 双狼
st.currentMob = null;
st.stage = 2;
st.nextAt = ts + 4000;
}
}
} else if (st.stage === 2) {
if (!st.spawn23 && ts >= st.nextAt) {
const a = this.spawnMob({ mkind: 'wolf', dir: 'N', eta: 5 }); // T3 教学狼 2、3,贴近
const b = this.spawnMob({ mkind: 'wolf', dir: 'NE', eta: 5 });
st.spawn23 = [a.id, b.id];
}
if (st.spawn23 && st.spawn23.every((id) => !this.mobs.has(id))) {
st.done = true;
this.pendingWave = { wave: 1, atTs: ts + REST_MS }; // 教学完同场接续第 1 波
}
}
}
// ---------------- 终局 ----------------
endGame(win) {
this.phase = 'end';
this.mobs.clear();
this.script = null;
this.pendingWave = null;
const grade = win ? gradeFor(this.dur) : null; // 失守无评级(§3.6/§15 裁决 9)
const kills = {};
for (const [k, v] of Object.entries(this.killsBySlot)) kills[k] = v;
for (const s of this.occupiedSlots()) kills[String(s.slot)] = s.kills;
this.broadcastEv({ kind: 'end', win, dur: this.dur, grade, kills });
this.endAt = this.now;
}
/** 房灭回收:断全部连接(end 后 close 1000)、清 resume 索引、出管理器。 */
destroy(code = 1000) {
if (this.destroyed) return;
this.destroyed = true;
for (const s of this.occupiedSlots()) {
const c = s.conn;
s.conn = null;
if (s.resume) this.manager.resumeIndex.delete(s.resume);
if (c) {
c.joinedRoom = null;
c.slotId = null;
try { c.close(code); } catch (_) { /* 忽略 */ }
}
}
this.manager.rooms.delete(this.code);
}
// ---------------- 断线宽限(§6.6) ----------------
graceTimeout(slot) {
const remaining = this.humanCount() - 1; // 除本槽外剩余人类
if (remaining <= 0) { // 最后一个人类宽限耗尽 → 房灭(bot 不续命)
this.destroy();
return;
}
if (this.phase === 'lobby' || this.phase === 'end' || remaining >= 3) {
this.removeSlot(slot); // 剩余人类 ≥3 → 槽位移除
} else {
this.convertToBot(slot); // 否则槽位转 bot(不可逆)
}
}
// ---------------- 主循环 ----------------
tick(dt) {
if (this.destroyed) return;
this.now += dt;
// 断线宽限到期处理
for (const s of this.occupiedSlots()) {
if (!s.bot && !s.alive && !s.conn && s.graceUntil && this.now >= s.graceUntil) {
this.graceTimeout(s);
if (this.destroyed) return;
}
}
if (this.phase === 'lobby') {
if (this.now >= LOBBY_MS) this.startGame(); // LOBBY 上限 12s
return;
}
if (this.phase === 'end') {
if (this.endAt !== null && this.now >= this.endAt + END_KEEP_MS) this.destroy(1000);
return;
}
const ts = this.ts();
// 波次启动(开局延迟 / 波间休整 / 教学接续)
if (this.pendingWave && ts >= this.pendingWave.atTs) {
const w = this.pendingWave.wave;
this.pendingWave = null;
this.startWave(w);
}
// 剧本刷怪
if (this.script) {
while (this.scriptIdx < this.script.length && this.script[this.scriptIdx].atTs <= ts) {
this.spawnMob(this.script[this.scriptIdx++]);
}
}
// 教学波推进
if (this.phase === 'teach') this.tickTeach();
// 怪物推进 + 突入结算
for (const mob of [...this.mobs.values()]) {
mob.eta -= dt / 1000;
if (mob.eta <= 0) {
this.breachMob(mob);
if (this.phase === 'end' || this.destroyed) return;
}
}
// 波出清 → 休整 → 下一波 / 终局
if (this.phase === 'wave' && !this.pendingWave && this.script
&& this.scriptIdx >= this.script.length && this.mobs.size === 0 && this.wave >= 1) {
if (this.wave >= LAST_WAVE) {
this.endGame(true);
return;
}
this.broadcastEv({ kind: 'waveclear', wave: this.wave });
this.pendingWave = { wave: this.wave + 1, atTs: ts + REST_MS };
}
// bot AI(500ms)
if (this.now - this.lastBotAt >= BOT_TICK_MS) {
this.lastBotAt = this.now;
this.botTick();
}
// 喊话调度(greet 等)
while (this.pendingLines.length && this.pendingLines[0].at <= ts) {
const l = this.pendingLines.shift();
const s = this.slots[l.slot];
if (s && s.bot) this.tryBotline(s, l.line);
}
// 快照兜底(2Hz)
if (this.now - this.lastSnapAt >= SNAP_MS) {
this.lastSnapAt = this.now;
this.broadcastSnap();
}
}
}
// ---------------- 房间管理器(§6.4 匹配 + 消息路由) ----------------
class HuntManager {
constructor(opts = {}) {
this.rooms = new Map(); // code -> Room
this.resumeIndex = new Map(); // resume token -> { room, slotId }
this.joinTimeoutMs = opts.joinTimeoutMs || 5000; // §6.2 连接后 5s 内须发 join
// 速率帽(§6.1)按真实墙钟计;仿真测试以快进 tick 驱动、瞬间大量消息,可传 Infinity 关闭
this.rateLimit = opts.rateLimit === undefined ? RATE_PER_SEC : opts.rateLimit;
// bot 难度参数(§15 裁决 10):生产用默认值;平衡扫描/测试可覆盖
this.botTuning = { ...BOT_TUNING_DEFAULT, ...(opts.botTuning || {}) };
}
createRoom(opts = {}) {
let code = opts.code;
if (!code) {
do {
code = Array.from({ length: 4 }, () => ROOM_CODE_CHARS[crypto.randomInt(ROOM_CODE_CHARS.length)]).join('');
} while (this.rooms.has(code));
}
const room = new Room(this, { ...opts, code });
this.rooms.set(code, room);
return room;
}
/** 生产驱动(server.js setInterval)与测试快进共用入口。 */
tick(dtMs) {
for (const room of [...this.rooms.values()]) room.tick(dtMs);
}
attach(conn) {
conn.joinedRoom = null;
conn.slotId = null;
conn.rlWindowStart = 0;
conn.rlCount = 0;
conn.rlStrikeAt = 0;
conn.joinTimer = setTimeout(() => {
if (!conn.joinedRoom) conn.close(1008, 'join timeout'); // 5s 未 join
}, this.joinTimeoutMs);
if (conn.joinTimer.unref) conn.joinTimer.unref();
conn.on('message', (str) => {
try {
this.handleMessage(conn, str);
} catch (e) {
console.error('[hunt] 消息处理异常:', e);
}
});
conn.on('close', () => this.handleClose(conn));
}
sendMsg(conn, obj) {
if (conn) conn.send(JSON.stringify(obj));
}
sendErr(conn, code, msg) {
this.sendMsg(conn, { t: 'err', code, msg });
}
handleMessage(conn, str) {
// §6.1 速率帽:20 条/s,超限 err rate,10s 内再犯 close
const now = Date.now();
if (now - conn.rlWindowStart > 1000) {
conn.rlWindowStart = now;
conn.rlCount = 0;
}
conn.rlCount += 1;
if (conn.rlCount > this.rateLimit) {
if (conn.rlStrikeAt && now - conn.rlStrikeAt < RATE_STRIKE_MS) {
conn.close(1008, 'rate limit');
return;
}
conn.rlStrikeAt = now;
return this.sendErr(conn, 'rate', '消息太快,慢一点。');
}
let msg;
try {
msg = JSON.parse(str);
} catch (_) {
return this.sendErr(conn, 'bad_msg', '不是合法 JSON');
}
if (!msg || typeof msg !== 'object' || Array.isArray(msg)) {
return this.sendErr(conn, 'bad_msg', '消息需为 JSON 对象');
}
if (!conn.joinedRoom) {
if (msg.t !== 'join') return this.sendErr(conn, 'bad_msg', '首条消息必须是 join');
return this.handleJoin(conn, msg);
}
if (msg.t === 'act') return this.handleAct(conn, msg);
if (msg.t === 'leave') return this.handleLeave(conn);
return this.sendErr(conn, 'bad_msg', '未知消息类型');
}
handleClose(conn) {
if (conn.joinTimer) {
clearTimeout(conn.joinTimer);
conn.joinTimer = null;
}
const room = conn.joinedRoom;
if (!room) return;
conn.joinedRoom = null;
const slotId = conn.slotId;
conn.slotId = null;
if (room.destroyed) return;
const slot = room.slots[slotId];
if (!slot || slot.conn !== conn || slot.bot) return;
// §6.6:断线 → 标记 + roster 广播 + 30s 宽限(lobby/end 同走宽限,到期按规则处理)
slot.conn = null;
slot.alive = false;
slot.graceUntil = room.now + GRACE_MS;
room.broadcastRoster();
}
handleJoin(conn, msg) {
if (msg.ver !== 1) return this.sendErr(conn, 'bad_msg', '协议版本不符(ver 恒 1)');
if (msg.resume !== undefined && msg.resume !== null) return this.handleResume(conn, msg);
const roomName = msg.room;
const isCode = typeof roomName === 'string' && /^[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{4}$/.test(roomName);
if (roomName !== 'quick' && !isCode) {
return this.sendErr(conn, 'bad_msg', 'room 需为 "quick" 或 4 位房码');
}
let name = String(msg.name === undefined || msg.name === null ? '猎人' : msg.name).trim().slice(0, 24);
if (!name) name = '猎人';
let room;
if (msg.teach === true) {
room = this.createRoom({ teach: true }); // §5/§6.4 教学房强制单人
} else if (roomName === 'quick') {
room = [...this.rooms.values()].find(
(r) => !r.destroyed && r.phase === 'lobby' && !r.teach && r.occupiedSlots().length < 4,
);
if (!room) room = this.createRoom({});
} else {
room = this.rooms.get(roomName);
if (room && (room.destroyed || room.phase !== 'lobby' || room.teach)) {
return this.sendErr(conn, 'room_full', '房间已开局或不可用'); // §6.4 对局中 join → room_full
}
if (!room) room = this.createRoom({ code: roomName });
}
const slotId = room.slots.findIndex((s) => s === null);
if (slotId === -1) return this.sendErr(conn, 'room_full', '房间已满(4 人)');
const slot = room.addHuman(slotId, conn, name);
conn.joinedRoom = room;
conn.slotId = slotId;
if (conn.joinTimer) {
clearTimeout(conn.joinTimer);
conn.joinTimer = null;
}
this.resumeIndex.set(slot.resume, { room, slotId });
if (room.teach && room.phase === 'lobby') room.startGame(); // 教学房直入 TEACH(无 lobby 等待)
room.sendJoined(slot);
room.broadcastRoster();
if (!room.teach && room.humanCount() === 4) room.startGame(); // 满 4 人立即开局
}
/** §6.6 宽限内 resume 回槽:重发 joined(同 slot/seed/char)+ 紧随 snap 全量。 */
handleResume(conn, msg) {
const token = String(msg.resume);
const rec = this.resumeIndex.get(token);
if (!rec || rec.room.destroyed) {
return this.sendErr(conn, 'room_gone', '那场围猎已经结束了'); // 房灭后迟到 resume
}
const { room, slotId } = rec;
const slot = room.slots[slotId];
if (!slot || slot.bot || slot.resume !== token) {
return this.sendErr(conn, 'room_gone', '座位已经没了(已转 bot)');
}
if (slot.conn && slot.conn !== conn) { // 顶号:旧连接让位
const old = slot.conn;
old.joinedRoom = null;
old.slotId = null;
try { old.close(1008, 'replaced'); } catch (_) { /* 忽略 */ }
}
slot.conn = conn;
slot.alive = true;
slot.graceUntil = 0;
conn.joinedRoom = room;
conn.slotId = slotId;
if (conn.joinTimer) {
clearTimeout(conn.joinTimer);
conn.joinTimer = null;
}
room.sendJoined(slot);
this.sendMsg(conn, room.buildSnap()); // 紧随 snap 全量
room.broadcastRoster();
}
handleAct(conn, msg) {
const room = conn.joinedRoom;
const slot = room.slots[conn.slotId];
if (!slot || slot.bot) return this.sendErr(conn, 'bad_msg', '座位状态异常');
const kind = msg.kind;
if (!['attack', 'callout', 'skill', 'start'].includes(kind)) {
return this.sendErr(conn, 'bad_msg', '未知 act.kind');
}
if (typeof msg.seq === 'number') { // 乱序/重复不拒,仅日志(§6.2)
if (msg.seq <= slot.lastSeq) {
console.log(`[hunt] act 乱序/重复: room=${room.code} slot=${slot.slot} seq=${msg.seq} last=${slot.lastSeq}`);
}
slot.lastSeq = Math.max(slot.lastSeq, msg.seq);
}
if (kind === 'start') {
if (room.phase !== 'lobby') return this.sendErr(conn, 'not_ingame', '已经开局了');
room.startGame();
return;
}
if (room.phase !== 'wave' && room.phase !== 'teach') {
return this.sendErr(conn, 'not_ingame', '不在对局中');
}
if (kind === 'attack') {
if (typeof msg.dir !== 'string' || !(msg.dir in DIR_IDX)) {
return this.sendErr(conn, 'bad_dir', '方位需为 8 罗盘向之一');
}
room.adjudicateAttack(slot, msg.dir);
} else if (kind === 'skill') {
room.adjudicateSkill(slot);
} else {
room.adjudicateCallout(slot);
}
}
/** leave:主动退出,槽位立即按 §6.6 处理(无 30s 宽限)。 */
handleLeave(conn) {
const room = conn.joinedRoom;
if (!room) {
conn.close(1000);
return;
}
conn.joinedRoom = null;
const slotId = conn.slotId;
conn.slotId = null;
const slot = room.slots[slotId];
if (!room.destroyed && slot && !slot.bot) {
slot.conn = null;
if (slot.resume) this.resumeIndex.delete(slot.resume);
slot.resume = null;
if (room.phase === 'lobby' || room.phase === 'end') {
room.slots[slotId] = null;
room.broadcastRoster();
if (room.humanCount() === 0) room.destroy();
} else {
const remaining = room.humanCount() - 1;
if (remaining <= 0) room.destroy(); // 无人类即灭
else if (remaining >= 3) room.removeSlot(slot);
else room.convertToBot(slot);
}
}
conn.close(1000);
}
}
function createManager(opts) {
return new HuntManager(opts);
}
module.exports = {
createManager,
Room,
buildWaveScript,
gradeFor,
mulberry32,
DIRS,
LAYOUTS,
WAVE_TABLE,
LAST_WAVE,
DUR_MAX,
MELEE_ETA,
TICK_MS,
ATTACK_CD_MS,
SKILL_CD_MS,
CALLOUT_THROTTLE_MS,
LOBBY_MS,
GRACE_MS,
END_KEEP_MS,
REST_MS,
ROOM_CODE_CHARS,
BOT_TUNING_DEFAULT,
};