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端到端冒烟通过
This commit is contained in:
xsl
2026-09-13 11:08:01 +08:00
parent 31372615b2
commit 005851d01a
392 changed files with 6826 additions and 19 deletions
+267
View File
@@ -0,0 +1,267 @@
/**
* ws.js — 零依赖 WebSocket 传输层(RFC 6455 子集),供 server.js 的 /ws/hunt 使用。
*
* 设计契约:docs/design/23-hunt.md §6.1(Node 原生 http upgrade + 手写握手与帧编解码,
* 不引任何 npm 包):
* - 握手:校验 Upgrade/Connection/Sec-WebSocket-Key/Version=13,回 101 +
* Sec-WebSocket-Accept = base64(sha1(key + GUID))(node:crypto)。
* - 帧解析:客户端帧必须带 mask(不带 → close 1002);支持 opcode 0(continuation
* 拼装)/1(text)/8(close,回显)/9(ping→pong)/10(pong);7/16/64 位长度全支持;
* RSV 位置位或未知 opcode → 1002;控制帧分片或 >125B → 1002;二进制帧 → 1003;
* 单条消息(含分片拼装)> 4KB → 1009;text 非合法 UTF-8 → 1007。
* - 服务器→客户端帧不 mask。
* - 活性:每 15s 服务器 ping,45s 无 pong 判死(触发 'close',由上层走 §6.6 断线流程)。
* - 发送背压:每连接一个待发队列;droppable(snap)新顶旧,其余不丢;队列 >200 → close 1013。
*
* 导出 accept(req, socket, head) → WsConn | null。WsConn 是 EventEmitter:
* 事件 'message'(string) / 'close'();方法 send(str, {droppable}) / close(code, reason);
* 属性 open。房间层(hunt-room.js)只依赖这个最小接口,测试可用假连接替身。
*/
'use strict';
const crypto = require('node:crypto');
const { EventEmitter } = require('node:events');
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const MAX_MESSAGE_BYTES = 4096; // 单条消息上限 4KB(§6.1)
const MAX_BUFFER_BYTES = 16384; // 接收缓冲兜底(消息上限 4KB,超出必为异常流量)
const MAX_QUEUE = 200; // 每连接待发队列上限(§6.1 背压)
const PING_INTERVAL_MS = 15000;
const PONG_TIMEOUT_MS = 45000;
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
/** 握手:校验升级请求并回 101;失败回 400 并销毁 socket,返回 null。 */
function accept(req, socket, head) {
const key = req.headers['sec-websocket-key'];
const upgrade = String(req.headers.upgrade || '').toLowerCase();
const connHdr = String(req.headers.connection || '').toLowerCase();
const version = String(req.headers['sec-websocket-version'] || '');
const ok = upgrade === 'websocket'
&& connHdr.split(',').map((s) => s.trim()).includes('upgrade')
&& typeof key === 'string' && key.length > 0
&& version === '13';
if (!ok) {
socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
socket.destroy();
return null;
}
const acceptKey = crypto.createHash('sha1').update(key + WS_GUID).digest('base64');
socket.write(
'HTTP/1.1 101 Switching Protocols\r\n'
+ 'Upgrade: websocket\r\n'
+ 'Connection: Upgrade\r\n'
+ `Sec-WebSocket-Accept: ${acceptKey}\r\n\r\n`,
);
return new WsConn(socket, head);
}
/** 服务器→客户端帧(不 mask)。 */
function encodeFrame(opcode, payload) {
const len = payload.length;
let header;
if (len < 126) {
header = Buffer.from([0x80 | opcode, len]);
} else if (len < 65536) {
header = Buffer.alloc(4);
header[0] = 0x80 | opcode;
header[1] = 126;
header.writeUInt16BE(len, 2);
} else {
header = Buffer.alloc(10);
header[0] = 0x80 | opcode;
header[1] = 127;
header.writeBigUInt64BE(BigInt(len), 2);
}
return Buffer.concat([header, payload]);
}
class WsConn extends EventEmitter {
constructor(socket, head) {
super();
this.socket = socket;
this.open = true; // 可写状态(close 帧发出即 false)
this.dead = false; // 'close' 事件只发一次
this.buf = Buffer.alloc(0);
this.frag = null; // 分片拼装中:{ chunks: Buffer[], size: number }
this.queue = []; // 待发队列 [{ buf, droppable }]
this.flushing = false;
this.lastPongAt = Date.now();
socket.on('data', (chunk) => this._onData(chunk));
socket.on('error', () => this._dead());
socket.on('close', () => this._dead());
socket.on('end', () => this._dead());
this.pingTimer = setInterval(() => this._pingTick(), PING_INTERVAL_MS);
if (this.pingTimer.unref) this.pingTimer.unref();
if (head && head.length) this._onData(head);
}
/** 应用消息发送(text 帧)。droppable=true 的待发消息被新同类顶掉(snap 专用)。 */
send(str, opts = {}) {
if (!this.open) return;
const buf = encodeFrame(0x1, Buffer.from(str, 'utf8'));
if (opts.droppable) this.queue = this.queue.filter((q) => !q.droppable);
this.queue.push({ buf, droppable: !!opts.droppable });
if (this.queue.length > MAX_QUEUE) {
this.close(1013, 'send queue overflow');
return;
}
this._flush();
}
/** 协议关闭:发 close 帧后收束 socket(50ms 内对端不回 close 也直接销毁)。 */
close(code = 1000, reason = '') {
if (!this.open) return;
this.open = false;
const reasonBuf = Buffer.from(String(reason), 'utf8');
const payload = Buffer.alloc(2 + Math.min(reasonBuf.length, 123));
payload.writeUInt16BE(code, 0);
reasonBuf.copy(payload, 2, 0, payload.length - 2);
this.queue = [];
try { this.socket.write(encodeFrame(0x8, payload)); } catch (_) { /* 已坏 */ }
try { this.socket.end(); } catch (_) { /* 已坏 */ }
this._deadTimer = setTimeout(() => this._dead(), 50);
if (this._deadTimer.unref) this._deadTimer.unref();
}
// ---------------- 内部 ----------------
_flush() {
if (this.flushing || !this.open) return;
this.flushing = true;
while (this.queue.length) {
const { buf } = this.queue.shift();
const ok = this.socket.write(buf);
if (!ok) {
this.socket.once('drain', () => {
this.flushing = false;
this._flush();
});
return;
}
}
this.flushing = false;
}
_dead() {
if (this.dead) return;
this.dead = true;
this.open = false;
clearInterval(this.pingTimer);
clearTimeout(this._deadTimer);
try { this.socket.destroy(); } catch (_) { /* 忽略 */ }
this.emit('close');
}
/** 15s 心跳:45s 无 pong 判死(等同断线,§6.1/§6.6)。 */
_pingTick() {
if (!this.open) return;
if (Date.now() - this.lastPongAt > PONG_TIMEOUT_MS) {
this._dead();
return;
}
try { this.socket.write(encodeFrame(0x9, Buffer.alloc(0))); } catch (_) { this._dead(); }
}
_onData(chunk) {
if (!this.open) return;
this.buf = Buffer.concat([this.buf, chunk]);
if (this.buf.length > MAX_BUFFER_BYTES) {
this._fail(1009);
return;
}
this._parseFrames();
}
_parseFrames() {
while (this.open) {
if (this.buf.length < 2) return;
const b0 = this.buf[0];
const b1 = this.buf[1];
const fin = (b0 & 0x80) !== 0;
const opcode = b0 & 0x0f;
const masked = (b1 & 0x80) !== 0;
let len = b1 & 0x7f;
let off = 2;
if (len === 126) {
if (this.buf.length < 4) return;
len = this.buf.readUInt16BE(2);
off = 4;
} else if (len === 127) {
if (this.buf.length < 10) return;
const big = this.buf.readBigUInt64BE(2);
if (big > BigInt(MAX_MESSAGE_BYTES)) { this._fail(1009); return; }
len = Number(big);
off = 10;
}
// 协议校验(RFC 6455 + §6.1 纪律)
if ((b0 & 0x70) !== 0) return this._fail(1002); // RSV 位(无扩展协商)
if (!masked) return this._fail(1002); // 客户端帧必须带 mask
if (![0, 1, 2, 8, 9, 10].includes(opcode)) return this._fail(1002);
if (opcode === 2) return this._fail(1003); // 二进制帧不受理
if (opcode >= 8 && (!fin || len > 125)) return this._fail(1002); // 控制帧不可分片、≤125B
if (opcode > 0 && len > MAX_MESSAGE_BYTES) return this._fail(1009);
if (opcode === 0 && this.frag === null) return this._fail(1002); // 无分片上下文
if ((opcode === 1 || opcode === 2) && this.frag !== null) return this._fail(1002); // 分片中又起新帧
const maskOff = off;
off += 4;
if (this.buf.length < off + len) return; // 等更多数据
const payload = Buffer.from(this.buf.subarray(off, off + len));
const mask = this.buf.subarray(maskOff, maskOff + 4);
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
this.buf = this.buf.subarray(off + len);
this._onFrame(fin, opcode, payload);
}
}
_onFrame(fin, opcode, payload) {
if (opcode === 8) { // close:回显后收束
if (this.open) {
this.queue = [];
try { this.socket.write(encodeFrame(0x8, payload)); } catch (_) { /* 忽略 */ }
}
this.open = false;
try { this.socket.end(); } catch (_) { /* 忽略 */ }
this._deadTimer = setTimeout(() => this._dead(), 50);
if (this._deadTimer.unref) this._deadTimer.unref();
return;
}
if (opcode === 9) { // ping → pong(回显载荷,立即写,不走队列)
try { this.socket.write(encodeFrame(0xA, payload)); } catch (_) { this._dead(); }
return;
}
if (opcode === 10) { // pong
this.lastPongAt = Date.now();
return;
}
// 数据帧(text / continuation)
if (opcode === 1) {
this.frag = { chunks: [payload], size: payload.length };
} else if (this.frag) {
this.frag.chunks.push(payload);
this.frag.size += payload.length;
if (this.frag.size > MAX_MESSAGE_BYTES) return this._fail(1009);
}
if (fin && this.frag) {
const full = Buffer.concat(this.frag.chunks);
this.frag = null;
let str;
try {
str = utf8Decoder.decode(full);
} catch (_) {
return this._fail(1007); // 非法 UTF-8
}
this.emit('message', str);
}
}
_fail(code) {
this.close(code, 'protocol error');
}
}
module.exports = { accept, encodeFrame, WsConn, MAX_MESSAGE_BYTES, MAX_QUEUE };