研究命题(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
149 lines
4.9 KiB
JavaScript
149 lines
4.9 KiB
JavaScript
/**
|
||
* 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); }
|
||
}
|