Files
xsl d78df35b5c fix(hunt): v2 九修 S1-S9 落地——10 人并发模拟迭代裁定(同阵容复测:去留 6/10→10/10)
研究闭环:v1 三轮 28 局四死因(失衡/社交真空/交互摩擦/观光羞辱)→ 四视角分析 →
synthesis S1-S9 裁定 → 实施 → 同 10 人配置复测两轮 20 局。依据 research/hunt-sim/
(synthesis.md / metrics-v2.md / report.md),设计登记 23-hunt.md §16。

服务端 hunt-room.js:
- S5 协作人声包:真人座位角色接线(杀熊/三连杀/旋风斩≥3 播角色句、greet 全座位);
  满编真人房呼喊 2.5s 无 bot 应答 → 邻座角色代答 help_ack;喊话预算 10s 窗 2→3
- S7 难度重构:waveCounts 4 人房 ×4/3;alignPincers 第 3-5 波齐压拍(跨座位同种类
  同秒进内圈,种子确定性保持);mobYielded bot 对人类防区怪让位 2s(只约束 bot,
  不拦真人避免挥空真话说谎);gradeForEx 双轴评级(S=耐久≥9 且 ≥2 人各 6 杀,
  单人带飞局上限 A)

客户端 game.js:
- S1 WAVE 下滑恒出击(无怪=空挥真话),菜单收窄到波间休整窗(E3 修订)——
  v1 误触菜单单人单局最多 14 次清零
- S2 冷却首拦才震 + 就绪 beat 轻叮(v1 全场 666 次乱滑连震)
- S3 0 杀不播 kill_band0(「你还没开张」下架;复测 P08 0杀→12杀)
- S4 看门狗重定义:本波出过手一级语音不触发、语音闲置每局 ≤1、一级降纯震
- S5③ help_call/help_ack 永不超时丢弃
- S6 后半平面 loop 低通 750Hz 第二线索(N/S 判读 63/69%→75/81%)
- S8 教学作答步(T1-T3)下滑不再误跳
- S9 状态链按变化播(「固若金汤」复读归零)

测试同步:hunt-sim A1/A2/A12 断言更新(真人 greet/波次缩放/预算 3)、
playtest Block 3 按 S1 新语义改写(战斗中下滑=空挥 + 波间菜单)

回归:hunt-sim 16/16 + playtest 213 + guide 132 + engine-voice 19 全绿;
balance-scan:挂机局 5/8 落 C/失守带(v1 恒满血)、optimal 单人带飞封顶 A
遗留:营地零损耗未破(18/20 局余10)——v3 头号,参数方案 report.md §5
2026-09-19 11:06:18 +08:00

