- 设计: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端到端冒烟通过
903 lines
38 KiB
JavaScript
903 lines
38 KiB
JavaScript
/**
|
||
* 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();
|
||
}
|
||
});
|