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
+280
View File
@@ -0,0 +1,280 @@
/**
* arena.mjs — 《围猎》多人模拟竞技场编排器。
*
* 用法:
* tools/node/bin/node web/harness/hunt-sim/arena.mjs <轮名> [--teach|--quick] [--only P01,P03]
*
* - 起一个真实 server.js(随机端口 + 临时数据目录,与生产数据隔离)
* - 读 research/hunt-sim/players/*.config.json + research/hunt-sim/state.json 的去留状态
* - 幸存玩家错峰并发进房(真实 WS、真实时长对局)
* - 全部收敛后:写每人的 percept.md / summary(进 state.json)+ 按房间聚合 match.json
* - 控制台打印本轮速览
*
* state.json 结构(跨轮维护):
* { players: { P01: { active: true, progress: {tutDone,runs,best,totalKills},
* matches: [{round, room, seed, result, summary}] } } }
*/
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, '..', '..', '..');
const RESEARCH = path.join(ROOT, 'research', 'hunt-sim');
const PLAYERS_DIR = path.join(RESEARCH, 'players');
const STATE = path.join(RESEARCH, 'state.json');
const SERVER_JS = path.join(ROOT, 'web', 'server', 'server.js');
const MIRROR = { N: 'S', S: 'N', NE: 'SE', SE: 'NE', NW: 'SW', SW: 'NW', E: 'E', W: 'W' };
const { loadAssets } = await import('./lib/assets.mjs');
const { SimClient } = await import('./lib/sim-client.mjs');
const { Brain, mulberry32 } = await import('./lib/brain.mjs');
const { Recorder } = await import('./lib/recorder.mjs');
// ---------------- CLI ----------------
const args = process.argv.slice(2);
const roundName = args.find((a) => !a.startsWith('-')) || 'round';
const mode = args.includes('--teach') ? 'teach' : 'quick';
const onlyArg = (args.find((a) => a.startsWith('--only=')) || '').split('=')[1];
const only = onlyArg ? onlyArg.split(',') : null;
// ---------------- 服务器 ----------------
function freePort() {
return new Promise((resolve) => {
const srv = net.createServer();
srv.listen(0, '127.0.0.1', () => { const p = srv.address().port; srv.close(() => resolve(p)); });
});
}
async function startServer() {
const dataDir = fs.mkdtempSync(path.join(RESEARCH, '.server-'));
const port = await freePort();
const child = spawn(process.execPath, [SERVER_JS], {
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);
}),
};
}
// ---------------- 状态 ----------------
function loadState() {
try { return JSON.parse(fs.readFileSync(STATE, 'utf8')); } catch (_) { return { players: {} }; }
}
function saveState(st) { fs.writeFileSync(STATE, JSON.stringify(st, null, 1)); }
// ---------------- 主流程 ----------------
const assets = loadAssets();
const text = (id) => assets.voiceText[id.replace(/^npc_/, '')] || assets.voiceText[id] || id;
const state = loadState();
const configFiles = fs.readdirSync(PLAYERS_DIR).filter((f) => f.endsWith('.config.json')).sort();
if (!configFiles.length) { console.error('没有玩家配置:先让玩家 agent 写 research/hunt-sim/players/*.config.json'); process.exit(1); }
const roundDir = path.join(RESEARCH, 'rounds', roundName);
fs.mkdirSync(path.join(roundDir, 'matches'), { recursive: true });
const server = await startServer();
console.log(`[arena] ${roundName}(${mode})· 服务器 ws://127.0.0.1:${server.port}/ws/hunt`);
const participants = [];
for (const f of configFiles) {
const id = f.replace('.config.json', '');
if (only && !only.includes(id)) continue;
const cfg = JSON.parse(fs.readFileSync(path.join(PLAYERS_DIR, f), 'utf8'));
const pst = state.players[id] || { active: true, progress: { tutDone: false, runs: 0, best: null, totalKills: 0 }, matches: [] };
state.players[id] = pst;
if (!pst.active) { console.log(`[arena] ${id} ${cfg.persona} 已离场,跳过`); continue; }
participants.push({ id, cfg, pst });
}
if (!participants.length) { console.error('没有参与者'); await server.stop(); process.exit(1); }
const matchDirByPlayer = new Map();
const clients = [];
const startWall = Date.now();
await Promise.all(participants.map(async (p, i) => {
const delay = 300 * i + Math.random() * 700; // 错峰进房(真实组队节奏)
await new Promise((r) => setTimeout(r, delay));
const matchId = `${roundName}-${p.id}`;
const mdir = path.join(roundDir, 'matches', matchId);
const recorder = new Recorder({
playerId: p.id, matchDir: mdir, gender: p.cfg.gender,
text: (id2) => assets.voiceText[id2] || id2,
});
const brain = new Brain(p.cfg, { rng: mulberry32(((p.cfg.seed || 1) * 7919 + Date.now() % 100000) >>> 0) });
const client = new SimClient({
port: server.port, name: p.cfg.name || p.id, gender: p.cfg.gender || 'male',
assets: { durations: assets.durations, voiceText: assets.voiceText },
recorder, brain, progress: { ...p.pst.progress },
});
recorder.noteSlot(-1);
clients.push({ p, client, recorder, brain, mdir });
try {
await client.startMatch({ teach: mode === 'teach' });
brain.start();
client._audioTimer = setInterval(() => client.audioTick(), 100);
} catch (e) {
recorder.log({ t: 'connect_failed', error: String(e) });
}
}));
// 等全部对局收敛(end 链播完 / forceEnd / 连接失败)
const HARD_CAP_MS = 11 * 60 * 1000;
const capTimer = setTimeout(() => {}, HARD_CAP_MS);
await Promise.race([
Promise.allSettled(clients.map(({ client }) => client.matchEnded)),
new Promise((r) => setTimeout(r, HARD_CAP_MS)),
]);
clearTimeout(capTimer);
await new Promise((r) => setTimeout(r, 1500)); // 尾句自然播完
// ---------------- 收档 ----------------
const roomMap = new Map(); // room → { players: [], seed, roster }
for (const { p, client, recorder, brain, mdir } of clients) {
const result = client.result;
const roomInfo = {
room: client.room, slot: client.slot, seed: client.seed,
sector: client.sector,
charName: client.char === 'yan' ? '阿岩' : client.char === 'zheng' ? '小筝' : client.char === 'zhou' ? '老周' : null,
rosterText: [...client.roster.values()].sort((a, b) => a.slot - b.slot)
.map((r) => `${r.bot ? '-bot' : ''}${r.name}${r.slot === client.slot ? '(我)' : ''}`).join('、'),
};
recorder.noteSlot(client.slot);
recorder.buildTranscript({ name: p.cfg.name, persona: p.cfg.persona, result, roomInfo });
const summary = recorder.buildSummary();
summary.brainStats = {
loopsHeard: brain.stats.loopsHeard, registered: brain.stats.registered,
noticeMedianMs: median(brain.stats.notices.map((n) => n.delayMs)),
noticeMaskedMedianMs: median(brain.stats.notices.filter((n) => n.masked).map((n) => n.delayMs)),
noticeCleanMedianMs: median(brain.stats.notices.filter((n) => !n.masked).map((n) => n.delayMs)),
gestures: brain.stats.gestures,
};
fs.writeFileSync(path.join(mdir, `${p.id}.summary.json`), JSON.stringify(summary, null, 1));
const matchRec = {
round: roundName, player: p.id, persona: p.cfg.persona,
room: client.room, slot: client.slot, seed: client.seed,
result, summary,
joined: !!client.joined, phaseAtEnd: client.phase,
};
p.pst.matches.push(matchRec);
p.pst.progress = client.progress;
// 战斗日志追加到玩家决策档案
const decPath = path.join(PLAYERS_DIR, `${p.id}.decisions.md`);
fs.appendFileSync(decPath, `\n## ${roundName} · 房间 ${client.room || '?'} · ${result ? (result.win ? `胜利 ${result.grade}(营地余${result.dur})` : '失守') : '未完成'}\n`
+ `- 个人击杀 ${result ? result.myKills : 0};我的出击 ${summary.attacks} 次(弧对 ${summary.attacksCorrectArc} / 弧错 ${summary.attacksWrongArc}),呼喊 ${summary.callouts} 次,旋风斩 ${summary.skills} 次\n`
+ `- 听到:语音 ${summary.voiceLinesHeard} 句(被打断 ${summary.voiceCuts})、队友喊话 ${summary.botlinesHeard} 条(含支援回应 ${summary.helpAcksHeard})、警报/提示若干;走神 ${summary.lapses} 次\n`
+ `- 转写与原始记录:\`${path.relative(RESEARCH, path.join(mdir, p.id + '.percept.md'))}\`\n`);
if (client.room && result) {
if (!roomMap.has(client.room)) roomMap.set(client.room, { players: [], seed: client.seed, result });
roomMap.get(client.room).players.push({ id: p.id, slot: client.slot, summary, result, persona: p.cfg.persona });
}
client.leave();
brain.stop();
if (client._audioTimer) clearInterval(client._audioTimer);
recorder.close();
}
// 按房间聚合 match.json(用任一成员的原始事件流重建房间真相)
for (const [room, info] of roomMap) {
const member = clients.find(({ client }) => client.room === room);
if (!member) continue;
const srvMsgs = member.recorder.events.filter((e) => e.t === 'srv').map((e) => e.msg);
const evs = srvMsgs.filter((m) => m.t === 'ev');
const waveOf = () => { let w = 0; return (ts) => w; };
let curWave = 0;
const waveAt = new Map();
for (const e of evs) {
if (e.kind === 'wave') curWave = e.wave;
if (e.kind === 'spawn') waveAt.set(e.id, curWave);
}
const breaches = evs.filter((e) => e.kind === 'breach').map((e) => ({ wave: waveAt.get(e.id) ?? curWave, dir: e.dir, mkind: e.mkind, dur: e.dur, ts: e.ts }));
const spawnsByWave = {};
for (const e of evs) if (e.kind === 'spawn') {
const w = waveAt.get(e.id) ?? 0;
spawnsByWave[w] = (spawnsByWave[w] || 0) + 1;
}
const killsBySlot = {};
for (const e of evs) {
if (e.kind === 'kill') killsBySlot[e.slot] = (killsBySlot[e.slot] || 0) + 1;
if (e.kind === 'skill') killsBySlot[e.slot] = (killsBySlot[e.slot] || 0) + (e.cleared || []).length;
}
const end = evs.filter((e) => e.kind === 'end').pop();
// 每人的攻击混淆账从各自 jsonl 提取
const arcStats = {};
for (const { p, client, recorder } of clients) {
if (client.room !== room) continue;
const ag = recorder.events.filter((e) => e.t === 'attack_gesture');
arcStats[p.id] = {
total: ag.length,
correct: ag.filter((e) => e.correct === true).length,
mirrorErr: ag.filter((e) => e.correct === false && MIRROR[(e.perceived)] === e.trueDir).length,
otherErr: ag.filter((e) => e.correct === false && MIRROR[e.perceived] !== e.trueDir).length,
byTrueDir: groupBy(ag.filter((e) => e.trueDir), (e) => e.trueDir, (e) => e.correct),
};
}
const match = {
room, seed: info.seed, round: roundName, wallStart: startWall,
roster: info.players.map((pl) => ({ id: pl.id, persona: pl.persona, slot: pl.slot, ...member.client.roster.get(pl.slot) })),
result: end ? { win: end.win, dur: end.dur, grade: end.grade, kills: end.kills } : null,
waves: evs.filter((e) => e.kind === 'wave').map((e) => e.wave),
spawnsByWave, breaches, killsBySlot,
perPlayer: Object.fromEntries(info.players.map((pl) => [pl.id, { result: pl.result, summary: pl.summary }])),
attackLocalization: arcStats,
};
fs.writeFileSync(path.join(roundDir, 'matches', `room-${room}.match.json`), JSON.stringify(match, null, 1));
}
saveState(state);
await server.stop();
// ---------------- 控制台速览 ----------------
console.log(`\n[arena] ${roundName} 收官:`);
for (const [room, info] of roomMap) {
const r = info.result;
const rosterTxt = info.players.map((pl) => pl.id).join('+');
console.log(` 房 ${room}(${rosterTxt}):${r ? (r.win ? `胜利·${r.grade}·余${r.dur}` : '失守') : '?'} 击杀账 ${JSON.stringify(r ? r.kills : null)}`);
}
for (const { p, client, recorder } of clients) {
const s = (p.pst.matches[p.pst.matches.length - 1] || {}).summary || {};
console.log(` ${p.id} ${p.cfg.persona}: 击杀${s.kills ?? '-'} 出击${s.attacks ?? '-'}(错弧${s.attacksWrongArc ?? '-'}) 呼喊${s.callouts ?? '-'} 旋风斩${s.skills ?? '-'} 走神${s.lapses ?? '-'}`);
}
console.log(`[arena] 产物:${path.relative(ROOT, roundDir)}`);
process.exit(0); // 兜底:清不净的长定时器不拖住进程
function median(arr) {
if (!arr.length) return null;
const a = [...arr].sort((x, y) => x - y);
return Math.round(a[Math.floor(a.length / 2)]);
}
function groupBy(arr, key, val) {
const out = {};
for (const x of arr) {
const k = key(x);
out[k] = out[k] || { total: 0, correct: 0 };
out[k].total += 1;
if (val(x)) out[k].correct += 1;
}
return out;
}
+75
View File
@@ -0,0 +1,75 @@
/**
* assets.mjs — 模拟竞技场的资产事实源:
* 1. durations:ffprobe 实测 web/hunt/audio 全部 ogg 时长(感知模型按真实节奏排语音链)
* 2. voiceText:解析 docs/design/23-hunt-voice-ids.md 的台词表(转写要写台词全文)
*
* 产物缓存 research/hunt-sim/assets-cache.json,arena 启动时刷新一次。
*/
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
export const ROOT = path.resolve(HERE, '..', '..', '..', '..'); // 仓库根(lib→hunt-sim→harness→web→根)
const AUDIO = path.join(ROOT, 'web', 'hunt', 'audio');
const VOICE_IDS = path.join(ROOT, 'docs', 'design', '23-hunt-voice-ids.md');
const CACHE = path.join(ROOT, 'research', 'hunt-sim', 'assets-cache.json');
const FFPROBE = path.join(ROOT, 'tools', 'bin', 'ffprobe');
function probeDurations() {
const out = {};
const walk = (dir) => {
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, ent.name);
if (ent.isDirectory()) walk(p);
else if (ent.name.endsWith('.ogg')) {
out[path.relative(AUDIO, p).replace(/\.ogg$/, '')] = null;
}
}
};
walk(AUDIO);
for (const rel of Object.keys(out)) {
let d = 0;
try {
const csv = execFileSync(FFPROBE, [
'-hide_banner', '-loglevel', 'error',
'-show_entries', 'format=duration', '-of', 'csv=p=0',
path.join(AUDIO, rel + '.ogg'),
], { encoding: 'utf8' });
d = Number(csv.trim()) || 0;
} catch (_) { /* 缺资产记 0(感知模型有兜底时长) */ }
out[rel] = d;
}
return out;
}
/** id → 台词全文(npc_* 逐条登记于台词表 N 组;V 组键盘变体不入映射)。 */
export function voiceTextMap() {
const md = fs.readFileSync(VOICE_IDS, 'utf8');
const map = {};
let inKeySection = false;
for (const line of md.split('\n')) {
if (/^##\s*V\s*组/.test(line)) inKeySection = true; // 键盘变体同 id 重排,会覆盖触屏原句
if (inKeySection) continue;
const m = line.match(/^\|\s*`([a-z0-9_]+)`\s*\|\s*([^|]+?)\s*\|/);
if (!m) continue;
const id = m[1];
const text = m[2].trim();
if (!text || /^[A-Za-z0-9_`]+$/.test(text.replace(/`/g, ''))) continue; // 表头/引用列
map[id] = text;
}
return map;
}
/** 加载(必要时生成)资产事实缓存。 */
export function loadAssets() {
let cache = null;
try { cache = JSON.parse(fs.readFileSync(CACHE, 'utf8')); } catch (_) { /* 重探 */ }
if (!cache || !cache.durations || !cache.voiceText) {
cache = { durations: probeDurations(), voiceText: voiceTextMap(), at: new Date().toISOString() };
fs.mkdirSync(path.dirname(CACHE), { recursive: true });
fs.writeFileSync(CACHE, JSON.stringify(cache, null, 1));
}
return cache;
}
+525
View File
@@ -0,0 +1,525 @@
/**
* brain.mjs — 玩家大脑:把「耳朵听到的东西」变成「这个人的反应」。
*
* 输入:SimClient 的 percept 事件流(voice / npc / oneshot / loop_* / vibrate / bgm)。
* 输出:gesture() 意图(由 SimClient 按真实客户端规则路由)。
*
* 认知模型(诚实拟人,不偷看服务器真值):
* - 方位判读:loop 的 pan(±1=东西 / ±0.7=斜向 / 0=南北轴)+ 音量档推前后
* (后半平面 ×0.6 的发闷线索)。前后判错 → 镜像方位(N↔S/NE↔SE/NW↔SW),
* E/W 由 pan 符号锚定永不镜像错。相邻滑动误听(NE↔N 等)多数被 ±45° 出击弧
* 无害吸收,忠实保留。
* - 察觉延迟:loop 出现/升档 → noticeMs 后才登记进「已知威胁表」;旁白在播
* (掩蔽)时按 voiceMaskFactor 放大;呼啸预先听到(howlHearP)则加速。
* - 分心窗(lapses):期间不处理感知、不出手;醒后重新听(重新察觉)。
* - 冷却感知:cooldownAware 的人掐得准 2.5s/8s;不掐的人会在冷却中双击 →
* 客户端走「重听威胁播报」真路径(额外语音掩蔽,真实行为)。
*/
import { DIR_AZ, panOfAz, azOf } from './sim-client.mjs';
const DIRS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
const MIRROR = { N: 'S', S: 'N', NE: 'SE', SE: 'NE', NW: 'SW', SW: 'NW', E: 'E', W: 'W' };
// 斜向的两个覆盖基向(出击弧 ±45°:两者都能打到)
const COVERING = {
N: ['up'], E: ['right'], S: ['down'], W: ['left'],
NE: ['up', 'right'], SE: ['right', 'down'], SW: ['down', 'left'], NW: ['left', 'up'],
};
export function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export class Brain {
constructor(cfg, { rng } = {}) {
this.cfg = this.applyDefaults(cfg);
const seed = (cfg.seed || 1) >>> 0;
// 独立随机流:判读/察觉/决策互不消耗彼此的流(单流会被密集决策带偏,教学
// 手势风暴实测造成 8/8 系统性镜像反转——已证伪,勿合并回单流)
this.rng = rng || mulberry32(seed);
this.rngLoc = mulberry32((seed ^ 0x10CA1) >>> 0);
this.rngNotice = mulberry32((seed ^ 0x0BE5) >>> 0);
this.rngDecide = mulberry32((seed ^ 0x0DEC) >>> 0);
this.client = null;
// 认知状态
this.known = new Map(); // mobId → { perceivedDir, band, mkind, etaEst, registeredAt, eta0,t0 }
this.pending = new Map(); // mobId → { at, ev }(察觉在途)
this.heardHowl = new Set(); // 生成呼啸听到过的方位粗记(加速察觉)
this.voiceBusy = false; // 旁白在播(掩蔽)
this.lastVoiceEnd = 0;
this.heardIds = []; // 听过的 voice id 序列(教学步骤门控)
this.lapse = null; // { until }(分心窗)
this.lapsesLeft = [];
this.menuIntentUntil = 0;
this.panicSkillFired = false;
this.calloutEvalAt = 0;
this.skillEvalAt = 0;
this.teach = { startedAt: 0, step: -1, t1Attempts: 0, done: false };
this.netDropDone = false;
this.attackArmedAt = 0; // 已排队出手时刻
this.attackArmedDir = null;
this.stats = {
loopsHeard: 0, registered: 0, localized: [],
gestures: 0, attacksSent: 0, callouts: 0, skills: 0,
notices: [], // {mobId, delayMs, masked}
};
this._inMatch = false;
this._tickTimer = null;
}
applyDefaults(c) {
const d = {
persona: c.persona || '无名', name: c.name || '猎人', gender: c.gender || 'male', seed: c.seed || 1,
perception: { noticeMsMin: 600, noticeMsMax: 1100, rearDiscrim: 0.75, adjSlideP: 0.15, howlHearP: 0.7, voiceMaskFactor: 0.8, etaErrS: 0.4 },
attack: { reactMs: 800, panicEtaS: 2.0, noviceSpamP: 0.05, bearFirst: false },
skill: { innerThreshold: 3, useP: 0.8, panicEtaS: 1.2, cooldownAware: true },
callout: { sectorThreshold: 3, useP: 0.5 },
habits: { statusCheck: 'never', menuProb: 0, lobbyTap: 'eager', lapses: [], netDrop: null, bluetoothMs: 0 },
teach: { t1WrongP: 0.2, tapDelayMs: 800, skipAfterS: null },
};
for (const k of ['perception', 'attack', 'skill', 'callout', 'habits', 'teach']) {
if (c[k]) d[k] = { ...d[k], ...c[k] };
}
return { ...d, ...{ persona: c.persona, name: c.name, gender: c.gender, seed: c.seed } };
}
// ================= 感知 =================
onPercept(ev) {
switch (ev.type) {
case 'voice':
this.voiceBusy = true;
this.lastVoiceEnd = ev.end || ev.t + ev.dur * 1000;
this.heardIds.push(ev.id);
this.stats.voiceHeard = (this.stats.voiceHeard || 0) + 1;
break;
case 'voice_cut':
if (this.heardIds[this.heardIds.length - 1] === ev.id) this.heardIds.pop();
break;
case 'npc':
this.lastNpc = ev;
this.heardIds.push('npc:' + ev.id);
break;
case 'oneshot':
if (ev.id.endsWith('howl_wolf') || ev.id.endsWith('howl_bear')) {
if (this.lot(Math.round(ev.t / 500), 10) < this.cfg.perception.howlHearP) {
this.heardHowl.add(ev.t); // 方位记不记得住不重要:加速后续察觉
this.heardHowlDir = this.perceivePanDir(ev.pan, ev.vol, ev.id.endsWith('bear'), Math.round(ev.t / 500));
}
}
break;
case 'loop_start':
case 'loop_band':
this.onLoop(ev);
break;
case 'loop_stop':
this.pending.delete(ev.id);
this.known.delete(ev.id);
break;
default: break;
}
}
/** 按实体 key 的独立抽签 [0,1):与一切调用顺序解耦(mulberry32 流会被刷怪的
* 座位轮转结构带偏——实测造成方位判读系统性镜像反转,勿改回流式)。 */
lot(key, salt) {
let h = ((this.cfg.seed | 0) ^ Math.imul(key | 0, 2654435761) ^ Math.imul(salt | 0, 40503)) >>> 0;
h ^= h >>> 16; h = Math.imul(h, 2246822507) >>> 0; h ^= h >>> 13;
h = Math.imul(h, 3266489909) >>> 0; h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
/** loop 出现/升档 → 排察觉;察觉后方位判读入已知表。 */
onLoop(ev) {
if (this.lapse) return; // 分心:听不见
const isNew = ev.type === 'loop_start';
if (isNew) this.stats.loopsHeard += 1;
if (this.known.has(ev.id) && !isNew) {
const k = this.known.get(ev.id);
k.band = ev.band; k.eta0 = ev.eta; k.t0 = ev.t;
return; // 已登记:音量渐变可连续感知,无需重新察觉
}
if (this.known.has(ev.id)) return;
const p = this.cfg.perception;
let notice = p.noticeMsMin + this.lot(ev.id, 6) * (p.noticeMsMax - p.noticeMsMin);
if (ev.band === 'mid') notice *= 1.6; // 远而轻,更晚注意到
if (this.voiceBusy) notice *= 1 + p.voiceMaskFactor; // 旁白掩蔽
if (this.heardHowl.size && this.lot(ev.id, 7) < 0.5) notice *= 0.7; // 呼啸预判加速
this.pending.set(ev.id, { at: ev.t + notice, ev, masked: this.voiceBusy, bornAt: ev.t, isNew });
}
/**
* 方位判读:pan + 音量前后线索 → 感知方位(可能镜像/滑动出错)。key=实体 id。
* 模型(以真值为条件的正确率,不是无条件硬币——无条件会把真后方系统性听反):
* - 左右轴:pan 符号清晰(±1/±0.7/0),永不镜像错;
* - 前后轴:真前/真后各以 rearDiscrim 概率判对(音量发闷线索;掩蔽 ×0.9;
* 熊声像更突出 +0.05);判错 → 沿东西轴镜像(N↔S/NE↔SE/NW↔SW);
* - 相邻滑动:adjSlideP(±45° 出击弧多数自愈)。
*/
perceivePanDir(pan, vol, isBear, key = 0, trueDir = null, muffledCue = false) {
const p = this.cfg.perception;
const side = pan > 0.35 ? 'E' : pan < -0.35 ? 'W' : 'C';
const isBackTrue = trueDir
? ['S', 'SE', 'SW'].includes(trueDir)
: vol < 0.32; // 无真值(呼啸粗判):闷=后
const rearSkill = Math.min(0.99, p.rearDiscrim + (muffledCue ? 0.1 : 0)); // v2 S6 双线索
const thresh = rearSkill * (this.voiceBusy ? 0.9 : 1) + (isBear ? 0.05 : 0);
const cueOK = this.lot(key, 1) < thresh; // 这一次前后线索判对了没
const frontGuess = isBackTrue ? !cueOK : cueOK;
let dir;
if (side === 'E') dir = Math.abs(Math.abs(pan) - 1) < 0.2 ? 'E' : (frontGuess ? 'NE' : 'SE');
else if (side === 'W') dir = Math.abs(Math.abs(pan) - 1) < 0.2 ? 'W' : (frontGuess ? 'NW' : 'SW');
else dir = frontGuess ? 'N' : 'S';
// 相邻滑动(多数被弧无害吸收)
if (this.lot(key, 3) < p.adjSlideP) {
const idx = DIRS.indexOf(dir);
dir = DIRS[(idx + (this.lot(key, 4) < 0.5 ? 1 : 7)) % 8];
}
return dir;
}
/** 感知 tick:处理在途察觉 + 已知表 eta 推进 + 掉出 loop 集的清理。 */
perceptTick(now) {
for (const [id, p] of [...this.pending]) {
if (now >= p.at) {
this.pending.delete(id);
if (!this.client.loops.has(id)) continue; // 已消失
const loop = this.client.loops.get(id);
// v2 S6:低通第二线索落地后,身后闷音判读增益(音色+响度双线索)
const perceivedDir = this.perceivePanDir(loop.pan, loop.vol, loop.mkind === 'bear', id, loop.dir, !!loop.muffled);
if (this.client.recorder) {
this.client.recorder.log({
t: 'localize', id, trueDir: loop.dir, pan: Math.round(loop.pan * 100) / 100,
vol: loop.vol, voiceBusy: this.voiceBusy, perceived: perceivedDir,
});
}
this.known.set(id, {
perceivedDir, band: loop.band, mkind: loop.mkind,
eta0: loop.eta, t0: now, registeredAt: now,
});
this.stats.registered += 1;
this.stats.notices.push({ mobId: id, delayMs: now - p.bornAt, masked: p.masked });
// 登记即武装出手(内圈才可打)
this.armAttackIfWanted(now, id);
}
}
// 已知表里 loop 已停(被杀/突入)由 loop_stop 清;此处只推进 eta 估计
for (const k of this.known.values()) k.etaEst = k.eta0 - (now - k.t0) / 1000;
}
/** 已知内圈威胁(band inner1/inner2)。 */
knownInner() {
return [...this.known.entries()].filter(([, k]) => k.band !== 'mid');
}
armAttackIfWanted(now, id) {
const k = this.known.get(id);
if (!k || k.band === 'mid') return;
if (this.attackArmedAt) return; // 已有出手在途
const jitter = 0.75 + this.lot(id, 9) * 0.5;
this.attackArmedAt = now + (this.cfg.attack.reactMs * jitter) + this.cfg.habits.bluetoothMs;
this.attackArmedDir = this.pickSwipeBase(k.perceivedDir, id);
this.attackArmedTarget = id;
}
pickSwipeBase(perceivedDir, key = 0) {
const covers = COVERING[perceivedDir] || ['up'];
return covers[this.lot(key, 5) < 0.5 ? 0 : Math.min(1, covers.length - 1)];
}
arcCovers(base, dir) {
const ARC = { up: ['NW', 'N', 'NE'], right: ['NE', 'E', 'SE'], down: ['SE', 'S', 'SW'], left: ['SW', 'W', 'NW'] };
return (ARC[base] || []).includes(dir);
}
// ================= 决策 tick =================
tick() {
if (!this.client || this.client._stopped) return;
const now = this.client.now();
if (!this._inMatch) return;
// 语音占用更新
this.voiceBusy = this.client.voiceQ.length > 0 || now < this.client.voiceBusyUntil;
// 分心窗管理
this.manageLapse(now);
if (this.client.phase === 'teach') this.teachTick(now);
else if (this.client.phase === 'wave') this.waveTick(now);
else if (this.client.phase === 'lobby') this.lobbyTick(now);
if (!this.lapse) this.perceptTick(now);
}
manageLapse(now) {
if (this.lapse) {
if (now >= this.lapse.until) {
this.lapse = null;
// 醒来:清空在途察觉与已知表(重新听)
this.pending.clear();
this.known.clear();
this.client.recorder.log({ t: 'lapse_end', at: now });
}
return;
}
if (!this.lapsesLeft.length) return;
const next = this.lapsesLeft[0];
if (now >= next.at) {
this.lapsesLeft.shift();
this.lapse = { until: now + next.dur };
this.pending.clear();
this.known.clear();
this.client.recorder.log({ t: 'lapse_start', until: this.lapse.until, reason: next.reason || '分心' });
}
}
// ---- LOBBY ----
lobbyTick(now) {
if (this.cfg.habits.lobbyTap === 'eager' && this.heard('room_wait') && now > this.lastVoiceEnd) {
if (!this._lobbyTapped) {
this._lobbyTapped = true;
this.delayGesture('tap', this.cfg.teach.tapDelayMs);
}
}
}
heard(id) { return this.heardIds.includes(id); }
heardNpcSuffix(suffix) { return this.heardIds.some((x) => x.startsWith('npc:') && x.includes(suffix)); }
delayGestureIf(cond, g, ms) {
setTimeout(() => {
if (!this.client || this.client._stopped || (cond && !cond())) return;
this.stats.gestures += 1;
this.client.gesture(g);
}, ms);
}
delayGesture(g, ms) {
setTimeout(() => {
if (!this.client || this.client._stopped) return;
this.stats.gestures += 1;
this.client.gesture(g);
}, ms);
}
// ---- TEACH ----
teachTick(now) {
if (this.teach.step === -1 && this.heard('tut_rule')) {
this.teach = { ...this.teach, step: 0, startedAt: this.teach.startedAt || now, t1Attempts: this.teach.t1Attempts };
}
const T = this.cfg.teach;
// 不耐烦跳过
if (T.skipAfterS && this.teach.startedAt && now - this.teach.startedAt > T.skipAfterS * 1000 && this.client.tut >= 0) {
this.client.recorder.log({ t: 'teach_skip_impatient' });
this.client.gesture('down');
return;
}
const tut = this.client.tut;
if (tut === 0 && this.teach.step === 0 && now > this.lastVoiceEnd + 200) {
this.teach.step = 0.5;
this.delayGesture('tap', T.tapDelayMs * (0.6 + this.lot(0, 21) * 0.8));
return;
}
if (tut === 1 && this.heard('tut_attack')) {
// 狼 loop 出现(N)且已察觉 → 滑(一次出手在途/冷却内不重复排——风暴护栏)
const wolfKnown = [...this.known.values()].some((k) => k.band !== 'mid');
const wolfLoopUp = [...this.client.loops.values()].some((l) => l.band !== 'mid');
const unlocked = !this._t1Fired || (this._t1UnlockAt && now > this._t1UnlockAt);
if (wolfKnown && wolfLoopUp && unlocked) {
this._t1Fired = true;
this._t1UnlockAt = now + 2800; // 出击冷却 2.5s + 余量:挥空后等狼重放/重教再试
const wrong = this.lot(this.teach.t1Attempts, 22) < T.t1WrongP * Math.pow(0.4, this.teach.t1Attempts);
const swipe = wrong ? ['left', 'right', 'down'][Math.floor(this.lot(this.teach.t1Attempts, 23) * 3)] : 'up'; // 教学狼恒 N
this.teach.t1Attempts += 1;
this.client.recorder.log({ t: 'teach_t1_swipe', swipe, wrong, attempts: this.teach.t1Attempts });
this.delayGesture(swipe, this.cfg.attack.reactMs * 0.8);
this._t1Acted = true;
}
return;
}
if (tut === 2 && this.heard('tut_callout') && now > this.lastVoiceEnd + 200) {
if (!this._t2Acted) {
this._t2Acted = true;
this.delayGesture('tap', T.tapDelayMs * (0.6 + this.lot(2, 21) * 0.8));
}
return;
}
if (tut === 3 && this.heard('tut_skill')) {
const inner = [...this.client.loops.values()].some((l) => l.band !== 'mid');
if (inner && now > this.lastVoiceEnd + 200 && !this._t3Acted) {
this._t3Acted = true;
this.delayGesture('doubletap', this.cfg.attack.reactMs * 0.8);
}
return;
}
if (tut === 4 && this.heard('tut_status') && now > this.lastVoiceEnd + 200) {
if (!this._t4Acted) {
this._t4Acted = true;
this.delayGesture('up', T.tapDelayMs * (0.6 + this.lot(4, 21) * 0.8));
}
return;
}
}
// ---- WAVE ----
waveTick(now) {
const c = this.cfg;
// 1) 出手(已武装)
if (this.attackArmedAt && now >= this.attackArmedAt) {
const target = this.known.get(this.attackArmedTarget);
const stillThere = target && this.client.loops.has(this.attackArmedTarget);
if (stillThere && target.band !== 'mid') {
const trueLoop = this.client.loops.get(this.attackArmedTarget);
this.stats.gestures += 1;
this.client.gesture(this.attackArmedDir);
this.stats.attacksSent += 1;
this.client.recorder.log({
t: 'attack_gesture', dir: this.attackArmedDir, perceived: target.perceivedDir,
trueDir: trueLoop ? trueLoop.dir : null,
correct: trueLoop ? this.arcCovers(this.attackArmedDir, trueLoop.dir) : null,
mkind: target.mkind, etaEst: Math.round((target.etaEst || 0) * 10) / 10,
});
}
this.attackArmedAt = 0;
this.attackArmedTarget = null;
}
// 2) 新目标扫描(无在途出手时武装最近威胁)
if (!this.attackArmedAt) {
let best = null;
for (const [id, k] of this.known) {
if (k.band === 'mid') continue;
let score = (k.etaEst ?? 3);
if (c.attack.bearFirst && k.mkind === 'bear') score -= 1.5;
if (!best || score < best.score) best = { id, k, score };
}
if (best) {
const jitter = 0.75 + this.lot(best.id, 9) * 0.5;
this.attackArmedAt = now + c.attack.reactMs * jitter + c.habits.bluetoothMs;
this.attackArmedDir = this.pickSwipeBase(best.k.perceivedDir, best.id);
this.attackArmedTarget = best.id;
}
}
// 3) 冷却内乱滑(新手真实行为:客户端本地拦,只震不发)
if (now < this.client.atkReadyAt && this.knownInner().length && this.lot(Math.floor(now / 600), 11) < c.attack.noviceSpamP) {
const [spamId, k] = this.knownInner()[0];
this.client.gesture(this.pickSwipeBase(k.perceivedDir, spamId));
this.client.recorder.log({ t: 'spam_swipe_on_cd', perceived: k.perceivedDir });
}
// 4) 旋风斩
if (now > this.skillEvalAt + 500) {
this.skillEvalAt = now;
const inner = this.knownInner();
const skillReady = now >= this.client.skillReadyAt;
const panic = inner.some(([, k]) => (k.etaEst ?? 9) < c.skill.panicEtaS && k.band === 'inner2');
const bucket = Math.floor(now / 500);
const want = (inner.length >= c.skill.innerThreshold && this.lot(bucket, 12) < c.skill.useP) || (panic && this.lot(bucket, 13) < 0.9);
if (want && skillReady) {
this.client.gesture('doubletap');
this.stats.skills += 1;
this.client.recorder.log({ t: 'skill_gesture', inner: inner.length, panic });
} else if (want && !skillReady && !c.skill.cooldownAware && this.lot(Math.floor(now / 500), 14) < 0.3) {
// 不掐冷却的人:冷却中双击 → 重听威胁播报(真实路径,产生掩蔽)
this.client.gesture('doubletap');
this.client.recorder.log({ t: 'skill_doubletap_on_cd', inner: inner.length });
}
}
// 5) 呼喊支援
if (now > this.calloutEvalAt + 700) {
this.calloutEvalAt = now;
const mine = [...this.known.values()].filter((k) => this.client.sector.includes(k.perceivedDir));
const pressure = mine.length + [...this.known.values()].filter((k) => k.band !== 'mid').length * 0.2;
const ready = now >= this.client.calloutReadyAt;
if (pressure >= c.callout.sectorThreshold && ready && this.lot(Math.floor(now / 700), 15) < c.callout.useP) {
this.client.gesture('tap');
this.stats.callouts += 1;
this.client.recorder.log({ t: 'callout_gesture', pressure: Math.round(pressure * 10) / 10 });
}
}
// 6) 习惯:波间状态查询 / 波中开菜单
if (c.habits.statusCheck === 'waveclear' && this.heard('wave_clear')
&& now > this.lastVoiceEnd + 800 && this.knownInner().length === 0 && !this._statusedThisRest) {
this._statusedThisRest = true;
this.delayGesture('up', 300);
this.client.recorder.log({ t: 'habit_status_check' });
}
if (this.client.wave !== this._lastWave) {
this._lastWave = this.client.wave;
this._statusedThisRest = false;
// 波开始时决定本波是否手滑开菜单
if (this.lot(this.client.wave || 1, 16) < c.habits.menuProb && !this._menuOpened) {
this._menuOpened = true;
this.delayGesture('down', 2000 + this.lot(this.client.wave || 1, 17) * 6000); // 随机时刻
this.client.recorder.log({ t: 'habit_menu_midwave' });
// 听两项再关;v2 S1:若战斗中菜单没开成(下滑=空挥),浏览手势作废
setTimeout(() => {
if (!this.client || this.client._stopped) return;
if (!this.client.menuOpen) {
this._menuBrowseCanceled = true;
this.client.recorder.log({ t: 'habit_menu_blocked_v2', note: '战斗中菜单打不开(v2 S1),改下滑空挥' });
}
}, 800);
this.delayGestureIf(() => this.client && this.client.menuOpen, 'tap', 9000);
this.delayGestureIf(() => this.client && this.client.menuOpen, 'down', 10500);
}
}
// 7) 断网剧本(P09 类)
const nd = c.habits.netDrop;
if (nd && !this.netDropDone && this.client.wave >= 1 && now - (this._waveStartedAt || 0) > nd.atMin * 1000
&& this.lot(Math.floor(now / 1000), 18) < 0.02) {
this.netDropDone = true;
this.doNetDrop(nd);
}
}
async doNetDrop(nd) {
const cli = this.client;
cli.recorder.log({ t: 'net_drop_scripted' });
this._resumeToken = cli._resumeToken;
if (cli.session) cli.session.drop();
const dur = (nd.durMin + this.lot(1, 19) * (nd.durMax - nd.durMin)) * 1000;
await new Promise((r) => setTimeout(r, dur));
if (cli._stopped) return;
await cli.reconnect(this._resumeToken);
}
onJoined(j) {
this._inMatch = true;
this._lobbyTapped = false;
this._t1Acted = false; this._t2Acted = false; this._t3Acted = false; this._t4Acted = false;
this._t1Fired = false; this._t1UnlockAt = 0;
this.teach = { step: -1, startedAt: 0, t1Attempts: 0, done: false };
if (j.state && j.state.phase === 'teach') this.teach.startedAt = Date.now();
// 生成分心窗时刻表(局内随机)
this.lapsesLeft = (this.cfg.habits.lapses || []).map((l, i) => ({
at: Date.now() + (l.atMin + this.lot(i, 24) * (l.atMax - l.atMin)) * 1000,
dur: (l.durMin + this.lot(i, 25) * (l.durMax - l.durMin)) * 1000,
reason: l.reason,
}));
if (j.state && j.state.phase === 'wave') this._waveStartedAt = Date.now();
}
onEv(msg) {
if (msg.kind === 'wave') this._waveStartedAt = Date.now();
if (msg.kind === 'end') this._inMatch = false;
}
start() {
this._tickTimer = setInterval(() => this.tick(), 150);
}
stop() {
if (this._tickTimer) clearInterval(this._tickTimer);
this._tickTimer = null;
}
}
+226
View File
@@ -0,0 +1,226 @@
/**
* recorder.mjs — 全量记录器:一个玩家一局 = events.jsonl + transcript.md + summary.md。
*
* events.jsonl:服务器每条消息 / 感知事件 / 手势与 act / 大脑决策注记,逐条带墙钟时间。
* transcript.md:给玩家 agent 读的「这一局耳朵听到了什么 + 我做了什么」时间线
* (LLM 可直接代入审体验;也是分析 agent 的取证材料)。
* summary.md:一局速览(结果/账目/混淆矩阵/体验事件计数),供局间去留决策。
*/
import fs from 'node:fs';
import path from 'node:path';
const DIR_CN = { N: '正前', NE: '右前', E: '正右', SE: '右后', S: '正后', SW: '左后', W: '正左', NW: '左前' };
const panCn = (pan) => {
if (pan > 0.85) return '右';
if (pan > 0.35) return '偏右';
if (pan > -0.35) return '中间';
if (pan > -0.85) return '偏左';
return '左';
};
const fmt = (ms, base) => {
const s = Math.max(0, (ms - base) / 1000);
const m = Math.floor(s / 60);
return `${String(m).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}.${String(Math.floor((s % 1) * 10))}`;
};
export class Recorder {
constructor({ playerId, matchDir, text, gender }) {
this.id = playerId;
this.dir = matchDir;
this.text = text || ((id) => id);
this.gender = gender;
this.events = [];
this.percepts = [];
this.t0 = Date.now();
fs.mkdirSync(matchDir, { recursive: true });
this._stream = fs.createWriteStream(path.join(matchDir, `${playerId}.events.jsonl`));
}
log(obj) {
const rec = { wall: Date.now(), ...obj };
this.events.push(rec);
this._stream.write(JSON.stringify(rec) + '\n');
}
percept(ev) {
this.log({ kind: 'percept', ...ev });
this.percepts.push({ ...ev });
}
close() { this._stream.end(); }
// ---------------- 转写 ----------------
buildTranscript({ name, persona, result, roomInfo }) {
const base = this.t0;
const lines = [];
lines.push(`# ${persona}(${name})· ${this.id} · 感知转写`);
lines.push('');
if (roomInfo) {
lines.push(`> 房间 ${roomInfo.room} | 座位 ${roomInfo.slot}(角色:${roomInfo.charName || '无'},主防区 ${roomInfo.sector && roomInfo.sector.join('/') || '?'})`
+ ` | 同场:${roomInfo.rosterText || '?'}`);
}
if (result) {
lines.push(`> 结局:${result.win ? `胜利 · 评级 ${result.grade} · 营地余 ${result.dur}` : '失守(营地耐久归零)'} | 个人击杀 ${result.myKills}`);
}
lines.push('');
lines.push(`> 读法:这是你这一局「耳朵听到 + 身体感到 + 自己做了什么」的完整时间线。`);
lines.push(`> loop=怪物逼近的持续脚步声(狼碎步/熊重步),音量档:远(0.30)→近(0.55)→贴脸(0.80),pan=左右声像,"闷"=身后方向音量减半。你只能靠这些判断方位。`);
lines.push('');
lines.push('| 时刻 | 通道 | 内容 |');
lines.push('|---|---|---|');
const rows = [];
for (const ev of this.percepts) rows.push(this.perceptRow(ev, base));
for (const e of this.events) {
const r = this.logRow(e, base);
if (r) rows.push(r);
}
rows.sort((a, b) => a.at - b.at);
let lastAt = null;
for (const r of rows) {
if (lastAt !== null && r.at - lastAt > 4000) {
lines.push(`| ${fmt(r.at - 2000, base)} | — | *(静默 ${( (r.at - lastAt) / 1000).toFixed(0)} 秒)* |`);
}
lines.push(`| ${fmt(r.at, base)} | ${r.ch} | ${r.text} |`);
lastAt = r.at;
}
fs.writeFileSync(path.join(this.dir, `${this.id}.percept.md`), lines.join('\n') + '\n');
}
perceptRow(ev, base) {
const at = ev.t;
const G = this.gender === 'female' ? '旁白(女)' : '旁白(男)';
switch (ev.type) {
case 'voice':
return { at, ch: G, text: `“${this.text(ev.id)}”${ev.tag ? ` _(${ev.tag})_` : ''}` };
case 'voice_cut':
return { at, ch: G, text: `(上一句被打断——“${this.text(ev.id)}”没播完)` };
case 'npc':
return { at, ch: '队友喊话', text: `“${ev.text}” _(${panCn(ev.pan)}侧传来)_` };
case 'npc_dropped':
return { at, ch: '队友喊话', text: `(一句喊话没能播出,被占线挤掉了)` };
case 'oneshot': {
const id = ev.id.slice(ev.id.lastIndexOf('/') + 1);
const CN = {
atk_whoosh: '自己挥击风声', atk_swish: '挥空(没打到任何东西)', kill_wolf: '狼被击杀的哀嚎倒地',
kill_bear: '熊闷重倒地', skill_spin: '旋风斩气旋', breach_thud: '营地被撞闷响+木栅裂',
horn_wave: '波次号角', join_chime: '队友加入提示音', call_whistle: '求援哨音',
howl_wolf: '狼嚎(生成,远处)', howl_bear: '熊低吼(生成,远处)',
};
const extra = ev.extra && ev.extra.note ? ` · ${ev.extra.note}` : '';
return { at, ch: '音效', text: `${CN[id] || id} _(${panCn(ev.pan)}${ev.vol < 0.5 ? '·轻' : ''})_${extra}` };
}
case 'oneshot_dropped':
return { at, ch: '音效', text: `(一声${ev.id.includes('howl') ? '狼嚎/熊吼' : '音效'}太挤没播出来——R4 拥挤丢弃)` };
case 'loop_start':
return { at, ch: 'loop', text: `${ev.mkind === 'bear' ? '熊重步+喘息' : '狼碎步+低呜'} 起 _(${panCn(ev.pan)}·${bandCn(ev.band)}${ev.vol < 0.4 && Math.abs(ev.pan) < 0.5 ? '·闷' : ''})_` };
case 'loop_band':
return { at, ch: 'loop', text: `${ev.mkind === 'bear' ? '熊' : '狼'}脚步${ev.band === 'inner2' ? '⚠️贴脸' : '逼近'} _(${panCn(ev.pan)}·${bandCn(ev.band)})_` };
case 'loop_stop':
return { at, ch: 'loop', text: `(一只的脚步声停了——被杀或突入)` };
case 'vibrate':
return { at, ch: '震动', text: `${ev.kind}${ev.note ? `(${ev.note})` : ''}` };
case 'bgm':
return { at, ch: 'BGM', text: `切换为 ${ev.id === 'bgm/menu' ? '营地夜氛围' : '紧张围猎鼓点'}${ev.vol < 1 ? '(音量压低)' : ''}` };
case 'earcon':
return { at, ch: '提示音', text: `状态签名(${ev.kind})` };
case 'watchdog':
return { at, ch: '提示音', text: `“还在吗?听到请碰一下屏幕。”(闲置提醒)` };
default:
return { at, ch: ev.type, text: JSON.stringify(ev).slice(0, 120) };
}
}
logRow(e, base) {
if (e.kind === 'percept') return null;
const at = e.wall;
switch (e.t) {
case 'join_sent': return { at, ch: '我', text: `进入匹配(${e.join.teach ? '首局·教学模式' : '快速匹配'})` };
case 'srv': return null; // 全量在 jsonl;转写只保留可感知内容
case 'gesture': {
if (e.note === '死窗吞掉') return { at, ch: '我', text: `${gestureCn(e.g)}(结算刚结束,手势被吞)` };
return { at, ch: '我', text: gestureCn(e.g) };
}
case 'attack_gesture':
return {
at, ch: '我',
text: `${gestureCn(dirToGesture(e.dir))}出击 → 我以为是${DIR_CN[e.perceived] || e.perceived}的${e.mkind === 'bear' ? '熊' : '狼'}${e.correct === false ? `(其实在${DIR_CN[e.trueDir] || e.trueDir}——打空了!)` : ''}`,
};
case 'attack_blocked_local':
return { at, ch: '我', text: `${gestureCn(dirToGesture(e.dir))}想打(冷却没好,只震了一下)` };
case 'swipe_rerouted':
return {
at, ch: '我',
text: e.to === 'status' ? '上滑(那个方向没有能打的怪→变成了听状态播报)' : '下滑(那个方向没有能打的怪→打开了菜单!)',
};
case 'spam_swipe_on_cd':
return { at, ch: '我', text: `连续滑动(着急,冷却中乱滑被拦)` };
case 'skill_doubletap_on_cd':
return { at, ch: '我', text: `双击想放旋风斩(冷却中→变成重听威胁播报)` };
case 'callout_gesture':
return { at, ch: '我', text: `单击呼喊支援(感觉我这边压力大了)` };
case 'skill_gesture':
return { at, ch: '我', text: `双击旋风斩(身边${e.inner}只逼近${e.panic ? '·慌了' : ''})` };
case 'habit_status_check':
return { at, ch: '我', text: `上滑听一下状态` };
case 'habit_menu_midwave':
return { at, ch: '我', text: `下滑开了下菜单(战斗没停!)` };
case 'teach_t1_swipe':
return { at, ch: '我', text: `教学中${gestureCn(e.swipe)}(${e.wrong ? '方向搞错了' : '朝狼滑'})` };
case 'lapse_start':
return { at, ch: '我', text: `**走神了 ${Math.round((e.until - at) / 1000)} 秒**(${e.reason || '注意力离开'}——这期间的声音没听到)` };
case 'lapse_end':
return { at, ch: '我', text: `回神,重新听场上的动静` };
case 'net_drop_scripted':
return { at, ch: '网络', text: `**网络断了**(正在重连)` };
case 'reconnect_attempt':
return { at, ch: '网络', text: `重连成功,回到座位继续` };
case 'ws_close':
return { at, ch: '网络', text: `连接断开(code ${e.code})` };
case 'force_end':
return { at, ch: '系统', text: `(异常收场:${e.reason})` };
case 'go_home_from_menu':
return { at, ch: '我', text: `菜单里选了退出房间` };
default: return null;
}
}
// ---------------- 一局速览 ----------------
buildSummary() {
const evs = this.events;
const count = (t, f) => evs.filter((e) => e.t === t && (!f || f(e))).length;
const srv = evs.filter((e) => e.t === 'srv');
const ev = (kind) => srv.filter((e) => e.evs || (e.msg && e.msg.t === 'ev' && e.msg.kind === kind));
const evK = (kind) => srv.filter((e) => e.msg && e.msg.t === 'ev' && e.msg.kind === kind);
const attacks = count('attack_gesture');
const correct = count('attack_gesture', (e) => e.correct === true);
const missed = count('attack_gesture', (e) => e.correct === false);
const misses = evK('miss').filter((e) => e.msg.slot === this._slot).length;
const kills = evK('kill').filter((e) => e.msg.slot === this._slot).length;
const breaches = evK('breach').length;
const callouts = count('callout_gesture');
const skills = count('skill_gesture');
const helpAcksHeard = this.percepts.filter((p) => p.type === 'npc' && p.id.includes('help_ack')).length;
const botlinesHeard = this.percepts.filter((p) => p.type === 'npc').length;
const voiceHeard = this.percepts.filter((p) => p.type === 'voice').length;
const voiceCuts = this.percepts.filter((p) => p.type === 'voice_cut').length;
const howlsDropped = this.percepts.filter((p) => p.type === 'oneshot_dropped').length;
const lapses = count('lapse_start');
const spam = count('spam_swipe_on_cd') + count('attack_blocked_local');
const threatRelisten = count('skill_doubletap_on_cd');
const gdIdle = this.percepts.filter((p) => p.type === 'voice' && p.id === 'gd_idle').length;
return {
attacks, attacksCorrectArc: correct, attacksWrongArc: missed, misses, kills, breachesTotal: breaches,
callouts, skills, helpAcksHeard, botlinesHeard, voiceLinesHeard: voiceHeard, voiceCuts,
howlsDropped, lapses, spamSwipes: spam, threatRelisten, watchdogIdles: gdIdle,
};
}
noteSlot(slot) { this._slot = slot; }
}
function bandCn(band) { return band === 'mid' ? '远' : band === 'inner1' ? '近' : '贴脸'; }
function gestureCn(g) {
return { up: '上滑', down: '下滑', left: '左滑', right: '右滑', tap: '单击', doubletap: '双击' }[g] || g;
}
function dirToGesture(base) { return { N: 'up', E: 'right', S: 'down', W: 'left' }[base] || 'up'; }
+956
View File
@@ -0,0 +1,956 @@
/**
* sim-client.mjs — 拟真模拟客户端 = game.js 的行为移植(协议 + 手势路由 + 音频调度)。
*
* 与真实客户端的对应关系(改动 game.js 音频相关逻辑时本文件必须同步):
* - 语音链 R1(_playChain/_alert):真实资产时长串行链播;警报顶句规则忠实移植
* (protect 链只可排队,非保护链被警报立即切断);queueFirst 等当前句。
* - bot 喊话 R2(_npcSchedule):同刻 1 条、待播 1 条新顶旧、旁白在播延迟 ≤2s
* (呼救语义 4s)超时丢弃。
* - 定位 one-shot R4(_pos):并发 ≤2、同类间隔 0.15s、呼啸 droppable 队列 ≤2、
* 排队 1.5s 过期丢弃。
* - 怪物 loop R3(_refreshLoops):500ms 重估、eta≤6 取前 3、音量档 0.30/0.55/0.80、
* 后半平面 ×0.6、pan=sin(az)。
* - 手势路由(_onWaveGesture 语境折中等)逐条对照;本地冷却先拦(乱滑只震不发)。
* - 看门狗(gd_idle 10s/25s)、结算死窗 800ms、skill_ready 冷却完成播报。
*
* 输出:percept 事件流(玩家耳朵听到/身体感到的一切)→ brain 决策 + recorder 落盘。
* 简化声明:语音句间留 0.12s 间隙(真机 onComplete 链接近无缝);SoundTouch 语速
* 恒 1.0;引导档 full。其余语义与 game.js 一致。
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { connect } from './ws.mjs';
// (本文件不直接用 ROOT;保留注释锚点:仓库根 = lib 上四级)
// ---- 方位(§8.3,与 game.js 一致)----
export const DIR_AZ = { N: 0, NE: 45, E: 90, SE: 135, S: 180, SW: 225, W: 270, NW: 315 };
export const azOf = (d) => DIR_AZ[d] ?? 0;
export const panOfAz = (az) => Math.sin((az * Math.PI) / 180);
export const isBackAz = (az) => az > 90 && az < 270;
export const panOfDir = (d) => panOfAz(azOf(d));
const ARCS = {
up: ['NW', 'N', 'NE'],
right: ['NE', 'E', 'SE'],
down: ['SE', 'S', 'SW'],
left: ['SW', 'W', 'NW'],
};
const SWIPE_BASE = { up: 'N', right: 'E', down: 'S', left: 'W' };
const sectorCenterAz = (sector) =>
(sector || []).reduce((s, d) => s + azOf(d), 0) / (sector || []).length;
// ---- 常量(game.js 同值)----
const ATTACK_CD_MS = 2500;
const SKILL_CD_MS = 8000;
const CALLOUT_THROTTLE_MS = 5000;
const INNER_ETA_S = 4;
const MID_ETA_S = 6;
const LOOP_MAX = 3;
const LOOP_REFRESH_MS = 500;
const ONESHOT_MAX = 2;
const ONESHOT_SPACING_MS = 150;
const HOWL_QUEUE_MAX = 2;
const HOWL_QUEUE_TTL_MS = 1500;
const NPC_VOL = 0.55;
const NPC_VOICE_DELAY_MS = 2000;
const NPC_PRIO_TTL_MS = 4000;
const BREACH_VOICE_GAP_MS = 3500;
const END_GRACE_MS = 800;
const VOICE_GAP_S = 0.12; // 句间间隙(模拟口径)
const SFX_DUR = {
atk_whoosh: 0.3, atk_swish: 0.3, kill_wolf: 0.5, kill_bear: 0.7,
skill_spin: 0.8, breach_thud: 0.6, horn_wave: 0.9, join_chime: 0.4,
call_whistle: 0.6, howl_wolf: 0.8, howl_bear: 1.0,
};
const NPC_FALLBACK_DUR_S = 1.6;
const MENU_ITEMS = ['sys_menu_resume', 'sys_menu_help', 'sys_menu_home'];
const hpBandId = (dur) => (dur >= 8 ? 'hp_band1' : dur >= 5 ? 'hp_band2' : dur >= 3 ? 'hp_band3' : 'hp_band4');
const stWaveId = (w) => (w <= 2 ? 'st_wave_a' : w <= 4 ? 'st_wave_b' : 'st_wave_c');
const killBandId = (k) => (k <= 0 ? 'kill_band0' : k <= 9 ? 'kill_band1' : 'kill_band2');
export class SimClient {
/**
* @param opts {
* port, name, gender ('male'|'female' 旁白音色), assets {durations, voiceText},
* recorder, brain, progress {tutDone, runs, best}, joinDelayMs, teach(bool)
* }
*/
constructor(opts) {
this.o = opts;
this.assets = opts.assets;
this.recorder = opts.recorder;
this.brain = opts.brain;
if (this.brain) this.brain.client = this;
this.session = null;
this.joined = false;
this.slot = -1;
this.char = null;
this.sector = [];
this.room = null;
this.seed = 0;
this.roster = new Map();
this.seq = 0;
this.phase = 'title'; // title/matching/lobby/teach/wave/end/lost
this.menuOpen = false;
this.menuIdx = 0;
this.srvPhase = null;
this.mobs = new Map(); // id → { id, mkind, dir, eta0, t0 }(t0=nowSrv)
this.wave = 0;
this.dur = 10;
this.myKills = 0;
this.srvOffset = 0;
this.atkReadyAt = 0;
this.skillReadyAt = 0;
this.calloutReadyAt = 0;
this.skillAnnounced = true;
this.lastBreachVoiceAt = -1e9;
this.dangerAnnounced = false;
this.graceUntil = 0;
this.lastGestureAt = 0; // 看门狗基准
this.watchdogSaid1 = false;
// 教学状态(_tut 0..4)
this.tut = -1;
this.tutMiss = 0;
this.tutDoneWaiting = false;
// 音频通道模型
this.voiceQ = []; // { id, dur, protect?, tag }(已含在播句)
this.voiceBusyUntil = 0; // 当前句结束时刻(wall ms)
this.voiceProtect = null; // 当前保护链标识(teach/announce/settle)
this.voiceChainToken = 0;
this.npcBusyUntil = 0;
this.npcPending = null; // { id, pan, expireAt, prio }
this.posActive = []; // [{ until }]
this.posQueue = [];
this.posTypeAt = {};
this.loops = new Map(); // id → { mkind, dir, band, vol, pan }
this.bgm = null;
this.progress = opts.progress || { tutDone: false, runs: 0, best: null, totalKills: 0 };
this._timers = new Set();
this._stopped = false;
this.result = null; // { win, dur, grade, kills, myKills }
this.matchEnded = new Promise((res) => { this._resolveEnd = res; });
}
// ================= 基础 =================
now() { return Date.now(); }
nowSrv() { return this.now() + this.srvOffset; }
voiceDur(id) {
const k = `voice/${this.o.gender}/${id}`;
return (this.assets.durations[k] || 0) || 2.2; // 缺资产兜底 2.2s
}
npcDur(id) {
return (this.assets.durations[id] || 0) || NPC_FALLBACK_DUR_S;
}
text(id) { return this.assets.voiceText[id] || id; }
vDurTotal(ids) { return ids.reduce((s, id) => s + this.voiceDur(id) + VOICE_GAP_S, 0); }
percept(ev) {
ev.t = this.now();
this.recorder.percept(ev);
if (this.brain) this.brain.onPercept(ev);
}
timer(fn, ms) {
const id = setTimeout(() => { this._timers.delete(id); if (!this._stopped) fn(); }, ms);
this._timers.add(id);
return id;
}
// ================= 生命周期 =================
async startMatch({ teach = false } = {}) {
this.phase = 'matching';
this.session = await connect(this.o.port);
this._wireSession();
const join = { t: 'join', ver: 1, room: 'quick', name: this.o.name };
if (teach || !this.progress.tutDone) join.teach = true;
this.session.send(join);
this.recorder.log({ t: 'join_sent', join });
// 兜底:对局 10 分钟必须收敛
this.timer(() => this.forceEnd('timeout'), 10 * 60 * 1000);
}
_wireSession() {
this.session.on('message', (m) => this.onMessage(m));
this.session.on('close', (code) => this.onClose(code));
}
/** §6.6 断线重连:带 resume 回槽(不重播波次播报,播 net_back)。 */
async reconnect(resumeToken) {
if (this._stopped) return;
this.recorder.log({ t: 'reconnect_attempt' });
this.session = await connect(this.o.port);
this._wireSession();
this.session.send({ t: 'join', ver: 1, room: 'quick', name: this.o.name, resume: resumeToken });
this._netDown = true;
}
onClose(code) {
if (this._stopped) return;
this.recorder.log({ t: 'ws_close', code });
const inRoom = this.phase === 'lobby' || this.phase === 'teach' || this.phase === 'wave';
if (inRoom || this.phase === 'matching') {
this._netDown = true;
this.alert('net_lost'); // 断线:本地静默重连 ≤30s(只播一次)
}
if (this.brain && this.brain.onNetDown) this.brain.onNetDown();
}
sendAct(kind, dir) {
this.seq += 1;
const act = { t: 'act', kind, seq: this.seq, at: this.now() };
if (dir) act.dir = dir;
const ok = this.session && this.session.send(act);
this.recorder.log({ t: 'act_sent', act, delivered: !!ok });
}
leave() {
if (this.session && this.joined) {
this.session.send({ t: 'leave' });
this.recorder.log({ t: 'leave_sent' });
}
this.stop();
}
stop() {
this._stopped = true;
for (const id of this._timers) clearTimeout(id);
this._timers.clear();
if (this.session) { this.session.close(); this.session = null; }
}
forceEnd(reason) {
this.recorder.log({ t: 'force_end', reason });
this.leave();
this._resolveEnd(null);
}
// ================= 服务器消息 =================
onMessage(msg) {
this.recorder.log({ t: 'srv', msg });
if (typeof msg.ts === 'number') this.srvOffset = msg.ts - this.now();
switch (msg.t) {
case 'joined': this.onJoined(msg); break;
case 'ev': this.onEv(msg); break;
case 'snap': this.onSnap(msg); break;
case 'err': this.onErr(msg); break;
default: break;
}
}
onJoined(j) {
const resumed = this._netDown === true;
this._netDown = false;
this.joined = true;
this._resumeToken = j.resume;
this.room = j.room;
this.slot = j.slot;
this.char = j.char || null;
this.sector = j.sector || [];
this.seed = j.seed;
this.roster = new Map((j.roster || []).map((r) => [r.slot, r]));
this.srvPhase = (j.state || {}).phase;
this.wave = j.state.wave ?? 0;
this.dur = j.state.dur ?? 10;
if (!resumed) this.resetRound();
if (this.srvPhase === 'lobby') this.enterLobby();
else if (this.srvPhase === 'teach') this.enterTeach();
else if (this.srvPhase === 'wave') {
if (resumed) {
// 回槽续局:net_back + 无缝续渲染(不重播号角/波次播报,§6.6)
this.phase = 'wave';
this.playBgm('hunt');
this.percept({ type: 'earcon', kind: 'battle' });
this.playChain(['net_back'], { protect: 'announce' });
} else this.enterWave(this.wave || 1, 'resume');
} else { this.phase = 'end'; this.playChain(['end_again']); }
if (this.brain && this.brain.onJoined) this.brain.onJoined(j);
}
resetRound() {
this.mobs.clear();
this.loops.clear();
this.myKills = 0;
this.wave = 0;
this.dur = 10;
this.atkReadyAt = 0;
this.skillReadyAt = 0;
this.calloutReadyAt = 0;
this.skillAnnounced = true;
this.dangerAnnounced = false;
this.lastBreachVoiceAt = -1e9;
this.tut = -1;
this.tutDoneWaiting = false;
this.actedThisWave = false; // v2 S4/S9 每局复位
this._idleVoiceSaid = false;
this._lastStatusSaid = null;
this._restUntil = 0;
this._atkBlockedThisCd = false;
}
onSnap(s) {
if (typeof s.ts === 'number') this.srvOffset = s.ts - this.now();
if (s.phase) this.srvPhase = s.phase;
if (typeof s.dur === 'number') this.dur = s.dur;
if (typeof s.wave === 'number') this.wave = s.wave;
const seen = new Set();
for (const sm of s.mobs || []) {
seen.add(sm.id);
const cur = this.mobs.get(sm.id);
if (!cur) this.mobs.set(sm.id, { id: sm.id, mkind: sm.mkind, dir: sm.dir, eta0: sm.eta, t0: s.ts });
else {
if (Math.abs(this.mobEta(cur) - sm.eta) > 0.5) { cur.eta0 = sm.eta; cur.t0 = s.ts; }
cur.mkind = sm.mkind; cur.dir = sm.dir;
}
}
for (const id of [...this.mobs.keys()]) if (!seen.has(id)) this.mobs.delete(id);
const me = (s.slots || []).find((x) => x.slot === this.slot);
if (me) {
const now = this.now();
if (typeof me.cd === 'number') this.atkReadyAt = now + me.cd * 1000;
if (typeof me.skillCd === 'number') {
this.skillReadyAt = now + me.skillCd * 1000;
if (me.skillCd > 0 && this.skillAnnounced) this.skillAnnounced = false; // 由 brain 侧播报计时
}
}
this.refreshLoops();
}
onErr(msg) {
if (msg.code === 'room_gone' || msg.code === 'room_full') {
this.phase = 'lost';
this.playChain(['net_gone'], { protect: 'announce' });
this._resolveEnd(null);
return;
}
this.recorder.log({ t: 'err_seen', code: msg.code });
}
mobEta(m) { return m.eta0 - (this.nowSrv() - m.t0) / 1000; }
// ================= 事件流 → 音频 =================
onEv(msg) {
switch (msg.kind) {
case 'spawn': this.onSpawn(msg); break;
case 'kill': this.onKill(msg); break;
case 'miss': this.onMiss(msg); break;
case 'skill': this.onSkillEv(msg); break;
case 'breach': this.onBreach(msg); break;
case 'wave':
this.srvPhase = 'wave';
this.enterWave(msg.wave, this.phase);
break;
case 'waveclear':
this._restUntil = this.now() + 8000; // v2 S1 波间休整窗
this.playChain(['wave_clear'], { queueFirst: true });
break;
case 'callout': this.onCalloutEv(msg); break;
case 'botline': this.onBotline(msg); break;
case 'roster': this.applyRoster(msg.roster || [], false); break;
case 'end': this.enterEnd(msg); break;
default: break;
}
if (this.brain && this.brain.onEv) this.brain.onEv(msg);
}
onSpawn(ev) {
this.mobs.set(ev.id, { id: ev.id, mkind: ev.mkind, dir: ev.dir, eta0: ev.eta, t0: ev.ts ?? this.nowSrv() });
const az = azOf(ev.dir);
this.pos('sfx/howl_' + ev.mkind, {
pan: panOfAz(az), vol: 0.4 * (isBackAz(az) ? 0.6 : 1), type: 'howl', droppable: true,
extra: { mkind: ev.mkind },
});
this.refreshLoops();
}
onKill(ev) {
this.mobs.delete(ev.id);
this.stopLoop(ev.id);
this.pos('sfx/kill_' + ev.mkind, { pan: panOfDir(ev.dir), vol: 1, type: 'kill', extra: { mkind: ev.mkind, by: ev.slot } });
if (ev.slot === this.slot) {
this.myKills += 1;
this.actedThisWave = true;
this.percept({ type: 'vibrate', kind: 'pickup', note: '击杀命中' });
}
if (this.phase === 'teach' && this.tut === 1) this.tutGoto(2, ['tut_kill_ok', 'tut_callout']);
}
onMiss(ev) {
if (ev.slot !== this.slot) return;
this.pos('sfx/atk_swish', { pan: panOfDir(ev.dir), vol: 1, type: 'atk', extra: { note: '挥空' } });
this.percept({ type: 'vibrate', kind: 'error', note: '挥空' });
if (this.phase === 'teach' && this.tut === 1) {
this.tutMiss += 1;
this.playChain(['gd_idle'], { protect: 'teach' });
}
}
onSkillEv(ev) {
for (const id of ev.cleared || []) { this.mobs.delete(id); this.stopLoop(id); }
this.pos('sfx/skill_spin', { pan: 0, vol: 1, type: 'skill', extra: { cleared: (ev.cleared || []).length, by: ev.slot } });
if (ev.slot === this.slot) this.percept({ type: 'vibrate', kind: 'success', note: '旋风斩' });
if (this.phase === 'teach' && this.tut === 3) this.tutGoto(4, ['tut_skill_cd', 'tut_status']);
}
onBreach(ev) {
this.mobs.delete(ev.id);
this.stopLoop(ev.id);
this.dur = ev.dur;
if (this.srvPhase === 'teach') return; // 学期零惩罚
this.pos('sfx/breach_thud', { pan: 0, vol: 1, type: 'breach', extra: { mkind: ev.mkind, dur: ev.dur } });
this.percept({ type: 'vibrate', kind: 'hit', note: `营地被突入(-${ev.mkind === 'bear' ? 2 : 1}) 余${ev.dur}` });
const now = this.now();
if (now - this.lastBreachVoiceAt >= BREACH_VOICE_GAP_MS) {
this.lastBreachVoiceAt = now;
this.alert(ev.mkind === 'bear' ? 'breach_heavy' : 'breach');
}
if (this.dur <= 2 && !this.dangerAnnounced) {
this.dangerAnnounced = true;
this.playChain(['danger_low'], { queue: true });
this.percept({ type: 'vibrate', kind: 'danger' });
this.percept({ type: 'earcon', kind: 'danger' });
}
}
onCalloutEv(ev) {
if (ev.slot === this.slot) return;
const r = this.roster.get(ev.slot);
const char = r ? r.char : null;
const rsector = r && r.sector ? r.sector : [];
const pan = panOfAz(sectorCenterAz(rsector));
if (char) this.npcSchedule('sfx/npc_' + char + '_help_call', pan, true);
else this.pos('sfx/call_whistle', { pan, vol: 1, type: 'whistle' });
this.percept({ type: 'vibrate', kind: 'warning', note: '队友呼喊支援' });
}
onBotline(ev) {
const r = this.roster.get(ev.slot);
const char = r ? r.char : null;
if (!char) return;
const prio = ev.line === 'help_call' || ev.line === 'help_ack';
const pan = panOfAz(sectorCenterAz(r && r.sector ? r.sector : []));
this.npcSchedule('sfx/npc_' + char + '_' + ev.line, pan, prio);
}
applyRoster(roster, initial) {
const old = this.roster;
this.roster = new Map(roster.map((r) => [r.slot, r]));
if (initial || !old.size) return;
for (const r of roster) {
const o = old.get(r.slot);
if (!o && this.phase === 'lobby') {
this.pos('sfx/join_chime', { pan: 0, vol: 1, type: 'chime', extra: { note: '队友加入' } });
} else if (o && !o.bot && r.bot && r.slot !== this.slot) {
this.alert('net_to_bot');
}
}
}
// ================= 状态进入 =================
enterLobby() {
this.phase = 'lobby';
this.playBgm('menu');
this.percept({ type: 'earcon', kind: 'menu' });
this.playChain(['room_wait'], { protect: 'announce' });
this.armWatchdog();
}
enterTeach() {
this.phase = 'teach';
this.tut = 0;
this.tutMiss = 0;
this.tutDoneWaiting = false;
this.playBgm('hunt');
this.percept({ type: 'earcon', kind: 'battle' });
this.playChain(['intro_1', 'intro_2', 'tut_rule'], { protect: 'teach', tag: 'teach-t0' });
}
enterWave(n, from) {
const wasInWave = this.phase === 'wave';
const wasTeach = this.phase === 'teach';
this.phase = 'wave';
this.actedThisWave = false; // v2 S4
this._restUntil = 0;
this.tut = -1;
this.tutDoneWaiting = false;
this.wave = n;
// 教学完成兜底:教学波出清后服务器同场接续第 1 波——只要从 teach 进波次,
// 教学即视为完成(tutDone 持久化,二周目不再进教学房)。T4 链 onDone 被波次
// 播报顶掉时由此兜底(真实进度语义不受语音链调度影响)。
if (wasTeach && !this.progress.tutDone) this.finishTeach();
this.playBgm('hunt');
if (!wasInWave) this.percept({ type: 'earcon', kind: 'battle' });
this.percept({ type: 'vibrate', kind: 'heartbeat', note: `第${n}波开始` });
this.pos('sfx/horn_wave', { pan: 0, vol: 1, type: 'horn' });
const band = 'wave_band' + Math.min(5, Math.max(1, n));
if (n === 1 && from === 'lobby') this.playChain(['room_start', band], { protect: 'announce' });
else this.playChain([band], { protect: 'announce' });
this.armWatchdog();
}
enterEnd(ev) {
this.phase = 'end';
this.menuOpen = false;
this.graceUntil = this.now() + END_GRACE_MS;
this.srvPhase = 'end';
this.dur = ev.dur;
const kills = (ev.kills && ev.kills[String(this.slot)]) || 0;
this.myKills = kills;
this.loops.clear();
this.playBgm('menu', 0.5);
this.percept({ type: 'earcon', kind: 'settle' });
// 进度账(§12 口径)
this.progress.runs += 1;
this.progress.totalKills += kills;
if (ev.win && ev.grade) {
const cand = { grade: ev.grade, kills, durLeft: ev.dur };
if (!this.progress.best || ev.grade !== this.progress.best.grade || ev.dur > this.progress.best.durLeft) {
this.progress.best = cand;
}
}
this.result = { win: ev.win, dur: ev.dur, grade: ev.grade, myKills: kills, kills: ev.kills, wave: this.wave };
const myBand = kills > 0 ? [killBandId(kills)] : []; // v2 S3:0 杀不播「你还没开张」
const chain = ev.win
? ['end_win', 'grade_' + String(ev.grade || 'C').toLowerCase(), ...myBand, 'end_rank', 'end_again']
: ['end_lose', ...myBand, 'end_again'];
this.playChain(chain, { protect: 'settle', tag: 'end-chain' });
// 结算链播完即视为对局收官(brain 的 END 手势由 arena 局间决策替代)
const totalMs = this.vDurTotal(chain) * 1000 + 1500;
this.timer(() => this._resolveEnd(this.result), totalMs);
}
tutGoto(step, ids) {
this.voiceChainToken += 1;
this.tut = step;
if (ids && ids.length) this.playChain(ids, { protect: 'teach', tag: 'teach-t' + step });
}
finishTeach() {
this.tut = -1;
this.tutDoneWaiting = true;
this.progress.tutDone = true;
}
// ================= 手势(brain 意图 → 客户端路由)=================
/** brain 发手势:'up'/'down'/'left'/'right'/'tap'/'doubletap' */
gesture(g) {
this.lastGestureAt = this.now();
this.watchdogSaid1 = false;
this.recorder.log({ t: 'gesture', g });
if (this.now() < this.graceUntil) { this.recorder.log({ t: 'gesture', note: '死窗吞掉', g }); return; }
if (this.menuOpen) { this.onMenuGesture(g); return; }
switch (this.phase) {
case 'lobby': this.onLobbyGesture(g); break;
case 'teach': this.onTeachGesture(g); break;
case 'wave': this.onWaveGesture(g); break;
case 'end': this.onEndGesture(g); break;
default: break; // title/matching 由 arena 控制,brain 不在此发手势
}
}
onLobbyGesture(g) {
if (g === 'tap') { this.sendAct('start'); return; }
if (g === 'doubletap') { this.playChain(['room_wait']); return; }
if (g === 'up') {
this.playChain([this.roster.size >= 4 ? 'team_4' : 'team_3'], { queueFirst: true });
return;
}
if (g === 'down') { this.openMenu(); return; }
this.invalid();
}
onTeachGesture(g) {
// v2 S8:作答步(1-3)内下滑 = 答错处理,不跳过;仅 T0/T4/完成等待态可跳
if (g === 'down' && (this.tut === 0 || this.tut === 4 || this.tut === -1)) {
this.voiceChainToken += 1;
this.playChain(['tut_skip'], { protect: 'teach', tag: 'teach-skip' });
this.finishTeach();
return;
}
switch (this.tut) {
case 0:
if (g === 'tap') this.tutGoto(1, ['tut_attack']);
else this.invalid();
break;
case 1:
if (g in SWIPE_BASE) this.attackDir(SWIPE_BASE[g]);
else this.invalid();
break;
case 2:
if (g === 'tap') this.callout();
else this.invalid();
break;
case 3:
if (g === 'doubletap') this.skill();
else this.invalid();
break;
case 4:
if (g === 'up') {
const ids = this.statusIds();
this.voiceChainToken += 1;
this.playChain([...ids, 'tut_breach', 'tut_done'], {
protect: 'teach',
tag: 'teach-t4',
onDone: () => this.finishTeach(),
});
} else this.invalid();
break;
default: this.invalid(); break;
}
}
onWaveGesture(g) {
if (g === 'tap') { this.callout(); return; }
if (g === 'doubletap') { this.skill(); return; }
if (g in SWIPE_BASE) {
// v2 S1:下滑恒出击(无怪=空挥);菜单只在波间休整窗(waveclear 后 8s)
const now = this.now();
if (g === 'up' && !this.arcHasTarget(ARCS.up)) {
this.recorder.log({ t: 'swipe_rerouted', g, to: 'status' });
this.statusChain();
return;
}
if (g === 'down' && now < this._restUntil) {
this.recorder.log({ t: 'swipe_rerouted', g, to: 'menu' });
this.openMenu();
return;
}
this.attackDir(SWIPE_BASE[g]);
return;
}
this.invalid();
}
onEndGesture(g) {
if (g === 'tap') { this.recorder.log({ t: 'end_again_gesture' }); return; } // 由 arena 局间决策接管
if (g === 'doubletap') { this.playChain(this._endChainEcho || ['end_win']); return; }
if (g === 'down') { this.openMenu(); return; }
this.invalid();
}
onMenuGesture(g) {
if (g === 'tap') {
this.menuIdx = (this.menuIdx + 1) % MENU_ITEMS.length;
this.playChain([MENU_ITEMS[this.menuIdx]]);
return;
}
if (g === 'doubletap') {
const item = MENU_ITEMS[this.menuIdx];
if (item === 'sys_menu_resume') this.closeMenu();
else if (item === 'sys_menu_help') this.playChain(['help_main']);
else { this.recorder.log({ t: 'go_home_from_menu' }); this.leave(); this._resolveEnd(null); }
return;
}
if (g === 'up') { this.playChain(['help_main']); return; }
if (g === 'down') this.closeMenu();
this.invalid();
}
openMenu() {
this.menuOpen = true;
this.menuIdx = 0;
this.percept({ type: 'earcon', kind: 'menu' });
this.playChain(['sys_menu', MENU_ITEMS[0], 'settings_hint'], { tag: 'menu-open' });
}
closeMenu() {
this.menuOpen = false;
this.voiceChainToken += 1;
if (this.phase === 'wave' || this.phase === 'teach') this.percept({ type: 'earcon', kind: 'battle' });
}
invalid() {
this.percept({ type: 'vibrate', kind: 'error', note: '无效手势' });
// gd_invalid onceKey 同局 1 次(full 档)
if (!this._invalidSaid) {
this._invalidSaid = true;
this.playChain(['gd_invalid'], { queueFirst: true });
}
}
// ---- 战斗意图(本地门控与 game.js 一致)----
attackDir(dir) {
const now = this.now();
if (now < this.atkReadyAt) {
if (!this._atkBlockedThisCd) { // v2 S2:每冷却窗首拦才震 + 就绪轻叮
this._atkBlockedThisCd = true;
this.percept({ type: 'vibrate', kind: 'error', note: '出击冷却中首拦' });
const wait = Math.max(0, this.atkReadyAt - now);
this.timer(() => {
if (this._atkBlockedThisCd && (this.phase === 'wave' || this.phase === 'teach')) {
this._atkBlockedThisCd = false;
this.percept({ type: 'vibrate', kind: 'beat', note: '出击就绪轻叮' });
}
}, wait);
}
this.recorder.log({ t: 'attack_blocked_local', dir });
return;
}
this._atkBlockedThisCd = false;
this.atkReadyAt = now + ATTACK_CD_MS;
this.actedThisWave = true;
this.sendAct('attack', dir);
this.pos('sfx/atk_whoosh', { pan: panOfDir(dir), vol: 1, type: 'atk', extra: { dir } });
}
callout() {
const now = this.now();
if (now < this.calloutReadyAt) {
this.percept({ type: 'vibrate', kind: 'error', note: '呼喊节流被拦' });
return;
}
this.calloutReadyAt = now + CALLOUT_THROTTLE_MS;
this.actedThisWave = true;
this.sendAct('callout');
if (this.char) this.npcFire('sfx/npc_' + this.char + '_help_call', 0);
else this.pos('sfx/call_whistle', { pan: panOfAz(sectorCenterAz(this.sector)), vol: 1, type: 'whistle' });
}
skill() {
const now = this.now();
if (now < this.skillReadyAt) { this.threatReport(); return; }
this.skillReadyAt = now + SKILL_CD_MS;
this.skillAnnounced = false;
this.actedThisWave = true;
this.sendAct('skill');
this.timer(() => {
if (this.skillAnnounced) return;
this.skillAnnounced = true;
if (this.phase === 'wave' || this.phase === 'teach') {
this.playChain(['skill_ready'], { queue: true });
}
}, SKILL_CD_MS);
}
threatReport() {
const inner = [...this.mobs.values()].filter((m) => { const e = this.mobEta(m); return e <= INNER_ETA_S && e > 0; }).length;
const id = inner === 0 ? 'threat_none' : inner <= 2 ? 'threat_few' : 'threat_many';
this.playChain([id], { queueFirst: true, tag: 'threat-relisten' });
}
statusIds() { // v2 S3/S9:0 杀不播 kill_band0;与上次播报一致的分段跳过
const ids = [hpBandId(this.dur), stWaveId(this.wave || 1), this.roster.size >= 4 ? 'team_4' : 'team_3', killBandId(this.myKills)]
.filter((id) => id !== 'kill_band0' || this.myKills > 0);
const changed = ids.filter((id) => !this._lastStatusSaid || !this._lastStatusSaid.includes(id));
this._lastStatusSaid = ids;
return changed;
}
statusChain(onDone = null) {
this.playChain(this.statusIds(), { queueFirst: true, tag: 'status-chain', onDone });
}
arcHasTarget(arc) {
for (const m of this.mobs.values()) {
if (!arc.includes(m.dir)) continue;
const eta = this.mobEta(m);
if (eta <= INNER_ETA_S && eta > 0) return true;
}
return false;
}
// ================= 音频通道模型 =================
playBgm(id, vol = 1) {
if (this.bgm === id) return;
this.bgm = id;
this.percept({ type: 'bgm', id, vol });
}
/**
* 语音链(R1 移植)。opts: { protect, queueFirst, queue, tag, onDone }
* - queue:true / queueFirst:true:等当前句与队列自然走完(不顶)
* - protect:保护链标识——警报只能插队(当前句后),不切断
* - 无 protect 无 queue:硬播,立即切断当前句并清空队列(引擎"打断即作废")
*/
playChain(ids, opts = {}) {
if (!ids.length) { if (opts.onDone) opts.onDone(); return; }
this.voiceChainToken += 1;
const token = this.voiceChainToken;
if (opts.protect) this.voiceProtect = opts.protect;
const now = this.now();
const items = ids.map((id) => ({ id, dur: this.voiceDur(id) }));
if (!opts.queue && !opts.queueFirst && !opts.protect) {
// 硬播:切断当前句(记 cut),清队列
if (this.voiceQ.length) {
this.percept({ type: 'voice_cut', id: this.voiceQ[0].id, note: '被警报类顶掉' });
}
this.voiceQ = [];
this.voiceBusyUntil = now;
this.voiceProtect = null;
} else if (opts.protect && this.voiceProtect && this.voiceProtect !== opts.protect && this.voiceQ.length > 1) {
// 新保护链插队到当前句之后,旧保护链剩余句顺延(真客户端 guard-advance 近似:
// 波次播报会打断教学当前句,但教学链剩余句由看门狗推进照播,不整链蒸发)
this.voiceQ.splice(1, 0, ...items.map((it) => ({ ...it, protect: opts.protect, tag: opts.tag || null, token })));
this.voiceBusyUntil = Math.min(this.voiceBusyUntil, now + this.voiceQ[0].dur * 1000);
if (opts.onDone) this.timer(opts.onDone, this.vDurTotal(ids) * 1000);
return;
} else if (!opts.queueFirst && this.voiceQ.length && opts.protect !== this.voiceProtect) {
// 新保护链顶旧非保护链(教学跳步等):切断
this.voiceQ = [];
this.voiceBusyUntil = now;
}
this.voiceQ.push(...items.map((it) => ({ ...it, protect: opts.protect || null, tag: opts.tag || null, token })));
if (opts.onDone) {
this.timer(() => { if (token === this.voiceChainToken || !opts.protect) opts.onDone(); },
this.vDurTotal(ids) * 1000 + (opts.queueFirst ? 3000 : 200));
}
}
/** 警报:保护链在播 → 排队(当前句后);否则硬播顶掉。 */
alert(id) {
if (this.voiceProtect || this.phase === 'teach') this.playChain([id], { queue: true, tag: 'alert-queued' });
else this.playChain([id], { tag: 'alert-interrupt' });
}
/** 音频 tick(100ms):推进三个通道状态机 + loop 重估(500ms 节拍)。 */
audioTick() {
const now = this.now();
// voice
if (this.voiceQ.length && now >= this.voiceBusyUntil) {
const item = this.voiceQ.shift();
this.voiceBusyUntil = now + item.dur * 1000;
this.percept({
type: 'voice', id: item.id, text: this.text(item.id), dur: item.dur,
protect: item.protect, tag: item.tag, end: now + item.dur * 1000,
});
}
if (!this.voiceQ.length && this.voiceProtect && now >= this.voiceBusyUntil) this.voiceProtect = null;
// npc(200ms 泵节奏并入 100ms tick)
if (this.npcPending && now > this.npcPending.expireAt) {
this.percept({ type: 'npc_dropped', id: this.npcPending.id, note: '延迟超时丢弃(R2)' });
this.npcPending = null;
}
if (this.npcPending && now >= this.npcBusyUntil && !this.voiceQ.length && now >= this.voiceBusyUntil) {
const p = this.npcPending;
this.npcPending = null;
this.npcFire(p.id, p.pan, p.onDone);
}
// pos one-shot 泵(100ms,与客户端一致)
this.posActive = this.posActive.filter((a) => a.until > now);
if (this.posQueue.length) {
const rest = [];
for (const q of this.posQueue) {
if (q.droppable && now - q.queuedAt > HOWL_QUEUE_TTL_MS) {
this.percept({ type: 'oneshot_dropped', id: q.id, note: '呼啸排队过期丢弃(R4)' });
continue;
}
const last = this.posTypeAt[q.type] ?? -1e9;
if (this.posActive.length < ONESHOT_MAX && now - last >= ONESHOT_SPACING_MS) {
this.posFire(q.id, q.pan, q.vol, q.type, now, q.extra);
} else rest.push(q);
}
this.posQueue = rest;
}
// 看门狗(gd_idle 10s;25s 纯震)
if ((this.phase === 'wave' || this.phase === 'lobby') && !this.menuOpen) {
const idle = now - this.lastGestureAt;
if (idle > 10000 && !this.watchdogSaid1) {
this.watchdogSaid1 = true;
// v2 S4:WAVE 内出过手 → 一级降纯震;语音闲置每局 ≤1 次
const canSay = this.phase === 'lobby' || (!this.actedThisWave && !this._idleVoiceSaid);
if (canSay) {
if (this.phase === 'wave') this._idleVoiceSaid = true;
this.playChain(['gd_idle'], { queueFirst: true, tag: 'watchdog' });
} else {
this.percept({ type: 'vibrate', kind: 'confirm', note: '闲置一级纯震' });
}
this.percept({ type: 'watchdog', level: 1 });
} else if (idle > 25000) {
this.percept({ type: 'vibrate', kind: 'confirm', note: '看门狗2级纯震' });
this.lastGestureAt = now - 12000; // 不再连震
}
}
// loop 重估(500ms 节拍)
if (!this._loopAt || now - this._loopAt >= LOOP_REFRESH_MS) {
this._loopAt = now;
this.refreshLoops();
}
}
refreshLoops() {
if (this.phase !== 'wave' && this.phase !== 'teach') {
for (const id of [...this.loops.keys()]) this.stopLoop(id);
return;
}
const cand = [...this.mobs.values()]
.map((m) => ({ m, eta: this.mobEta(m) }))
.filter((x) => x.eta <= MID_ETA_S && x.eta > 0)
.sort((a, b) => a.eta - b.eta)
.slice(0, LOOP_MAX);
const keep = new Set(cand.map((x) => x.m.id));
for (const id of [...this.loops.keys()]) if (!keep.has(id)) this.stopLoop(id);
for (const { m, eta } of cand) {
const az = azOf(m.dir);
const pan = panOfAz(az);
const bandVol = eta > 4 ? 0.3 : eta > 2.5 ? 0.55 : 0.8;
const band = eta > 4 ? 'mid' : eta > 2.5 ? 'inner1' : 'inner2';
const vol = bandVol * (isBackAz(az) ? 0.6 : 1);
const h = this.loops.get(m.id);
if (!h) {
const handle = {
id: m.id, mkind: m.mkind, dir: m.dir, band, vol, pan, eta,
muffled: isBackAz(az), // v2 S6:低通第二线索(音色+响度双线索判前后)
};
this.loops.set(m.id, handle);
this.percept({ type: 'loop_start', ...handle });
} else if (h.band !== band) {
h.band = band; h.vol = vol; h.eta = eta;
this.percept({ type: 'loop_band', ...h });
} else {
h.eta = eta; h.vol = vol;
}
}
}
stopLoop(id) {
const h = this.loops.get(id);
if (!h) return;
this.loops.delete(id);
this.percept({ type: 'loop_stop', id });
}
pos(id, { pan = 0, vol = 1, type = 'misc', droppable = false, extra = null } = {}) {
const now = this.now();
this.posActive = this.posActive.filter((a) => a.until > now);
const last = this.posTypeAt[type] ?? -1e9;
if (this.posActive.length < ONESHOT_MAX && now - last >= ONESHOT_SPACING_MS) {
this.posFire(id, pan, vol, type, now, extra);
return;
}
if (droppable && this.posQueue.filter((q) => q.type === 'howl').length >= HOWL_QUEUE_MAX) {
this.percept({ type: 'oneshot_dropped', id, note: '呼啸队列满丢弃(R4)' });
return;
}
this.posQueue.push({ id, pan, vol, type, droppable, queuedAt: now, extra });
}
posFire(id, pan, vol, type, now, extra = null) {
this.percept({ type: 'oneshot', kind: type, id, pan, vol, extra });
const durMs = (SFX_DUR[id.slice(id.lastIndexOf('/') + 1)] ?? 0.5) * 1000;
this.posActive.push({ until: now + durMs });
this.posTypeAt[type] = now;
}
npcSchedule(id, pan, prio = false, onDone = null) {
const now = this.now();
const voiceBusy = this.voiceQ.length > 0 || now < this.voiceBusyUntil;
if (now >= this.npcBusyUntil && !voiceBusy) { this.npcFire(id, pan, onDone); return; }
this.npcPending = { id, pan, onDone, expireAt: prio ? Infinity : now + NPC_VOICE_DELAY_MS }; // v2 S5③
}
npcFire(id, pan, onDone = null) {
const now = this.now();
this.npcPending = null;
const dur = this.npcDur(id);
this.npcBusyUntil = now + dur * 1000;
this.percept({ type: 'npc', id, pan, vol: NPC_VOL, dur, text: this.text(id.slice(4)), end: now + dur * 1000 });
if (onDone) this.timer(onDone, dur * 1000);
}
armWatchdog() { this.lastGestureAt = this.now(); this.watchdogSaid1 = false; }
}
+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); }
}