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