Files
SoundGame/web/harness/hunt-sim/lib/brain.mjs
T
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

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;
}
}