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
This commit is contained in:
xsl
2026-09-19 11:06:07 +08:00
parent 963dfa1cc8
commit 0652c2f0e6
224 changed files with 78576 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
/**
* 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); }
}