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