1119 lines
41 KiB
JavaScript
Raw Permalink 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 = 3; // 窗口内氛围句上限(v2 S5① 真人座位角色接线后 2→3;help_* 恒优先)
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; // 再犯判定窗
const SECTOR_YIELD_MS = 2000; // v2 S7③:bot 对人类防区怪的让位窗
const CALLOUT_ACK_DELAY_MS = 2500; // v2 S5②:无 bot 应答时邻座角色代答延迟
// §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 counts = waveCounts(spec, seatCount);
const rng = waveRng(seed, wave);
const layout = LAYOUTS[seatCount];
const mkinds = [];
for (let i = 0; i < counts.wolf; i++) mkinds.push('wolf');
for (let i = 0; i < counts.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,
};
});
}
/**
* 波次量按座位缩放(v2 修复 S7①:3 人房维持原值;4 人房 ×4/3——
* 6 局真人快速局全部零损耗满血 S 的根因之一是吞吐冗余,见 research/hunt-sim/analysis/01)。
*/
function waveCounts(spec, seatCount) {
const f = seatCount / 3;
return {
wolf: Math.round(spec.wolf * f),
bear: spec.bear ? Math.max(1, Math.round(spec.bear * f)) : 0,
};
}
/**
* 齐压拍(v2 修复 S7②):第 3-5 波把既有剧本里 1-2 组「不同座位」的怪
* 重定时到同一毫秒进内圈(数量不变、种子确定性)。单人 2.5s 出击冷却在
* 齐压拍数学上必漏 → 呼喊/分工/旋风斩分配第一次成为唯一解。
* 纯函数:不动 buildWaveScript(A2 单测口径)。
*/
function alignPincers(script, seed, wave) {
if (wave < 3) return script;
const rng = waveRng((seed ^ 0x91CE) >>> 0, wave);
const groups = wave === 3 ? 1 : 2;
const used = new Set();
for (let g = 0; g < groups; g++) {
const avail = script.map((e, i) => ({ e, i }))
.filter(({ e, i }) => !used.has(i));
// 找一对不同座位、同种类的条目(齐压必须跨防区才有强制分工意义;
// 同种类保证 eta 原值不动——狼仍是狼速,只是同秒抵达)
let pair = null;
for (let a = 0; a < avail.length && !pair; a++) {
for (let b = avail.length - 1; b > a && !pair; b--) {
if (avail[a].e.seat !== avail[b].e.seat && avail[a].e.mkind === avail[b].e.mkind) {
pair = [avail[a], avail[b]];
}
}
}
if (!pair) break;
// 对齐到较晚时刻(同秒进内圈),加 ≤300ms 微差防叠声
const late = Math.max(pair[0].e.at, pair[1].e.at);
pair[0].e.at = late;
pair[1].e.at = late + Math.floor(rng() * 300);
used.add(pair[0].i);
used.add(pair[1].i);
}
return script.sort((a, b) => a.at - b.at);
}
/**
* 评级(v2 修复 S7④ 双轴):S = 耐久≥9 且 至少两名猎人均有 ≥6 个人击杀
* (零损耗 + 打了配合的房间才是 S;「1 高手带飞局」上限 A)。
* gradeFor 保留原单轴口径供兼容引用。
*/
function gradeForEx(dur, killsBySlot) {
if (dur >= 9) {
const contributors = Object.values(killsBySlot || {}).filter((k) => k >= 6).length;
if (contributors >= 2) return 'S';
return 'A';
}
return gradeFor(dur);
}
/** §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.occupiedSlots().sort((a, b) => a.slot - b.slot).forEach((s, i) => { // v2 S5①:真人座位角色也打招呼(§7.4 间隔)
if (s.char) this.pendingLines.push({ at: GREET_FIRST_MS + i * GREET_GAP_MS, slot: s.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 = alignPincers(
buildWaveScript(this.seed, n, this.seatCount).map((e) => ({ ...e, atTs: base + e.at })),
this.seed, n,
);
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, seat, innerAt: null }; // seat/innerAt:bot 让位判定(S7③)
this.mobs.set(id, mob);
this.broadcastEv({ kind: 'spawn', id, mkind, dir: d, eta });
return mob;
}
/**
* bot 让位窗(v2 修复 S7③,bot 单侧礼貌版):怪进内圈头 2 秒,bot 不与
* 该防区的人类座位抢——弱者的保底出手窗。只约束 bot(对真人不可见、
* 不拦截真人出手,避免「挥空真话说谎」的暗墙)。
*/
mobYielded(mob) {
if (mob.innerAt === null || mob.seat === undefined) return false;
if (this.now - mob.innerAt >= SECTOR_YIELD_MS) return false;
const owner = this.slots[mob.seat];
return !!(owner && !owner.bot && owner.alive);
}
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 && !(slot.bot && this.mobYielded(m))); // bot 旋风斩也让位(S7③)
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.char && clearedMobs.length >= 3) this.tryBotline(slot, 'skill'); // v2 S5①:真人大清场也报
}
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,
};
} else {
// v2 S5②:满编真人房无 bot 可应——2.5s 后由邻座真人「扮演的角色」代答
// help_ack(呼喊-应答听觉闭环在真人房成立的兜底;v3 若加真人应答手势则替换)
const neighbor = this.occupiedSlots()
.filter((s) => s.slot !== slot.slot && s.char && s.alive)
.sort((a, b) => a.slot - b.slot)[0];
if (neighbor) {
this.pendingLines.push({
at: this.ts() + CALLOUT_ACK_DELAY_MS, slot: neighbor.slot, line: 'help_ack', priority: true,
});
}
}
}
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.char) { // v2 S5①:真人也「扮演座位角色」——杀熊/三连杀照播角色 kill 句(§3.3 声音身份接线)
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 && !this.mobYielded(m)) 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);让位怪不抢(S7③)
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 || this.mobYielded(m)) 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 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;
const grade = win ? gradeForEx(this.dur, kills) : null; // v2 S7④ 双轴评级(失守无评级,§3.6/§15 裁决 9)
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();
// 怪物推进 + 突入结算(进内圈打让位时间戳,S7③)
for (const mob of [...this.mobs.values()]) {
mob.eta -= dt / 1000;
if (mob.innerAt === null && mob.eta <= MELEE_ETA) mob.innerAt = this.now;
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 等;v2 S5② 真人代答 help_ack 也走此队列)
while (this.pendingLines.length && this.pendingLines[0].at <= ts) {
const l = this.pendingLines.shift();
const s = this.slots[l.slot];
if (s && s.char && s.alive !== false) {
this.emitBotline(s, l.line, { priority: !!l.priority });
}
}
// 快照兜底(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,
waveCounts,
alignPincers,
gradeFor,
gradeForEx,
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,
SECTOR_YIELD_MS,
};