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

76 lines
2.9 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.
/**
* 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;
}