Files
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

281 lines
13 KiB
JavaScript
Raw Permalink 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.
/**
* 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;
}