/** * brain.mjs — 玩家大脑:把「耳朵听到的东西」变成「这个人的反应」。 * * 输入:SimClient 的 percept 事件流(voice / npc / oneshot / loop_* / vibrate / bgm)。 * 输出:gesture() 意图(由 SimClient 按真实客户端规则路由)。 * * 认知模型(诚实拟人,不偷看服务器真值): * - 方位判读:loop 的 pan(±1=东西 / ±0.7=斜向 / 0=南北轴)+ 音量档推前后 * (后半平面 ×0.6 的发闷线索)。前后判错 → 镜像方位(N↔S/NE↔SE/NW↔SW), * E/W 由 pan 符号锚定永不镜像错。相邻滑动误听(NE↔N 等)多数被 ±45° 出击弧 * 无害吸收,忠实保留。 * - 察觉延迟:loop 出现/升档 → noticeMs 后才登记进「已知威胁表」;旁白在播 * (掩蔽)时按 voiceMaskFactor 放大;呼啸预先听到(howlHearP)则加速。 * - 分心窗(lapses):期间不处理感知、不出手;醒后重新听(重新察觉)。 * - 冷却感知:cooldownAware 的人掐得准 2.5s/8s;不掐的人会在冷却中双击 → * 客户端走「重听威胁播报」真路径(额外语音掩蔽,真实行为)。 */ import { DIR_AZ, panOfAz, azOf } from './sim-client.mjs'; const DIRS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; const MIRROR = { N: 'S', S: 'N', NE: 'SE', SE: 'NE', NW: 'SW', SW: 'NW', E: 'E', W: 'W' }; // 斜向的两个覆盖基向(出击弧 ±45°:两者都能打到) const COVERING = { N: ['up'], E: ['right'], S: ['down'], W: ['left'], NE: ['up', 'right'], SE: ['right', 'down'], SW: ['down', 'left'], NW: ['left', 'up'], }; export function mulberry32(a) { return function () { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } export class Brain { constructor(cfg, { rng } = {}) { this.cfg = this.applyDefaults(cfg); const seed = (cfg.seed || 1) >>> 0; // 独立随机流:判读/察觉/决策互不消耗彼此的流(单流会被密集决策带偏,教学 // 手势风暴实测造成 8/8 系统性镜像反转——已证伪,勿合并回单流) this.rng = rng || mulberry32(seed); this.rngLoc = mulberry32((seed ^ 0x10CA1) >>> 0); this.rngNotice = mulberry32((seed ^ 0x0BE5) >>> 0); this.rngDecide = mulberry32((seed ^ 0x0DEC) >>> 0); this.client = null; // 认知状态 this.known = new Map(); // mobId → { perceivedDir, band, mkind, etaEst, registeredAt, eta0,t0 } this.pending = new Map(); // mobId → { at, ev }(察觉在途) this.heardHowl = new Set(); // 生成呼啸听到过的方位粗记(加速察觉) this.voiceBusy = false; // 旁白在播(掩蔽) this.lastVoiceEnd = 0; this.heardIds = []; // 听过的 voice id 序列(教学步骤门控) this.lapse = null; // { until }(分心窗) this.lapsesLeft = []; this.menuIntentUntil = 0; this.panicSkillFired = false; this.calloutEvalAt = 0; this.skillEvalAt = 0; this.teach = { startedAt: 0, step: -1, t1Attempts: 0, done: false }; this.netDropDone = false; this.attackArmedAt = 0; // 已排队出手时刻 this.attackArmedDir = null; this.stats = { loopsHeard: 0, registered: 0, localized: [], gestures: 0, attacksSent: 0, callouts: 0, skills: 0, notices: [], // {mobId, delayMs, masked} }; this._inMatch = false; this._tickTimer = null; } applyDefaults(c) { const d = { persona: c.persona || '无名', name: c.name || '猎人', gender: c.gender || 'male', seed: c.seed || 1, perception: { noticeMsMin: 600, noticeMsMax: 1100, rearDiscrim: 0.75, adjSlideP: 0.15, howlHearP: 0.7, voiceMaskFactor: 0.8, etaErrS: 0.4 }, attack: { reactMs: 800, panicEtaS: 2.0, noviceSpamP: 0.05, bearFirst: false }, skill: { innerThreshold: 3, useP: 0.8, panicEtaS: 1.2, cooldownAware: true }, callout: { sectorThreshold: 3, useP: 0.5 }, habits: { statusCheck: 'never', menuProb: 0, lobbyTap: 'eager', lapses: [], netDrop: null, bluetoothMs: 0 }, teach: { t1WrongP: 0.2, tapDelayMs: 800, skipAfterS: null }, }; for (const k of ['perception', 'attack', 'skill', 'callout', 'habits', 'teach']) { if (c[k]) d[k] = { ...d[k], ...c[k] }; } return { ...d, ...{ persona: c.persona, name: c.name, gender: c.gender, seed: c.seed } }; } // ================= 感知 ================= onPercept(ev) { switch (ev.type) { case 'voice': this.voiceBusy = true; this.lastVoiceEnd = ev.end || ev.t + ev.dur * 1000; this.heardIds.push(ev.id); this.stats.voiceHeard = (this.stats.voiceHeard || 0) + 1; break; case 'voice_cut': if (this.heardIds[this.heardIds.length - 1] === ev.id) this.heardIds.pop(); break; case 'npc': this.lastNpc = ev; this.heardIds.push('npc:' + ev.id); break; case 'oneshot': if (ev.id.endsWith('howl_wolf') || ev.id.endsWith('howl_bear')) { if (this.lot(Math.round(ev.t / 500), 10) < this.cfg.perception.howlHearP) { this.heardHowl.add(ev.t); // 方位记不记得住不重要:加速后续察觉 this.heardHowlDir = this.perceivePanDir(ev.pan, ev.vol, ev.id.endsWith('bear'), Math.round(ev.t / 500)); } } break; case 'loop_start': case 'loop_band': this.onLoop(ev); break; case 'loop_stop': this.pending.delete(ev.id); this.known.delete(ev.id); break; default: break; } } /** 按实体 key 的独立抽签 [0,1):与一切调用顺序解耦(mulberry32 流会被刷怪的 * 座位轮转结构带偏——实测造成方位判读系统性镜像反转,勿改回流式)。 */ lot(key, salt) { let h = ((this.cfg.seed | 0) ^ Math.imul(key | 0, 2654435761) ^ Math.imul(salt | 0, 40503)) >>> 0; h ^= h >>> 16; h = Math.imul(h, 2246822507) >>> 0; h ^= h >>> 13; h = Math.imul(h, 3266489909) >>> 0; h ^= h >>> 16; return (h >>> 0) / 4294967296; } /** loop 出现/升档 → 排察觉;察觉后方位判读入已知表。 */ onLoop(ev) { if (this.lapse) return; // 分心:听不见 const isNew = ev.type === 'loop_start'; if (isNew) this.stats.loopsHeard += 1; if (this.known.has(ev.id) && !isNew) { const k = this.known.get(ev.id); k.band = ev.band; k.eta0 = ev.eta; k.t0 = ev.t; return; // 已登记:音量渐变可连续感知,无需重新察觉 } if (this.known.has(ev.id)) return; const p = this.cfg.perception; let notice = p.noticeMsMin + this.lot(ev.id, 6) * (p.noticeMsMax - p.noticeMsMin); if (ev.band === 'mid') notice *= 1.6; // 远而轻,更晚注意到 if (this.voiceBusy) notice *= 1 + p.voiceMaskFactor; // 旁白掩蔽 if (this.heardHowl.size && this.lot(ev.id, 7) < 0.5) notice *= 0.7; // 呼啸预判加速 this.pending.set(ev.id, { at: ev.t + notice, ev, masked: this.voiceBusy, bornAt: ev.t, isNew }); } /** * 方位判读:pan + 音量前后线索 → 感知方位(可能镜像/滑动出错)。key=实体 id。 * 模型(以真值为条件的正确率,不是无条件硬币——无条件会把真后方系统性听反): * - 左右轴:pan 符号清晰(±1/±0.7/0),永不镜像错; * - 前后轴:真前/真后各以 rearDiscrim 概率判对(音量发闷线索;掩蔽 ×0.9; * 熊声像更突出 +0.05);判错 → 沿东西轴镜像(N↔S/NE↔SE/NW↔SW); * - 相邻滑动:adjSlideP(±45° 出击弧多数自愈)。 */ perceivePanDir(pan, vol, isBear, key = 0, trueDir = null, muffledCue = false) { const p = this.cfg.perception; const side = pan > 0.35 ? 'E' : pan < -0.35 ? 'W' : 'C'; const isBackTrue = trueDir ? ['S', 'SE', 'SW'].includes(trueDir) : vol < 0.32; // 无真值(呼啸粗判):闷=后 const rearSkill = Math.min(0.99, p.rearDiscrim + (muffledCue ? 0.1 : 0)); // v2 S6 双线索 const thresh = rearSkill * (this.voiceBusy ? 0.9 : 1) + (isBear ? 0.05 : 0); const cueOK = this.lot(key, 1) < thresh; // 这一次前后线索判对了没 const frontGuess = isBackTrue ? !cueOK : cueOK; let dir; if (side === 'E') dir = Math.abs(Math.abs(pan) - 1) < 0.2 ? 'E' : (frontGuess ? 'NE' : 'SE'); else if (side === 'W') dir = Math.abs(Math.abs(pan) - 1) < 0.2 ? 'W' : (frontGuess ? 'NW' : 'SW'); else dir = frontGuess ? 'N' : 'S'; // 相邻滑动(多数被弧无害吸收) if (this.lot(key, 3) < p.adjSlideP) { const idx = DIRS.indexOf(dir); dir = DIRS[(idx + (this.lot(key, 4) < 0.5 ? 1 : 7)) % 8]; } return dir; } /** 感知 tick:处理在途察觉 + 已知表 eta 推进 + 掉出 loop 集的清理。 */ perceptTick(now) { for (const [id, p] of [...this.pending]) { if (now >= p.at) { this.pending.delete(id); if (!this.client.loops.has(id)) continue; // 已消失 const loop = this.client.loops.get(id); // v2 S6:低通第二线索落地后,身后闷音判读增益(音色+响度双线索) const perceivedDir = this.perceivePanDir(loop.pan, loop.vol, loop.mkind === 'bear', id, loop.dir, !!loop.muffled); if (this.client.recorder) { this.client.recorder.log({ t: 'localize', id, trueDir: loop.dir, pan: Math.round(loop.pan * 100) / 100, vol: loop.vol, voiceBusy: this.voiceBusy, perceived: perceivedDir, }); } this.known.set(id, { perceivedDir, band: loop.band, mkind: loop.mkind, eta0: loop.eta, t0: now, registeredAt: now, }); this.stats.registered += 1; this.stats.notices.push({ mobId: id, delayMs: now - p.bornAt, masked: p.masked }); // 登记即武装出手(内圈才可打) this.armAttackIfWanted(now, id); } } // 已知表里 loop 已停(被杀/突入)由 loop_stop 清;此处只推进 eta 估计 for (const k of this.known.values()) k.etaEst = k.eta0 - (now - k.t0) / 1000; } /** 已知内圈威胁(band inner1/inner2)。 */ knownInner() { return [...this.known.entries()].filter(([, k]) => k.band !== 'mid'); } armAttackIfWanted(now, id) { const k = this.known.get(id); if (!k || k.band === 'mid') return; if (this.attackArmedAt) return; // 已有出手在途 const jitter = 0.75 + this.lot(id, 9) * 0.5; this.attackArmedAt = now + (this.cfg.attack.reactMs * jitter) + this.cfg.habits.bluetoothMs; this.attackArmedDir = this.pickSwipeBase(k.perceivedDir, id); this.attackArmedTarget = id; } pickSwipeBase(perceivedDir, key = 0) { const covers = COVERING[perceivedDir] || ['up']; return covers[this.lot(key, 5) < 0.5 ? 0 : Math.min(1, covers.length - 1)]; } arcCovers(base, dir) { const ARC = { up: ['NW', 'N', 'NE'], right: ['NE', 'E', 'SE'], down: ['SE', 'S', 'SW'], left: ['SW', 'W', 'NW'] }; return (ARC[base] || []).includes(dir); } // ================= 决策 tick ================= tick() { if (!this.client || this.client._stopped) return; const now = this.client.now(); if (!this._inMatch) return; // 语音占用更新 this.voiceBusy = this.client.voiceQ.length > 0 || now < this.client.voiceBusyUntil; // 分心窗管理 this.manageLapse(now); if (this.client.phase === 'teach') this.teachTick(now); else if (this.client.phase === 'wave') this.waveTick(now); else if (this.client.phase === 'lobby') this.lobbyTick(now); if (!this.lapse) this.perceptTick(now); } manageLapse(now) { if (this.lapse) { if (now >= this.lapse.until) { this.lapse = null; // 醒来:清空在途察觉与已知表(重新听) this.pending.clear(); this.known.clear(); this.client.recorder.log({ t: 'lapse_end', at: now }); } return; } if (!this.lapsesLeft.length) return; const next = this.lapsesLeft[0]; if (now >= next.at) { this.lapsesLeft.shift(); this.lapse = { until: now + next.dur }; this.pending.clear(); this.known.clear(); this.client.recorder.log({ t: 'lapse_start', until: this.lapse.until, reason: next.reason || '分心' }); } } // ---- LOBBY ---- lobbyTick(now) { if (this.cfg.habits.lobbyTap === 'eager' && this.heard('room_wait') && now > this.lastVoiceEnd) { if (!this._lobbyTapped) { this._lobbyTapped = true; this.delayGesture('tap', this.cfg.teach.tapDelayMs); } } } heard(id) { return this.heardIds.includes(id); } heardNpcSuffix(suffix) { return this.heardIds.some((x) => x.startsWith('npc:') && x.includes(suffix)); } delayGestureIf(cond, g, ms) { setTimeout(() => { if (!this.client || this.client._stopped || (cond && !cond())) return; this.stats.gestures += 1; this.client.gesture(g); }, ms); } delayGesture(g, ms) { setTimeout(() => { if (!this.client || this.client._stopped) return; this.stats.gestures += 1; this.client.gesture(g); }, ms); } // ---- TEACH ---- teachTick(now) { if (this.teach.step === -1 && this.heard('tut_rule')) { this.teach = { ...this.teach, step: 0, startedAt: this.teach.startedAt || now, t1Attempts: this.teach.t1Attempts }; } const T = this.cfg.teach; // 不耐烦跳过 if (T.skipAfterS && this.teach.startedAt && now - this.teach.startedAt > T.skipAfterS * 1000 && this.client.tut >= 0) { this.client.recorder.log({ t: 'teach_skip_impatient' }); this.client.gesture('down'); return; } const tut = this.client.tut; if (tut === 0 && this.teach.step === 0 && now > this.lastVoiceEnd + 200) { this.teach.step = 0.5; this.delayGesture('tap', T.tapDelayMs * (0.6 + this.lot(0, 21) * 0.8)); return; } if (tut === 1 && this.heard('tut_attack')) { // 狼 loop 出现(N)且已察觉 → 滑(一次出手在途/冷却内不重复排——风暴护栏) const wolfKnown = [...this.known.values()].some((k) => k.band !== 'mid'); const wolfLoopUp = [...this.client.loops.values()].some((l) => l.band !== 'mid'); const unlocked = !this._t1Fired || (this._t1UnlockAt && now > this._t1UnlockAt); if (wolfKnown && wolfLoopUp && unlocked) { this._t1Fired = true; this._t1UnlockAt = now + 2800; // 出击冷却 2.5s + 余量:挥空后等狼重放/重教再试 const wrong = this.lot(this.teach.t1Attempts, 22) < T.t1WrongP * Math.pow(0.4, this.teach.t1Attempts); const swipe = wrong ? ['left', 'right', 'down'][Math.floor(this.lot(this.teach.t1Attempts, 23) * 3)] : 'up'; // 教学狼恒 N this.teach.t1Attempts += 1; this.client.recorder.log({ t: 'teach_t1_swipe', swipe, wrong, attempts: this.teach.t1Attempts }); this.delayGesture(swipe, this.cfg.attack.reactMs * 0.8); this._t1Acted = true; } return; } if (tut === 2 && this.heard('tut_callout') && now > this.lastVoiceEnd + 200) { if (!this._t2Acted) { this._t2Acted = true; this.delayGesture('tap', T.tapDelayMs * (0.6 + this.lot(2, 21) * 0.8)); } return; } if (tut === 3 && this.heard('tut_skill')) { const inner = [...this.client.loops.values()].some((l) => l.band !== 'mid'); if (inner && now > this.lastVoiceEnd + 200 && !this._t3Acted) { this._t3Acted = true; this.delayGesture('doubletap', this.cfg.attack.reactMs * 0.8); } return; } if (tut === 4 && this.heard('tut_status') && now > this.lastVoiceEnd + 200) { if (!this._t4Acted) { this._t4Acted = true; this.delayGesture('up', T.tapDelayMs * (0.6 + this.lot(4, 21) * 0.8)); } return; } } // ---- WAVE ---- waveTick(now) { const c = this.cfg; // 1) 出手(已武装) if (this.attackArmedAt && now >= this.attackArmedAt) { const target = this.known.get(this.attackArmedTarget); const stillThere = target && this.client.loops.has(this.attackArmedTarget); if (stillThere && target.band !== 'mid') { const trueLoop = this.client.loops.get(this.attackArmedTarget); this.stats.gestures += 1; this.client.gesture(this.attackArmedDir); this.stats.attacksSent += 1; this.client.recorder.log({ t: 'attack_gesture', dir: this.attackArmedDir, perceived: target.perceivedDir, trueDir: trueLoop ? trueLoop.dir : null, correct: trueLoop ? this.arcCovers(this.attackArmedDir, trueLoop.dir) : null, mkind: target.mkind, etaEst: Math.round((target.etaEst || 0) * 10) / 10, }); } this.attackArmedAt = 0; this.attackArmedTarget = null; } // 2) 新目标扫描(无在途出手时武装最近威胁) if (!this.attackArmedAt) { let best = null; for (const [id, k] of this.known) { if (k.band === 'mid') continue; let score = (k.etaEst ?? 3); if (c.attack.bearFirst && k.mkind === 'bear') score -= 1.5; if (!best || score < best.score) best = { id, k, score }; } if (best) { const jitter = 0.75 + this.lot(best.id, 9) * 0.5; this.attackArmedAt = now + c.attack.reactMs * jitter + c.habits.bluetoothMs; this.attackArmedDir = this.pickSwipeBase(best.k.perceivedDir, best.id); this.attackArmedTarget = best.id; } } // 3) 冷却内乱滑(新手真实行为:客户端本地拦,只震不发) if (now < this.client.atkReadyAt && this.knownInner().length && this.lot(Math.floor(now / 600), 11) < c.attack.noviceSpamP) { const [spamId, k] = this.knownInner()[0]; this.client.gesture(this.pickSwipeBase(k.perceivedDir, spamId)); this.client.recorder.log({ t: 'spam_swipe_on_cd', perceived: k.perceivedDir }); } // 4) 旋风斩 if (now > this.skillEvalAt + 500) { this.skillEvalAt = now; const inner = this.knownInner(); const skillReady = now >= this.client.skillReadyAt; const panic = inner.some(([, k]) => (k.etaEst ?? 9) < c.skill.panicEtaS && k.band === 'inner2'); const bucket = Math.floor(now / 500); const want = (inner.length >= c.skill.innerThreshold && this.lot(bucket, 12) < c.skill.useP) || (panic && this.lot(bucket, 13) < 0.9); if (want && skillReady) { this.client.gesture('doubletap'); this.stats.skills += 1; this.client.recorder.log({ t: 'skill_gesture', inner: inner.length, panic }); } else if (want && !skillReady && !c.skill.cooldownAware && this.lot(Math.floor(now / 500), 14) < 0.3) { // 不掐冷却的人:冷却中双击 → 重听威胁播报(真实路径,产生掩蔽) this.client.gesture('doubletap'); this.client.recorder.log({ t: 'skill_doubletap_on_cd', inner: inner.length }); } } // 5) 呼喊支援 if (now > this.calloutEvalAt + 700) { this.calloutEvalAt = now; const mine = [...this.known.values()].filter((k) => this.client.sector.includes(k.perceivedDir)); const pressure = mine.length + [...this.known.values()].filter((k) => k.band !== 'mid').length * 0.2; const ready = now >= this.client.calloutReadyAt; if (pressure >= c.callout.sectorThreshold && ready && this.lot(Math.floor(now / 700), 15) < c.callout.useP) { this.client.gesture('tap'); this.stats.callouts += 1; this.client.recorder.log({ t: 'callout_gesture', pressure: Math.round(pressure * 10) / 10 }); } } // 6) 习惯:波间状态查询 / 波中开菜单 if (c.habits.statusCheck === 'waveclear' && this.heard('wave_clear') && now > this.lastVoiceEnd + 800 && this.knownInner().length === 0 && !this._statusedThisRest) { this._statusedThisRest = true; this.delayGesture('up', 300); this.client.recorder.log({ t: 'habit_status_check' }); } if (this.client.wave !== this._lastWave) { this._lastWave = this.client.wave; this._statusedThisRest = false; // 波开始时决定本波是否手滑开菜单 if (this.lot(this.client.wave || 1, 16) < c.habits.menuProb && !this._menuOpened) { this._menuOpened = true; this.delayGesture('down', 2000 + this.lot(this.client.wave || 1, 17) * 6000); // 随机时刻 this.client.recorder.log({ t: 'habit_menu_midwave' }); // 听两项再关;v2 S1:若战斗中菜单没开成(下滑=空挥),浏览手势作废 setTimeout(() => { if (!this.client || this.client._stopped) return; if (!this.client.menuOpen) { this._menuBrowseCanceled = true; this.client.recorder.log({ t: 'habit_menu_blocked_v2', note: '战斗中菜单打不开(v2 S1),改下滑空挥' }); } }, 800); this.delayGestureIf(() => this.client && this.client.menuOpen, 'tap', 9000); this.delayGestureIf(() => this.client && this.client.menuOpen, 'down', 10500); } } // 7) 断网剧本(P09 类) const nd = c.habits.netDrop; if (nd && !this.netDropDone && this.client.wave >= 1 && now - (this._waveStartedAt || 0) > nd.atMin * 1000 && this.lot(Math.floor(now / 1000), 18) < 0.02) { this.netDropDone = true; this.doNetDrop(nd); } } async doNetDrop(nd) { const cli = this.client; cli.recorder.log({ t: 'net_drop_scripted' }); this._resumeToken = cli._resumeToken; if (cli.session) cli.session.drop(); const dur = (nd.durMin + this.lot(1, 19) * (nd.durMax - nd.durMin)) * 1000; await new Promise((r) => setTimeout(r, dur)); if (cli._stopped) return; await cli.reconnect(this._resumeToken); } onJoined(j) { this._inMatch = true; this._lobbyTapped = false; this._t1Acted = false; this._t2Acted = false; this._t3Acted = false; this._t4Acted = false; this._t1Fired = false; this._t1UnlockAt = 0; this.teach = { step: -1, startedAt: 0, t1Attempts: 0, done: false }; if (j.state && j.state.phase === 'teach') this.teach.startedAt = Date.now(); // 生成分心窗时刻表(局内随机) this.lapsesLeft = (this.cfg.habits.lapses || []).map((l, i) => ({ at: Date.now() + (l.atMin + this.lot(i, 24) * (l.atMax - l.atMin)) * 1000, dur: (l.durMin + this.lot(i, 25) * (l.durMax - l.durMin)) * 1000, reason: l.reason, })); if (j.state && j.state.phase === 'wave') this._waveStartedAt = Date.now(); } onEv(msg) { if (msg.kind === 'wave') this._waveStartedAt = Date.now(); if (msg.kind === 'end') this._inMatch = false; } start() { this._tickTimer = setInterval(() => this.tick(), 150); } stop() { if (this._tickTimer) clearInterval(this._tickTimer); this._tickTimer = null; } }