研究闭环: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
1119 lines
41 KiB
JavaScript
1119 lines
41 KiB
JavaScript
/**
|
||
* 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,
|
||
};
|