Files
SoundGame/web/server/test/hunt-sim.test.mjs
T
xsl 005851d01a feat(hunt): 《围猎》2-4人实时联机纯音频协作守卫——纯音频MMORPG第一阶段切片
- 设计:docs/design/23-hunt.md + voice-ids(84 id,166条)+ 13号断言登记,§15记录10+1条设计裁决
- 服务器:零依赖WebSocket(ws.js)+ 房间管理/服务端权威战斗/bot AI(hunt-room.js),断线30s宽限resume回槽、超时转bot;修复package.json模块类型误判的既有破坏
- 客户端:web/hunt/ 全套(教学T0-T4/听觉视野裁剪R1-R8/结算排行榜/guide接入),playtest 211断言
- 引擎:audio.js 后向兼容 opts.pan(StereoPannerNode),web-engine-api.md 同步
- 资产:166条TTS语音(QC无离群)+ 13件合成sfx(真峰值≤-3dBFS)+ 2首Mureka BGM,终检全绿
- 平衡:bot调参至挂机局71%失守/C、3bot局71% A/B(hunt-balance-scan.mjs 24种子扫描)
- 接线:大厅卡片、profiles.js hunt桶、README/roadmap MMO路线条目

验证:hunt-sim 16/16 + api-smoke 12/12 + hunt playtest 211/211 + 既有10游戏与引擎回归零回退 + 真服务器WS端到端冒烟通过
2026-09-13 11:08:01 +08:00

903 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* hunt-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();
}
});