/** * 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; }