/** * hunt-sim.test.mjs — 《围猎》服务器侧自测(13-playtest-criteria.md §24 服务器侧条目)。 * * 两层: * A. 房间/战斗逻辑(hunt-room.js 直接 import + FakeConn 假连接,固定 seed + 快进 tick, * 确定性复现):全流程 1 真人+2 bot 打完 5 波、seed 复现剧本、断线转 bot / 重连回槽 / * 槽位移除、leave 立即处理、冷却与节流 err 码、出击弧/近战窗/旋风斩、突入扣耐久与 * 失守(不写榜口径:win:false + grade:null)、教学波零惩罚、lobby 行为(12s 自动开局 / * 满 4 人开局 / 快速匹配复用 / 显式房码 / 教学房单人直入 teach)、bot 补位偏置(§7.3)、 * 喊话节流(§7.4)、评级映射(§3.6)、速率帽、join 超时。 * B. WS 传输冒烟(spawn 真服务器,node:net 手写握手+帧):101 握手与 Accept 哈希、 * 无 mask → 1002、二进制 → 1003、ping → pong、join → joined 走真帧、静态/REST 回归不破。 * * 运行:tools/node/bin/node web/server/test/hunt-sim.test.mjs(node ≥18 均可) * 全部通过退出码 0;任一失败 node:test 非零退出。测试数据写临时目录。 */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { spawn } from 'node:child_process'; import net from 'node:net'; import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import hunt from '../hunt-room.js'; const { createManager, buildWaveScript, gradeFor, DIRS, LAYOUTS, WAVE_TABLE, TICK_MS, GRACE_MS, REST_MS, LOBBY_MS, END_KEEP_MS, } = hunt; const HERE = path.dirname(fileURLToPath(import.meta.url)); const SERVER = path.join(HERE, '..', 'server.js'); const NODE = process.execPath; // ---------------- FakeConn:管理器连接契约的测试替身 ---------------- class FakeConn extends EventEmitter { constructor() { super(); this.sent = []; // 已解析的服务器消息(按序) this.closedCode = null; } get open() { return this.closedCode === null; } send(str) { this.sent.push(JSON.parse(str)); } close(code = 1000) { if (this.closedCode === null) { this.closedCode = code; queueMicrotask(() => this.emit('close')); // 与真实 socket 异步关闭对齐 } } clientSend(obj) { this.emit('message', JSON.stringify(obj)); } clientClose() { this.emit('close'); } } function evs(conn, kind) { return conn.sent.filter((m) => m.t === 'ev' && (kind === undefined || m.kind === kind)); } function lastEv(conn, kind) { const list = evs(conn, kind); return list[list.length - 1]; } function errs(conn, code) { return conn.sent.filter((m) => m.t === 'err' && (code === undefined || m.code === code)); } function joinQuick(mgr, name, opts = {}) { const conn = new FakeConn(); mgr.attach(conn); conn.clientSend({ t: 'join', ver: 1, room: 'quick', name, ...opts }); const joined = conn.sent.find((m) => m.t === 'joined'); return { conn, joined }; } /** 脚本化真人策略:读房间实例字段(13 §24 测试钩子),旋风斩 ≥3 内圈,否则打 eta 最小内圈怪。 */ function greedyAct(room, conn, seq) { const slot = room.slots[conn.slotId]; if (!slot || slot.bot || !slot.alive || slot.conn !== conn) return; if (room.phase !== 'wave' && room.phase !== 'teach') return; const mobs = [...room.mobs.values()]; if (room.now >= slot.skillCdUntil && mobs.filter((m) => m.eta <= 3).length >= 3) { conn.clientSend({ t: 'act', kind: 'skill', seq: seq.n++ }); return; } if (room.now < slot.cdUntil) return; const inner = mobs.filter((m) => m.eta <= 4); if (!inner.length) return; inner.sort((a, b) => a.eta - b.eta); conn.clientSend({ t: 'act', kind: 'attack', dir: inner[0].dir, seq: seq.n++ }); } /** 快进驱动:每 TICK_MS 一步;可选真人策略在每一步后出手。 */ function fastForward(mgr, seconds, drivers = []) { const steps = Math.round(seconds * 1000 / TICK_MS); for (let i = 0; i < steps; i++) { mgr.tick(TICK_MS); for (const d of drivers) d(); } } // ---------------- A1. 全流程:1 真人 + 2 bot,5 波(seed=42 快进) ---------------- test('A1 全流程:匹配→开局 bot 补位→5 波→胜利结算,事件流与数值表逐波核对', () => { const mgr = createManager({ rateLimit: Infinity }); mgr.createRoom({ seed: 42 }); const { conn, joined } = joinQuick(mgr, ' tester '); assert.ok(joined, 'join 受理'); assert.equal(joined.slot, 0); assert.equal(joined.seed, 42); assert.equal(joined.char, 'yan'); assert.deepEqual(joined.sector, ['N', 'NE', 'E']); assert.match(joined.resume, /^[0-9a-f]{16}$/); assert.equal(joined.state.phase, 'lobby'); assert.equal(joined.roster.length, 1); // 立即开局(act start) conn.clientSend({ t: 'act', kind: 'start', seq: 1 }); const room = mgr.rooms.get(joined.room); assert.equal(room.phase, 'wave'); // 开局 bot 补位:猎人总数 = max(3, 1) = 3,bot 槽 char 齐全 const rosterEv = lastEv(conn, 'roster'); assert.equal(rosterEv.roster.length, 3); const bots = rosterEv.roster.filter((r) => r.bot); assert.equal(bots.length, 2); assert.deepEqual(bots.map((b) => b.char), ['zheng', 'zhou']); assert.deepEqual(bots.map((b) => b.name), ['小筝', '老周']); assert.deepEqual(bots.map((b) => b.sector), [['SE', 'S', 'SW'], ['W', 'NW']]); // 快进打完全场(上限 400s 防死循环);逐 tick 校验 snap 与房间真值一致(§6.7 兜底口径) const seq = { n: 2 }; let guard = 0; let snapCursor = 0; let snapsChecked = 0; while (room.phase !== 'end' && guard++ < 4000) { mgr.tick(TICK_MS); // 本 tick 内新发的 snap 必须与同刻房间怪表完全一致(id 集合 + eta 零偏差量级) while (snapCursor < conn.sent.length) { const m = conn.sent[snapCursor++]; if (m.t !== 'snap') continue; snapsChecked += 1; assert.deepEqual(new Set(m.mobs.map((x) => x.id)), new Set(room.mobs.keys()), 'snap 怪表 = 房间真值'); for (const x of m.mobs) { assert.ok(Math.abs(x.eta - room.mobs.get(x.id).eta) <= 0.11, 'snap eta 偏差在校正窗内'); } assert.ok(m.slots.every((s) => typeof s.cd === 'number' && typeof s.skillCd === 'number' && typeof s.alive === 'boolean' && typeof s.bot === 'boolean'), 'snap slots 形状 §6.3'); } greedyAct(room, conn, seq); } assert.equal(room.phase, 'end', '对局收敛到终局'); // ---- 逐波核对 §3.4 表 ---- const all = conn.sent.filter((m) => m.t === 'ev'); const waves = evs(conn, 'wave').map((e) => e.wave); assert.deepEqual(waves, [1, 2, 3, 4, 5], 'ev wave 1-5 有序'); assert.deepEqual(evs(conn, 'waveclear').map((e) => e.wave), [1, 2, 3, 4], '波 1-4 出清'); // 按波切分 spawn 事件 const spawnByWave = { 1: [], 2: [], 3: [], 4: [], 5: [] }; let cur = 0; for (const m of all) { if (m.kind === 'wave') cur = m.wave; else if (m.kind === 'spawn' && cur >= 1) spawnByWave[cur].push(m); } const totalByTable = [0, 0, 0, 0, 0, 0]; for (let w = 1; w <= 5; w++) { const spec = WAVE_TABLE[w]; totalByTable[w] = spec.wolf + spec.bear; const got = spawnByWave[w]; assert.equal(got.length, totalByTable[w], `第 ${w} 波 spawn 数 = ${spec.wolf}狼+${spec.bear}熊`); assert.equal(got.filter((s) => s.mkind === 'bear').length, spec.bear, `第 ${w} 波熊数`); for (const s of got) { assert.ok(DIRS.includes(s.dir), 'spawn 方位 ∈ 8 罗盘向'); const expectEta = s.mkind === 'bear' ? spec.bearEta : spec.wolfEta; assert.equal(s.eta, expectEta, `第 ${w} 波 ${s.mkind} 逼近时长 §3.4`); } } assert.equal(evs(conn, 'spawn').length, 39, '全程 39 只(§3.4)'); // ---- 击杀账:ev end.kills = kill 事件 + skill cleared 逐槽合计 ---- const killCount = { 0: 0, 1: 0, 2: 0, 3: 0 }; for (const m of all) { if (m.kind === 'kill') killCount[m.slot] += 1; if (m.kind === 'skill') killCount[m.slot] += m.cleared.length; } const end = lastEv(conn, 'end'); assert.equal(end.win, true, '1 真人+2 bot 应能守过 5 波'); assert.ok(end.dur >= 1 && end.dur <= 10, `余耐久 ${end.dur} 在 1-10`); assert.equal(end.grade, gradeFor(end.dur), '评级与耐久一致(§3.6,归因真话)'); let killSum = 0; for (const [k, v] of Object.entries(end.kills)) { assert.ok(['0', '1', '2'].includes(k)); assert.equal(v, killCount[k], `槽 ${k} 击杀账一致`); killSum += v; } assert.equal(killSum, 39 - evs(conn, 'breach').length, '击杀+漏怪=总怪数'); assert.ok(end.kills['0'] > 0, '真人必须有击杀(协作非摆设)'); // ---- ts 单调、err 零打扰(策略合规不应触发任何 err) ---- for (let i = 1; i < all.length; i++) assert.ok(all[i].ts >= all[i - 1].ts, 'ev ts 单调不减'); assert.equal(errs(conn).length, 0, '合规客户端零 err'); // ---- snap 兜底:2Hz 持续下发、终局后停发、逐 tick 一致性已在主循环断言 ---- const snaps = conn.sent.filter((m) => m.t === 'snap'); assert.ok(snapsChecked > 100, '2Hz snap 持续下发且逐 tick 校验过'); assert.equal(snaps.length, snapsChecked); const endTs = end.ts; assert.ok(snaps[snaps.length - 1].ts <= endTs, '终局后 snap 停发'); // ---- botline greet:逐只间隔 ≥1.5s(§7.4) ---- const greets = evs(conn, 'botline').filter((e) => e.line === 'greet'); assert.equal(greets.length, 2); assert.ok(greets[1].ts - greets[0].ts >= 1500, 'greet 间隔 ≥1.5s'); // ---- 失守不写榜(服务器侧口径):本局为胜局,反向用 A9 覆盖 ---- room.destroy(); }); // ---------------- A2. seed 复现剧本(§3.5) ---------------- test('A2 刷怪剧本 seed 复现:同 seed 同剧本,方位 ∈ 座位防区轮转', () => { for (const seatCount of [3, 4]) { for (let w = 1; w <= 5; w++) { const a = buildWaveScript(42, w, seatCount); const b = buildWaveScript(42, w, seatCount); assert.deepEqual(a, b, `seat=${seatCount} wave=${w} 同 seed 同剧本`); const c = buildWaveScript(43, w, seatCount); assert.notDeepEqual(a, c, '异 seed 异剧本'); const layout = LAYOUTS[seatCount]; a.forEach((e, i) => { assert.equal(e.seat, i % seatCount, '按座位数轮转发牌'); assert.ok(layout[e.seat].sector.includes(e.dir), '方位落在该座位防区内'); assert.equal(e.at, (i + 1) * WAVE_TABLE[w].gap * 1000, '刷怪间隔 §3.4'); }); } } }); // ---------------- A3. 断线重连回槽(§6.6 宽限内 resume) ---------------- test('A3 断线 30s 内 resume 回槽:joined 同 slot/seed/char + 紧随 snap', async () => { const mgr = createManager({ rateLimit: Infinity }); mgr.createRoom({ seed: 7 }); const a = joinQuick(mgr, '甲'); const b = joinQuick(mgr, '乙'); assert.equal(a.joined.room, b.joined.room, '快速匹配同房'); const room = mgr.rooms.get(a.joined.room); a.conn.clientSend({ t: 'act', kind: 'start', seq: 1 }); assert.equal(room.phase, 'wave'); assert.equal(lastEv(a.conn, 'roster').roster.length, 3, '2 人局补 1 bot'); fastForward(mgr, 2); const seqB = { n: 1 }; a.conn.clientClose(); // 真人甲断线 await new Promise((r) => setImmediate(r)); let roster = lastEv(b.conn, 'roster'); assert.equal(roster.roster.find((r) => r.slot === 0).alive, false, '断线槽 alive=false 广播'); assert.equal(room.slots[0].bot, false, '宽限期内仍是人类槽(静默)'); // 宽限内重连回槽 fastForward(mgr, 5, [() => greedyAct(room, b.conn, seqB)]); const reConn = new FakeConn(); mgr.attach(reConn); reConn.clientSend({ t: 'join', ver: 1, room: 'quick', name: '甲', resume: a.joined.resume }); const rejoined = reConn.sent.find((m) => m.t === 'joined'); assert.ok(rejoined, 'resume 受理'); assert.equal(rejoined.slot, a.joined.slot, '同 slot 回槽'); assert.equal(rejoined.seed, a.joined.seed, '同 seed'); assert.equal(rejoined.char, a.joined.char, '同 char'); assert.equal(rejoined.resume, a.joined.resume, 'resume 凭证不变'); const joinedIdx = reConn.sent.findIndex((m) => m.t === 'joined'); assert.equal(reConn.sent[joinedIdx + 1].t, 'snap', 'joined 紧随 snap 全量(§6.6)'); assert.ok(reConn.sent[joinedIdx + 1].mobs !== undefined); roster = lastEv(b.conn, 'roster'); assert.equal(roster.roster.find((r) => r.slot === 0).alive, true, '回槽 alive=true 广播'); room.destroy(); }); // ---------------- A4. 断线超时转 bot(剩余人类 <3) ---------------- test('A4 断线 30s 宽限耗尽 → 槽位转 bot(char 沿用、产出 act、cover 喊话)', async () => { const mgr = createManager({ rateLimit: Infinity }); mgr.createRoom({ seed: 9 }); const a = joinQuick(mgr, '甲'); const b = joinQuick(mgr, '乙'); const room = mgr.rooms.get(a.joined.room); a.conn.clientSend({ t: 'act', kind: 'start', seq: 1 }); fastForward(mgr, 2); a.conn.clientClose(); await new Promise((r) => setImmediate(r)); const seqB = { n: 1 }; fastForward(mgr, GRACE_MS / 1000 + 1, [() => greedyAct(room, b.conn, seqB)]); assert.ok(room.phase === 'wave', '房不灭(还有 1 人类)'); const slot0 = room.slots[0]; assert.equal(slot0.bot, true, '宽限超时转 bot'); assert.equal(slot0.char, 'yan', 'char 沿用座角色'); assert.equal(slot0.name, '阿岩', 'bot 名 = 角色名'); const roster = lastEv(b.conn, 'roster'); assert.equal(roster.roster.find((r) => r.slot === 0).bot, true, 'roster 广播 bot=true'); assert.ok(evs(b.conn, 'botline').some((e) => e.slot === 0 && e.line === 'cover'), '转 bot 播 cover'); // 转 bot 后该槽开始产出 act(§7 AI):放两只狼保证两只 bot 各有目标(另一只 bot 不抢光) room.spawnMob({ mkind: 'wolf', dir: 'N', eta: 3 }); room.spawnMob({ mkind: 'wolf', dir: 'NE', eta: 3 }); fastForward(mgr, 3); // 乙停手,隔离观察转 bot 槽位 assert.ok( evs(b.conn, 'kill').some((e) => e.slot === 0), '转 bot 槽位产出 attack act 并击杀', ); // 迟到 resume → 转 bot 不可逆 const late = new FakeConn(); mgr.attach(late); late.clientSend({ t: 'join', ver: 1, room: 'quick', name: '甲', resume: a.joined.resume }); assert.equal(errs(late, 'room_gone').length, 1, '转 bot 后 resume → room_gone'); room.destroy(); }); // ---------------- A5. 剩余人类 ≥3 → 槽位移除(roster 缩短) ---------------- test('A5 4 人局断线超时:剩余 ≥3 → 槽位移除而非转 bot', async () => { const mgr = createManager({ rateLimit: Infinity }); mgr.createRoom({ seed: 11 }); const players = [joinQuick(mgr, '甲'), joinQuick(mgr, '乙'), joinQuick(mgr, '丙'), joinQuick(mgr, '丁')]; const room = mgr.rooms.get(players[0].joined.room); assert.equal(room.phase, 'wave', '满 4 人立即开局'); assert.equal(lastEv(players[0].conn, 'roster').roster.length, 4); assert.ok(lastEv(players[0].conn, 'roster').roster.every((r) => !r.bot), '4 人局无 bot'); assert.equal(room.slots[3].char, null, '座 3 恒 null(§3.3)'); fastForward(mgr, 2); players[0].conn.clientClose(); await new Promise((r) => setImmediate(r)); // 其余 3 人正常打(驱动策略保证房间不因失守提前终局) const seqs = [{ n: 1 }, { n: 1 }, { n: 1 }]; const drivers = players.slice(1).map((p, i) => () => greedyAct(room, p.conn, seqs[i])); fastForward(mgr, GRACE_MS / 1000 + 1, drivers); assert.equal(room.slots[0], null, '槽位移除'); assert.equal(room.occupiedSlots().length, 3); assert.ok(room.occupiedSlots().every((s) => !s.bot), '不补 bot'); const roster = lastEv(players[1].conn, 'roster'); assert.equal(roster.roster.length, 3, 'roster 缩短'); assert.ok(!roster.roster.some((r) => r.slot === 0)); room.destroy(); }); // ---------------- A6. leave 立即处理(无 30s 宽限) ---------------- test('A6 主动 leave:立即转 bot(无宽限),连接 close 1000', () => { const mgr = createManager({ rateLimit: Infinity }); mgr.createRoom({ seed: 13 }); const a = joinQuick(mgr, '甲'); const b = joinQuick(mgr, '乙'); const room = mgr.rooms.get(a.joined.room); a.conn.clientSend({ t: 'act', kind: 'start', seq: 1 }); fastForward(mgr, 1); a.conn.clientSend({ t: 'leave' }); assert.equal(a.conn.closedCode, 1000); assert.equal(room.slots[0].bot, true, 'leave 立即转 bot(剩余 1 <3)'); assert.equal(room.slots[0].graceUntil, 0, '无 30s 宽限'); assert.equal(lastEv(b.conn, 'roster').roster.find((r) => r.slot === 0).bot, true); room.destroy(); }); // ---------------- A7. 冷却 / 节流 / err 码(§6.2/§6.3) ---------------- test('A7 出击冷却 2.5s / 旋风斩 8s / 呼喊 5s / bad_dir / not_ingame / bad_msg', () => { const mgr = createManager({ rateLimit: Infinity }); mgr.createRoom({ seed: 17 }); const { conn } = joinQuick(mgr, '甲'); // lobby 期攻击 → not_ingame conn.clientSend({ t: 'act', kind: 'attack', dir: 'N', seq: 1 }); assert.equal(errs(conn, 'not_ingame').length, 1); conn.clientSend({ t: 'act', kind: 'start', seq: 2 }); const room = mgr.rooms.get([...mgr.rooms.keys()][0]); fastForward(mgr, 0.5); // 出击冷却:首发裁决,冷却内 err cooldown conn.clientSend({ t: 'act', kind: 'attack', dir: 'N', seq: 3 }); assert.equal(evs(conn, 'miss').length, 1, '无怪弧 → 挥空'); conn.clientSend({ t: 'act', kind: 'attack', dir: 'N', seq: 4 }); assert.equal(errs(conn, 'cooldown').length, 1, '冷却内第二次被拒'); assert.equal(evs(conn, 'miss').length, 1, '被拒不重复裁决'); fastForward(mgr, 2.5); conn.clientSend({ t: 'act', kind: 'attack', dir: 'N', seq: 5 }); assert.equal(evs(conn, 'miss').length, 2, '冷却过后恢复裁决'); // 旋风斩冷却 8s conn.clientSend({ t: 'act', kind: 'skill', seq: 6 }); assert.equal(evs(conn, 'skill').length, 1); conn.clientSend({ t: 'act', kind: 'skill', seq: 7 }); assert.equal(errs(conn, 'skill_cd').length, 1, '伪造越冷却 skill → err skill_cd'); fastForward(mgr, 8); conn.clientSend({ t: 'act', kind: 'skill', seq: 8 }); assert.equal(evs(conn, 'skill').length, 2); // 呼喊节流 5s:连点只出一条 callout ev conn.clientSend({ t: 'act', kind: 'callout', seq: 9 }); conn.clientSend({ t: 'act', kind: 'callout', seq: 10 }); conn.clientSend({ t: 'act', kind: 'callout', seq: 11 }); assert.equal(evs(conn, 'callout').length, 1, '呼喊节流 5s(连点只出一条)'); assert.ok(errs(conn, 'cooldown').length >= 2); // bad_dir / bad_msg / 版本 conn.clientSend({ t: 'act', kind: 'attack', dir: 'X', seq: 12 }); assert.equal(errs(conn, 'bad_dir').length, 1); fastForward(mgr, 2.5); conn.clientSend({ t: 'act', kind: 'attack', seq: 13 }); // 缺 dir assert.equal(errs(conn, 'bad_dir').length, 2); conn.clientSend({ t: 'act', kind: 'warp', seq: 14 }); assert.ok(errs(conn, 'bad_msg').length >= 1); conn.clientSend({ t: 'join', ver: 1, room: 'quick', name: '甲' }); // 重复 join assert.ok(errs(conn, 'bad_msg').length >= 2); const bad1 = new FakeConn(); mgr.attach(bad1); bad1.clientSend({ t: 'join', ver: 2, room: 'quick', name: 'x' }); assert.equal(errs(bad1, 'bad_msg').length, 1, 'ver 不符 → bad_msg'); const bad2 = new FakeConn(); mgr.attach(bad2); bad2.emit('message', 'not-json{{{'); assert.equal(errs(bad2, 'bad_msg').length, 1, '非 JSON → bad_msg'); const bad3 = new FakeConn(); mgr.attach(bad3); bad3.clientSend({ t: 'act', kind: 'attack', dir: 'N', seq: 1 }); assert.equal(errs(bad3, 'bad_msg').length, 1, '首条非 join → bad_msg'); room.destroy(); }); // ---------------- A8. 出击弧 / 近战窗 / 旋风斩清场(教学房隔离 bot 干扰) ---------------- test('A8 弧 ±45° 击杀 eta 最小者 / eta>4 不可打 / skill 清内圈', () => { const mgr = createManager({ rateLimit: Infinity }); const { conn, joined } = joinQuick(mgr, '甲', { teach: true }); // 教学段 bot AI 挂起 assert.equal(joined.state.phase, 'teach', '教学房直入 TEACH'); assert.equal(joined.roster.filter((r) => r.bot).length, 2, '教学房 2 bot'); const room = mgr.rooms.get(joined.room); // 弧映射:NE 怪可被 N 出击弧命中(上滑弧 {NW,N,NE}) room.spawnMob({ mkind: 'wolf', dir: 'NE', eta: 3 }); conn.clientSend({ t: 'act', kind: 'attack', dir: 'N', seq: 1 }); let kill = lastEv(conn, 'kill'); assert.ok(kill && kill.slot === 0 && kill.dir === 'NE', 'N 弧命中 NE 怪,事件 dir 为怪实际方位'); // 挥空:弧内无怪 fastForward(mgr, 2.5); conn.clientSend({ t: 'act', kind: 'attack', dir: 'S', seq: 2 }); assert.ok(lastEv(conn, 'miss'), '弧内无怪 → miss'); // 近战窗:eta>4 不可打 fastForward(mgr, 2.5); room.spawnMob({ mkind: 'wolf', dir: 'S', eta: 5 }); conn.clientSend({ t: 'act', kind: 'attack', dir: 'S', seq: 3 }); assert.equal(lastEv(conn, 'miss').slot, 0, 'eta>4 窗外 → miss'); // 多目标取 eta 最小者 fastForward(mgr, 2.5); const m1 = room.spawnMob({ mkind: 'wolf', dir: 'W', eta: 3.5 }); const m2 = room.spawnMob({ mkind: 'wolf', dir: 'NW', eta: 2.0 }); conn.clientSend({ t: 'act', kind: 'attack', dir: 'W', seq: 4 }); kill = lastEv(conn, 'kill'); assert.equal(kill.id, m2.id, '击杀弧内 eta 最小者'); assert.ok(room.mobs.has(m1.id)); // 旋风斩:清全场 eta≤4(不分方向),窗外保留 fastForward(mgr, 2.5); room.mobs.get(m1.id).eta = 3.0; const far = room.spawnMob({ mkind: 'wolf', dir: 'E', eta: 7 }); conn.clientSend({ t: 'act', kind: 'skill', seq: 5 }); const skill = lastEv(conn, 'skill'); assert.ok(skill.cleared.length >= 2, '清掉内圈全部'); assert.ok(room.mobs.has(far.id), 'eta>4 保留'); room.destroy(); }); // ---------------- A9. 突入扣耐久 / 失守终局 / 房灭回收 / 评级映射 ---------------- test('A9 突入:狼 -1 熊 -2;耐久归零立即失守终局(无评级不写榜口径);30s 后房灭', () => { const mgr = createManager({ rateLimit: Infinity }); mgr.createRoom({ seed: 23 }); const { conn, joined } = joinQuick(mgr, '甲'); conn.clientSend({ t: 'act', kind: 'start', seq: 1 }); const room = mgr.rooms.get(joined.room); fastForward(mgr, 1); room.spawnMob({ mkind: 'wolf', dir: 'N', eta: 0.05 }); fastForward(mgr, 0.1); let breach = lastEv(conn, 'breach'); assert.equal(breach.dur, 9, '狼突入 -1'); room.spawnMob({ mkind: 'bear', dir: 'S', eta: 0.05 }); fastForward(mgr, 0.1); breach = lastEv(conn, 'breach'); assert.equal(breach.dur, 7, '熊突入 -2(§15 裁决 8)'); // 失守:5 熊扣空耐久 for (let i = 0; i < 5; i++) room.spawnMob({ mkind: 'bear', dir: 'N', eta: 0.05 }); fastForward(mgr, 0.2); const end = lastEv(conn, 'end'); assert.equal(end.win, false, '耐久归零 = 失守立即终局'); assert.equal(end.dur, 0); assert.equal(end.grade, null, '失守无评级(客户端不写 best 的协议前提)'); assert.equal(typeof end.kills, 'object'); assert.equal(room.phase, 'end'); // 终局保留 30s 后 close 1000 + 房灭回收 fastForward(mgr, END_KEEP_MS / 1000 + 0.5); assert.equal(conn.closedCode, 1000, '终局后 close 1000'); assert.ok(room.destroyed, '房灭回收'); assert.equal(mgr.rooms.size, 0); // 房灭后迟到 resume → room_gone const late = new FakeConn(); mgr.attach(late); late.clientSend({ t: 'join', ver: 1, room: 'quick', name: '甲', resume: joined.resume }); assert.equal(errs(late, 'room_gone').length, 1, '房灭后 resume → room_gone'); }); test('A9b 评级映射 §3.6:≥9 S / 7-8 A / 4-6 B / 1-3 C / 0 失守', () => { assert.equal(gradeFor(10), 'S'); assert.equal(gradeFor(9), 'S'); assert.equal(gradeFor(8), 'A'); assert.equal(gradeFor(7), 'A'); assert.equal(gradeFor(6), 'B'); assert.equal(gradeFor(4), 'B'); assert.equal(gradeFor(3), 'C'); assert.equal(gradeFor(1), 'C'); assert.equal(gradeFor(0), null); }); // ---------------- A10. lobby 行为(§6.4) ---------------- test('A10 lobby:12s 自动开局 / 教学狼零惩罚接续第 1 波 / 房码 join / 对局 room_full', () => { const mgr = createManager({ rateLimit: Infinity }); // 12s 到点自动开局 mgr.createRoom({ seed: 31 }); const a = joinQuick(mgr, '甲'); const roomA = mgr.rooms.get(a.joined.room); fastForward(mgr, LOBBY_MS / 1000); assert.equal(roomA.phase, 'wave', 'lobby 12s 到点自动开局'); // 显式房码:首 join 建房、次 join 同房 const c1 = new FakeConn(); mgr.attach(c1); c1.clientSend({ t: 'join', ver: 1, room: 'WXYZ', name: '房客一' }); const j1 = c1.sent.find((m) => m.t === 'joined'); assert.equal(j1.room, 'WXYZ', '显式房码建房'); const c2 = new FakeConn(); mgr.attach(c2); c2.clientSend({ t: 'join', ver: 1, room: 'WXYZ', name: '房客二' }); assert.equal(c2.sent.find((m) => m.t === 'joined').room, 'WXYZ', '同房码 join 同房'); // 对局中 join → room_full c1.clientSend({ t: 'act', kind: 'start', seq: 1 }); const c3 = new FakeConn(); mgr.attach(c3); c3.clientSend({ t: 'join', ver: 1, room: 'WXYZ', name: '迟到者' }); assert.equal(errs(c3, 'room_full').length, 1, '对局中 join → room_full'); // 非法房码 const c4 = new FakeConn(); mgr.attach(c4); c4.clientSend({ t: 'join', ver: 1, room: 'AB12!', name: 'x' }); assert.equal(errs(c4, 'bad_msg').length, 1); // 教学房:强制单人 + 直入 teach;教学波突入零惩罚;接续第 1 波 const t = joinQuick(mgr, '新生', { teach: true }); const teachRoom = mgr.rooms.get(t.joined.room); assert.equal(t.joined.state.phase, 'teach'); assert.equal(teachRoom.humanCount(), 1, '教学房强制单人'); assert.equal(teachRoom.occupiedSlots().length, 3, '2 bot 补位'); fastForward(mgr, 6.5); // 狼 1 放出(ts 6s,eta 12) const tSpawn = lastEv(t.conn, 'spawn'); assert.ok(tSpawn && tSpawn.dir === 'N' && tSpawn.eta === 12, '教学狼 1:方位 N、逼近 12s'); fastForward(mgr, 12); // 不操作 → 突入 const tBreach = lastEv(t.conn, 'breach'); assert.ok(tBreach && tBreach.dur === 10, '教学波突入不扣耐久(学期零惩罚)'); fastForward(mgr, 90); // 补放重教 ≤2 次后放过 → 双狼 → 接续 assert.equal(teachRoom.phase, 'wave', '教学波完同场接续第 1 波'); assert.ok(evs(t.conn, 'wave').some((e) => e.wave === 1), 'ev wave 1 到达'); roomA.destroy(); mgr.rooms.get('WXYZ').destroy(); teachRoom.destroy(); }); // ---------------- A11. bot 补位(§7.3:呼喊 → 偏置 ×2 + help_ack 恰一次) ---------------- test('A11 呼喊支援:空闲 bot help_ack + 10s 内下次出击方向落入呼喊者防区', () => { const mgr = createManager({ rateLimit: Infinity }); mgr.createRoom({ seed: 37 }); const { conn, joined } = joinQuick(mgr, '甲'); conn.clientSend({ t: 'act', kind: 'start', seq: 1 }); const room = mgr.rooms.get(joined.room); conn.clientSend({ t: 'act', kind: 'callout', seq: 2 }); const acks = evs(conn, 'botline').filter((e) => e.line === 'help_ack'); assert.equal(acks.length, 1, 'help_ack 恰播一次'); const acker = room.slots[acks[0].slot]; assert.ok(acker.bot, '响应者为 bot'); assert.deepEqual(acker.cover.callerSector, ['N', 'NE', 'E'], '补位偏置 = 呼喊者防区(座 0)'); assert.ok(acker.cover.until - room.now <= 10000 && acker.cover.until > room.now, '偏置 10s 窗'); // 构造压力对比:呼喊者防区 N(eta 2.9)vs 响应者主防区 S(eta 2.1,更近) // 无偏置时 P(S)=1/2.1×1.5=0.71 > P(N)=1/2.9=0.34;补位 ×2 覆盖后 P(N)=0.69 > P(S)=0.48 room.spawnMob({ mkind: 'wolf', dir: 'N', eta: 2.9 }); room.spawnMob({ mkind: 'wolf', dir: 'S', eta: 2.1 }); fastForward(mgr, 2.5); // bot 500ms tick;eta 衰减到反应带内必出手 const coverKill = evs(conn, 'kill').find((e) => e.slot === acker.slot); assert.ok(coverKill, '补位 bot 出手'); assert.ok(['N', 'NE', 'E'].includes(coverKill.dir), '下一次 act.dir ∈ 呼喊者防区(偏置 ×2 生效)'); room.destroy(); }); // ---------------- A12. 喊话节流(§7.4:氛围 10s ≤2 条,呼救优先不丢) ---------------- test('A12 botline 全局节流:10s 窗氛围 ≤2 条;help_call/help_ack 优先不被顶掉', () => { const mgr = createManager({ rateLimit: Infinity }); mgr.createRoom({ seed: 41 }); const { conn, joined } = joinQuick(mgr, '甲'); conn.clientSend({ t: 'act', kind: 'start', seq: 1 }); const room = mgr.rooms.get(joined.room); const bot = room.botSlots()[0]; const before = evs(conn, 'botline').length; for (let i = 0; i < 5; i++) room.emitBotline(bot, 'greet'); // 连发 5 条氛围句 assert.equal(evs(conn, 'botline').length - before, 2, '10s 窗氛围句 ≤2,超出丢弃'); // 预算耗尽时呼救语义仍必达 room.emitBotline(bot, 'help_ack', { priority: true }); assert.equal(evs(conn, 'botline').length - before, 3, 'help_ack 优先不被氛围顶掉'); assert.equal(evs(conn, 'botline').filter((e) => e.line === 'help_ack').length, 1); // 窗口滑过后恢复 fastForward(mgr, 10.5); room.emitBotline(bot, 'greet'); assert.equal(evs(conn, 'botline').length - before, 4, '10s 窗滑过后氛围恢复投递'); room.destroy(); }); // ---------------- A13. 最后人类断开:宽限耗尽房灭,bot 不续命 ---------------- test('A13 单人房断线:30s 宽限后房灭(bot 不续命),resume → room_gone', async () => { const mgr = createManager({ rateLimit: Infinity }); mgr.createRoom({ seed: 43 }); const { conn, joined } = joinQuick(mgr, '甲'); conn.clientSend({ t: 'act', kind: 'start', seq: 1 }); const room = mgr.rooms.get(joined.room); fastForward(mgr, 1); conn.clientClose(); await new Promise((r) => setImmediate(r)); fastForward(mgr, GRACE_MS / 1000 - 5); assert.ok(!room.destroyed, '宽限期内房间保留'); fastForward(mgr, 6); assert.ok(room.destroyed, '宽限耗尽房灭'); assert.equal(mgr.rooms.size, 0); const late = new FakeConn(); mgr.attach(late); late.clientSend({ t: 'join', ver: 1, room: 'quick', name: '甲', resume: joined.resume }); assert.equal(errs(late, 'room_gone').length, 1); }); // ---------------- A14. 速率帽与 join 超时(§6.1/§6.2) ---------------- test('A14 消息速率帽 20/s:err rate → 再犯 close;join 超时 close', async () => { const mgr = createManager({ joinTimeoutMs: 200 }); const { conn } = joinQuick(mgr, '甲'); for (let i = 0; i < 25; i++) conn.clientSend({ t: 'act', kind: 'start', seq: i + 1 }); assert.ok(errs(conn, 'rate').length >= 1, '超 20 条/s → err rate'); assert.equal(conn.closedCode, 1008, '再犯 → close'); const slow = new FakeConn(); mgr.attach(slow); await new Promise((r) => setTimeout(r, 300)); assert.equal(slow.closedCode, 1008, '5s(测试档 200ms)未 join → close'); for (const room of [...mgr.rooms.values()]) room.destroy(); }); // ---------------- B. WS 传输冒烟(真服务器 + 手写握手/帧客户端) ---------------- function frameFromClient(opcode, payload) { const mask = crypto.randomBytes(4); const len = payload.length; let header; if (len < 126) { header = Buffer.from([0x80 | opcode, 0x80 | len]); } else if (len < 65536) { header = Buffer.alloc(4); header[0] = 0x80 | opcode; header[1] = 0x80 | 126; header.writeUInt16BE(len, 2); } else { header = Buffer.alloc(10); header[0] = 0x80 | opcode; header[1] = 0x80 | 127; header.writeBigUInt64BE(BigInt(len), 2); } const masked = Buffer.from(payload); for (let i = 0; i < masked.length; i++) masked[i] ^= mask[i & 3]; return Buffer.concat([header, mask, masked]); } /** 服务器帧流解析器(服务器帧不 mask)。 */ function makeServerFrameParser(onFrame) { let buf = Buffer.alloc(0); return (chunk) => { buf = Buffer.concat([buf, chunk]); for (;;) { if (buf.length < 2) return; const b0 = buf[0]; const b1 = buf[1]; let len = b1 & 0x7f; let off = 2; if (len === 126) { if (buf.length < 4) return; len = buf.readUInt16BE(2); off = 4; } else if (len === 127) { if (buf.length < 10) return; len = Number(buf.readBigUInt64BE(2)); off = 10; } if (buf.length < off + len) return; const payload = Buffer.from(buf.subarray(off, off + len)); buf = buf.subarray(off + len); onFrame({ fin: (b0 & 0x80) !== 0, opcode: b0 & 0x0f, masked: (b1 & 0x80) !== 0, payload }); } }; } async function startRealServer() { const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'soundgame-hunt-')); const port = 20000 + Math.floor(Math.random() * 20000); const child = spawn(NODE, [SERVER], { env: { ...process.env, PORT: String(port), SG_DATA_DIR: dataDir }, stdio: ['ignore', 'pipe', 'pipe'], }); const logs = []; child.stdout.on('data', (c) => logs.push(String(c))); child.stderr.on('data', (c) => logs.push(String(c))); const base = `http://127.0.0.1:${port}`; const deadline = Date.now() + 15000; for (;;) { if (child.exitCode !== null) throw new Error(`服务器提前退出:\n${logs.join('')}`); if (Date.now() > deadline) throw new Error('服务器启动超时'); try { const r = await fetch(base + '/'); if (r.ok) break; } catch (_) { /* 尚未监听 */ } await new Promise((r) => setTimeout(r, 100)); } return { base, port, logs, stop: () => new Promise((resolve) => { child.on('exit', resolve); child.kill('SIGTERM'); setTimeout(() => { try { child.kill('SIGKILL'); } catch (_) {} resolve(); }, 2000); }), }; } /** 手写 WS 客户端:握手 + 帧收发(§6.1 验证口径)。 */ function wsClient(port) { return new Promise((resolve, reject) => { const key = crypto.randomBytes(16).toString('base64'); const sock = net.connect(port, '127.0.0.1'); const frames = []; let messages; const parse = makeServerFrameParser((f) => { frames.push(f); if (f.opcode === 1) messages.push(JSON.parse(f.payload.toString('utf8'))); }); let handshake = Buffer.alloc(0); let upgraded = false; sock.on('data', (chunk) => { if (!upgraded) { handshake = Buffer.concat([handshake, chunk]); const idx = handshake.indexOf('\r\n\r\n'); if (idx === -1) return; const head = handshake.subarray(0, idx).toString('latin1'); upgraded = true; const rest = handshake.subarray(idx + 4); if (rest.length) parse(rest); resolve({ sock, head, key, frames, messages, sendText: (obj) => sock.write(frameFromClient(1, Buffer.from(JSON.stringify(obj)))), sendRaw: (opcode, payload, masked = true) => { if (masked) sock.write(frameFromClient(opcode, payload)); else { // 无 mask 违规帧 const len = payload.length; sock.write(Buffer.concat([Buffer.from([0x80 | opcode, len]), payload])); } }, }); } else { parse(chunk); } }); sock.on('error', reject); messages = []; sock.write( 'GET /ws/hunt HTTP/1.1\r\n' + 'Host: 127.0.0.1\r\n' + 'Upgrade: websocket\r\n' + 'Connection: keep-alive, Upgrade\r\n' + `Sec-WebSocket-Key: ${key}\r\n` + 'Sec-WebSocket-Version: 13\r\n\r\n', ); }); } function waitFor(pred, ms = 5000) { const deadline = Date.now() + ms; return new Promise((resolve, reject) => { const check = () => { try { const v = pred(); if (v) return resolve(v); } catch (_) { /* 继续等 */ } if (Date.now() > deadline) return reject(new Error('waitFor 超时')); setTimeout(check, 20); }; check(); }); } test('B1 WS 冒烟:握手/违规帧/ping-pong/join 全流程 + 静态与 REST 回归', async () => { const srv = await startRealServer(); try { // 静态托管与 REST 不受 upgrade 增补影响 const home = await fetch(srv.base + '/'); assert.equal(home.status, 200, '静态大厅照常'); const dev = await fetch(srv.base + '/api/device', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', }); assert.equal(dev.status, 201, 'REST 建档照常'); // 握手:101 + Accept 哈希正确 const c = await wsClient(srv.port); assert.match(c.head, /^HTTP\/1\.1 101 /, '101 Switching Protocols'); const expectAccept = crypto.createHash('sha1') .update(c.key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64'); assert.ok(c.head.includes(`Sec-WebSocket-Accept: ${expectAccept}`), 'Accept = sha1(key+GUID)'); // join → joined 经真帧到达(服务器帧无 mask) c.sendText({ t: 'join', ver: 1, room: 'quick', name: '冒烟' }); await waitFor(() => c.messages.find((m) => m.t === 'joined')); assert.ok(c.frames.every((f) => !f.masked), '服务器→客户端帧不 mask'); // ping → pong c.frames.length = 0; c.sendRaw(9, Buffer.from('hb')); await waitFor(() => c.frames.find((f) => f.opcode === 10)); assert.equal(c.frames.find((f) => f.opcode === 10).payload.toString(), 'hb', 'pong 回显载荷'); // 无 mask 帧 → close 1002 c.sendRaw(1, Buffer.from('{}'), false); await waitFor(() => c.frames.find((f) => f.opcode === 8)); assert.equal(c.frames.find((f) => f.opcode === 8).payload.readUInt16BE(0), 1002, '无 mask → 1002'); await waitFor(() => c.sock.destroyed === true || true); // 收束即可 c.sock.destroy(); // 二进制帧 → close 1003 const c2 = await wsClient(srv.port); c2.sendRaw(2, Buffer.from([1, 2, 3])); await waitFor(() => c2.frames.find((f) => f.opcode === 8)); assert.equal(c2.frames.find((f) => f.opcode === 8).payload.readUInt16BE(0), 1003, '二进制 → 1003'); c2.sock.destroy(); // 分片 text 拼装(continuation) const c3 = await wsClient(srv.port); const full = JSON.stringify({ t: 'join', ver: 1, room: 'quick', name: '分片' }); const half = Math.floor(full.length / 2); const headBuf = Buffer.from(full.slice(0, half)); const tailBuf = Buffer.from(full.slice(half)); // 首片 FIN=0 opcode=1 const mask1 = crypto.randomBytes(4); const m1 = Buffer.from(headBuf); for (let i = 0; i < m1.length; i++) m1[i] ^= mask1[i & 3]; c3.sock.write(Buffer.concat([Buffer.from([0x01, 0x80 | m1.length]), mask1, m1])); // 尾片 FIN=1 opcode=0 const mask2 = crypto.randomBytes(4); const m2 = Buffer.from(tailBuf); for (let i = 0; i < m2.length; i++) m2[i] ^= mask2[i & 3]; c3.sock.write(Buffer.concat([Buffer.from([0x80, 0x80 | m2.length]), mask2, m2])); await waitFor(() => c3.messages.find((m) => m.t === 'joined'), 5000); assert.ok(true, '分片 text 拼装成完整消息'); c3.sock.destroy(); } finally { await srv.stop(); } });