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

227 lines
12 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.
/**
* 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'; }