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