Files
SoundGame/web/harness/hunt-sim/lib/ws.mjs
T
xsl 0652c2f0e6 research(hunt-sim): 10 人并发在线模拟×多 agent 设计迭代——研究设施与全套档案
研究命题(00-plan):验证并打磨《围猎》联机纯音频体验——不是逻辑能跑通,
而是真人玩起来是什么体验、愿不愿意再来一局。

设施 web/harness/hunt-sim/(可复用,零依赖):
- arena.mjs 编排器:起真实服务器(随机端口+临时数据目录隔离),10 个拟人客户端
  经真实 WS 同时在线,错峰组队、真实时长对局、局间状态跨轮维护
- sim-client.mjs:game.js 行为移植(协议+手势路由+R1-R8 音频调度逐字节对齐),
  产出「玩家耳朵听到什么」的感知时间线(语音全文/loop 方位音量档/喊话/震动)
- brain.mjs:人设大脑——察觉延迟/前后判读(以真值为条件的正确率模型)/掩蔽放大/
  冷却乱滑/技能囤积/呼喊倾向/分心断网,全部随机判定用按实体 id 哈希的独立抽签
  (mulberry32 流会被刷怪座位轮转结构带偏,实测造成系统性镜像反转,已证伪)
- recorder.mjs:每人每局 events.jsonl 全量 + percept.md 感知转写 + 对局速览

研究档案 research/hunt-sim/:
- players/:10 人配置(LLM 亲笔)+策略自述+逐局去留决策+人设终评+退游者重访
- rounds/:v1 三轮(R0 教学+R1/R2 真人快速)+ v2 复测两轮,共 48 局全记录
- analysis/01-04:数值曲线/音频信息设计/协作社交/上手留存四视角报告
- synthesis.md(S1-S9 裁定)、metrics.md/metrics-v2.md(两版对比总账)、report.md(总报告)

v1 核心发现:10 人 3 轮 28 局 100% 胜率 26S/营地零损耗;击杀垄断(专家单人 30/39);
呼喊 3 次/28 局应答 0;N/S 前后判读仅 63/69% 可命中;四种退游死因
(失衡/社交真空/交互摩擦/观光无聊)→ 详见 report.md
2026-09-19 11:06:07 +08:00

149 lines
4.9 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.
/**
* ws.mjs — 《围猎》模拟竞技场的 WebSocket 客户端(node:net 手写,零依赖)。
* 与 web/server/test/hunt-sim.test.mjs 的 B 组冒烟客户端同源,增加:
* - 自动回 pong(服务器 15s ping,45s 无 pong 判死——对局长打必须应答)
* - close 帧解析与自动重连占位(P09 断网重连走 arena 的 drop/resume)
* - 发送队列(连接建立前的消息排队)
*
* 协议契约:docs/design/23-hunt.md §6.1。
*/
import net from 'node:net';
import crypto from 'node:crypto';
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
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]);
}
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({ opcode: b0 & 0x0f, payload });
}
};
}
/**
* connect(port, { name }) → Promise<WsSession>
* WsSession: { send(obj), close(), drop(), messages, on('message'|'close') }
* drop():模拟断网——直接销毁 socket(不发 close 帧,等同 §6.6 断线路径)。
*/
export function connect(port) {
return new Promise((resolve, reject) => {
const key = crypto.randomBytes(16).toString('base64');
const sock = net.connect(port, '127.0.0.1');
const session = new EventEmitterLite();
session.sock = sock;
session.messages = [];
session.send = (obj) => {
if (sock.destroyed) return false;
sock.write(frameFromClient(1, Buffer.from(JSON.stringify(obj))));
return true;
};
session.close = () => {
try { sock.write(frameFromClient(8, Buffer.alloc(0))); } catch (_) { /* 已死 */ }
setTimeout(() => { try { sock.destroy(); } catch (_) { /* 已死 */ } }, 50);
};
session.drop = () => { try { sock.destroy(); } catch (_) { /* 已死 */ } };
const parse = makeServerFrameParser((f) => {
if (f.opcode === 9) { // ping → 自动 pong(保活,§6.1)
try { sock.write(frameFromClient(10, f.payload)); } catch (_) { /* 已死 */ }
return;
}
if (f.opcode === 8) {
session.emit('close', f.payload.length >= 2 ? f.payload.readUInt16BE(0) : 1005);
try { sock.destroy(); } catch (_) { /* 已死 */ }
return;
}
if (f.opcode === 1) {
let msg = null;
try { msg = JSON.parse(f.payload.toString('utf8')); } catch (_) { /* 坏帧 */ }
if (msg) {
session.messages.push(msg);
session.emit('message', msg);
}
}
});
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');
if (!/^HTTP\/1\.1 101 /.test(head)) {
reject(new Error('握手失败: ' + head.split('\r\n')[0]));
sock.destroy();
return;
}
upgraded = true;
const rest = handshake.subarray(idx + 4);
if (rest.length) parse(rest);
resolve(session);
} else {
parse(chunk);
}
});
sock.on('error', (e) => {
if (!upgraded) reject(e);
else session.emit('close', 1006);
});
sock.on('close', () => { if (upgraded) session.emit('close', 1006); });
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',
);
});
}
class EventEmitterLite {
constructor() { this._l = new Map(); }
on(ev, fn) { (this._l.get(ev) || this._l.set(ev, []).get(ev)).push(fn); return this; }
emit(ev, ...args) { for (const fn of this._l.get(ev) || []) fn(...args); }
}