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