/** * recorder.mjs — 全量记录器:一个玩家一局 = events.jsonl + transcript.md + summary.md。 * * events.jsonl:服务器每条消息 / 感知事件 / 手势与 act / 大脑决策注记,逐条带墙钟时间。 * transcript.md:给玩家 agent 读的「这一局耳朵听到了什么 + 我做了什么」时间线 * (LLM 可直接代入审体验;也是分析 agent 的取证材料)。 * summary.md:一局速览(结果/账目/混淆矩阵/体验事件计数),供局间去留决策。 */ import fs from 'node:fs'; import path from 'node:path'; const DIR_CN = { N: '正前', NE: '右前', E: '正右', SE: '右后', S: '正后', SW: '左后', W: '正左', NW: '左前' }; const panCn = (pan) => { if (pan > 0.85) return '右'; if (pan > 0.35) return '偏右'; if (pan > -0.35) return '中间'; if (pan > -0.85) return '偏左'; return '左'; }; const fmt = (ms, base) => { const s = Math.max(0, (ms - base) / 1000); const m = Math.floor(s / 60); return `${String(m).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}.${String(Math.floor((s % 1) * 10))}`; }; export class Recorder { constructor({ playerId, matchDir, text, gender }) { this.id = playerId; this.dir = matchDir; this.text = text || ((id) => id); this.gender = gender; this.events = []; this.percepts = []; this.t0 = Date.now(); fs.mkdirSync(matchDir, { recursive: true }); this._stream = fs.createWriteStream(path.join(matchDir, `${playerId}.events.jsonl`)); } log(obj) { const rec = { wall: Date.now(), ...obj }; this.events.push(rec); this._stream.write(JSON.stringify(rec) + '\n'); } percept(ev) { this.log({ kind: 'percept', ...ev }); this.percepts.push({ ...ev }); } close() { this._stream.end(); } // ---------------- 转写 ---------------- buildTranscript({ name, persona, result, roomInfo }) { const base = this.t0; const lines = []; lines.push(`# ${persona}(${name})· ${this.id} · 感知转写`); lines.push(''); if (roomInfo) { lines.push(`> 房间 ${roomInfo.room} | 座位 ${roomInfo.slot}(角色:${roomInfo.charName || '无'},主防区 ${roomInfo.sector && roomInfo.sector.join('/') || '?'})` + ` | 同场:${roomInfo.rosterText || '?'}`); } if (result) { lines.push(`> 结局:${result.win ? `胜利 · 评级 ${result.grade} · 营地余 ${result.dur}` : '失守(营地耐久归零)'} | 个人击杀 ${result.myKills}`); } lines.push(''); lines.push(`> 读法:这是你这一局「耳朵听到 + 身体感到 + 自己做了什么」的完整时间线。`); lines.push(`> loop=怪物逼近的持续脚步声(狼碎步/熊重步),音量档:远(0.30)→近(0.55)→贴脸(0.80),pan=左右声像,"闷"=身后方向音量减半。你只能靠这些判断方位。`); lines.push(''); lines.push('| 时刻 | 通道 | 内容 |'); lines.push('|---|---|---|'); const rows = []; for (const ev of this.percepts) rows.push(this.perceptRow(ev, base)); for (const e of this.events) { const r = this.logRow(e, base); if (r) rows.push(r); } rows.sort((a, b) => a.at - b.at); let lastAt = null; for (const r of rows) { if (lastAt !== null && r.at - lastAt > 4000) { lines.push(`| ${fmt(r.at - 2000, base)} | — | *(静默 ${( (r.at - lastAt) / 1000).toFixed(0)} 秒)* |`); } lines.push(`| ${fmt(r.at, base)} | ${r.ch} | ${r.text} |`); lastAt = r.at; } fs.writeFileSync(path.join(this.dir, `${this.id}.percept.md`), lines.join('\n') + '\n'); } perceptRow(ev, base) { const at = ev.t; const G = this.gender === 'female' ? '旁白(女)' : '旁白(男)'; switch (ev.type) { case 'voice': return { at, ch: G, text: `“${this.text(ev.id)}”${ev.tag ? ` _(${ev.tag})_` : ''}` }; case 'voice_cut': return { at, ch: G, text: `(上一句被打断——“${this.text(ev.id)}”没播完)` }; case 'npc': return { at, ch: '队友喊话', text: `“${ev.text}” _(${panCn(ev.pan)}侧传来)_` }; case 'npc_dropped': return { at, ch: '队友喊话', text: `(一句喊话没能播出,被占线挤掉了)` }; case 'oneshot': { const id = ev.id.slice(ev.id.lastIndexOf('/') + 1); const CN = { atk_whoosh: '自己挥击风声', atk_swish: '挥空(没打到任何东西)', kill_wolf: '狼被击杀的哀嚎倒地', kill_bear: '熊闷重倒地', skill_spin: '旋风斩气旋', breach_thud: '营地被撞闷响+木栅裂', horn_wave: '波次号角', join_chime: '队友加入提示音', call_whistle: '求援哨音', howl_wolf: '狼嚎(生成,远处)', howl_bear: '熊低吼(生成,远处)', }; const extra = ev.extra && ev.extra.note ? ` · ${ev.extra.note}` : ''; return { at, ch: '音效', text: `${CN[id] || id} _(${panCn(ev.pan)}${ev.vol < 0.5 ? '·轻' : ''})_${extra}` }; } case 'oneshot_dropped': return { at, ch: '音效', text: `(一声${ev.id.includes('howl') ? '狼嚎/熊吼' : '音效'}太挤没播出来——R4 拥挤丢弃)` }; case 'loop_start': return { at, ch: 'loop', text: `${ev.mkind === 'bear' ? '熊重步+喘息' : '狼碎步+低呜'} 起 _(${panCn(ev.pan)}·${bandCn(ev.band)}${ev.vol < 0.4 && Math.abs(ev.pan) < 0.5 ? '·闷' : ''})_` }; case 'loop_band': return { at, ch: 'loop', text: `${ev.mkind === 'bear' ? '熊' : '狼'}脚步${ev.band === 'inner2' ? '⚠️贴脸' : '逼近'} _(${panCn(ev.pan)}·${bandCn(ev.band)})_` }; case 'loop_stop': return { at, ch: 'loop', text: `(一只的脚步声停了——被杀或突入)` }; case 'vibrate': return { at, ch: '震动', text: `${ev.kind}${ev.note ? `(${ev.note})` : ''}` }; case 'bgm': return { at, ch: 'BGM', text: `切换为 ${ev.id === 'bgm/menu' ? '营地夜氛围' : '紧张围猎鼓点'}${ev.vol < 1 ? '(音量压低)' : ''}` }; case 'earcon': return { at, ch: '提示音', text: `状态签名(${ev.kind})` }; case 'watchdog': return { at, ch: '提示音', text: `“还在吗?听到请碰一下屏幕。”(闲置提醒)` }; default: return { at, ch: ev.type, text: JSON.stringify(ev).slice(0, 120) }; } } logRow(e, base) { if (e.kind === 'percept') return null; const at = e.wall; switch (e.t) { case 'join_sent': return { at, ch: '我', text: `进入匹配(${e.join.teach ? '首局·教学模式' : '快速匹配'})` }; case 'srv': return null; // 全量在 jsonl;转写只保留可感知内容 case 'gesture': { if (e.note === '死窗吞掉') return { at, ch: '我', text: `${gestureCn(e.g)}(结算刚结束,手势被吞)` }; return { at, ch: '我', text: gestureCn(e.g) }; } case 'attack_gesture': return { at, ch: '我', text: `${gestureCn(dirToGesture(e.dir))}出击 → 我以为是${DIR_CN[e.perceived] || e.perceived}的${e.mkind === 'bear' ? '熊' : '狼'}${e.correct === false ? `(其实在${DIR_CN[e.trueDir] || e.trueDir}——打空了!)` : ''}`, }; case 'attack_blocked_local': return { at, ch: '我', text: `${gestureCn(dirToGesture(e.dir))}想打(冷却没好,只震了一下)` }; case 'swipe_rerouted': return { at, ch: '我', text: e.to === 'status' ? '上滑(那个方向没有能打的怪→变成了听状态播报)' : '下滑(那个方向没有能打的怪→打开了菜单!)', }; case 'spam_swipe_on_cd': return { at, ch: '我', text: `连续滑动(着急,冷却中乱滑被拦)` }; case 'skill_doubletap_on_cd': return { at, ch: '我', text: `双击想放旋风斩(冷却中→变成重听威胁播报)` }; case 'callout_gesture': return { at, ch: '我', text: `单击呼喊支援(感觉我这边压力大了)` }; case 'skill_gesture': return { at, ch: '我', text: `双击旋风斩(身边${e.inner}只逼近${e.panic ? '·慌了' : ''})` }; case 'habit_status_check': return { at, ch: '我', text: `上滑听一下状态` }; case 'habit_menu_midwave': return { at, ch: '我', text: `下滑开了下菜单(战斗没停!)` }; case 'teach_t1_swipe': return { at, ch: '我', text: `教学中${gestureCn(e.swipe)}(${e.wrong ? '方向搞错了' : '朝狼滑'})` }; case 'lapse_start': return { at, ch: '我', text: `**走神了 ${Math.round((e.until - at) / 1000)} 秒**(${e.reason || '注意力离开'}——这期间的声音没听到)` }; case 'lapse_end': return { at, ch: '我', text: `回神,重新听场上的动静` }; case 'net_drop_scripted': return { at, ch: '网络', text: `**网络断了**(正在重连)` }; case 'reconnect_attempt': return { at, ch: '网络', text: `重连成功,回到座位继续` }; case 'ws_close': return { at, ch: '网络', text: `连接断开(code ${e.code})` }; case 'force_end': return { at, ch: '系统', text: `(异常收场:${e.reason})` }; case 'go_home_from_menu': return { at, ch: '我', text: `菜单里选了退出房间` }; default: return null; } } // ---------------- 一局速览 ---------------- buildSummary() { const evs = this.events; const count = (t, f) => evs.filter((e) => e.t === t && (!f || f(e))).length; const srv = evs.filter((e) => e.t === 'srv'); const ev = (kind) => srv.filter((e) => e.evs || (e.msg && e.msg.t === 'ev' && e.msg.kind === kind)); const evK = (kind) => srv.filter((e) => e.msg && e.msg.t === 'ev' && e.msg.kind === kind); const attacks = count('attack_gesture'); const correct = count('attack_gesture', (e) => e.correct === true); const missed = count('attack_gesture', (e) => e.correct === false); const misses = evK('miss').filter((e) => e.msg.slot === this._slot).length; const kills = evK('kill').filter((e) => e.msg.slot === this._slot).length; const breaches = evK('breach').length; const callouts = count('callout_gesture'); const skills = count('skill_gesture'); const helpAcksHeard = this.percepts.filter((p) => p.type === 'npc' && p.id.includes('help_ack')).length; const botlinesHeard = this.percepts.filter((p) => p.type === 'npc').length; const voiceHeard = this.percepts.filter((p) => p.type === 'voice').length; const voiceCuts = this.percepts.filter((p) => p.type === 'voice_cut').length; const howlsDropped = this.percepts.filter((p) => p.type === 'oneshot_dropped').length; const lapses = count('lapse_start'); const spam = count('spam_swipe_on_cd') + count('attack_blocked_local'); const threatRelisten = count('skill_doubletap_on_cd'); const gdIdle = this.percepts.filter((p) => p.type === 'voice' && p.id === 'gd_idle').length; return { attacks, attacksCorrectArc: correct, attacksWrongArc: missed, misses, kills, breachesTotal: breaches, callouts, skills, helpAcksHeard, botlinesHeard, voiceLinesHeard: voiceHeard, voiceCuts, howlsDropped, lapses, spamSwipes: spam, threatRelisten, watchdogIdles: gdIdle, }; } noteSlot(slot) { this._slot = slot; } } function bandCn(band) { return band === 'mid' ? '远' : band === 'inner1' ? '近' : '贴脸'; } function gestureCn(g) { return { up: '上滑', down: '下滑', left: '左滑', right: '右滑', tap: '单击', doubletap: '双击' }[g] || g; } function dirToGesture(base) { return { N: 'up', E: 'right', S: 'down', W: 'left' }[base] || 'up'; }