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