/** * 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, };