// ==UserScript==
// @name MWI 战斗副官(续航 / 掉落 / 蓝量管家)
// @namespace workbuddy.mwi.adjutant
// @version 1.6.0
// @description 只读增强面板:续航=队友补给与战斗续航状况,掉落=战利品分配记录,蓝量/血量=战斗模拟器按触发器×装备×技能×食物×敌人推演消耗(另有实测对照)。纯显示,不向游戏服务器发送任何请求(MWI 规则 4.2 允许的 UI 增强类脚本)。
// @author WorkBuddy
// @match https://www.milkywayidle.com/*
// @match https://milkywayidle.com/*
// @match https://test.milkywayidle.com/*
// @match https://www.milkywayidlecn.com/*
// @match https://milkywayidlecn.com/*
// @match https://test.milkywayidlecn.com/*
// @grant none
// @run-at document-start
// @noframes
// @license MIT
// ==/UserScript==
/*
* MWI 战斗副官 —— 续航 / 掉落 / 蓝量管家 / 血量管家
*
* 合规声明(MWI 官方规则 4.1 / 4.2):
* - 本脚本只被动读取游戏 WebSocket 已收到的消息(MessageEvent.data 只读劫持),
* 不调用 fetch / XHR / WebSocket.send,不向游戏服务器发送任何请求。
* - 面板为独立 fixed 悬浮层,不注入、不修改游戏 DOM。
* - 数据仅存于页面 localStorage(面板位置等 UI 偏好),不上传任何远端。
*
* 四个页签
* ─────────────────────────────────────────────────────────────
* ★ v1.5.14:页签**改名** —— 「出警」→「续航」、「分赃」→「掉落」。
* 仅改**显示名**:内部键仍是 data-tab="dispatch" / "loot"、
* 函数仍是 renderDispatch / renderLoot、CSS 类仍是 .adj-* 老名,
* 因此历史数据、页签选中状态与全部测试都不受影响。
*
* ★ v1.5.15:**重复实例自检**(v1.5.14 踩的坑,务必保留)。
* 事故过程:v1.5.14 顺手把 @name 里的「出警 / 分赃」改成了「续航 / 掉落」——
* 而 Tampermonkey 用 @name + @namespace 识别脚本,于是它把这份当成**全新脚本**
* 新建了一条 uuid 记录,旧版(@name 含「出警 / 分赃」)并没有被覆盖、仍在启用
* → **两份同时运行**。两份各有独立 state,却都用
* getElementById('mwi-adjutant-root') 取面板 —— 永远拿到文档里第一个 root:
* 你点「蓝量 / 掉落」,A 份按你的点击渲染;B 份的 state.tab 还停在默认的 dispatch,
* 它一收到 battle_updated 推送或每秒心跳重渲染,就把界面按自己的 tab 刷回续航页。
* 用户症状原话:「打开蓝量或分赃后都会直接切回出警界面」。
* 对策(本版):
* ① 面板打归属标记 root.__adjOwner = ADJ_OWNER;
* ② ensurePanel() 发现面板已被**别的实例**建好 → adjDisabled = true,彻底停用;
* ③ render() / processRawMessage() 开头短路,绝不写不属于自己的 DOM。
* ⇒ 即便以后再出现双份,也只有一个实例在动面板,不会再互抢。
* **教训:面板显示名随便改,@name 不要动**(要动就必须提示用户删掉旧脚本)。
*
* ★ v1.6.0:**战斗模拟器(推演引擎)** —— 蓝量/血量从「实测采样 + 冷却轮转估算」
* 升级为「按 触发器×装备×技能×食物×敌人 逐事件推演」,模型与游戏客户端及
* 社区模拟器(MilkyWayIdleCombatSim)逐条对齐:
* · 行动调度:动作结束立即决策 —— 技能冷却好、触发器满足、蓝够就吟唱
* (吟唱占时 castDuration/(1+castSpeed),技能**不占**攻击间隔),否则普攻;
* 蓝量在吟唱结束瞬间扣,冷却从吟唱结束起算。
* · 命中 acc^1.4/(acc^1.4+eva^1.4);减伤 100/(100+防御−穿透折算);
* 掷骰 [min,max] 均匀、暴击锁 max×(1+暴伤)、远程基础暴击 30%×命中。
* · 触发器逐事件精确判定(缺蓝≥X 吃糖 / 血量≤Y 吃甜甜圈 / 敌≥2 才放烈焰风暴),
* 不是拍脑袋概率;命中/暴击/掷骰取期望 → 同输入恒同输出(确定性、可测试)。
* · 回复 10 秒一跳 +floor(上限×regenPer10);持续食物 5 秒一跳均摊;
* 怪全灭 3 秒整波复活(打得快受伤反而少);多只怪按 totalThreat 占比分摊伤害。
* 数据来源(全部被动读取,零请求):
* 自己/怪物面板 = new_battle 原始 combatDetails(36 字段全给);
* 技能/物品表 = 页面全局 localStorageUtil.getInitClientData();
* 触发器 = init_character_data 的 abilityCombatTriggersMap / consumableCombatTriggersMap。
* 刻意简化(面板会注明):控制(晕/沉默/致盲)、狂暴、怪物减甲 debuff 未建模;
* 队友治疗/增益不计(看不到别人面板)。
*
* 【续航】队友补给状况(原「出警」)
* 食物:软糖 / 甜甜圈 / 酸奶 / 蛋糕(能恢复 HP/MP 的消耗品)
* 增益:咖啡 / 茶类消耗品 + 技能自带的增益(含具体 buff 名与数值)
* 技能增益覆盖光环(守护 / 凶猛 / 秘法 / 暴击 / 迅捷)、反伤(反击 / 无敌)
* 等,芯片上带 Lv 角标。
* ★ v1.5.9:新增**生效**行 —— 增益是**随时间改变**的。
* 游戏在 new_battle 每个 unit、以及 battle_updated 的 pMap[i] 上都会下发
* combatBuffMap(此刻真的挂着哪些增益,含 startTime + duration)。
* 有持续时间的(duration 纳秒,实测咖啡 = 300 秒)按剩余时间升序排列、
* 芯片带倒计时,并挂 1 秒心跳让它真的走;duration = 0 的(宅邸/公会/社区
* 常驻加成)汇总成一个「常驻 ×N」芯片,悬停看明细。
* ※ 与上面「增益」行的区别:「增益」行是这套技能/消耗品**能提供**什么(静态),
* 「生效」行是此刻**真的挂着**什么(动态、会过期)。
* 随身:背包里的增益消耗品(茶等)—— 只有自己那一行有(见下)
* 减益:技能施加的负面效果(如破甲 -20%、冰矛 -25% 攻速)
*
* 【掉落】战利品分配(原「分赃」)
* 按人统计掉落品种数与市场估值(≈ 金币)。
* ★ v1.5.13:消耗按**类别**拆行显示 —— 「食物开销」+「咖啡开销」。
* 实测战斗槽里的消耗品有两类:食物(hp/mp 恢复量 > 0)和咖啡(buffs 非空)。
* 旧实现两类混在一行、行名却只叫「食物开销」,咖啡花的钱被名字盖住,
* 用户看不出咖啡也算进去了(实测 wisdom_coffee 确实被差分统计到)。
* 个人净收益 = 本行战利品 − 本行**全部消耗**(食物 + 咖啡)。
* ※ 茶**不算**(用户已确认「做咖啡,不做茶」):茶只对生活技能生效
* (usableInActionTypeMap 无 combat),永远不会出现在 combatConsumables,
* 且背包快照只在开页面时下发一次 → 既没有通路、也不该混进战斗成本。
* ★ v1.5.12:食物开销拆到**每个人自己的框里**,并给出**个人净收益**。
* 金额一律是 Σ(这个人吃掉的个数 × 市场卖价),不是只报种类数。
* 食物开销 3 种 · 126 个 · ≈ 1.1万 金币
* 净收益 ≈ 1.4万 金币 ← 本行战利品估值 − 本行食物开销
* 记账键从角色 id 改成**角色名**,与掉落栏原有的记账(lootSession /
* lootCredited 都按名字索引)对齐 —— 这样同一个人的开销可以直接挂在他
* 那一行下面,不必再造 name⇄id 映射。页脚的全队合计行保留。
* ★ v1.5.11:新增**食物开销**行 —— 打怪要吃喝,吃喝也是钱。
* 口径:用 new_battle.players[i].combatConsumables[j].count(实测是背包里
* 该补给的**实时存量**,战斗自动吃喝会把它减下去)做差分:
* 消耗量 = 上一快照 − 当前快照 (只认下降;补货引起的上升绝不算消耗)
* 金额 = Σ(消耗个数 × 当前挂单卖价) —— 用卖价而非买价,因为「不吃就能卖掉」
* 才是这些食物的真实机会成本,且与战利品估值同口径。
* · 视图「累计」= 面板加载以来的全部消耗(跨场累加)
* · 视图「本场」= 本场战斗起始快照 − 当前快照
* 下面再给一行「净收益 = 战利品 − 食物」,一眼看出这趟到底赚了多少。
* ★ v1.5.10:修「只能点累计、点不动本场」。切换按钮的 HTML 属性名原先写成
* data-lootView —— HTML 属性名不区分大小写,浏览器会把它规范成
* "data-lootview"(一个词),而 dataset.lootView 查找的是 "data-loot-view"
* (连字符分割),两边对不上 → t.dataset.lootView 恒为 undefined →
* 点击「本场」永远走不进切换分支,视图卡在累计。
* 现改用标准的 data-loot-view,并在读取处用 dataset.lootview 兜底。
* ★ v1.5.8:**行头只报「种」,不再报「件」**。金币(/items/coin)这类
* 堆叠掉落一场能到几千件,把「件」合计写得完全没法参照,还盖过了
* 真正关心的「打到了哪几种东西」。现在行头只写「N 种 · 估值」,
* 件数收进 title 提示(鼠标悬停可见),芯片上仍保留单品「× 数量」。
* ★ v1.5.7:**没行情就不显示数字**。旧实现只要「读到了某个行情源」就照算,
* 结果是本机只装了 Edible Tools 时(它的键是**英文显示名**,不是 hrid)
* 全部物品都查不到价 → 面板每行都是「≈ 0 金币」,看着像真的只值 0。
* 现在:① 所有本机行情源**合并**(MWITools + MWICore + Edible),不再
* 「取第一个能解析的就锁定」;② Edible 的显示名键用 itemDetailMap
* 反查成 hrid;③ 一种都没价时显示「N 种均无行情」而不是「≈ 0 金币」;
* ④ 每个掉落芯片上直接标注单价或「无价」,一眼看出缺哪些行情。
* ★ v1.5.5:**掉落一律显示**,行情只决定能不能给金币估值。
* 旧实现按「有没有行情」过滤掉落,而本机行情的 key 其实带 ":0" 后缀
* (实测 MWITools 的键是**裸 hrid**、值是**按强化等级再套一层**的对象:
* {"/items/acrobatic_hood":{"0":{a,b,p,v},"5":{a,b}}};
* 旧代码按 md[hrid][0].a 取值永远取不到 → askPrice 恒为 0 →
* 掉落栏两个视图(累计/本场)全被过滤成空 —— 这就是「掉落那一栏不显示」。
* 现在同时兼容 MWITools / Edible Tools / MWICore 三种缓存外形。
*
* 【蓝量管家】战斗续航推演
* 以 300 秒为周期(= 2.5 × 最长的 120 秒技能冷却),按每个技能
* 真实的 (冷却 + 吟唱) 独立轮转算出平均耗蓝速率,再减去自然回蓝,
* 然后以 10 秒步长向前推演:缺蓝达到阈值就消耗食物,直到蓝量归零。
* 输出「几天几时几分几秒」的可战斗时长,并给出不用食物的对照值。
*
* ★ v1.5.6:推演起点固定为**满蓝 mpMax**,「食物可撑」改用闭式稳态。
* 旧实现从**当前蓝量**起算、食物时长取自会滑动的推演采样窗口,
* 导致面板数字在战斗中一直跳(同一套配装每次刷新结果都不同),无法参照。
* 现在两个数字都只由「配装 + 补给库存」决定:
* · 可战斗时长 = 满蓝起、含食物补蓝的连续输出时长(稳定)
* · 食物可撑 = 库存总补蓝量 ÷ 吃速上限(稳定,= 这些食物能吃多久)
* 当前蓝量另有「当前蓝量」一行单独显示,不再参与主结论。
*
* ★ v1.5.5:isSpecialAbility 技能(复活术等)**不参与常规轮转**。
* 实测 /abilities/revive 的 isSpecialAbility=true、cd 300s、耗蓝 200,
* 它只在「队友阵亡」时施放。旧实现按 CD 一律计 1 次,等于给每场战斗
* 凭空加了 200 蓝固定开销。现在排除,单列显示「按需施放 · 未计入」。
*
* ★ v1.5.4:可战斗时长 = 自然回蓝 + 技能耗蓝 + **食物补蓝** 三者共同推演,
* 并单独给出「食物何时耗尽」。面板上明确拆成两行,避免误读:
* · 自然回蓝能撑 X (不吃任何食物,只靠 mpRegen 的极限)
* · 食物再补 +Y (食物把续航从 X 拉到 X+Y)
* · 食物可撑 Z (当前食物库存按实测吃速能撑多久,即「食物何时耗尽」)
* 三种关系会分别标注:
* ① 蓝先耗尽(食物还剩)—— 瓶颈在**供蓝不足**,加食物没用,得提回蓝/降耗蓝
* ② 食物先耗尽 —— 瓶颈在**库存不够**,补货即可延长
* ③ 两者都够(无限续航) —— 自然回蓝已覆盖耗蓝,食物只是备胎
*
* 每 300 秒的消耗口径(v1.5.0,v1.5.2 修正)
* ─────────────────────────────────────────────────────────────
* 除了原有的「耗蓝速率」,还按**推演分桶**给出真实的每 300 秒消耗量:
* · 净耗蓝量 / 自然回蓝量(点)
* · 耗食物数量(个),并按种类拆分:哪种吃了几个、各补了多少蓝
* · 食物供蓝能力(蓝/秒)vs 净耗速率 —— 直接判定稳态能否成立
* · 食物续航:平均多少秒消耗 1 个
* · 累计消耗:整场推演一共烧了多少蓝、吃了多少个食物
* 取**第 2 个**走完的完整 300 秒窗口作稳态样本 —— 起手窗口蓝量接近满值,
* 要掉一会儿才开始吃食物,拿它当稳态会系统性低估消耗。只有一个窗口才退回第 1 个。
* 若续航不足 300 秒,按实际速率线性折算,并**封顶到 CD 物理上限**,
* 否则会算出「每 300 秒吃 60 个」这种物理上不可能的数字。
*
* 食物消耗的四条硬约束(都是真实机制,别改):
* 1) 必须缺蓝达到该食物的触发阈值才吃(实测阈值 === 回蓝量)
* 2) 一次只吃 1 个——喝完即进自身 CD,不能连灌
* 3) CD 未就绪不再吃(readyAt 逐点推进,不是取整近似)
* 4) 恢复分两类:recoveryDuration = 0(软糖 / 甜甜圈)瞬回到账;
* > 0(酸奶 / 蛋糕,实测恒为 30 秒)在这段时间内**匀速补完**,不是瞬回。
* 按瞬回建模会让「残血吃一口能否救回来」判错。
*
* v1.5.2 修掉的三个食物推演错误(每一个都会让消耗量明显算错)
* ─────────────────────────────────────────────────────────────
* a) 耗尽时刻插值分母写成了**总耗蓝**,应为**净耗**(总耗 − 自然回蓝)。
* 实测净耗 34.3 蓝/秒、回蓝 2 蓝/秒时,真实 16.9 秒被算成 12.9 秒;
* 并连带破坏蓝量收支守恒(差额曾高达 -221 点)。
* b) 食物触发判定只在**步末**做。若某一步开始时就已经缺蓝(残血场景),
* 整步 10 秒的消耗会先于食物发生,续航严重偏短(实测 18 秒被算成 6 秒)。
* 现在步首、步末各判一次,由 readyAt 保证不会重复吃。
* c) 未建模 recoveryDuration。酸奶被当瞬回,急救助效果判错。
*
* 数据来源
* ─────────────────────────────────────────────────────────────
* 全部字段来自游戏客户端自身下发的数据,脚本不做任何猜测:
* itemDetailMap[hrid].consumableDetail
* { hitpointRestore, manapointRestore, recoveryDuration,
* cooldownDuration, buffs: null | [...], defaultCombatTriggers: [...] }
* abilityDetailMap[hrid]
* { manaCost, cooldownDuration, castDuration, abilityEffects: [{ effectType, buffs }] }
* buffTypeDetailMap[hrid] { name, isCombat, description }
* combatStats.mpRegenPer10 —— 每 10 秒回复最大蓝量的百分比
*
* ★ 三张静态表**不走 WebSocket**(v1.5.3 修复,勿删)
* ─────────────────────────────────────────────────────────────
* itemDetailMap / abilityDetailMap / buffTypeDetailMap 是游戏启动时缓存在本地的,
* 通过 window.localStorageUtil.getInitClientData() 读取(MWITools 同源做法)。
* 游戏的 WS 消息类型里**没有** init_client_data。
* 旧实现只监听 WS → 三张表恒为 null → 两个症状:
* · consumableCategory() 全落到 'other' → **食物/茶/咖啡全被归到「其他」**
* · abilityBuffs() 恒返回 null → **光环、反伤等技能增益一个都不显示**
* 表可能比脚本晚就绪,故 ensureClientData() + scheduleClientDataRetry() 退避轮询。
* 回归测试见 test-clientdata-e2e.js(它 mock 的是 localStorageUtil,不是 WS)。
*
* ★ buff 数值格式(照搬游戏自己的 tooltip 规则)
* ─────────────────────────────────────────────────────────────
* ratioBoost 永远百分比;flatBoost 只在白名单里按百分比(_amplify / critical_* /
* hp_regen / mp_regen / life_steal / *_thorns / retaliation / efficiency / wisdom …),
* 其余一律**绝对点数**(armor / *_resistance / tenacity / *_level …)。
* 别用「flat >= 100 就当点数」这种启发式 —— 守护光环 armor flat=5 会显示成 500%。
*
* ★ 关于「茶」(v1.5.3)
* ─────────────────────────────────────────────────────────────
* 茶**永远不会**出现在战斗补给槽里:tea 的 usableInActionTypeMap 只含生活动作
* (alchemy / cooking / brewing / cheesesmithing / enhancing …)。
* 实测 combatConsumables 只出现过 donut / gummy / yogurt / coffee / cake。
* 因此「茶」只能从背包读(init_character_data.characterItems,位置
* /item_locations/inventory),且 characterItems 只描述自己 → 「随身」行仅自己可见。
*
* 关于分类判据(曾踩过的坑)
* ─────────────────────────────────────────────────────────────
* · 食物 vs 增益:**buffs 是否为非空数组**是唯一可靠判据。
* 注意 consumableDetail 里没有 combatStats / noncombatStats(那在 equipmentDetail 下)。
* · 技能的增益分两类:effectType = buff 的(狂暴、致命光环…),
* 以及 effectType = damage 但 buffs 为负的(破甲、冰矛…)。
* 反过来 maim 的 damage_taken +8% 是正向的,不能只看效果类型。
* · 食物自动触发阈值 === 该食物的回蓝量(实测),故为「掉到 X 就补回 X」的锯齿回补。
* · 冷却为 0 的技能(火球、缠绕)是纯 GCD,需用吟唱时长作为周期下限。
*
* 【血量管家】生存续航(★ v1.5.13 新增,与蓝量管家同构、并列一个页签)
* 和蓝量管家长得一样,但**底层取数完全不同** —— 这一点必须先说清:
* · 蓝量:耗蓝由**技能轮转推演**得出(每个技能的蓝耗 × 冷却次数),与怪无关。
* · 血量:掉血**没有任何公式可推**,只能靠**实测** —— 被怪打掉多少血,
* 是怪物强度、护甲、减伤、队友拉仇恨等一大堆因素的结果。
* 所以血量的「300 秒平均消耗」是**实测值**,不是推演值:
* 每次收到 new_battle / battle_updated 就把自己的 (时刻, 血量) 记进滑动窗口,
* 窗口内按「自然回血速率」把回血从总变化里剥掉,剩下的下降量就是被打掉的血。
* 具体口径(每一对相邻采样,dt 秒,Δ = hp_后 − hp_前):
* · 自然回血 r = hpMax × hpRegenPer10 / 10(点/秒),区间回血 regen = r × dt
* · hp_前 已满血 → regen 记 0(满血时自然回血被上限顶掉,不能算成进账)
* · Δ 明显**高于**区间回血(> regen + max(2, regen×10%))→ 判定为**吃到回血食物**,
* 该区间只记一次「回血事件」,不计掉血(否则食物回的血会被当成负的伤害)
* · 其余情况:掉血 = regen − Δ(Δ 为负即血在掉,正数即血在涨但涨得比自然回血慢,
* 说明区间内仍受了伤)
* · dt > 60 秒的区间一律跳过 —— 那是挂机/脱战,血量早被自然回血拉满,
* 算进来会把「平均掉血」稀释成一个好看但没用的数字
* 输出:
* · 当前血量(血条 + 百分比)
* · 自然恢复(hpRegenPer10 → 点/秒)
* · 食物恢复(Σ 单个回血量 ÷ 自身 CD,即这套补给的**可持续供血上限**)
* · 300 秒平均消耗(实测掉血 + 折算的耗食物个数),并标注采样覆盖秒数
* · 食物还能撑多久(库存总回血量 ÷ 吃速上限,与蓝量侧同一闭式口径)
* · 可战斗时长(满血起)= hpMax ÷ (掉血速率 − 自然恢复 − 食物供血),
* 并拆成「自然恢复能撑 X / 食物再补 +Y」两行,避免误读
* 样本不足(覆盖 < 20 秒)时显式显示「采样中」并给出已覆盖秒数,不硬凑数字。
*
* 消息通路(v1.5.1 修复,勿删)
* ─────────────────────────────────────────────────────────────
* hookWS() 劫持 MessageEvent.prototype.data 的 getter 后,**必须**把读到的
* message 交给 processRawMessage(),否则 state 永远是空的、所有页签都不出数据。
* 该调用一旦缺失,症状是「进战斗了却一直显示『进入战斗后显示蓝量信息』」——
* 极易被误判为服务挂了或游戏没发数据。回归测试见 test-ws-e2e.js。
*/
(function () {
'use strict';
var VERSION = '1.6.0';
// ============================== 持久化键 ==============================
var LS = {
pos: 'mwiAdjutant.pos',
collapsed: 'mwiAdjutant.collapsed',
lootView: 'mwiAdjutant.lootView',
};
function lsGet(k) {
try { return localStorage.getItem(k); } catch (e) { return null; }
}
function lsSet(k, v) {
try { localStorage.setItem(k, v); } catch (e) { /* 忽略 */ }
}
// ============================== 运行时状态 ==============================
var state = {
itemDetailMap: null, // hrid -> { name, consumableDetail }
abilityDetailMap: null, // hrid -> { name, manaCost, cooldownDuration, castDuration, abilityEffects }
buffTypeDetailMap: null, // hrid -> { name, isCombat, description }
ownId: null,
ownName: null,
players: [], // 规范化后的本场玩家列表
battleKey: null,
battleCount: 0,
lastBattleAt: 0,
currentAction: '',
difficultyTier: 0,
lootCredited: Object.create(null), // name -> { hrid -> 已计入数量 }(差分基准)
lootSession: Object.create(null), // name -> { hrid -> 会话累计 }
// ★ v1.5.11 食物开销(战斗补给消耗 → 金币)
consumSnap: Object.create(null), // key -> { hrid -> 上一次看到的库存 }(差分基准)
foodUsedSession: Object.create(null), // key -> { hrid -> 会话累计吃掉个数 }
foodBase: Object.create(null), // key -> { hrid -> 本场战斗起始库存 }
marketData: null, // 兼容旧字段(v1.5.7 起行情索引改由 buildMarketIndex 统一维护,不再用此字段)
marketAt: 0,
expBaseline: 0, // 首次读到的战斗经验总和(作为本次增量基线)
expSkillsBaseline: null,
expSkillsNow: null, // 最近一次读到的各技能经验
tab: 'dispatch', // dispatch | loot | mana | hp(显示名:续航|掉落|蓝量|血量)
lootView: 'session', // session | battle
collapsed: false,
// 背包里的「增益消耗品」(茶/咖啡等)—— 只有自己可见(见 captureBackpackBuffs)
rawCharacterItems: null,
backpackBuffs: [],
// ★ v1.5.13 血量管家:掉血只能实测,不能像蓝量那样靠技能推演。
// hpSamples = 自身血量的滑动时间序列 [{t(ms), hp}],窗口见 HP_WIN_MS。
// hpLastHealAt = 最近一次识别到「回血跳变」的时刻(用于面板提示)。
hpSamples: [],
hpLastHealAt: 0,
// ★ v1.6.0 战斗模拟器原料(全部来自已收到的 WS 消息,零请求)
selfRawCombat: null, // new_battle 里自己的**原始** unit(含完整 combatDetails)
monstersRaw: [], // new_battle 的原始怪物列表(含 combatDetails + combatAbilities)
partyThreatTotal: 0, // 全队 totalThreat 之和(算自己的承伤分摊比例)
abilityTriggers: null, // init_character_data.abilityCombatTriggersMap
consumableTriggers: null, // init_character_data.consumableCombatTriggersMap
selfUnitDetails: null, // init_character_data.combatUnit.combatDetails(脱战兜底)
selfUnitAbilities: null // init_character_data.combatUnit.combatAbilities(脱战兜底)
};
// ============================== 工具函数 ==============================
function num(v) {
var n = Number(v);
return isFinite(n) ? n : 0;
}
function fmtInt(n) {
return num(n).toLocaleString('zh-CN');
}
function fmtDur(sec) {
if (!isFinite(sec) || sec < 0) return '--';
sec = Math.round(sec);
if (sec < 60) return sec + ' 秒';
var m = Math.floor(sec / 60);
var s = sec % 60;
return m + ' 分 ' + (s > 0 ? s + ' 秒' : '');
}
function hridTail(hrid) {
return String(hrid || '').split('/').pop().replace(/_/g, ' ');
}
// ============================== 游戏字典(中文名) ==============================
// 游戏客户端通过 i18next 渲染中文,物品的 name 字段始终是英文,
// 中文名存在游戏的本地化资源里:resources.itemNames["/items/apple_gummy"] = "苹果软糖"。
// 下面按「可靠性递减」三层尝试获取,全部只读,不发送任何请求。
var i18nRes = null; // 缓存命中后的资源对象 { itemNames, abilityNames, skillNames, ... }
var i18nTried = false;
function gameLocale() {
var raw = '';
try { raw = localStorage.getItem('i18nextLng') || ''; } catch (e) { /* 忽略 */ }
if (!raw) raw = (document.documentElement && document.documentElement.lang) || navigator.language || 'en';
raw = String(raw).trim().replace(/_/g, '-').toLowerCase();
if (raw === 'zh-tw' || raw === 'zh-hant' || raw.indexOf('zh-hant') === 0 || raw === 'zh-hk' || raw === 'zh-mo') return 'zh-TW';
if (raw === 'zh' || raw.indexOf('zh-') === 0) return 'zh';
return raw.split('-')[0] || 'en';
}
function looksLikeResources(v) {
return !!(v && typeof v === 'object' &&
v.itemNames && typeof v.itemNames === 'object' &&
v.abilityNames && typeof v.abilityNames === 'object');
}
// 途径 1:从 React fiber 上找 i18next 实例(游戏 UI 一定挂了)
function fromReactFiber() {
var seeds = [];
var root = document.getElementById('root');
if (root) seeds.push(root);
var gp = document.querySelector('[class^="GamePage"], [class*="GamePage"]');
if (gp) seeds.push(gp);
if (!seeds.length) {
// 退一步:扫前 300 个元素找带 __reactFiber$ 的
var all = document.querySelectorAll('*');
for (var i = 0; i < all.length && seeds.length < 60; i++) {
var keys = Object.getOwnPropertyNames(all[i]);
for (var k = 0; k < keys.length; k++) {
if (keys[k].indexOf('__reactFiber$') === 0) { seeds.push(all[i]); break; }
}
}
}
var loc = gameLocale();
for (var s = 0; s < seeds.length; s++) {
var fk = Object.getOwnPropertyNames(seeds[s]).filter(function (x) { return x.indexOf('__reactFiber$') === 0; })[0];
if (!fk) continue;
var fiber = seeds[s][fk];
for (var d = 0; fiber && d < 80; d++, fiber = fiber.return) {
var cands = [
fiber.memoizedProps && fiber.memoizedProps.i18n,
fiber.pendingProps && fiber.pendingProps.i18n,
fiber.stateNode && fiber.stateNode.props && fiber.stateNode.props.i18n,
fiber.stateNode && fiber.stateNode.i18n
];
for (var c = 0; c < cands.length; c++) {
var inst = cands[c];
if (!inst || !inst.options || !inst.options.resources) continue;
var hit = pickLocaleResources(inst.options.resources, loc);
if (hit) return hit;
}
}
}
return null;
}
function pickLocaleResources(resources, loc) {
if (!resources || typeof resources !== 'object') return null;
var keys = Object.keys(resources);
// 精确匹配 → 前缀匹配 → 任一中文 → 默认
var pick = null, i;
for (i = 0; i < keys.length; i++) if (keys[i].toLowerCase() === loc.toLowerCase()) { pick = keys[i]; break; }
if (!pick) for (i = 0; i < keys.length; i++) if (keys[i].toLowerCase().indexOf(loc.toLowerCase()) === 0) { pick = keys[i]; break; }
if (!pick && loc.toLowerCase().indexOf('zh') === 0) {
for (i = 0; i < keys.length; i++) if (keys[i].toLowerCase().indexOf('zh') === 0) { pick = keys[i]; break; }
}
if (!pick) return null;
var entry = resources[pick];
var tr = entry && entry.translation ? entry.translation : entry;
return looksLikeResources(tr) ? tr : null;
}
// 途径 2:webpack 模块里直接跑 locale factory(较重,仅在途径 1 失败时执行一次)
function fromWebpack() {
try {
var queues = [];
var names = Object.getOwnPropertyNames(window);
for (var i = 0; i < names.length; i++) {
if (/^webpack(Jsonp|Chunk)/i.test(names[i])) {
var q = window[names[i]];
if (q && q.push && Array.isArray(q)) queues.push(q);
}
}
var entries = [];
for (var a = 0; a < queues.length; a++) {
for (var b = 0; b < queues[a].length; b++) {
var e = queues[a][b];
if (e && e[1] && typeof e[1] === 'object') entries.push(e);
}
}
var loc = gameLocale();
var expectedId = null;
// 找 locale 模块映射
for (var x = 0; x < entries.length; x++) {
var factories = Object.keys(entries[x][1]);
for (var y = 0; y < factories.length; y++) {
var f = entries[x][1][factories[y]];
if (typeof f !== 'function') continue;
var src = Function.prototype.toString.call(f);
if (src.indexOf('./zh-TW/index.js') === -1) continue;
var re = /["']\.\/([^"'\\]+)\/index\.js["']\s*:\s*\[\s*(\d+)\s*,\s*(\d+)\s*\]/g, m;
while ((m = re.exec(src))) {
var lang = m[1].toLowerCase();
if (lang === loc.toLowerCase() || lang.indexOf(loc.toLowerCase()) === 0) expectedId = m[2];
}
}
}
if (!expectedId) return null;
// 跑目标工厂
for (var p = 0; p < entries.length; p++) {
var mods = Object.keys(entries[p][1]);
for (var q2 = 0; q2 < mods.length; q2++) {
if (mods[q2] !== expectedId) continue;
var fn = entries[p][1][mods[q2]];
if (typeof fn !== 'function') continue;
var exportsObj = {};
function fakeRequire() { throw new Error('blocked'); }
fakeRequire.r = function (t) { try { Object.defineProperty(t, '__esModule', { value: true }); } catch (e2) { /* 忽略 */ } };
fakeRequire.n = function (t) { var g = t && t.__esModule ? function () { return t['default']; } : function () { return t; }; g.a = g; return g; };
try {
fn({ exports: exportsObj }, exportsObj, fakeRequire);
} catch (e3) { continue; }
var out = exportsObj['default'] || exportsObj;
if (looksLikeResources(out)) return out;
var tr = out && out.translation;
if (looksLikeResources(tr)) return tr;
}
}
} catch (e) { /* 忽略 */ }
return null;
}
function ensureI18n() {
if (i18nRes || i18nTried) return i18nRes;
i18nTried = true;
i18nRes = fromReactFiber() || fromWebpack();
return i18nRes;
}
// 按 hrid 查中文:itemNames / abilityNames / skillNames ...
function localizedName(hrid) {
var res = ensureI18n();
if (!res || !hrid) return '';
var keys = ['itemNames', 'abilityNames', 'skillNames', 'monsterNames', 'actionNames'];
for (var i = 0; i < keys.length; i++) {
var m = res[keys[i]];
if (m && typeof m[hrid] === 'string' && m[hrid]) return m[hrid];
}
return '';
}
function itemName(hrid) {
var zh = localizedName(hrid);
if (zh) return zh;
var d = state.itemDetailMap && state.itemDetailMap[hrid];
return (d && d.name) || hridTail(hrid);
}
function abilityName(hrid) {
var zh = localizedName(hrid);
if (zh) return zh;
var d = state.abilityDetailMap && state.abilityDetailMap[hrid];
return (d && d.name) || hridTail(hrid);
}
// ==================== 技能提供的增益(buff) ====================
//
// 真实数据结构(从客户端缓存实锤 60 个技能):
// abilityDetailMap[hrid] = {
// manaCost, cooldownDuration, castDuration,
// abilityEffects: [{
// targetType: 'self' | 'allAllies' | 'enemy' | 'allEnemies',
// effectType: '/ability_effect_types/buff' | '/ability_effect_types/damage' | 'heal' | ...,
// buffs: [{ typeHrid: '/buff_types/xxx', flatBoost, ratioBoost, duration }]
// }]
// }
//
// 两类带 buff 的技能:
// 1) 纯增益:effectType = '/ability_effect_types/buff'
// berserk(0.18 physical_amplify)、critical_aura(2项)、
// elemental_affinity(3项元素增伤)、frenzy(0.24 攻速)、
// guardian_aura(4项)、insanity(3项)、invincible(4项抗性)、
// precision(0.4 命中)、retribution(0.25 反击)、taunt/provoke(威胁)、…
// 2) 附带减益:effectType = 'damage'/'heal' 但 buffs 里是负数
// crippling_slash(-0.12 damage)、frost_surge(-0.1 evasion)、
// ice_spear(-0.25 攻速)、puncture(-0.2 armor)、smoke_burst(-0.15)…
// 判断技能是否提供增益(含正向 buff,或纯增益型效果)
function abilityBuffs(hrid) {
var det = state.abilityDetailMap && state.abilityDetailMap[hrid];
if (!det) return null;
var effs = det.abilityEffects;
if (!effs || !effs.length) return null;
var positive = [];
var negative = [];
var hasBuffEffect = false;
effs.forEach(function (e) {
if (!e) return;
var et = String(e.effectType || '');
var isBuffEffect = et.indexOf('/buff') !== -1;
if (isBuffEffect) hasBuffEffect = true;
var list = e.buffs;
if (!list || !list.length) return;
list.forEach(function (b) {
if (!b || !b.typeHrid) return;
var flat = num(b.flatBoost);
var ratio = num(b.ratioBoost);
var mag = Math.abs(flat) + Math.abs(ratio);
if (mag <= 0) return;
var item = { typeHrid: b.typeHrid, flat: flat, ratio: ratio };
// 减益判定:效果类型不是 buff,或数值全为负
var isDebuff = !isBuffEffect && (flat < 0 || ratio < 0);
if (isDebuff) negative.push(item);
else positive.push(item);
});
});
// 正向和负向都没有值 → 不是 buff 类技能
if (!positive.length && !negative.length && !hasBuffEffect) return null;
return { positive: positive, negative: negative, selfTarget: hasBuffEffect };
}
// buff 类型的中文名(字典里键名未知,多处尝试 + 英文降级)
function buffTypeName(hrid) {
var zh = localizedName(hrid);
if (zh) return zh;
var res = ensureI18n();
if (res) {
var keys = ['buffNames', 'buffTypeNames', 'combatBuffNames', 'names'];
for (var i = 0; i < keys.length; i++) {
var m = res[keys[i]];
if (m && typeof m[hrid] === 'string' && m[hrid]) return m[hrid];
}
}
var d = state.buffTypeDetailMap && state.buffTypeDetailMap[hrid];
return (d && d.name) || hridTail(hrid);
}
// flatBoost 的两种语义 —— 判据直接照搬**游戏自己的 tooltip 代码**
// (main.8add56c3.chunk.js 里 `buffTypeNames.${hrid}` 那段):
// · ratioBoost 永远按百分比显示
// · flatBoost 只在下表列出的 buff 上按百分比,其余一律按**绝对点数**
// 真实反例:guardian_aura 的 armor flat=5 → 官方按点数显示「+5」,
// 若按比率渲染会得到荒谬的「500%」。
var FLAT_PERCENT_BUFFS = {
cast_speed: 1, critical_rate: 1, critical_damage: 1,
physical_amplify: 1, water_amplify: 1, nature_amplify: 1, fire_amplify: 1,
healing_amplify: 1, life_steal: 1,
physical_thorns: 1, elemental_thorns: 1, retaliation: 1,
hp_regen: 1, mp_regen: 1,
combat_drop_rate: 1, combat_drop_quantity: 1,
gathering: 1, efficiency: 1, action_speed: 1, task_action_speed: 1,
gourmet: 1, wisdom: 1, processing: 1, artisan: 1, blessed: 1,
essence_find: 1, rare_find: 1, damage: 1
};
function flatIsPercent(typeHrid) {
var tail = String(typeHrid || '').split('/').pop();
return FLAT_PERCENT_BUFFS[tail] === 1;
}
// 比例显示:绝对值不足 1% 时保留一位小数(如 mp_regen 0.005 → +0.5%);
// 不足 0.1% 时保留两位 —— 否则宅邸 hp_regen/mp_regen(实测 0.0003 → 0.03%)
// 会被四舍五入成「+0.0%」,看起来像这条增益根本没生效。
function pct(v) {
var n = v * 100;
var a = Math.abs(n);
var s = a >= 1 ? n.toFixed(0) : (a >= 0.1 ? n.toFixed(1) : n.toFixed(2));
return (n > 0 ? '+' : '') + s + '%';
}
function buffValueText(b) {
if (b.ratio) return pct(b.ratio);
if (b.flat) {
if (flatIsPercent(b.typeHrid)) return pct(b.flat);
var v = Math.abs(b.flat) >= 100 ? String(Math.round(b.flat)) : String(b.flat);
return (b.flat > 0 ? '+' : '') + v;
}
return '';
}
// ==================== 实时生效增益(combatBuffMap) ====================
//
// ★ v1.5.9:增益**随时间改变** —— 显示「此刻真正挂着的增益」并倒计时。
// 游戏在 new_battle 的每个 unit、以及 battle_updated 的 pMap[i] 上都下发
// combatBuffMap。这和 abilityBuffs() 是两回事:
// abilityBuffs() = 这套技能**能提供**什么增益(静态,来自技能表)
// combatBuffMap = 此刻**真的挂着**哪些增益(动态,会过期)
//
// 实测外形(从本机 TM 存储里的原始 WS 消息实取,2026-10-10):
// combatBuffMap = {
// "/buff_uniques/intelligence_coffee_mp_regen": {
// uniqueHrid: "/buff_uniques/intelligence_coffee_mp_regen",
// typeHrid: "/buff_types/mp_regen",
// ratioBoost: 0, ratioBoostLevelBonus: 0,
// flatBoost: 0.005, flatBoostLevelBonus: 0,
// startTime: "2026-10-10T11:02:56.933389059Z", // 9 位小数(纳秒精度)
// duration: 300000000000 // ← 纳秒!= 300 秒
// },
// "/buff_uniques/house_attack_level": { ..., duration: 0, startTime: "0001-01-01T00:00:00Z" }
// }
//
// ★ duration 单位判据照搬 MWITools 的 durationToMs(别自己猜):
// > 86400000(1 天)→ 纳秒;> 1000 → 毫秒;否则秒;<= 0 → 常驻
// 把 300000000000 当毫秒会得到 3472 天,荒谬。
// ★ startTime 是 9 位小数的 ISO 串,Date.parse 能解(实测 → 1791630176933)。
// expiresAt = startTime + duration 直接算剩余;只有「起始时间在未来」才夹紧一次
// (防服务端/本机时钟偏差把倒计时推到比总时长还长)。**已过期的不复活**,
// 由渲染层过滤 —— 复活会让早该消失的 buff 以满时长重新出现(踩过)。
// ★ flatBoostLevelBonus / ratioBoostLevelBonus 一律忽略 —— 游戏自己的 tooltip 与
// MWITools 都只用 boost 本身(MWITools 里 flatBoostLevelBonus 出现 0 次)。
function buffDurationMs(raw) {
var n = num(raw);
if (!(n > 0)) return 0;
if (n > 86400000) return n / 1e6; // 纳秒 → 毫秒
if (n > 1000) return n; // 毫秒
return n * 1000; // 秒 → 毫秒
}
// combatBuffMap → 数组。常驻(duration 0)的 expiresAt = 0。
function parseBuffMap(map) {
var out = [];
if (!map || typeof map !== 'object') return out;
var now = Date.now();
Object.keys(map).forEach(function (k) {
var b = map[k];
if (!b || typeof b !== 'object') return;
var durMs = buffDurationMs(b.duration);
var expiresAt = 0;
if (durMs > 0) {
var startMs = Date.parse(String(b.startTime || ''));
expiresAt = (isFinite(startMs) && startMs > 0) ? startMs + durMs : now + durMs;
// 起始时间落在未来(服务端与本机时钟有偏差)→ 按「刚刚施加」算,防倒计时越界。
// ★ 已过期的不「复活」—— 修过的坑:曾经把 expiresAt <= now 也夹成 now + durMs,
// 结果一条早该消失的 buff 会以满时长重新出现(实测 5:00)。
// 过期的原样留着,由 liveBuffRow / hasTimedBuff 过滤掉。
if ((expiresAt - now) > durMs) expiresAt = now + durMs;
}
out.push({
uniqueHrid: String(b.uniqueHrid || k),
typeHrid: String(b.typeHrid || ''),
ratio: num(b.ratioBoost),
flat: num(b.flatBoost),
durMs: durMs,
expiresAt: expiresAt, // 0 = 常驻
permanent: !(durMs > 0)
});
});
return out;
}
// 剩余时长:4:52 / 1:02:03
function fmtRemain(ms) {
var total = Math.max(0, Math.round(num(ms) / 1000));
var s = total % 60;
var m = Math.floor(total / 60) % 60;
var h = Math.floor(total / 3600);
var pad = function (v) { return (v < 10 ? '0' : '') + v; };
return h > 0 ? (h + ':' + pad(m) + ':' + pad(s)) : (m + ':' + pad(s));
}
// combatBuffMap 的条目用 ratioBoost/flatBoost,转成 buffValueText 认的 {ratio,flat}
function liveBuffValue(b) {
return buffValueText({ typeHrid: b.typeHrid, ratio: b.ratio, flat: b.flat });
}
// 「生效」整行:有时限的带倒计时(按剩余升序,快过期的在前),常驻的汇总成一个芯片。
function liveBuffRow(p) {
var now = Date.now();
var timed = [], perms = [];
(p.buffs || []).forEach(function (b) {
if (!b.permanent && !(b.expiresAt > now)) return; // 已过期 → 不显示
(b.permanent ? perms : timed).push(b);
});
timed.sort(function (a, b) { return a.expiresAt - b.expiresAt; });
var parts = timed.map(function (b) {
var nm = buffTypeName(b.typeHrid);
var v = liveBuffValue(b);
var neg = (b.ratio < 0) || (!b.ratio && b.flat < 0);
return '' +
esc(nm) + (v ? ' ' + v : '') +
'' + fmtRemain(b.expiresAt - now) + ' ';
});
if (perms.length) {
var detail = perms.map(function (b) {
var v = liveBuffValue(b);
return buffTypeName(b.typeHrid) + (v ? ' ' + v : '');
}).join('、');
parts.push('常驻 ×' + perms.length + ' ');
}
return '
' +
'生效 ' +
'' +
(parts.length ? parts.join('') : '无 ') +
'
';
}
// ============================== 市场行情(估值用) ==============================
// 关键设计:只读取游戏/其他脚本**已经缓存在本机**的行情,不发任何网络请求。
//
// 各来源的真实外形(用正规 leveldb 解析从本机实取,2026-10-10):
//
// MWITools → localStorage['MWITools_marketAPI_json']
// { marketData: {
// "/items/abyssal_essence": { "0": { a:187, b:184, p:186, v:474862 } },
// "/items/acrobatic_hood": { "0": { a:67500000, b:64500000, p:.., v:1 },
// "1": { a:76500000, b:-1 },
// "5": { a:78000000, b:67500000 }, ... } },
// timestamp: 1791627045926 }
// ★ 三层结构:键是**裸 hrid(无 :N 后缀)**,值是**按强化等级再套一层的对象**。
// 实测 881 个品种 / 2000+ 个「品种×等级」条目。b:-1 表示无买价。
//
// MWICore → localStorage['MWICore_marketData']
// { "/items/abyssal_essence:0": { rise, ask:187, bid:184, aq, bq, asks, bids, time },
// "/items/acrobatic_hood:5": { rise, ask:78000000, ... }, ... }
// ★ 扁平结构:键**带 :N 后缀**。实测 3595 条 / 867 个基础品种。
//
// EdibleTools → localStorage['Edible_Tools_marketAPI_json']
// { market: { "Abyssal Essence": { ask:48187, bid:32 }, ... } }
// ★ 键是**英文显示名**,不是 hrid。
//
// ★ 四个必须处理的坑(全是踩过的真坑):
// (1) 键可能带 ":N" 强化后缀(MWICore),也可能不带(MWITools)。
// (2) MWITools 的值是按等级**再套一层**的对象,不是直接的价格。
// —— 漏了这层,MWITools 整整 881 个品种全读不出价。
// (3) Edible Tools 用**英文显示名**做键 —— 必须用 itemDetailMap 反查成 hrid。
// (4) 各来源都只是**部分**覆盖(只有本机浏览/订阅过的物品)。
//
// ★ 匹配策略:**多来源合并**,不再「第一个能解析的就锁定」。
// 旧实现按顺序取第一个能解析的源 —— 本机只有 Edible Tools 时(键是显示名)
// 全部查不到价,于是整页渲染成「≈ 0 金币」。合并后覆盖率最高,
// 且靠前的源先写入、不被后来的覆盖(等价于旧的优先级)。
// ★ 价格优先级:同一品种优先取 **+0 级**价(掉落物默认无强化),
// 没有 0 级时才退到该品种存在的最低等级价。
var MARKET_SOURCES = [
{ key: 'MWITools_marketAPI_json', label: 'MWITools', tsKeys: ['timestamp'] },
{ key: 'MWICore_marketData', label: 'MWICore', tsKeys: [] }, // 时间戳在每条报价行内
{ key: 'Edible_Tools_marketAPI_json', label: 'Edible', tsKeys: [] },
{ key: 'mwi_marketAPI_json', label: 'mwi', tsKeys: ['timestamp'] },
{ key: 'marketAPI_json', label: 'market', tsKeys: ['timestamp'] }
];
var MARKET_TTL_MS = 30000; // 超过 30 秒重读一次,让新浏览到的行情自动进来
var marketIndex = null; // hrid -> 卖价(已归一:去掉 :N 后缀、已选好等级)
var marketMeta = { sources: [], kinds: 0, at: 0, builtAt: 0 };
var nameToHrid = null; // 英文显示名(小写) -> hrid
var nameIdxSrc = null; // 建 nameToHrid 时的 itemDetailMap 引用(变了就重建)
var priceCache = Object.create(null); // hrid -> 卖价(含 0,避免重复扫描)
// 「英文显示名 → hrid」反查表。
// 首选 itemDetailMap[hrid].name(官方英文名),兜底用 hrid 尾段(abyssal_essence → "abyssal essence")。
function ensureNameIndex() {
var im = state.itemDetailMap;
if (nameToHrid && nameIdxSrc === im) return nameToHrid;
var m = Object.create(null);
if (im) {
var h;
for (h in im) {
if (!Object.prototype.hasOwnProperty.call(im, h)) continue;
var d = im[h];
if (d && typeof d.name === 'string' && d.name) m[d.name.toLowerCase()] = h;
}
for (h in im) {
if (!Object.prototype.hasOwnProperty.call(im, h)) continue;
var tail = hridTail(h).toLowerCase();
if (tail && !(tail in m)) m[tail] = h;
}
}
nameToHrid = m;
nameIdxSrc = im;
return m;
}
// 从「平铺」报价行取卖价:{a} (MWITools/Edible) / {ask} (MWICore) / {p} / {price}
function flatPrice(o) {
if (!o || typeof o !== 'object') return 0;
var v = num(o.a);
if (!(v > 0)) v = num(o.ask);
if (!(v > 0)) v = num(o.p);
if (!(v > 0)) v = num(o.price);
return v > 0 ? v : 0;
}
// 把一行报价摊平成 [{ lv, price }, ...]:
// 平铺 { a:187 } → [{ lv:null, price:187 }]
// 嵌套 { "0":{a:187}, "5":{a:78000000} } → [{ lv:0, price:187 }, { lv:5, ... }] ← MWITools 真实形态
// 数组 [ { a:187 } ](早期形态) → [{ lv:null, price:187 }]
function extractLevels(row) {
var out = [];
if (row == null) return out;
if (Array.isArray(row)) row = row[0];
if (row == null) return out;
if (typeof row === 'number') {
var n0 = num(row);
if (n0 > 0) out.push({ lv: null, price: n0 });
return out;
}
if (typeof row !== 'object') return out;
var direct = flatPrice(row);
if (direct > 0) { out.push({ lv: null, price: direct }); return out; }
// 嵌套形态:键是强化等级数字
for (var k in row) {
if (!Object.prototype.hasOwnProperty.call(row, k)) continue;
if (!/^\d+$/.test(k)) continue;
var sub = row[k];
var p = (sub && typeof sub === 'object') ? flatPrice(sub) : num(sub);
if (p > 0) out.push({ lv: parseInt(k, 10), price: p });
}
return out;
}
// 把所有本机行情来源合并成一张「hrid -> 卖价」索引。
function buildMarketIndex(force) {
var now = Date.now();
if (marketIndex && !force && (now - marketMeta.builtAt) < MARKET_TTL_MS) return marketIndex;
var idx = Object.create(null); // hrid -> 首选价(+0 级优先)
var lowLv = Object.create(null); // hrid -> 已见过的最低等级
var lowPx = Object.create(null); // hrid -> 该等级的价格
var seen = Object.create(null); // "hrid:N" -> true(跨源先到优先)
var sources = [], at = 0;
var names = ensureNameIndex();
MARKET_SOURCES.forEach(function (src) {
var raw = null;
try { raw = localStorage.getItem(src.key); } catch (e) { raw = null; }
if (!raw) return;
var obj = null;
try { obj = JSON.parse(raw); } catch (e2) { return; }
if (!obj || typeof obj !== 'object') return;
// 抽出「键 -> 报价行」的表
var map = null;
if (obj.marketData && typeof obj.marketData === 'object') map = obj.marketData;
else if (obj.market && typeof obj.market === 'object') map = obj.market;
else map = obj; // MWICore:顶层直接就是行情表
if (!map) return;
var got = 0;
for (var k in map) {
if (!Object.prototype.hasOwnProperty.call(map, k)) continue;
if (k === 'timestamp' || k === 'time') continue;
// 键 → hrid(+ 可选的外层等级)
var hrid, lv = null;
var mm = /^(.*):(\d+)$/.exec(k);
if (mm) { hrid = mm[1]; lv = parseInt(mm[2], 10); }
else if (k.charAt(0) === '/') { hrid = k; }
else {
hrid = names[String(k).toLowerCase()];
if (!hrid) {
// 兜底:把显示名还原成 hrid 形状("Abyssal Essence" → /items/abyssal_essence)
var slug = String(k).toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
if (!slug) continue;
hrid = '/items/' + slug;
}
}
if (!hrid) continue;
var row = map[k];
// 行内时间戳(MWICore 每条报价自带 time,单位秒)
if (row && typeof row === 'object' && !Array.isArray(row) && row.time) {
var rt = num(row.time);
if (rt > 0) { if (rt < 1e12) rt *= 1000; if (rt > at) at = rt; }
}
var list = extractLevels(row);
for (var j = 0; j < list.length; j++) {
var L = (list[j].lv === null) ? (lv === null ? 0 : lv) : list[j].lv;
var key = hrid + ':' + L;
if (key in seen) continue; // 先到优先(靠前的源赢)
seen[key] = true;
got++;
if (L === 0) { if (!idx[hrid]) idx[hrid] = list[j].price; }
if (!(hrid in lowLv) || L < lowLv[hrid]) { lowLv[hrid] = L; lowPx[hrid] = list[j].price; }
}
}
if (got) sources.push(src.label);
var t = 0;
for (var i = 0; i < src.tsKeys.length; i++) t = num(obj[src.tsKeys[i]]) || t;
if (t > 0) { if (t < 1e12) t *= 1000; if (t > at) at = t; } // 秒 → 毫秒
});
// 没有 +0 级价的品种,退到该品种存在的最低等级价
var kinds = 0;
for (var h in lowPx) {
if (!(h in idx)) idx[h] = lowPx[h];
kinds++;
}
marketIndex = idx;
marketMeta = { sources: sources, kinds: kinds, at: at, builtAt: now };
priceCache = Object.create(null); // 索引变了,旧报价作废
return idx;
}
// 对外语义:拿得到「至少一种有价物品」才返回非 null。
function ensureMarketData() {
var idx = buildMarketIndex(false);
return (idx && marketMeta.kinds > 0) ? idx : null;
}
// 取单品卖价(挂单能成交的价)。取不到返回 0。
// 索引已在 buildMarketIndex 里归一,这里只需按 hrid 直查。
function askPrice(hrid) {
if (!hrid) return 0;
if (hrid in priceCache) return priceCache[hrid];
var idx = buildMarketIndex(false);
var out = num(idx && idx[hrid]);
priceCache[hrid] = out;
return out;
}
// 金币缩写显示:12.3万 / 4.5亿
function fmtCoin(n) {
n = num(n);
if (n <= 0) return '0';
if (n >= 1e8) return (n / 1e8).toFixed(2) + '亿';
if (n >= 1e4) return (n / 1e4).toFixed(1) + '万';
return fmtInt(n);
}
// 一组战利品的估值(按卖价)
function lootValue(entries) {
var sum = 0, priced = 0, kinds = entries.length;
entries.forEach(function (e) {
var p = askPrice(e.hrid);
if (p > 0) { sum += p * e.count; priced++; }
});
// unpriced = 没拿到行情的品种数。这类物品**照样显示**,只是不计入估值。
return { value: sum, priced: priced, kinds: kinds, unpriced: kinds - priced };
}
// 估值文案。
// ★ 关键:一个品种都没价时**不显示数字**。
// 旧实现无条件拼 '≈ ' + fmtCoin(0) → 渲染成「≈ 0 金币」,
// 看起来像「这些战利品真的只值 0 金币」,而不是「没读到行情」。
function valueLabel(val) {
if (!ensureMarketData()) return '行情未就绪';
if (val.priced === 0) {
return '' + val.kinds + ' 种均无行情 ';
}
var t = '≈ ' + fmtCoin(val.value) + ' 金币';
if (val.unpriced > 0) {
t += '另有 ' + val.unpriced + ' 种无价 ';
}
return t;
}
// 页脚行情状态。把「读到了哪些源、共几种、什么时候的价」摆出来,
// 这样「为什么全是无行情」一眼可查(通常是没装行情脚本,或只装了 Edible)。
function marketLine() {
buildMarketIndex(false);
if (!marketMeta.kinds) {
return ' · 未读到行情缓存 ';
}
var s = ' · 行情 ' + marketMeta.kinds + ' 种';
if (marketMeta.sources.length) s += '(' + marketMeta.sources.join(' + ') + ')';
if (marketMeta.at) s += ' · ' + new Date(marketMeta.at).toLocaleString('zh-CN');
return s;
}
function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
});
}
// 消耗品分类 —— 判据来自真实物品数据(从 game 缓存实锤的 consumableDetail)。
//
// 真实结构(92 个消耗品样本,完美 1:1 分离,无重叠):
// consumableDetail: {
// cooldownDuration, usableInActionTypeMap,
// hitpointRestore, manapointRestore, // 恢复量
// recoveryDuration, // 持续回复时长
// buffs: null | [{ typeHrid, flatBoost, ratioBoost, duration }, ...],
// defaultCombatTriggers: [...]
// }
//
// 样本对照:
// apple_gummy hp=0 mp=80 recovery=0 buffs=null → 食物
// apple_yogurt hp=0 mp=100 recovery=30s buffs=null → 食物
// donut hp=40 mp=0 recovery=0 buffs=null → 食物
// cupcake hp=50 mp=0 recovery=30s buffs=null → 食物
// attack_coffee hp=0 mp=0 recovery=0 buffs=[1项] → 增益
// alchemy_tea hp=0 mp=0 recovery=0 buffs=[2项] → 增益
//
// 结论:区分「食物」与「增益」的唯一可靠标志是 **buffs 是否为非空数组**。
// 之前用的 cd.combatStats / cd.noncombatStats 是错的——那是 equipmentDetail 下的字段,
// consumableDetail 里根本不存在,导致增益类永远落进 other,看起来就像「食物和增益反了」。
//
// 官方 wiki Food Slots 也印证:Donut / Cupcake / Gummy / Yogurt 四类恢复品全部装在食物槽。
// 时长格式化:精确到 天/时/分/秒(用户要求「几d几h几m几s」)
function fmtFull(sec) {
if (!isFinite(sec) || sec < 0) return '--';
sec = Math.floor(sec);
var d = Math.floor(sec / 86400);
var h = Math.floor(sec % 86400 / 3600);
var m = Math.floor(sec % 3600 / 60);
var s = sec % 60;
var out = '';
if (d) out += d + ' 天 ';
if (d || h) out += h + ' 时 ';
if (d || h || m) out += m + ' 分 ';
out += s + ' 秒';
return out;
}
function consumableCategory(hrid) {
var d = state.itemDetailMap && state.itemDetailMap[hrid];
var cd = d && d.consumableDetail;
if (!cd) return 'other';
// 1) 带 buff 的(咖啡、茶)→ 增益
if (Array.isArray(cd.buffs) && cd.buffs.length > 0) return 'buff';
// 2) 有瞬时或持续恢复量的(软糖、甜甜圈、酸奶、蛋糕)→ 食物
if (num(cd.hitpointRestore) > 0 || num(cd.manapointRestore) > 0) return 'food';
// 3) 只靠 recoveryDuration 持续回复的(无起始量但有持续)→ 食物
if (num(cd.recoveryDuration) > 0) return 'food';
return 'other';
}
function getSelf() {
for (var i = 0; i < state.players.length; i++) {
if (state.players[i].isSelf) return state.players[i];
}
return state.players.length === 1 ? state.players[0] : null;
}
function sortedPlayers() {
var self = null;
var rest = [];
state.players.forEach(function (p) {
if (p.isSelf) self = p; else rest.push(p);
});
rest.sort(function (a, b) { return a.name.localeCompare(b.name); });
return self ? [self].concat(rest) : rest;
}
// ============================== WebSocket 只读劫持 ==============================
// 与 MWITools / 食用工具同源的技术:重写 MessageEvent.prototype.data 的 getter。
// 游戏自己读取 event.data 时触发,我们顺带看一眼内容后原样返回。
// 不修改消息、不触碰 send()。
function hookWS() {
var desc = Object.getOwnPropertyDescriptor(MessageEvent.prototype, 'data');
if (!desc || !desc.get) return;
var origGet = desc.get;
// 去重表:若 data 因故无法被固定为自身属性,同一事件对象的 getter 会被反复调用,
// 用它保证同一条消息只解析一次。
var seen = (typeof WeakSet === 'function') ? new WeakSet() : null;
desc.get = function () {
var message = origGet.call(this);
// 防重入:读一次后把值固定为自身属性(后续读取不再进入本 getter)
try { Object.defineProperty(this, 'data', { value: message, configurable: true }); } catch (e) { /* 忽略 */ }
// —— 只读采集:把游戏「已经收到」的消息交给解析器 ——
// socket 优先取 currentTarget(事件派发期间有效),派发结束后回退 target。
var socket = this.currentTarget || this.target;
var isGame = false;
if (socket instanceof WebSocket) {
// 兼容 URL 变体:只要落在游戏域上即视为游戏 socket(不强制包含 /ws 路径)
isGame = String(socket.url || '').indexOf('milkywayidle') !== -1;
} else if (typeof message === 'string' && message.charAt(0) === '{' &&
message.indexOf('"type"') !== -1 &&
String(location.host || '').indexOf('milkywayidle') !== -1) {
// 兜底:拿不到 socket 引用时,按「游戏域 + MWI 消息特征」识别
isGame = true;
}
if (isGame) {
var dup = false;
if (seen) { if (seen.has(this)) dup = true; else seen.add(this); }
if (!dup) {
// 解析异常绝不能影响游戏自身读取消息,全部吞掉
try { processRawMessage(message); } catch (e) { /* 忽略 */ }
}
}
return message;
};
try {
Object.defineProperty(MessageEvent.prototype, 'data', desc);
} catch (e) {
console.warn('[战斗副官] 劫持安装失败', e);
}
}
// ============================== 消息处理 ==============================
function processRawMessage(raw) {
if (adjDisabled) return; // ★ v1.5.15 重复实例已停用
var msg = null;
try { msg = JSON.parse(raw); } catch (e) { return; }
if (!msg || typeof msg !== 'object' || typeof msg.type !== 'string') return;
switch (msg.type) {
case 'init_client_data': onClientData(msg); break;
case 'init_character_data': onCharacterData(msg); break;
case 'new_battle': onNewBattle(msg); break;
case 'battle_updated': onBattleUpdated(msg); break;
case 'action_completed': onActionCompleted(msg); break;
}
}
// ============ 客户端静态表:三张表**不走 WebSocket**,必须从游戏内存读 ============
//
// 【勿删·核心数据源】itemDetailMap / abilityDetailMap / buffTypeDetailMap
// 实测游戏的 WebSocket 消息类型里**没有** init_client_data(WS 只有
// new_battle / battle_updated / action_completed / init_character_data 等)。
// 这三张表是游戏启动时缓存在本地、由 window.localStorageUtil.getInitClientData()
// 读取的 —— 与 MWITools 同源的做法(MWITools 同样只在这里取,不监听 WS)。
//
// 只监听 WS 的后果(v1.5.2 的真实故障):
// · itemDetailMap 恒为 null → consumableCategory() 全部落到 'other'
// → 症状:食物、茶、咖啡全被归到「其他」
// · abilityDetailMap 恒为 null → abilityBuffs() 恒返回 null
// → 症状:光环(guardian/fierce/mystic/critical_aura)、反伤(retribution/
// invincible)等技能增益一个都不显示
// 即「食物跑到其他」与「技能增益缺失」是同一个根因。
var clientDataTries = 0;
var clientDataTimer = null;
function pageGlobal() {
// @grant none → 脚本与页面同上下文,window 即页面的 window。
// 仍做多重兼容,防止将来改成沙箱模式后失效。
try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow) return unsafeWindow; } catch (e) { /* 忽略 */ }
return (typeof window !== 'undefined' && window) ||
(typeof globalThis !== 'undefined' && globalThis) || {};
}
// 把 init_client_data 的三张表灌进 state(WS 通道与内存通道共用)
function applyClientData(data) {
if (!data || typeof data !== 'object') return false;
var got = false;
if (data.itemDetailMap && typeof data.itemDetailMap === 'object') {
state.itemDetailMap = data.itemDetailMap; got = true;
}
if (data.abilityDetailMap && typeof data.abilityDetailMap === 'object') {
state.abilityDetailMap = data.abilityDetailMap; got = true;
}
if (data.buffTypeDetailMap && typeof data.buffTypeDetailMap === 'object') {
state.buffTypeDetailMap = data.buffTypeDetailMap; got = true;
}
return got;
}
// 是否「够用」:物品表到手,消耗品分类就能正确(食物不再落入其他)
function clientDataUsable() { return !!state.itemDetailMap; }
// 是否「完整」:技能增益还要 abilityDetailMap
function clientDataComplete() { return !!(state.itemDetailMap && state.abilityDetailMap); }
// 找到游戏暴露的 localStorageUtil。
// 常规位置是 window.localStorageUtil(MWITools 同源做法);
// 万一分构建挂到别的全局名下,退一步扫描 window 顶层对象。
var lsUtilCache = null;
function findLocalStorageUtil() {
if (lsUtilCache && typeof lsUtilCache.getInitClientData === 'function') return lsUtilCache;
var pg = pageGlobal();
var direct = pg.localStorageUtil;
if (direct && typeof direct.getInitClientData === 'function') {
lsUtilCache = direct;
return direct;
}
try {
var names = Object.getOwnPropertyNames(pg);
for (var i = 0; i < names.length; i++) {
var v;
try { v = pg[names[i]]; } catch (e) { continue; }
if (v && typeof v === 'object' && typeof v.getInitClientData === 'function') {
lsUtilCache = v;
return v;
}
}
} catch (e2) { /* 忽略 */ }
return null;
}
// 从游戏内存读一次(这是唯一可靠来源)
function readClientDataFromGame() {
var u = findLocalStorageUtil();
if (!u) return null;
try {
var d = u.getInitClientData();
return (d && typeof d === 'object') ? d : null;
} catch (e) { return null; }
}
// 返回 true 表示「这次调用拿到了新数据并已触发重渲染」
function ensureClientData() {
if (clientDataComplete()) return false;
var got = applyClientData(readClientDataFromGame());
if (got && clientDataUsable()) {
// 物品表到位后重算背包增益(茶/咖啡)——此前可能因表缺失而算不出来
captureBackpackBuffs();
scheduleRender();
return !clientDataComplete();
}
return false;
}
// 表可能比脚本晚就绪(localStorageUtil 未挂载 / 游戏缓存未写完)。
// 退避轮询,拿到即停。上限 60 次(约 42 秒)避免无限占用。
function scheduleClientDataRetry() {
if (clientDataTimer || clientDataTries >= 60) return;
clientDataTries++;
clientDataTimer = setTimeout(function () {
clientDataTimer = null;
if (ensureClientData()) scheduleClientDataRetry();
}, 700);
}
function onClientData(msg) {
// 若将来游戏确实通过 WS 下发了这张表,这里同样兼容(双通道)
applyClientData(msg);
scheduleRender();
}
function onCharacterData(msg) {
var c = msg.character;
if (c && c.id != null) state.ownId = c.id;
if (c && c.name) state.ownName = c.name;
// ★ v1.6.0:模拟器原料 —— 触发器 + 脱战时的自己面板/技能
if (msg.abilityCombatTriggersMap) state.abilityTriggers = msg.abilityCombatTriggersMap;
if (msg.consumableCombatTriggersMap) state.consumableTriggers = msg.consumableCombatTriggersMap;
if (msg.combatUnit && msg.combatUnit.combatDetails) state.selfUnitDetails = msg.combatUnit.combatDetails;
if (msg.combatUnit && msg.combatUnit.combatAbilities) state.selfUnitAbilities = msg.combatUnit.combatAbilities;
captureExperience(msg);
captureBackpackBuffs(msg);
// 顺手记录当前战斗地图(如果正在打)
var acts = msg.characterActions || [];
for (var i = 0; i < acts.length; i++) {
var h = acts[i] && acts[i].actionHrid;
if (h && h.indexOf('/actions/combat/') === 0) {
state.currentAction = h;
state.difficultyTier = num(acts[i].difficultyTier);
break;
}
}
scheduleRender();
}
// ============================== 经验采集 ==============================
// 经验数据在 init_character_data.combatUnit.combatAbilities[]:
// { abilityHrid, level, experience } —— experience 是该技能累计经验
// 另外 combatUnit 顶层可能有 combatExperience(非战斗技能经验),同样累加。
// 做法:记下首次读到的基线,之后用「当前值 - 基线」算本次面板加载后的增量。
function captureExperience(msg) {
var unit = msg.combatUnit;
if (!unit) return;
var skills = {};
var sum = 0, found = false;
(unit.combatAbilities || []).forEach(function (a) {
if (!a || !a.abilityHrid) return;
found = true;
skills[a.abilityHrid] = { level: num(a.level), exp: num(a.experience) };
sum += num(a.experience);
});
if (found && !state.expBaseline) {
state.expBaseline = sum; // 首次读到,作为基线
state.expSkillsBaseline = skills;
return;
}
if (found) state.expSkillsNow = skills;
}
// 本次(面板加载后)获得的战斗经验 = 当前总经验 - 基线
function sessionExperience() {
var now = 0, has = false;
var skills = state.expSkillsNow;
if (skills && Object.keys(skills).length) {
has = true;
for (var k in skills) if (Object.prototype.hasOwnProperty.call(skills, k)) now += skills[k].exp;
}
if (!has || !state.expBaseline) return { gained: 0, total: has ? now : 0, ready: has && !!state.expBaseline };
var gained = now - state.expBaseline;
return { gained: gained > 0 ? gained : 0, total: now, ready: true };
}
// ============================== 背包增益消耗品(茶 / 咖啡)==============================
// 需求来源:用户要「茶的增益也显示在续航栏」。
// 关键事实(实测):茶**永远不会**出现在战斗补给槽里 ——
// tea 的 consumableDetail.usableInActionTypeMap 只含生活动作
// (alchemy / cooking / brewing / cheesesmithing / enhancing …),
// 实测 combatConsumables 里只出现过 donut / gummy / yogurt / coffee / cake。
// 所以「茶」只能从背包(init_character_data.characterItems,位置 /item_locations/inventory)里找。
//
// 限制:characterItems 只描述**玩家自己**的角色,队友的背包游戏不下发,
// 因此「随身」一行只会出现在自己身上。
function captureBackpackBuffs(msg) {
if (msg && Array.isArray(msg.characterItems)) {
state.rawCharacterItems = msg.characterItems;
state.backpackBuffs = [];
}
var items = state.rawCharacterItems;
// 物品表未就绪时不急,等 ensureClientData() 拿到表后会再算一次
if (!items || !state.itemDetailMap) return;
var map = Object.create(null);
items.forEach(function (it) {
if (!it || !it.itemHrid) return;
if (String(it.itemLocationHrid || '').indexOf('/item_locations/inventory') !== 0) return;
var cnt = num(it.count);
if (cnt <= 0) return;
var d = state.itemDetailMap[it.itemHrid];
var cd = d && d.consumableDetail;
if (!cd) return;
// 只收「带 buff 的消耗品」——食物(回血回蓝)不算增益
if (!Array.isArray(cd.buffs) || !cd.buffs.length) return;
var e = map[it.itemHrid];
if (!e) e = map[it.itemHrid] = { hrid: it.itemHrid, count: 0, enh: num(it.enhancementLevel) };
e.count += cnt;
});
var out = Object.keys(map).map(function (k) { return map[k]; });
out.sort(function (a, b) { return b.count - a.count || a.hrid.localeCompare(b.hrid); });
state.backpackBuffs = out;
}
// 把某个消耗品自带的 buff 渲染成「名称 数值」串(背包增益用)
function consumableBuffText(hrid) {
var d = state.itemDetailMap && state.itemDetailMap[hrid];
var cd = d && d.consumableDetail;
var list = (cd && cd.buffs) || [];
return list.map(function (x) {
if (!x || !x.typeHrid) return '';
var v = buffValueText({
typeHrid: x.typeHrid,
flat: num(x.flatBoost),
ratio: num(x.ratioBoost)
});
return esc(buffTypeName(x.typeHrid)) + (v ? ' ' + v : '');
}).filter(Boolean).join('·');
}
function normalizePlayer(p) {
var cd = p.combatDetails || {};
var cs = cd.combatStats || {};
var isSelf = false;
if (state.ownId != null && p.character && p.character.id === state.ownId) isSelf = true;
else if (state.ownName && p.character && p.character.name === state.ownName) isSelf = true;
var consumables = [];
(p.combatConsumables || []).forEach(function (c) {
if (!c || !c.itemHrid) return;
consumables.push({
hrid: c.itemHrid,
count: num(c.count),
enh: num(c.enhancementLevel)
});
});
var abilities = [];
(p.combatAbilities || []).forEach(function (a) {
if (!a || !a.abilityHrid) return;
abilities.push({
hrid: a.abilityHrid,
level: num(a.level),
availableTime: a.availableTime || ''
});
});
return {
id: p.character ? p.character.id : null,
name: (p.character && p.character.name) || p.name || '?',
isSelf: isSelf,
hp: num(cd.currentHitpoints != null ? cd.currentHitpoints : p.currentHitpoints),
hpMax: num(cd.maxHitpoints != null ? cd.maxHitpoints : (p.maxHitpoints != null ? p.maxHitpoints : cs.maxHitpoints)),
mp: num(cd.currentManapoints != null ? cd.currentManapoints : p.currentManapoints),
mpMax: num(cd.maxManapoints != null ? cd.maxManapoints : (p.maxManapoints != null ? p.maxManapoints : cs.maxManapoints)),
mpRegenPer10: num(cs.mpRegenPer10),
hpRegenPer10: num(cs.hpRegenPer10),
consumables: consumables,
abilities: abilities,
// ★ v1.5.9:此刻真正生效的增益(会随时间过期),来自 combatBuffMap
buffs: parseBuffMap(p.combatBuffMap),
loot: {} // 由 onNewBattle 填充
};
}
function onNewBattle(msg) {
var key = msg.combatStartTime || msg.battleId || msg.combatId || null;
var isNewRound = (state.battleKey === null || key !== state.battleKey);
if (isNewRound) {
state.battleCount++;
state.battleKey = key;
}
state.lastBattleAt = Date.now();
var list = Array.isArray(msg.players) ? msg.players : [];
state.players = list.map(normalizePlayer);
// ★ v1.6.0:为模拟器留存原始战斗数据(归一化会丢掉 combatDetails 的大多数字段)
state.monstersRaw = Array.isArray(msg.monsters) ? msg.monsters : [];
state.selfRawCombat = null;
state.partyThreatTotal = 0;
for (var ri = 0; ri < list.length; ri++) {
var rcd = list[ri] && list[ri].combatDetails;
state.partyThreatTotal += num(rcd && rcd.totalThreat);
if (state.players[ri] && state.players[ri].isSelf) state.selfRawCombat = list[ri];
}
// ★ v1.5.13:血量管家采样。new_battle 每次都带最新血量,正好当采样点。
recordHpSample();
// ★ v1.5.11:先结算补给库存的下降量(会话累计),再在开新一场时重置本场基线。
// 顺序不能反 —— 新一场的首次差分里含「上一场最后一次快照 → 收工」吃掉的那部分,
// 必须先记进累计,再让本场从零开始。
trackConsumables();
if (isNewRound) resetBattleFoodBase();
// —— 战利品差分累计 ——
// new_battle 在战斗进行中会反复推送,totalLootMap 是本场累计。
// 每次只把「新增量」计入会话累计;数量回落(新一场重置)时同步基准即可。
// 直接遍历原始 players 取 totalLootMap(与 state.players 顺序一致)
list.forEach(function (raw, idx) {
var np = state.players[idx];
if (!np) return;
var curMap = Object.create(null);
var tlm = raw && raw.totalLootMap;
if (tlm && typeof tlm === 'object') {
Object.keys(tlm).forEach(function (k) {
var e = tlm[k];
if (e && e.itemHrid) curMap[e.itemHrid] = (curMap[e.itemHrid] || 0) + num(e.count);
});
}
np.loot = curMap;
var credited = state.lootCredited[np.name];
if (!credited) { credited = state.lootCredited[np.name] = Object.create(null); }
var hrids = Object.keys(credited).concat(Object.keys(curMap));
for (var i = 0; i < hrids.length; i++) {
var h = hrids[i];
var c = curMap[h] || 0;
var prev = credited[h] || 0;
if (c > prev) {
// ★ v1.5.5:掉落**一律记录**,不再按「有没有行情」决定记不记。
// 旧实现只在 askPrice(h) > 0 时才累加,本机行情表读不到时
// (key 对不上)会让会话累计永远是空的 —— 掉落页「累计」直接空白。
// 行情只影响「估值」那一列,不该影响「掉没掉」这件事。
var sess = state.lootSession[np.name];
if (!sess) sess = state.lootSession[np.name] = Object.create(null);
sess[h] = (sess[h] || 0) + (c - prev);
}
credited[h] = c; // 回落(新战斗重置)时基准同步下调
}
});
scheduleRender();
}
function onBattleUpdated(msg) {
// battle_updated 的 pMap/mMap 是稀疏增量;实体上可能带最新血蓝与**实时增益**,
// 字段名做多重兼容,有就刷新,没有就等下一次 new_battle。
var pMap = msg && msg.pMap;
if (!pMap || typeof pMap !== 'object' || !state.players.length) return;
var changed = false;
Object.keys(pMap).forEach(function (k) {
var idx = Number(k);
if (!isFinite(idx) || idx < 0 || idx >= state.players.length) return;
var e = pMap[k];
if (!e || typeof e !== 'object') return;
var np = state.players[idx];
var mp = (e.currentManapoints != null) ? e.currentManapoints : e.cMP;
// ★ v1.5.13:血量别名多一档 —— 血量管家的掉血实测全指望这条通路。
var hp = (e.currentHitpoints != null) ? e.currentHitpoints
: (e.cHP != null ? e.cHP : e.hp);
var mpMax = (e.maxManapoints != null) ? e.maxManapoints : e.mMP;
var hpMax = (e.maxHitpoints != null) ? e.maxHitpoints
: (e.mHP != null ? e.mHP : e.hpMax);
if (mp != null && num(mp) !== np.mp) { np.mp = num(mp); changed = true; }
if (hp != null && num(hp) !== np.hp) { np.hp = num(hp); changed = true; }
if (mpMax != null && num(mpMax) > 0) np.mpMax = num(mpMax);
if (hpMax != null && num(hpMax) > 0) np.hpMax = num(hpMax);
// ★ v1.5.9:combatBuffMap 是**该单位的完整增益快照**(不是增量),
// 出现即以它为准覆盖 —— 增益随时间增删、到期都靠这条通路刷新。
if (e.combatBuffMap && typeof e.combatBuffMap === 'object') {
np.buffs = parseBuffMap(e.combatBuffMap);
changed = true;
}
});
// ★ v1.5.13:血量采样必须在 forEach **之后** —— 上面刚把 np.hp 刷新完,
// 这里取到的才是这一条报文里的最新血量(放在循环里会对同一毫秒重复推)。
if (changed) {
recordHpSample();
scheduleRender();
}
}
function onActionCompleted(msg) {
var a = msg && msg.endCharacterAction;
if (a && a.actionHrid && a.actionHrid.indexOf('/actions/combat/') === 0) {
state.currentAction = a.actionHrid;
state.difficultyTier = num(a.difficultyTier);
scheduleRender();
}
}
// ============================== UI ==============================
// ★ v1.5.15 重复实例自检(血泪教训,务必保留)
// Tampermonkey 用 @name + @namespace 识别脚本:**@name 一改,旧版不删就会两份同跑**。
// 实测:v1.5.14 把 @name 里的「出警 / 分赃」改成「续航 / 掉落」后,TM 直接建了一条
// 新 uuid 的记录,旧的仍是 enabled —— 用户报告「点蓝量 / 掉落,界面秒跳回续航页」。
// 病理:两份各有独立 state,却都用 getElementById('mwi-adjutant-root') 取面板 ——
// 永远拿到文档里**第一个** root:你点「蓝量」,A 份渲染成蓝量;B 份的 state.tab 还停在
// 默认的 dispatch,它收到战斗推送或每秒心跳重渲染时,就把界面按自己的 tab 刷回续航页。
// 对策:给面板打本实例归属标记;一旦发现面板已被别的实例建好,本份**彻底停用**
// (不建面板、不处理消息、不写任何 DOM)。这样即便再出现双份,也只有一个在动面板。
var ADJ_OWNER = 'adj-' + Math.random().toString(36).slice(2) + '-' + Date.now();
var adjDisabled = false;
var renderTimer = null;
function scheduleRender() {
if (renderTimer) return;
renderTimer = setTimeout(function () {
renderTimer = null;
render();
}, 120);
}
// 字典是异步就绪的(游戏 UI 挂载后才存在)。若尚未拿到,退避重试,
// 拿到后自动重渲染一次,把英文名刷成中文。
var i18nRetry = null, i18nRetryCount = 0;
function scheduleI18nRetry() {
if (i18nRes || i18nRetry) return;
if (i18nRetryCount >= 20) return; // 最多试 20 次(约 20 秒)
i18nRetryCount++;
i18nRetry = setTimeout(function () {
i18nRetry = null;
i18nTried = false; // 允许重新尝试
if (ensureI18n()) scheduleRender();
else scheduleI18nRetry();
}, 1000);
}
// ★ v1.5.9:有没有「还没到期」的增益(常驻不算)。
function hasTimedBuff() {
var now = Date.now();
for (var i = 0; i < state.players.length; i++) {
var bs = state.players[i].buffs || [];
for (var j = 0; j < bs.length; j++) {
if (!bs[j].permanent && bs[j].expiresAt > now) return true;
}
}
return false;
}
// 倒计时心跳:只在「续航页 + 存在有时限的增益」时开着,其它情况立刻关掉。
// 用独立布尔量记住开关状态 —— setInterval 的返回值在某些环境可能是 0,
// 不能拿它本身当「是否已开启」的判据。
var buffTicker = null, buffTickerOn = false;
function ensureBuffTicker(active) {
if (active === buffTickerOn) return;
buffTickerOn = active;
if (active) {
buffTicker = setInterval(function () { render(); }, 1000);
} else if (buffTicker) {
clearInterval(buffTicker);
buffTicker = null;
}
}
function ensurePanel() {
var exist = document.getElementById('mwi-adjutant-root');
if (exist) {
// ★ v1.5.15:面板已被**别的实例**建好 → 本份彻底停用,绝不写不属于自己的 DOM。
if (exist.__adjOwner !== ADJ_OWNER) {
adjDisabled = true;
console.warn('[战斗副官] 检测到重复实例(面板已由另一份建立),本份已停用。' +
'请到 Tampermonkey 删除多余的同名脚本。');
}
return;
}
if (adjDisabled) return;
if (!document.body) { setTimeout(ensurePanel, 200); return; }
buildPanel();
}
function buildPanel() {
var style = document.createElement('style');
style.textContent = [
'#mwi-adjutant-root{position:fixed;top:90px;right:16px;z-index:30000;width:342px;background:rgba(22,22,32,.97);border:1px solid #2e2e42;border-radius:10px;box-shadow:0 6px 24px rgba(0,0,0,.45);font:12px/1.5 "Segoe UI","Microsoft YaHei",sans-serif;color:#d8d8e8;user-select:none;}',
'#mwi-adjutant-root .adj-header{display:flex;align-items:center;gap:6px;padding:8px 10px;cursor:move;border-bottom:1px solid #2e2e42;background:rgba(255,255,255,.02);border-radius:10px 10px 0 0;touch-action:none;}',
'#mwi-adjutant-root .adj-title{font-weight:700;color:#e8c06a;white-space:nowrap;}',
'#mwi-adjutant-root .adj-dot{font-size:10px;}',
'#mwi-adjutant-root .adj-map{font-size:10px;color:#9494ab;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;}',
'#mwi-adjutant-root .adj-tabs{display:flex;gap:4px;margin-left:auto;}',
'#mwi-adjutant-root .adj-tabs button{background:#262636;color:#b9b9cc;border:1px solid #3a3a52;border-radius:6px;padding:2px 8px;cursor:pointer;font-size:11px;}',
'#mwi-adjutant-root .adj-tabs button.on{background:#3a3352;color:#e8c06a;border-color:#b89a55;}',
'#mwi-adjutant-root .adj-body{padding:8px 10px;max-height:56vh;overflow-y:auto;}',
'#mwi-adjutant-root .adj-body::-webkit-scrollbar{width:6px;}',
'#mwi-adjutant-root .adj-body::-webkit-scrollbar-thumb{background:#3a3a52;border-radius:3px;}',
'#mwi-adjutant-root .adj-footer{display:flex;justify-content:space-between;align-items:center;padding:5px 10px;border-top:1px solid #2e2e42;font-size:10px;color:#77778c;}',
'#mwi-adjutant-root .adj-ver{color:#5f5f78;}',
'#mwi-adjutant-root .adj-hint{color:#77778c;text-align:center;padding:14px 6px;}',
'#mwi-adjutant-root .adj-row{padding:6px 8px;border:1px solid #2b2b3e;border-radius:8px;margin-bottom:6px;background:rgba(255,255,255,.015);}',
'#mwi-adjutant-root .adj-row.self{border-color:#8a7a4a;background:rgba(232,192,106,.04);}',
'#mwi-adjutant-root .adj-row-head{display:flex;justify-content:space-between;gap:8px;align-items:baseline;}',
'#mwi-adjutant-root .adj-name{font-weight:700;color:#e6e6f2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}',
'#mwi-adjutant-root .adj-row.self .adj-name{color:#e8c06a;}',
'#mwi-adjutant-root .adj-hpmp{font-size:11px;color:#9494ab;white-space:nowrap;}',
'#mwi-adjutant-root .adj-hpmp b.hp{color:#e08a8a;font-weight:600;}',
'#mwi-adjutant-root .adj-hpmp b.mp{color:#8aa8e0;font-weight:600;}',
'#mwi-adjutant-root .adj-chips{display:flex;flex-wrap:wrap;gap:4px;}',
'#mwi-adjutant-root .adj-supplies{margin-top:5px;display:flex;flex-direction:column;gap:3px;}',
'#mwi-adjutant-root .adj-supply{display:flex;align-items:flex-start;gap:6px;}',
'#mwi-adjutant-root .adj-supply-label{flex:none;width:30px;text-align:center;font-size:10.5px;line-height:16px;border-radius:4px;padding:0 3px;background:#2a2a3c;color:#9494ab;}',
'#mwi-adjutant-root .adj-supply-label.food{color:#e0a97f;border:1px solid #6b4a33;}',
'#mwi-adjutant-root .adj-supply-label.buff{color:#a998dd;border:1px solid #4a3a6b;}',
'#mwi-adjutant-root .adj-supply-label.other{color:#a998dd;border:1px solid #4a3a6b;}',
'#mwi-adjutant-root .adj-supply-label.debuff{color:#e07a6a;border:1px solid #6b3333;}',
'#mwi-adjutant-root .adj-supply-label.buff{width:auto;min-width:30px;}',
'#mwi-adjutant-root .adj-chip{background:#262636;border:1px solid #3a3a52;border-radius:5px;padding:1px 6px;font-size:11px;color:#c9c9dd;white-space:nowrap;}',
'#mwi-adjutant-root .adj-chip.food{border-color:#6b4a33;color:#e0a97f;}',
'#mwi-adjutant-root .adj-chip.buff{border-color:#4a3a6b;color:#a998dd;}',
'#mwi-adjutant-root .adj-chip.other{border-color:#4a3a6b;color:#a998dd;}',
'#mwi-adjutant-root .adj-chip.none{border-color:#6b3333;color:#e07a6a;}',
'#mwi-adjutant-root .adj-chip.debuff{border-color:#6b3333;color:#e07a6a;}',
// 技能 buff:技能名 + 具体 buff 列表(斜体小字)
'#mwi-adjutant-root .adj-chip.skill-buff{display:inline-flex;flex-direction:column;align-items:flex-start;line-height:1.35;}',
'#mwi-adjutant-root .adj-chip.skill-buff i{font-style:normal;font-size:9px;opacity:.72;font-weight:400;}',
'#mwi-adjutant-root .adj-lv{font-size:9px;opacity:.7;}',
// 随身(背包)增益:茶/咖啡等,用青色与战斗增益区分
'#mwi-adjutant-root .adj-chip.pack{border-color:#2f5a52;color:#8fd0bd;}',
// ★ v1.5.9 实时生效增益(combatBuffMap):绿色系 + 倒计时角标
'#mwi-adjutant-root .adj-supply-label.live{color:#7fd6a8;border:1px solid #2f6b52;}',
'#mwi-adjutant-root .adj-chip.live{border-color:#2f6b52;color:#8fe0b4;}',
'#mwi-adjutant-root .adj-chip.live.neg{border-color:#6b3333;color:#e07a6a;}',
'#mwi-adjutant-root .adj-timer{font-style:normal;font-size:9.5px;margin-left:4px;padding:0 3px;border-radius:3px;background:rgba(127,214,168,.14);color:#7fd6a8;}',
'#mwi-adjutant-root .adj-chip.live.neg .adj-timer{background:rgba(224,122,106,.14);color:#e07a6a;}',
'#mwi-adjutant-root .adj-chip.perm{border-color:#3a3a52;color:#9494ab;opacity:.75;}',
'#mwi-adjutant-root .adj-bar{height:10px;background:#262636;border-radius:5px;overflow:hidden;margin:6px 0 4px;}',
'#mwi-adjutant-root .adj-bar>i{display:block;height:100%;border-radius:5px;background:linear-gradient(90deg,#4a7ac0,#6a9be0);}',
// ★ v1.5.13:血量条用暖色,跟蓝条(冷色)一眼分得开
'#mwi-adjutant-root .adj-bar.hp>i{background:linear-gradient(90deg,#b8544f,#e07a6a);}',
'#mwi-adjutant-root .adj-kv{display:flex;justify-content:space-between;padding:2px 0;font-size:11.5px;}',
'#mwi-adjutant-root .adj-kv .k{color:#9494ab;}',
'#mwi-adjutant-root .adj-kv .k small.sub{color:#6f6f88;font-size:9.5px;}',
'#mwi-adjutant-root .adj-kv .v{color:#e6e6f2;font-weight:600;}',
'#mwi-adjutant-root .adj-kv .v.ok{color:#6ad18a;}',
'#mwi-adjutant-root .adj-kv .v.warn{color:#e0b06a;}',
'#mwi-adjutant-root .adj-kv .v.gold{color:#ffd76a;}',
'#mwi-adjutant-root .adj-sec{margin-top:8px;padding-top:6px;border-top:1px dashed #2b2b3e;}',
'#mwi-adjutant-root .adj-sec-title{font-size:11px;color:#9494ab;margin-bottom:3px;}',
'#mwi-adjutant-root .adj-skill{display:flex;align-items:baseline;gap:6px;padding:3px 6px;border-radius:6px;background:rgba(255,255,255,.02);margin-top:3px;font-size:11.5px;}',
'#mwi-adjutant-root .adj-skill .nm{color:#d8d8e8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;}',
'#mwi-adjutant-root .adj-skill .lv{color:#77778c;font-size:10px;white-space:nowrap;}',
'#mwi-adjutant-root .adj-skill .cost{color:#8aa8e0;white-space:nowrap;}',
'#mwi-adjutant-root .adj-skill .casts{color:#e8c06a;white-space:nowrap;font-weight:700;}',
'#mwi-adjutant-root .adj-skill .cd{color:#e07a6a;white-space:nowrap;min-width:44px;text-align:right;font-size:10.5px;}',
'#mwi-adjutant-root .adj-skill .cd.ok{color:#6ad18a;}',
'#mwi-adjutant-root .adj-hpmp b.gold{color:#e8c06a;font-weight:600;}',
'#mwi-adjutant-root .adj-note{color:#77778c;font-size:10px;padding:1px 0 3px;}',
'#mwi-adjutant-root .adj-total{display:flex;justify-content:space-between;align-items:center;padding:6px 8px;margin-top:2px;border:1px solid #4a4020;border-radius:8px;background:rgba(232,192,106,.06);font-size:11.5px;color:#e8c06a;font-weight:700;}',
'#mwi-adjutant-root .noprice{font-style:normal;color:#9a8a5a;font-size:10px;margin-left:4px;}',
'#mwi-adjutant-root .adj-chip .unit{font-style:normal;color:#e8c06a;font-size:9.5px;margin-left:3px;}',
'#mwi-adjutant-root .gold.dim{color:#9494ab;font-weight:400;}',
'#mwi-adjutant-root .adj-total.muted{border-color:#3a3a4a;background:rgba(255,255,255,.02);color:#9494ab;font-weight:400;}',
// ★ v1.5.11 食物开销(成本色:橙)+ 净收益
'#mwi-adjutant-root .adj-cost{display:flex;justify-content:space-between;align-items:center;padding:6px 8px;margin-top:4px;border:1px solid #6b4a33;border-radius:8px;background:rgba(224,169,127,.07);font-size:11.5px;color:#e0a97f;font-weight:700;}',
'#mwi-adjutant-root .adj-cost.muted{border-color:#3a3a4a;background:rgba(255,255,255,.02);color:#9494ab;font-weight:400;}',
'#mwi-adjutant-root .adj-cost b{color:#f0c08a;font-weight:700;}',
'#mwi-adjutant-root .adj-cost.muted b{color:#9494ab;}',
'#mwi-adjutant-root .adj-net{display:flex;justify-content:space-between;align-items:center;padding:5px 8px;margin-top:3px;border:1px dashed #3a3a52;border-radius:8px;font-size:11px;color:#9494ab;}',
'#mwi-adjutant-root .adj-net b{font-weight:700;}',
'#mwi-adjutant-root .adj-net b.pos{color:#6ad18a;}',
'#mwi-adjutant-root .adj-net b.neg{color:#e07a6a;}',
// ★ v1.5.12 每个玩家框内的「食物开销 / 净收益」
'#mwi-adjutant-root .adj-pcosts{margin-top:5px;padding-top:4px;border-top:1px dashed #2f2f42;}',
'#mwi-adjutant-root .adj-pcost{display:flex;justify-content:space-between;align-items:baseline;gap:8px;font-size:11px;padding:1px 0;}',
'#mwi-adjutant-root .adj-pcost .k{flex:none;color:#a9836a;}',
'#mwi-adjutant-root .adj-pcost .v{color:#e0a97f;font-weight:700;text-align:right;}',
'#mwi-adjutant-root .adj-pcost .v.muted{color:#9494ab;font-weight:400;}',
'#mwi-adjutant-root .adj-pcost .v.pos{color:#6ad18a;}',
'#mwi-adjutant-root .adj-pcost .v.neg{color:#e07a6a;}',
'#mwi-adjutant-root .adj-mini{display:flex;align-items:center;gap:6px;padding:2px 0;font-size:11px;}',
'#mwi-adjutant-root .adj-mini .nm{width:86px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#c9c9dd;}',
'#mwi-adjutant-root .adj-mini .adj-bar{flex:1;margin:0;height:7px;}',
'#mwi-adjutant-root .adj-mini .pct{width:56px;text-align:right;color:#8aa8e0;}',
'#mwi-adjutant-root .adj-loot-bar{display:flex;gap:6px;align-items:center;margin-bottom:8px;}',
'#mwi-adjutant-root .adj-loot-bar .sp{flex:1;}',
'#mwi-adjutant-root .adj-btn{background:#262636;color:#b9b9cc;border:1px solid #3a3a52;border-radius:6px;padding:2px 8px;cursor:pointer;font-size:11px;}',
'#mwi-adjutant-root .adj-btn.on{color:#e8c06a;border-color:#b89a55;}',
'#mwi-adjutant-root .adj-btn.warn{color:#e07a6a;}',
'#mwi-adjutant-root .adj-toast{position:absolute;left:50%;top:44px;transform:translateX(-50%);background:#3a3352;color:#e8c06a;border:1px solid #b89a55;border-radius:6px;padding:4px 12px;font-size:11px;pointer-events:none;opacity:0;transition:opacity .25s;}',
'#mwi-adjutant-root .adj-toast.show{opacity:1;}',
'#mwi-adjutant-root.is-collapsed .adj-body,#mwi-adjutant-root.is-collapsed .adj-footer{display:none;}'
].join('\n');
document.head.appendChild(style);
var root = document.createElement('div');
root.id = 'mwi-adjutant-root';
root.__adjOwner = ADJ_OWNER; // ★ v1.5.15 归属标记(重复实例自检,勿删)
root.innerHTML =
'' +
'
' +
'' +
'
';
document.body.appendChild(root);
// 恢复位置与折叠状态
var savedView = lsGet(LS.lootView);
if (savedView === 'battle' || savedView === 'session') state.lootView = savedView;
if (lsGet(LS.collapsed) === '1') { state.collapsed = true; root.classList.add('is-collapsed'); }
var pos = null;
try { pos = JSON.parse(lsGet(LS.pos) || 'null'); } catch (e) { pos = null; }
if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
root.style.left = pos.x + 'px';
root.style.top = pos.y + 'px';
root.style.right = 'auto';
}
// 事件委托
root.addEventListener('click', function (e) {
var t = e.target.closest ? e.target.closest('button') : null;
if (!t) return;
// ★ v1.5.10:dataset.lootView 与 dataset.lootview 都读一遍。
// 正常情况下 HTML 写 data-loot-view(连字符),dataset.lootView 能取到;
// 兜底再读 lootview,防止哪天有人手改成 data-lootview 又踩同一个坑。
var lv = t.dataset.lootView || t.dataset.lootview;
if (t.dataset.tab) {
state.tab = t.dataset.tab;
render();
} else if (t.id === 'adj-collapse') {
state.collapsed = !state.collapsed;
root.classList.toggle('is-collapsed', state.collapsed);
lsSet(LS.collapsed, state.collapsed ? '1' : '0');
} else if (lv) {
state.lootView = lv;
lsSet(LS.lootView, state.lootView);
render();
} else if (t.id === 'adj-loot-reset') {
state.lootSession = Object.create(null);
render();
toast('累计掉落已清零');
}
});
makeDraggable(root, document.getElementById('adj-header'));
window.addEventListener('resize', function () { clampToViewport(root); });
render();
}
function clampToViewport(root) {
var r = root.getBoundingClientRect();
var x = Math.max(0, Math.min(window.innerWidth - r.width, r.left));
var y = Math.max(0, Math.min(window.innerHeight - 40, r.top));
if (x !== r.left || y !== r.top) {
root.style.left = x + 'px';
root.style.top = y + 'px';
root.style.right = 'auto';
}
}
function makeDraggable(root, handle) {
if (!handle) return;
var dragging = false, sx = 0, sy = 0, ox = 0, oy = 0;
handle.addEventListener('pointerdown', function (e) {
if (e.target.closest && e.target.closest('button')) return;
dragging = true;
sx = e.clientX; sy = e.clientY;
var r = root.getBoundingClientRect();
ox = r.left; oy = r.top;
try { handle.setPointerCapture(e.pointerId); } catch (err) { /* 忽略 */ }
e.preventDefault();
});
handle.addEventListener('pointermove', function (e) {
if (!dragging) return;
var r = root.getBoundingClientRect();
var x = Math.max(0, Math.min(window.innerWidth - r.width, ox + e.clientX - sx));
var y = Math.max(0, Math.min(window.innerHeight - 40, oy + e.clientY - sy));
root.style.left = x + 'px';
root.style.top = y + 'px';
root.style.right = 'auto';
});
function end() {
if (!dragging) return;
dragging = false;
var r = root.getBoundingClientRect();
lsSet(LS.pos, JSON.stringify({ x: Math.round(r.left), y: Math.round(r.top) }));
}
handle.addEventListener('pointerup', end);
handle.addEventListener('pointercancel', end);
}
function toast(text) {
var el = document.getElementById('adj-toast');
if (!el) return;
el.textContent = text;
el.classList.add('show');
clearTimeout(toast._t);
toast._t = setTimeout(function () { el.classList.remove('show'); }, 1800);
}
// ============================== 渲染 ==============================
function render() {
if (adjDisabled) return; // ★ v1.5.15 重复实例已停用
var root = document.getElementById('mwi-adjutant-root');
if (!root) return;
// ★ v1.5.15:面板不是本实例建的 → 绝不改写(双实例时避免互相刷屏)
if (root.__adjOwner && root.__adjOwner !== ADJ_OWNER) return;
// ★ v1.5.9:只要还有「有时限的增益」且停在续航页,就挂 1 秒心跳刷新倒计时。
// 游戏只在有事件时推 battle_updated,增益静默到期是不会来消息的 ——
// 没有这个心跳,倒计时就会卡住不动。
ensureBuffTicker(state.tab === 'dispatch' && hasTimedBuff());
// 状态点
var dot = document.getElementById('adj-dot');
if (dot) {
if (!state.lastBattleAt) {
dot.textContent = '●'; dot.style.color = '#77778c'; dot.title = '等待战斗数据';
} else if (Date.now() - state.lastBattleAt < 30000) {
dot.textContent = '●'; dot.style.color = '#6ad18a'; dot.title = '战斗数据正常';
} else {
dot.textContent = '●'; dot.style.color = '#e0b06a'; dot.title = '战斗数据超30秒未更新(可能在非战斗状态)';
}
}
// 地图信息
var mapEl = document.getElementById('adj-map');
if (mapEl) {
var t = state.currentAction ? hridTail(state.currentAction) : '';
var tier = state.difficultyTier > 0 ? ' T' + state.difficultyTier : '';
var ago = state.lastBattleAt ? ' · ' + Math.max(0, Math.round((Date.now() - state.lastBattleAt) / 1000)) + 's前' : '';
mapEl.textContent = (t ? '🗺 ' + t + tier : '') + ago;
}
// 页签高亮
var tabs = root.querySelectorAll('.adj-tabs button');
for (var i = 0; i < tabs.length; i++) {
tabs[i].classList.toggle('on', tabs[i].dataset.tab === state.tab);
}
var body = document.getElementById('adj-body');
if (!body) return;
if (state.tab === 'dispatch') body.innerHTML = renderDispatch();
else if (state.tab === 'loot') body.innerHTML = renderLoot();
else if (state.tab === 'hp') body.innerHTML = renderHp();
else body.innerHTML = renderMana();
updateDynTexts();
// 字典未就绪时安排重试(拿到后会自动再渲染一次)
if (!ensureI18n()) scheduleI18nRetry();
}
// —— 续航:队友补给(原「出警」)——
function renderDispatch() {
if (!state.players.length) {
return '进入战斗后显示队友补给状况
';
}
var html = '';
sortedPlayers().forEach(function (p) {
var groups = { food: [], buff: [], other: [] };
p.consumables.forEach(function (c) {
groups[consumableCategory(c.hrid)].push(c);
});
// —— 技能提供的增益 ——
// 用户要求:增益栏除了消耗品(咖啡/茶),还要包含技能自带的 buff。
// 数据来源:abilityDetailMap[hrid].abilityEffects[].buffs[]
var skillBuffs = [];
var skillDebuffs = [];
(p.abilities || []).forEach(function (a) {
var info = abilityBuffs(a.hrid);
if (!info) return;
var nm = abilityName(a.hrid);
// level 必须带上 —— 渲染层用 s.level 显示「Lv」角标(曾经漏传导致等级从不显示)
var lv = num(a.level);
if (info.positive.length || info.selfTarget) {
skillBuffs.push({ hrid: a.hrid, name: nm, level: lv, buffs: info.positive });
}
if (info.negative.length) {
skillDebuffs.push({ hrid: a.hrid, name: nm, level: lv, buffs: info.negative });
}
});
// 一组补给渲染成一行:中文类别标签 + 该类别下的各物品
function groupRow(label, cls, list, emptyText) {
var body;
if (list.length) {
body = list.map(function (c) {
var enh = c.enh > 0 ? '+' + c.enh + ' ' : '';
return '' +
esc(itemName(c.hrid)) + (enh ? ' ' + enh : '') + ' ×' + fmtInt(c.count) + ' ';
}).join('');
} else {
body = '' + emptyText + ' ';
}
return '' +
'' + label + ' ' +
'' + body + ' ' +
'
';
}
var foodHtml = groupRow('食物', 'food', groups.food, '未携带');
// ★ v1.5.9:**生效**行 —— 游戏实时下发的 combatBuffMap(会随时间过期)
var liveHtml = liveBuffRow(p);
// 增益栏 = 消耗品增益 + 技能增益,合并显示
var buffParts = [];
if (groups.buff.length) {
buffParts.push(groups.buff.map(function (c) {
var enh = c.enh > 0 ? '+' + c.enh + ' ' : '';
return '' + esc(itemName(c.hrid)) +
(enh ? ' ' + enh : '') + ' ×' + fmtInt(c.count) + ' ';
}).join(''));
}
if (skillBuffs.length) {
buffParts.push(skillBuffs.map(function (s) {
var det = s.buffs.map(function (b) {
var v = buffValueText(b);
return esc(buffTypeName(b.typeHrid)) + (v ? ' ' + v : '');
}).join('·');
var lv = s.level > 0 ? ' Lv' + s.level + ' ' : '';
return '' + esc(s.name) + lv +
(det ? '' + det + ' ' : '') + ' ';
}).join(''));
}
var buffHtml = '' +
'增益 ' +
'' +
(buffParts.length ? buffParts.join('') : '无 ') +
'
';
// 技能减益(敌方施加的 debuff),单独一行
var debuffHtml = '';
if (skillDebuffs.length) {
var db = skillDebuffs.map(function (s) {
var det = s.buffs.map(function (b) {
var v = buffValueText(b);
return esc(buffTypeName(b.typeHrid)) + (v ? ' ' + v : '');
}).join('·');
return '' + esc(s.name) +
(det ? '' + det + ' ' : '') + ' ';
}).join('');
debuffHtml = '' +
'减益 ' +
'' + db + '
';
}
// —— 随身增益:背包里的茶 / 咖啡(仅自己可见,见 captureBackpackBuffs)——
// 茶在游戏里只对生活动作生效(usableInActionTypeMap 无 combat),
// 不会进战斗补给槽,所以单独列一行,避免和战斗增益混淆。
var packHtml = '';
if (p.isSelf && state.backpackBuffs && state.backpackBuffs.length) {
var packChips = state.backpackBuffs.map(function (b) {
var det = consumableBuffText(b.hrid);
return '' +
esc(itemName(b.hrid)) + ' ×' + fmtInt(b.count) +
(det ? '' + det + ' ' : '') + ' ';
}).join('');
packHtml = '' +
'随身 ' +
'' + packChips + '
';
}
var otherHtml = groups.other.length ? groupRow('其他', 'other', groups.other, '') : '';
html += '' +
'
' +
' ' + (p.isSelf ? '★ ' : '') + esc(p.name) + ' ' +
' 血 ' + fmtInt(p.hp) + ' /' + fmtInt(p.hpMax) + ' · 蓝 ' + fmtInt(p.mp) + ' /' + fmtInt(p.mpMax) + ' ' +
'
' +
'
' + liveHtml + foodHtml + buffHtml + debuffHtml + packHtml + otherHtml + '
' +
'
';
});
return html;
}
// —— 掉落:战利品分配(原「分赃」)——
// ============================== 食物开销(v1.5.11)==============================
// 需求来源:用户要「预估食物消耗掉的钱」,摆在掉落栏和战利品收益对照。
//
// 数据口径(实测自本机 TM 日志里的真实 new_battle 报文):
// new_battle.players[i].combatConsumables[j] = {
// itemHash, itemHrid, enhancementLevel, count, availableTime
// }
// count 是**该补给在背包里的实时存量** —— 实测同一账号 mooberry_donut
// 在连续报文里 440 → 438 → 360 递减,战斗自动吃喝会把它减下去。
// 于是:消耗量 = 上一快照 − 当前快照。
// · 只统计**下降**。补货 / 做菜 / 交易引起的上升绝不算成消耗。
// · 只统计「上一快照里已存在」的品种 —— 新品种从 0 冒出来是补货,不是消耗。
// new_battle 在战斗中会反复推送,所以同一场战斗内也能持续结算。
//
// 两个口径:
// 会话累计 = 所有下降量之和(跨场);本场 = 本场起始快照 − 当前快照。
// 跨场不会漏:新一场开始时的首次差分,正好补上「上一场最后一次快照 →
// 收工」之间吃掉的那部分。
//
// 金额 = Σ(消耗个数 × 当前挂单**卖价**)。用卖价而非买价,是因为「不吃就能
// 卖掉」才是这些食物的真实机会成本,而且与战利品估值(askPrice)同口径。
// 因此这个数字是**折算值/预估值**,不是真的花掉了等量金币。
// ★ v1.5.12:记账键改用**角色名**(原来是 id)。
// 掉落栏的记账(lootSession / lootCredited)本来就按名字索引,两边对齐之后,
// 同一个人的「食物开销 / 净收益」可以直接挂在他那一行的掉落下面,
// 不必再造一张 name⇄id 映射表。MWI 角色名全局唯一,够用;id 只在缺名字时兜底。
function playerKey(p) {
if (!p) return null;
if (p.name) return p.name;
return (p.id != null) ? ('#' + p.id) : null;
}
// ★ v1.5.12:单个玩家自己的消耗 map(hrid -> 个数)。
// 本场口径 = 本场起始库存 − 当前库存;会话口径 = 累计下降量。
function usedMapFor(name, isSession) {
if (!name) return {};
if (isSession) return state.foodUsedSession[name] || {};
var base = state.foodBase[name] || {};
var now = state.consumSnap[name] || {};
var out = Object.create(null);
Object.keys(base).forEach(function (h) {
var c = (now[h] != null) ? now[h] : base[h];
var used = base[h] - c;
if (used > 0) out[h] = (out[h] || 0) + used;
});
return out;
}
// map -> 明细数组(按消耗个数降序)
function entriesFromUsedMap(m) {
return Object.keys(m || {})
.map(function (h) { return { hrid: h, count: m[h] }; })
.filter(function (e) { return e.count > 0; })
.sort(function (a, b) { return b.count - a.count || a.hrid.localeCompare(b.hrid); });
}
// 折算文案。三种状态分开写,绝不出现「≈ 0 金币」:
// 完全没读到行情源 → 行情未就绪;读到了但这个品种没价 → N 种均无行情
function costMoneyText(val) {
if (!ensureMarketData()) return '行情未就绪';
if (val.priced === 0) return '' + val.kinds + ' 种均无行情 ';
var t = '≈ ' + fmtCoin(val.value) + ' 金币';
if (val.unpriced > 0) t += '另有 ' + val.unpriced + ' 种无价 ';
return t;
}
// 逐项对账明细(放 title 里):「名字 ×个数 @单价 = 小计」。
// 这里刻意用 fmtInt(精确到个位)而不是 fmtCoin(会缩成「1.0万」)——
// 明细就是拿来对账的,缩写反而看不出差在哪。
function costDetailText(entries) {
return entries.map(function (e) {
var up = askPrice(e.hrid);
var n = itemName(e.hrid) + ' ×' + fmtInt(e.count);
return up > 0 ? n + ' @' + fmtInt(up) + ' = ' + fmtInt(up * e.count)
: n + ' (无行情)';
}).join('\n');
}
// ★ v1.5.12:挂在**每个玩家自己的框**里的开销行 ——
// ① 消耗 = Σ(这个人吃掉的个数 × 市场卖价),按类别拆分(食物 / 咖啡 / 其他)
// ② 个人净收益 = 这个人自己的战利品估值 − 他自己的**全部消耗**
// 没有任何消耗时返回空串("没吃"这件事不值得占一行)。
// ★ v1.5.13:原来只出一行「食物开销」,把咖啡的钱也塞在里面;
// 现在咖啡单列「咖啡开销」,个人净收益照样把两类一起扣。
function renderPlayerCost(fEntries, fVal, lootVal) {
if (!fEntries || !fEntries.length) return '';
var b = splitCostBuckets(fEntries);
var out = '';
['food', 'buff', 'other'].forEach(function (k) {
var list = b[k];
if (!list.length) return;
var v = lootValue(list);
var cnt = list.reduce(function (s, e) { return s + e.count; }, 0);
out += '
' + COST_LABEL[k] + ' ' +
'' +
list.length + ' 种 · ' + fmtInt(cnt) + ' 个 · ' + costMoneyText(v) +
'
';
});
if (fVal.priced > 0 && lootVal && lootVal.priced > 0) {
var net = lootVal.value - fVal.value;
out += '
净收益 ' +
'' +
(net < 0 ? '-' : '') + '≈ ' + fmtCoin(Math.abs(net)) + ' 金币' +
'战利品 − 消耗
';
}
out += '
';
return out;
}
// 用当前 players 的 combatConsumables 做一次差分,下降量喂给会话累计
function trackConsumables() {
if (!state.players.length) return;
state.players.forEach(function (p) {
var k = playerKey(p);
if (!k) return;
var cur = Object.create(null);
(p.consumables || []).forEach(function (c) {
if (c && c.hrid) cur[c.hrid] = num(c.count);
});
var prev = state.consumSnap[k];
if (prev) {
var sess = state.foodUsedSession[k];
if (!sess) sess = state.foodUsedSession[k] = Object.create(null);
Object.keys(cur).forEach(function (h) {
if (prev[h] != null && cur[h] < prev[h]) {
sess[h] = (sess[h] || 0) + (prev[h] - cur[h]);
}
});
}
state.consumSnap[k] = cur;
});
}
// 新一场战斗开打:把当前库存记为「本场基线」(必须在 trackConsumables 之后调用,
// 这样上一场的尾款已经结算进会话累计,本场从零开始)。
function resetBattleFoodBase() {
var base = Object.create(null);
Object.keys(state.consumSnap).forEach(function (k) {
var src = state.consumSnap[k];
var cp = Object.create(null);
Object.keys(src).forEach(function (h) { cp[h] = src[h]; });
base[k] = cp;
});
state.foodBase = base;
}
// 全队本场消耗 map(跨玩家合并成 hrid -> 个数)
function battleFoodUsed() {
var out = Object.create(null);
state.players.forEach(function (p) {
var m = usedMapFor(playerKey(p), false);
Object.keys(m).forEach(function (h) { out[h] = (out[h] || 0) + m[h]; });
});
return out;
}
// 全队消耗明细(跨玩家按 hrid 合并):[{hrid, count}],按个数降序
function foodCostEntries(isSession) {
var map = Object.create(null);
var names = isSession
? Object.keys(state.foodUsedSession) // 累计口径:有账可查的人
: state.players.map(playerKey).filter(Boolean); // 本场口径:当前队伍
names.forEach(function (n) {
var m = usedMapFor(n, isSession);
Object.keys(m).forEach(function (h) { map[h] = (map[h] || 0) + m[h]; });
});
return entriesFromUsedMap(map);
}
// ★ v1.5.13:消耗按**类别**拆成两行显示(用户:「咖啡也算上」)。
// 实测战斗槽里的消耗品其实有两类:
// · 食物 —— hitpointRestore / manapointRestore > 0(软糖、甜甜圈、酸奶、蛋糕)
// · 咖啡 —— buffs 非空(智慧咖啡、防御咖啡…),战斗中同样是自动喝的
// 旧实现把两类混在一行、行名却只叫「食物开销」,咖啡花的钱被「食物」这个名字
// 盖住了,用户完全看不出咖啡也算进去了(实测 wisdom_coffee 会被差分统计到)。
// 现在拆开:食物开销 / 咖啡开销,净收益把两者一起扣。
//
// 判据来自 consumableCategory()(buffs 非空 → 增益,恢复量 > 0 → 食物)。
// ★ 物品表还没就绪时**不硬猜**,归入主行 food —— 保证行名稳定,
// 不会因为字典迟到就突然冒出一个「其他开销」。
function costBucketOf(hrid) {
var d = state.itemDetailMap && state.itemDetailMap[hrid];
var cd = d && d.consumableDetail;
if (!cd) return 'food';
var c = consumableCategory(hrid);
if (c === 'buff') return 'buff';
if (c === 'other') return 'other';
return 'food';
}
var COST_LABEL = { food: '食物开销', buff: '咖啡开销', other: '其他开销' };
var COST_HINT = {
food: '回血 / 回蓝食物(软糖、甜甜圈、酸奶、蛋糕…)',
buff: '增益类消耗品(咖啡等)—— 战斗中同样是自动喝的',
other: '其它消耗品'
};
function splitCostBuckets(entries) {
var out = { food: [], buff: [], other: [] };
(entries || []).forEach(function (e) {
var k = costBucketOf(e.hrid);
if (!out[k]) k = 'other';
out[k].push(e);
});
return out;
}
// 一类消耗渲染成一行(页脚用)
function costRowHtml(kind, entries) {
var val = lootValue(entries);
var cnt = entries.reduce(function (s, e) { return s + e.count; }, 0);
return '' +
'' + COST_LABEL[kind] + ' ' +
entries.length + ' 种 · ' + fmtInt(cnt) + ' 个 · ' + costMoneyText(val) + ' ' +
'
';
}
// 全队消耗行(页脚合计)。没有消耗时给一行灰字提示,而不是整块消失 ——
// 否则用户会以为「功能没装上」。
// 有消耗时按类别出若干行(食物 / 咖啡 / 其他),空类别不渲染。
function renderFoodCost(entries) {
if (!entries || !entries.length) {
return '' +
'食物开销 ' +
(state.lootView === 'session' ? '暂无消耗记录' : '本场暂无消耗') + '
';
}
var b = splitCostBuckets(entries);
var out = '';
['food', 'buff', 'other'].forEach(function (k) {
if (b[k].length) out += costRowHtml(k, b[k]);
});
return out;
}
// 净收益 = 战利品合计 − **全部消耗**(食物 + 咖啡;两边都拿到行情才显示)
function renderNetGain(lootVal, costVal, show) {
if (!show || costVal.priced === 0 || lootVal.priced === 0) return '';
var net = lootVal.value - costVal.value;
return '净收益 ' +
'' +
(net < 0 ? '-' : '') + '≈ ' + fmtCoin(Math.abs(net)) + ' 金币 ' +
'战利品 − 消耗
';
}
function renderLoot() {
var isSession = state.lootView === 'session';
var html = '' +
// ★ v1.5.10 真 bug 修复:属性名必须写成 data-loot-view(连字符小写)。
// 原来写的是 data-lootView —— HTML 属性名不区分大小写,浏览器会把它
// 规范成 "data-lootview"(一个词),而 dataset.lootView 查找的是
// "data-loot-view"(连字符分割),两边对不上 → t.dataset.lootView 恒为
// undefined → 点击「本场」永远走不进分支,视图卡在累计。
'累计 ' +
'本场 ' +
' ' +
(isSession ? '🗑 清零 ' : '') +
'
';
// 收集数据
var perPlayer = [];
if (isSession) {
Object.keys(state.lootSession).forEach(function (name) {
var m = state.lootSession[name];
var entries = Object.keys(m).map(function (h) { return { hrid: h, count: m[h] }; });
if (entries.length) perPlayer.push({ name: name, entries: entries });
});
} else {
state.players.forEach(function (p) {
var entries = Object.keys(p.loot)
// ★ v1.5.5:有掉落就显示。行情只决定「估值」列能不能算,
// 不该决定「掉了什么」显不显示(旧实现按行情过滤,导致整栏空白)。
.filter(function (h) { return num(p.loot[h]) > 0; })
.map(function (h) { return { hrid: h, count: p.loot[h] }; });
if (entries.length) perPlayer.push({ name: p.name, entries: entries, self: p.isSelf });
});
}
if (!perPlayer.length) {
html += '暂无掉落记录 ' +
(isSession ? '进入战斗后自动记录(自面板加载起累计)' : '本场战斗尚未掉落物品') + '
';
// ★ v1.5.11:没有掉落 ≠ 没有成本。已经吃进去的食物照样要算。
html += renderFoodCost(foodCostEntries(isSession));
if (!ensureMarketData()) {
html += '' +
'未读到本机行情缓存(MWITools / MWICore / Edible Tools)—— ' +
'只影响金币估值,不影响掉落记录。装了行情脚本后打开一次市场页即可。
';
}
return html;
}
perPlayer.sort(function (a, b) {
if (!!a.self !== !!b.self) return a.self ? -1 : 1;
return a.name.localeCompare(b.name);
});
perPlayer.forEach(function (pp) {
pp.entries.sort(function (a, b) { return b.count - a.count || a.hrid.localeCompare(b.hrid); });
var total = 0;
var chipsHtml = pp.entries.map(function (e) {
total += e.count;
// ★ v1.5.7:每个掉落直接标单价 / 标「无价」。
// 否则整行只有一个「≈ 0 金币」,根本看不出是哪几种缺行情。
var up = askPrice(e.hrid);
var tag = up > 0
? '≈' + fmtCoin(up) + ' '
: '无价 ';
return '' + esc(itemName(e.hrid)) +
' ×' + fmtInt(e.count) + tag + ' ';
}).join('');
var val = lootValue(pp.entries);
var valText = valueLabel(val);
// ★ v1.5.12:这个人**自己**的食物开销 + 个人净收益,直接挂在他这一行的掉落下面。
// 金额一律是 Σ(吃掉的个数 × 市场卖价),不是只报种类数。
var fEntries = entriesFromUsedMap(usedMapFor(pp.name, isSession));
var costHtml = renderPlayerCost(fEntries, lootValue(fEntries), val);
html += '' +
'
' + (pp.self ? '★ ' : '') + esc(pp.name) + ' ' +
// ★ v1.5.8:行头**只报「种」**。金币这类堆叠掉落一场几千件,
// 「件」合计既没参照价值、又盖过真正关心的「打到了哪几种」
// (用户原话:一万件有四千件都是金币,不好计算)。
// 件数不丢,收进 title(鼠标悬停可见);芯片上仍保留单品「× 数量」。
'' +
val.kinds + ' 种 · ' +
'' + valText + '
' +
'
' + chipsHtml + '
' +
costHtml + '
';
});
// 全部玩家的总估值 —— 同品种**先跨玩家合并再统计**。
// ★ v1.5.8:「种」成了行头的主数字,就必须是**去重后的品种数**。
// 旧实现直接把各玩家的条目拼起来 → 两人各掉兽皮,合计会写成「兽皮 2 种」。
var entryMap = Object.create(null);
var allEntries = [];
perPlayer.forEach(function (pp) {
pp.entries.forEach(function (e) {
var m = entryMap[e.hrid];
if (m) m.count += e.count;
else { m = { hrid: e.hrid, count: e.count }; entryMap[e.hrid] = m; allEntries.push(m); }
});
});
var grand = lootValue(allEntries);
if (allEntries.length) {
var gText = valueLabel(grand);
var gCount = allEntries.reduce(function (s, e) { return s + e.count; }, 0);
html += '合计 ' +
'' + grand.kinds + ' 种 · ' + gText + '
';
}
// ★ v1.5.11:食物开销 + 净收益。成本摆在收益正下方,一眼看出这趟赚没赚。
var foodEntries = foodCostEntries(isSession);
html += renderFoodCost(foodEntries);
html += renderNetGain(grand, lootValue(foodEntries), allEntries.length > 0);
html += '已记录 ' +
state.battleCount + ' 场战斗' + marketLine() + '
';
return html;
}
// ==================== 血量管家(生存续航 · 实测) ====================
//
// 为什么血量**不能**像蓝量那样推演(这是与蓝量管家最根本的区别):
// 蓝耗 = Σ(技能蓝耗 × 每周期次数) ← 只跟你的配装有关,与怪无关,能算
// 掉血 = f(怪强度, 护甲, 减伤, 仇恨, 走位) ← 没有闭式解,只能**实测**
// 所以这里维护一条自己的血量时间序列,把自然回血剥掉,剩下的下降量就是被打掉的血。
//
// ★ 采样只认「自己」:队友掉血与你无关,混进来会把你的生存结论拖偏。
// 用 getSelf()(isSelf 由 ownId / ownName 匹配而来)。
//
// ★ 为什么用滑动窗口 + 跳过长空档:
// ① 只回看最近 HP_WIN_MS(300 秒),与面板上「300 秒平均消耗」口径一致;
// ② 相邻采样间隔 > HP_MAX_GAP_SEC(60 秒)一律跳过 —— 那是脱战/挂机,
// 期间血量早被自然回血拉满,「回血被上限顶掉」这件事会让区间账目失真。
var HP_WIN_MS = 300000; // 测量窗口:最近 300 秒
var HP_MAX_GAP_SEC = 60; // 间隔超过这个秒数视为脱战,整段跳过
var HP_MIN_COVER_SEC = 20; // 覆盖不足 20 秒 → 显示「采样中」,不硬凑数字
var HP_MAX_SAMPLES = 3000; // 内存保护上限
// 记一次血量采样。由 onNewBattle / onBattleUpdated 调用(函数声明会提升,
// 放在这里不影响前面的调用点)。
function recordHpSample() {
var self = getSelf();
if (!self || !(self.hpMax > 0)) return;
var hp = num(self.hp);
if (!(hp > 0)) return; // 血量未知;0 点血 = 已倒地,不采
var now = Date.now();
var arr = state.hpSamples;
var last = arr.length ? arr[arr.length - 1] : null;
if (last && last.t === now) { last.hp = hp; return; } // 同毫秒多次推送 → 覆盖
arr.push({ t: now, hp: hp });
while (arr.length > 2 && now - arr[0].t > HP_WIN_MS) arr.shift(); // 滑出窗口
if (arr.length > HP_MAX_SAMPLES) arr.splice(0, arr.length - HP_MAX_SAMPLES);
}
// 分析血量窗口 → 实测掉血速率 / 回血事件次数 / 采样覆盖秒数
function analyzeHp(self) {
var r = {
ok: false, coverSec: 0, samples: 0, damage: 0, healEvents: 0,
damageRate: 0, healPerSec: 0, lastHealAt: 0, note: ''
};
if (!self || !(self.hpMax > 0)) { r.note = '血量上限未知'; return r; }
var arr = state.hpSamples || [];
r.samples = arr.length;
if (arr.length < 2) { r.note = '采样中(血量样本不足)'; return r; }
var hpMax = self.hpMax;
var regenPerSec = hpMax * num(self.hpRegenPer10) / 10;
var cover = 0, dmg = 0, heals = 0, lastHealAt = 0;
for (var i = 1; i < arr.length; i++) {
var a = arr[i - 1], b = arr[i];
var dt = (b.t - a.t) / 1000;
if (!(dt > 0) || dt > HP_MAX_GAP_SEC) continue; // 脱战/挂机整段跳过
var regen = regenPerSec * dt;
// 区间**起始已满血** → 自然回血被上限顶掉,本区间回血记 0。
// 不这么做的话,满血时段会被当成「本来能回 regen 点」,
// 一旦血量因受伤略降,就会凭空多算出一份「掉血」。
if (a.hp >= hpMax - 0.5) regen = 0;
else if (b.hp >= hpMax - 0.5) regen = Math.min(regen, Math.max(0, hpMax - a.hp));
var delta = b.hp - a.hp;
var jump = Math.max(2, regen * 0.1);
if (delta > regen + jump) {
// 向上跳变明显超出自然回血 → 判定为**吃到回血食物**。
// 真实机制是一次只吃 1 个(吃下即进 CD),故记为 1 次回血事件。
// 该区间不计掉血 —— 否则食物回的血会被当成「负的伤害」抵掉真实掉血。
heals += 1;
lastHealAt = b.t;
} else {
var d = regen - delta; // Δ 为负(血在掉)→ d > 0;涨得比自然回血慢 → 仍算受伤
if (d > 0) dmg += d;
}
cover += dt;
}
r.coverSec = cover;
r.damage = dmg;
r.healEvents = heals;
r.lastHealAt = lastHealAt;
r.damageRate = cover > 0 ? dmg / cover : 0;
r.healPerSec = cover > 0 ? heals / cover : 0;
r.ok = cover >= HP_MIN_COVER_SEC;
if (!r.ok) r.note = '采样中(已覆盖 ' + Math.round(cover) + ' 秒)';
return r;
}
// 回血食物清单:只取 hitpointRestore > 0 的(与 manaFoods 完全对称,
// 只是触发条件换成 missing_hp —— 实测同样是「阈值 === 回血量」)。
function hpFoods(self) {
var out = [];
(self.consumables || []).forEach(function (c) {
if (!c || c.count <= 0) return;
var hr = c.hrid || c.itemHrid;
if (!hr) return;
var d = state.itemDetailMap && state.itemDetailMap[hr];
var cd = d && d.consumableDetail;
if (!cd) return;
var hp = num(cd.hitpointRestore);
if (hp <= 0) return;
var cdSec = num(cd.cooldownDuration) / 1e9 || 60;
var recoverySec = num(cd.recoveryDuration) / 1e9;
var threshold = null;
var trigs = cd.defaultCombatTriggers;
if (trigs && trigs.length) {
for (var i = 0; i < trigs.length; i++) {
var t = trigs[i];
if (t && String(t.conditionHrid || '').indexOf('missing_hp') !== -1) {
threshold = num(t.value);
break;
}
}
}
if (!(threshold > 0)) threshold = hp;
out.push({
hrid: hr, hp: hp, count: c.count, cdSec: cdSec, recoverySec: recoverySec,
threshold: threshold, enh: c.enh
});
});
out.sort(function (a, b) { return b.threshold - a.threshold || b.hp - a.hp; });
return out;
}
// 血量续航 —— 解析式(不逐步模拟)。
// 蓝量要逐步模拟是因为「食物受 CD 约束、按阈值触发」这种事件序列会累积误差;
// 血量这边「掉血速率」本身已经是实测均值,再用同一套模拟反而是自欺欺人,
// 所以直接解析求算:
// 净掉血 = 掉血速率 − 自然恢复
// 自然能撑 = hpMax ÷ 净掉血
// 含食能撑 = hpMax ÷ (净掉血 − 食物可持续供血)
// 食物可撑 = 库存总回血量 ÷ 吃速上限 (与蓝量侧同一闭式口径)
function projectHp(self) {
var res = {
ok: false, analyze: null, hpMax: 0, regenRate: 0, damageRate: 0, netDrain: 0,
foodSupply: 0, foodKinds: 0, foodCount: 0, foodTotalHp: 0,
naturalOnly: Infinity, withFood: Infinity, sustainable: false,
foodLastsSec: Infinity, foodStockCount: 0, foodEstimated: false,
foodIsBottleneck: false, per300: null, note: ''
};
if (!self || !(self.hpMax > 0)) { res.note = '血量上限未知'; return res; }
res.hpMax = num(self.hpMax);
res.regenRate = res.hpMax * num(self.hpRegenPer10) / 10;
var an = analyzeHp(self);
res.analyze = an;
var foods = hpFoods(self);
res.foodKinds = foods.length;
res.foodCount = foods.reduce(function (s, f) { return s + f.count; }, 0);
res.foodStockCount = res.foodCount;
res.foodTotalHp = foods.reduce(function (s, f) { return s + f.hp * f.count; }, 0);
res.foodSupply = foods.reduce(function (s, f) { return s + f.hp / f.cdSec; }, 0);
// ★ 食物侧的两个量是**闭式**的(只由库存 + 冷却决定),与掉血实测无关,
// 所以必须在 an.ok 这道闸之前算出来 —— 采样还没热的时候也该能看到
// 「这套补给能供多少血、能吃多久」,而不是整块空着。
res.foodLastsSec = (res.foodSupply > 0 && res.foodTotalHp > 0)
? res.foodTotalHp / res.foodSupply : Infinity;
res.foodEstimated = isFinite(res.foodLastsSec);
// 掉血采样不够 → 只报「采样中」,绝不拿半个窗口硬算出一个漂亮数字
if (!an.ok) { res.note = an.note || '采样中'; return res; }
res.damageRate = an.damageRate;
res.netDrain = an.damageRate - res.regenRate; // > 0 = 净掉血
res.naturalOnly = res.netDrain > 0 ? res.hpMax / res.netDrain : Infinity;
var afterFood = res.netDrain - res.foodSupply;
res.sustainable = afterFood <= 0;
res.withFood = (!res.sustainable && afterFood > 0) ? res.hpMax / afterFood : Infinity;
res.foodIsBottleneck = isFinite(res.foodLastsSec) && isFinite(res.withFood)
&& res.foodLastsSec < res.withFood - 1;
res.per300 = {
damage: res.damageRate * CYCLE_SEC,
netDrain: Math.max(0, res.netDrain) * CYCLE_SEC,
foodCount: an.healPerSec * CYCLE_SEC,
recovered: Math.max(0, -res.netDrain) * CYCLE_SEC
};
res.ok = true;
res.note = foods.length
? '已计入 ' + foods.length + ' 种回血食物共 ' + res.foodCount + ' 个'
: '未携带回血食物';
return res;
}
// ====================== v1.6.0 战斗模拟器(推演引擎) ======================
//
// 不看实测采样,直接按「配装 + 技能 + 触发器 + 食物 + 敌人」逐事件推演
// 300 秒窗口内的蓝耗与掉血。模型与游戏客户端 / 社区模拟器逐条对齐:
// · 行动调度:动作结束后立即决策 —— 技能冷却好、触发器满足、蓝够 → 吟唱
// (占时 castDuration/(1+castSpeed),技能不占攻击间隔),否则普攻(attackInterval)。
// 蓝量在吟唱结束瞬间扣,冷却从吟唱结束起算。
// · 命中 = acc^1.4 / (acc^1.4 + eva^1.4);减伤 = 100/(100+防御),防御先按
// 穿透折算(def/(1+pen)),负防御转为增伤 (100-def)/100。
// · 掷骰 [min,max] 均匀;暴击锁定 max×(1+暴伤);远程基础暴击 30%×命中。
// · 触发器逐事件精确判定(缺蓝≥X 吃糖 / 血≤Y 吃甜甜圈 / 敌≥2 才放烈焰风暴)——
// 判定的是模拟状态本身,不是拍脑袋概率。
// · 自然回复 10 秒一跳 +floor(上限×regenPer10);持续食物 5 秒一跳均摊;
// 怪全灭 3 秒后整波复活(打得快 → 怪在场时间短 → 受伤反而少);
// 多只怪的伤害按 totalThreat 占比分摊给自己(期望值)。
// 不确定性一律取期望(命中/暴击/掷骰)→ 推演是确定性的:同输入恒同输出。
// 刻意简化(面板注明):控制(晕/沉默/致盲)、狂暴、怪物减甲 debuff 未建模;
// 队友的治疗/增益不计(只能看到自己的面板)。
var SIM_REGEN_TICK = 10; // 自然回复 tick(秒),与客户端一致
var SIM_HOT_TICK = 5; // 食物持续恢复 tick(秒),与客户端一致
var SIM_RESPAWN_SEC = 3; // 怪全灭后整波复活间隔(秒)
var SIM_MAX_EVENTS = 40000; // 死循环保险
var SIM_EPS = 1e-9;
function simNs(v) { return num(v) / 1e9; }
function simTail(hrid) { return String(hrid || '').split('/').pop(); }
function simHitChance(acc, eva) {
acc = Math.max(1, num(acc)); eva = Math.max(1, num(eva));
var a = Math.pow(acc, 1.4), e = Math.pow(eva, 1.4);
return a / (a + e);
}
// 期望掷骰:[min,max] 均匀;暴击锁定 max×(1+暴伤)
function simExpRoll(minD, maxD, critC, critDmg) {
minD = num(minD); maxD = Math.max(minD, num(maxD));
critC = Math.min(1, Math.max(0, num(critC)));
return (1 - critC) * (minD + maxD) / 2 + critC * maxD * (1 + num(critDmg));
}
function simMitigation(defense, pen) {
var eff = num(defense);
if (num(pen) > 0 && eff > 0) eff = eff / (1 + num(pen));
return eff < 0 ? (100 - eff) / 100 : 100 / (100 + eff);
}
function simStyleOf(cs) {
var arr = cs && cs.combatStyleHrids;
return simTail((arr && arr[0]) || '/combat_styles/smash');
}
function simAcc(cd, style) { return num(cd && cd[style + 'AccuracyRating']); }
function simEva(cd, style) { return num(cd && cd[style + 'EvasionRating']); }
function simMaxDmg(cd, style) { return num(cd && cd[style + 'MaxDamage']); }
function simResist(cd, dmgType) {
var t = simTail(dmgType);
if (t === 'water') return num(cd && cd.totalWaterResistance);
if (t === 'nature') return num(cd && cd.totalNatureResistance);
if (t === 'fire') return num(cd && cd.totalFireResistance);
return num(cd && cd.totalArmor);
}
function simPen(cs, dmgType) {
var t = simTail(dmgType);
if (t === 'physical') return num(cs && cs.armorPenetration);
return num(cs && cs[t + 'Penetration']);
}
function simAmplify(cs, dmgType) { return num(cs && cs[simTail(dmgType) + 'Amplify']); }
// 一次攻击的期望伤害(att/def = { cd: combatDetails, cs: combatStats })
function simExpAttack(att, def, opt) {
var style = opt.style;
var hit = simHitChance(simAcc(att.cd, style) * (1 + num(opt.bonusAcc)), simEva(def.cd, style));
var amp = 1 + simAmplify(att.cs, opt.dmgType);
var minD = amp * (1 + num(opt.flatDmg));
var maxD = amp * (num(opt.dmgRatio) * simMaxDmg(att.cd, style) + num(opt.flatDmg));
var critC = (style === 'ranged' ? 0.3 * hit : 0) + num(att.cs.criticalRate);
var roll = simExpRoll(minD, maxD, critC, att.cs.criticalDamage);
roll *= (1 + num(att.cs.taskDamage));
roll *= (1 + num(def.cs.damageTaken));
if (opt.isAuto) roll *= (1 + num(att.cs.autoAttackDamage));
return hit * roll * simMitigation(simResist(def.cd, opt.dmgType), simPen(att.cs, opt.dmgType));
}
// 技能定义(按等级折算数值;自定义触发器优先,缺省用默认触发器)
function simAbilityDef(hrid, level, triggerMap) {
var d = state.abilityDetailMap && state.abilityDetailMap[hrid];
if (!d) return null;
var lv = Math.max(1, num(level) || 1);
var effects = [];
(d.abilityEffects || []).forEach(function (e) {
effects.push({
targetType: e.targetType,
type: simTail(e.effectType),
style: simTail(e.combatStyleHrid),
dmgType: e.damageType,
flat: num(e.baseDamageFlat) + (lv - 1) * num(e.baseDamageFlatLevelBonus),
ratio: num(e.baseDamageRatio) + (lv - 1) * num(e.baseDamageRatioLevelBonus),
bonusAcc: num(e.bonusAccuracyRatio) + (lv - 1) * num(e.bonusAccuracyRatioLevelBonus),
dotRatio: num(e.damageOverTimeRatio),
spendHpRatio: num(e.spendHpRatio),
drainRatio: num(e.hpDrainRatio),
buffs: e.buffs || null
});
});
var trig = triggerMap && triggerMap[hrid];
if (!trig || !trig.length) trig = d.defaultCombatTriggers;
return {
hrid: hrid, level: lv, cost: num(d.manaCost),
cdSec: simNs(d.cooldownDuration), castSec: simNs(d.castDuration),
special: d.isSpecialAbility === true,
effects: effects, triggers: trig || []
};
}
// 食物/饮料定义(只收能回血回蓝的;纯增益饮料如咖啡由实测页记账,不进续航推演)
function simFoodDef(hrid, count) {
var d = state.itemDetailMap && state.itemDetailMap[hrid];
var cd = d && d.consumableDetail;
if (!cd) return null;
var hp = num(cd.hitpointRestore), mp = num(cd.manapointRestore);
if (hp <= 0 && mp <= 0) return null;
var trig = state.consumableTriggers && state.consumableTriggers[hrid];
if (!trig || !trig.length) trig = cd.defaultCombatTriggers;
return {
hrid: hrid, count: num(count), hp: hp, mp: mp,
cdSec: simNs(cd.cooldownDuration) || 60,
recSec: simNs(cd.recoveryDuration),
triggers: trig || []
};
}
// 组装一次推演所需的全部输入;缺关键数据时 ok=false + reason
function simBuildInput(durSec) {
var out = { ok: false, reason: '', durSec: durSec || CYCLE_SEC };
if (!state.abilityDetailMap) { out.reason = '技能字典未加载'; return out; }
if (!state.itemDetailMap) { out.reason = '物品字典未加载'; return out; }
var raw = state.selfRawCombat;
var cd = (raw && raw.combatDetails) || state.selfUnitDetails;
if (!cd || !(num(cd.maxHitpoints) > 0) || !(num(cd.maxManapoints) > 0)) {
out.reason = '没有你的战斗面板(打一场战斗后可用)';
return out;
}
var cs = cd.combatStats || {};
var abilSrc = (raw && raw.combatAbilities && raw.combatAbilities.length
? raw.combatAbilities : state.selfUnitAbilities) || [];
var abilities = [];
abilSrc.forEach(function (a) {
if (!a || !a.abilityHrid) return;
var def = simAbilityDef(a.abilityHrid, a.level, state.abilityTriggers);
if (def) abilities.push(def);
});
var self = getSelf();
var foods = [];
((self && self.consumables) || []).forEach(function (c) {
var f = simFoodDef(c.hrid, c.count);
if (f) foods.push(f);
});
var monsters = [];
(state.monstersRaw || []).forEach(function (m) {
var mcd = m && m.combatDetails;
if (!mcd || !(num(mcd.maxHitpoints) > 0)) return;
var mcs = mcd.combatStats || {};
var mab = [];
(m.combatAbilities || []).forEach(function (a) {
if (!a || !a.abilityHrid) return;
var def = simAbilityDef(a.abilityHrid, a.level, null); // 怪物:默认触发器
if (def) mab.push(def);
});
monsters.push({
hrid: m.hrid || '?', cd: mcd, cs: mcs,
hpMax: num(mcd.maxHitpoints),
interval: simNs(mcd.attackInterval) || simNs(mcs.attackInterval) || 3,
castSpeed: num(mcd.totalCastSpeed),
mpMax: num(mcd.maxManapoints),
abilities: mab
});
});
if (!monsters.length) { out.reason = '不在战斗中(看不到敌人)'; return out; }
var share = 1;
if (state.partyThreatTotal > 0 && num(cd.totalThreat) > 0) {
share = num(cd.totalThreat) / state.partyThreatTotal;
}
out.ok = true;
out.threatShare = share;
out.self = {
cd: cd, cs: cs,
hpMax: num(cd.maxHitpoints), mpMax: num(cd.maxManapoints),
hpRegen: num(cs.hpRegenPer10), mpRegen: num(cs.mpRegenPer10),
interval: simNs(cd.attackInterval) || simNs(cs.attackInterval) || 3.5,
castSpeed: num(cd.totalCastSpeed),
style: simStyleOf(cs), dmgType: cs.damageType || '/damage_types/physical',
lifeSteal: num(cs.lifeSteal), manaLeech: num(cs.manaLeech),
blaze: num(cs.blaze), bloom: num(cs.bloom), ripple: num(cs.ripple),
abilities: abilities, foods: foods
};
out.monsters = monsters;
return out;
}
// 推演主循环(确定性,期望值模型)
function simRun(inp) {
var dur = inp.durSec;
var si = inp.self;
var share = Math.min(1, Math.max(0, num(inp.threatShare) || 1));
var res = {
ok: true, durSec: dur, threatShare: share,
mpSpent: 0, mpRegen: 0, mpFood: 0, mpLeech: 0, mpRipple: 0,
hpTaken: 0, hpRegen: 0, hpFood: 0, hpHeal: 0, hpLeech: 0, hpBloom: 0,
casts: Object.create(null), autos: 0,
foodsUsed: Object.create(null), foodsLeft: Object.create(null),
monActs: Object.create(null), monDamage: Object.create(null),
dmgDealt: 0, kills: 0, uptimeSec: 0,
oomAt: null, deadAt: null,
debuffSkipped: false, unknownTrigs: Object.create(null)
};
var S = {
hp: si.hpMax, mp: si.mpMax, // 满状态起算:推演稳态续航,与实测面板口径一致
hpMax: si.hpMax, mpMax: si.mpMax, // 触发器判 missing_hp/missing_mp 要用上限
abs: si.abilities.map(function (d) { return { d: d, last: -1e9 }; }),
foods: si.foods.map(function (d) { return { d: d, last: -1e9, left: d.count }; })
};
var M = inp.monsters.map(function (m) {
return {
def: m, hp: m.hpMax, alive: true, next: m.interval, mp: m.mpMax,
abs: m.abilities.map(function (d) { return { d: d, last: -1e9 }; })
};
});
var t = 0, lastT = 0, guard = 0;
var selfNext = 0, selfCast = null, selfAuto = false;
var regenNext = SIM_REGEN_TICK;
var respawnAt = -1;
var hots = []; // { next, left, hpPer, mpPer }
var buffUntil = Object.create(null); // buff 尾名 -> 到期时刻
function firstAlive() { for (var i = 0; i < M.length; i++) if (M[i].alive) return M[i]; return null; }
function aliveList() { return M.filter(function (m) { return m.alive; }); }
function buffActive(name) {
for (var k in buffUntil) {
if (Object.prototype.hasOwnProperty.call(buffUntil, k)
&& k.indexOf(name) === 0 && buffUntil[k] > t) return true;
}
return false;
}
function addHp(v, bucket) {
if (v <= 0 || S.hp <= 0) return;
var real = Math.min(v, si.hpMax - S.hp);
S.hp += real;
if (bucket) res[bucket] += real;
}
function addMp(v, bucket) {
if (v <= 0 || S.hp <= 0) return;
var real = Math.min(v, si.mpMax - S.mp);
S.mp += real;
if (bucket) res[bucket] += real;
}
// —— 触发器:客户端同款条件,对模拟状态精确求值 ——
function trigPass(trig) {
var dep = String(trig.dependencyHrid || '');
var cond = simTail(trig.conditionHrid);
var comp = String(trig.comparatorHrid || '');
var val = num(trig.value);
var v = 0, na = false;
var unit = S;
if (dep.indexOf('targeted_enemy') !== -1) {
var m = firstAlive();
if (!m) na = true;
else unit = { hp: m.hp, hpMax: m.def.hpMax, mp: m.mp, mpMax: m.def.mpMax };
}
switch (cond) {
case 'current_hp': v = unit.hp; break;
case 'missing_hp': v = unit.hpMax - unit.hp; break;
case 'current_hp_percentage': v = unit.hpMax > 0 ? unit.hp / unit.hpMax * 100 : 0; break;
case 'missing_hp_percentage': v = 100 - (unit.hpMax > 0 ? unit.hp / unit.hpMax * 100 : 0); break;
case 'current_mp': v = num(unit.mp); break;
case 'missing_mp': v = unit.mpMax - num(unit.mp); break;
case 'current_mp_percentage': v = unit.mpMax > 0 ? num(unit.mp) / unit.mpMax * 100 : 0; break;
case 'missing_mp_percentage': v = 100 - (unit.mpMax > 0 ? num(unit.mp) / unit.mpMax * 100 : 0); break;
case 'number_of_active_units':
v = dep.indexOf('all_enemies') !== -1 ? aliveList().length : 1; break; // 队友只算自己(保守)
case 'number_of_dead_units':
v = dep.indexOf('all_enemies') !== -1 ? M.length - aliveList().length : 0; break;
default:
// buff 类条件(insanity / frenzy / 咖啡…):按对应 buff 是否存续判定
if (comp.indexOf('is_inactive') !== -1) return !buffActive(cond);
if (comp.indexOf('is_active') !== -1) return buffActive(cond);
res.unknownTrigs[cond] = true;
return true; // 未识别 → 放行并注记
}
if (na) return false; // 怪死光 → 以敌为目标的条件一律不满足
if (comp.indexOf('greater_than_equal') !== -1) return v >= val;
if (comp.indexOf('less_than_equal') !== -1) return v <= val;
if (comp.indexOf('greater_than') !== -1) return v > val;
if (comp.indexOf('less_than') !== -1) return v < val;
if (comp.indexOf('is_inactive') !== -1) return true;
if (comp.indexOf('is_active') !== -1) return false;
return true;
}
function trigsPass(list) {
for (var i = 0; i < list.length; i++) if (!trigPass(list[i])) return false;
return true;
}
function hurtSelf(dmg, mHrid) {
if (dmg <= 0 || S.hp <= 0) return;
var d = dmg * share;
res.hpTaken += d;
if (mHrid) res.monDamage[mHrid] = (res.monDamage[mHrid] || 0) + d;
S.hp -= d;
if (S.hp <= 0) { S.hp = 0; if (res.deadAt == null) res.deadAt = t; }
}
function dealTo(m, dmg) {
if (!m || !m.alive || dmg <= 0) return;
res.dmgDealt += dmg;
m.hp -= dmg;
if (m.hp <= 0) {
m.hp = 0; m.alive = false;
res.kills++;
if (!firstAlive() && respawnAt < 0) respawnAt = t + SIM_RESPAWN_SEC;
}
}
// 技能效果结算(isMonster:怪物技能打玩家;否则玩家技能打怪/奶自己)
function applyEffects(x, mOwner) {
var d = x.d;
var attView = mOwner ? { cd: mOwner.def.cd, cs: mOwner.def.cs } : { cd: si.cd, cs: si.cs };
for (var i = 0; i < d.effects.length; i++) {
var ef = d.effects[i];
if (ef.type === 'damage') {
if (mOwner) {
var md = simExpAttack(attView, { cd: si.cd, cs: si.cs }, {
style: ef.style || simStyleOf(mOwner.def.cs),
dmgType: ef.dmgType || mOwner.def.cs.damageType,
flatDmg: ef.flat, dmgRatio: ef.ratio, bonusAcc: ef.bonusAcc
});
hurtSelf(md, mOwner.def.hrid);
} else {
var targets = ef.targetType === 'all enemies' ? aliveList() : [firstAlive()];
for (var j = 0; j < targets.length; j++) {
var mm = targets[j];
if (!mm) continue;
var pd = simExpAttack(attView, { cd: mm.def.cd, cs: mm.def.cs }, {
style: ef.style || si.style, dmgType: ef.dmgType || si.dmgType,
flatDmg: ef.flat, dmgRatio: ef.ratio, bonusAcc: ef.bonusAcc
});
pd *= (1 + ef.dotRatio); // DOT 折算进本次(客户端:DOT 总量 = 直接伤害 × dotRatio)
dealTo(mm, pd);
if (ef.drainRatio > 0) addHp(pd * ef.drainRatio, 'hpHeal');
}
}
} else if (ef.type === 'heal' && !mOwner) {
var amp = 1 + num(si.cs.healingAmplify);
addHp(simExpRoll(amp * (1 + ef.flat),
amp * (ef.ratio * simMaxDmg(si.cd, 'magic') + ef.flat), 0, 0), 'hpHeal');
} else if (ef.type === 'buff') {
if (mOwner) { res.debuffSkipped = true; continue; } // 怪的减甲/控制未建模
(ef.buffs || []).forEach(function (b) {
var bh = simTail(b.uniqueHrid || b.typeHrid);
var bd = simNs(b.duration);
buffUntil[bh] = t + (bd > 0 ? bd : 30);
});
}
// spend_hp / revive:特技与换血技能极少见,跳过(isSpecialAbility 已排除大部分)
}
}
// 自己:行动决策(技能按槽位顺序,冷却好+触发器过+蓝够 → 吟唱;否则普攻)
// ※ 特技(isSpecialAbility)**不排除**:与客户端一致,特技只是「没有常开触发器」,
// 触发条件满足一样放 —— insanity(狂化)就是特技,靠「buff 已消失 + 血量≥500」触发,
// 90 秒一次 80 蓝,排除它会系统性低估蓝耗;revive(复活术)靠「有队友阵亡」触发,
// 推演里没人阵亡 → 自然一次不放,无需特判。
function decideSelf() {
for (var i = 0; i < S.abs.length; i++) {
var x = S.abs[i], d = x.d;
if (x.last + d.cdSec > t + SIM_EPS) continue;
if (!trigsPass(d.triggers)) continue;
if (S.mp < d.cost) {
if (res.oomAt == null && d.cost > 0) res.oomAt = t;
continue;
}
selfCast = x;
selfNext = t + d.castSec / (1 + si.castSpeed);
return;
}
selfCast = null;
selfAuto = true;
selfNext = t + si.interval;
}
function resolveSelf() {
if (selfCast) {
var x = selfCast; selfCast = null;
var d = x.d;
S.mp -= d.cost; res.mpSpent += d.cost;
x.last = t;
res.casts[d.hrid] = (res.casts[d.hrid] || 0) + 1;
if (si.ripple > 0) { // 涟漪:施放后概率回 10 蓝 + 全技能 CD-2s(取期望)
addMp(10 * si.ripple, 'mpRipple');
for (var i = 0; i < S.abs.length; i++) S.abs[i].last -= 2 * si.ripple;
}
applyEffects(x, null);
if (si.blaze > 0) { // 烈焰:30% 最大伤害 AoE(取期望,只影响击杀速度)
var al = aliveList();
for (var j = 0; j < al.length; j++) {
dealTo(al[j], 0.3 * simMaxDmg(si.cd, si.style) * si.blaze);
}
}
if (si.bloom > 0) { // 绽放:奶最低血队友(取期望,视为自己受益)
addHp((10 + 0.15 * simMaxDmg(si.cd, si.style)) * si.bloom, 'hpBloom');
}
} else if (selfAuto) {
selfAuto = false;
var m = firstAlive();
if (m) {
res.autos++;
var dmg = simExpAttack({ cd: si.cd, cs: si.cs }, { cd: m.def.cd, cs: m.def.cs }, {
style: si.style, dmgType: si.dmgType, flatDmg: 0, dmgRatio: 1, isAuto: true
});
dealTo(m, dmg);
if (si.lifeSteal > 0) addHp(dmg * si.lifeSteal, 'hpLeech');
if (si.manaLeech > 0) addMp(dmg * si.manaLeech, 'mpLeech');
}
}
if (S.hp > 0) decideSelf();
}
// 怪物:技能优先(冷却+蓝够),否则普攻;吟唱时间并入下次行动间隔(瞬发简化)
function resolveMonster(m) {
var used = null;
for (var i = 0; i < m.abs.length; i++) {
var x = m.abs[i];
if (x.last + x.d.cdSec > t + SIM_EPS) continue;
if (m.mp < x.d.cost) continue;
used = x; break;
}
res.monActs[m.def.hrid] = (res.monActs[m.def.hrid] || 0) + 1;
if (used) {
m.mp -= used.d.cost; used.last = t;
applyEffects(used, m);
m.next = t + Math.max(m.def.interval, used.d.castSec / (1 + m.def.castSpeed));
} else {
hurtSelf(simExpAttack({ cd: m.def.cd, cs: m.def.cs }, { cd: si.cd, cs: si.cs }, {
style: simStyleOf(m.def.cs), dmgType: m.def.cs.damageType || '/damage_types/physical',
flatDmg: 0, dmgRatio: 1, isAuto: true
}), m.def.hrid);
m.next = t + m.def.interval;
}
}
// 食物/饮料:每个事件后检查(冷却好 + 触发器满足 → 吃)
function checkFood() {
if (S.hp <= 0) return;
for (var i = 0; i < S.foods.length; i++) {
var f = S.foods[i], d = f.d;
if (f.left <= 0) continue;
if (f.last + d.cdSec > t + SIM_EPS) continue;
if (!trigsPass(d.triggers)) continue;
f.left--; f.last = t;
res.foodsUsed[d.hrid] = (res.foodsUsed[d.hrid] || 0) + 1;
if (d.recSec > 0) {
var ticks = Math.max(1, Math.round(d.recSec / SIM_HOT_TICK));
hots.push({ next: t + SIM_HOT_TICK, left: ticks, hpPer: d.hp / ticks, mpPer: d.mp / ticks });
} else {
addHp(d.hp, 'hpFood'); addMp(d.mp, 'mpFood');
}
}
}
// —— 主循环:每次取最近的下一事件 ——
decideSelf(); // 开局立即决策(与客户端 startAttacks 一致)
while (guard++ < SIM_MAX_EVENTS) {
var nt = Infinity, i;
if (selfNext < nt) nt = selfNext;
if (regenNext < nt) nt = regenNext;
if (respawnAt >= 0 && respawnAt < nt) nt = respawnAt;
for (i = 0; i < hots.length; i++) if (hots[i].next < nt) nt = hots[i].next;
for (i = 0; i < M.length; i++) if (M[i].alive && M[i].next < nt) nt = M[i].next;
if (!isFinite(nt) || nt > dur) { if (firstAlive()) res.uptimeSec += dur - lastT; break; }
if (firstAlive()) res.uptimeSec += nt - lastT;
t = nt; lastT = nt;
if (t >= regenNext - SIM_EPS && regenNext <= t + SIM_EPS) {
addHp(Math.floor(si.hpMax * si.hpRegen), 'hpRegen');
addMp(Math.floor(si.mpMax * si.mpRegen), 'mpRegen');
regenNext += SIM_REGEN_TICK;
}
if (respawnAt >= 0 && t >= respawnAt - SIM_EPS) {
respawnAt = -1;
for (i = 0; i < M.length; i++) {
M[i].alive = true; M[i].hp = M[i].def.hpMax;
M[i].next = t + M[i].def.interval;
}
}
for (i = hots.length - 1; i >= 0; i--) {
var h = hots[i];
if (h.next > t + SIM_EPS) continue;
addHp(h.hpPer, 'hpFood'); addMp(h.mpPer, 'mpFood');
h.left--;
if (h.left <= 0) hots.splice(i, 1);
else h.next += SIM_HOT_TICK;
}
for (i = 0; i < M.length; i++) {
if (M[i].alive && M[i].next <= t + SIM_EPS) resolveMonster(M[i]);
}
if (S.hp > 0 && selfNext <= t + SIM_EPS) resolveSelf();
checkFood();
if (res.deadAt != null) break; // 推演到首次阵亡为止
}
// —— 汇总 ——
res.tSim = Math.min(t, dur);
res.hpEnd = S.hp; res.mpEnd = S.mp;
res.selfHpMax = si.hpMax; res.selfMpMax = si.mpMax;
var span = res.tSim > 0 ? res.tSim : dur;
res.spanSec = span;
for (var fi = 0; fi < S.foods.length; fi++) {
res.foodsLeft[S.foods[fi].d.hrid] = S.foods[fi].left;
}
res.mpNet = (res.mpSpent - res.mpRegen - res.mpFood - res.mpLeech - res.mpRipple) / span;
res.hpNet = (res.hpTaken - res.hpRegen - res.hpFood - res.hpHeal - res.hpLeech - res.hpBloom) / span;
res.hpTakenRate = res.hpTaken / span;
return res;
}
// 渲染级缓存:同一场战斗 2 秒内复用推演结果(render 触发频繁,推演本身毫秒级)
var simCache = { key: '', at: 0, res: null };
function simCached() {
var now = Date.now();
var key = [state.battleKey, state.battleCount, state.players.length,
(state.monstersRaw || []).length].join('|');
if (simCache.res && simCache.key === key && now - simCache.at < 2000) return simCache.res;
var inp = simBuildInput(CYCLE_SEC);
var res = inp.ok ? simRun(inp) : inp; // !ok 时 inp 自带 reason
simCache = { key: key, at: now, res: res };
return res;
}
function simNoteHtml(res) {
var notes = [];
var uk = Object.keys(res.unknownTrigs || {});
if (uk.length) notes.push('未识别的触发条件按常开处理:' + uk.join('、'));
if (res.debuffSkipped) notes.push('怪物的减甲/控制类技能未建模,实际受伤可能略多');
notes.push('队友的输出与治疗不计入(看不到别人面板)→ 击杀节奏偏慢、承伤偏多');
notes.push('命中/暴击/掷骰取期望 → 推演是确定值,单场实况会有波动');
return '' + esc(notes.join(';')) + '
';
}
function simFoodUsedText(res, hpOnly) {
var parts = [];
Object.keys(res.foodsUsed).forEach(function (h) {
var d = state.itemDetailMap && state.itemDetailMap[h];
var cd = d && d.consumableDetail;
var isHp = cd && num(cd.hitpointRestore) > 0;
if (hpOnly && !isHp) return;
if (!hpOnly && isHp) return;
var n = res.foodsUsed[h];
parts.push(itemName(h) + ' ×' + (Math.abs(n - Math.round(n)) < 0.05 ? Math.round(n) : n.toFixed(1)));
});
return parts;
}
// 蓝量页:模拟器推演区块
function simMpHtml(res, measuredNet300) {
var html = '模拟器推演(300 秒 · 触发器×装备×敌人)
';
var parts = [];
Object.keys(res.casts).forEach(function (h) {
parts.push(esc(abilityName(h)) + ' ×' + res.casts[h]);
});
html += '
技能耗蓝 -' +
fmtInt(Math.round(res.mpSpent)) + ' 蓝' +
(parts.length ? '(' + parts.join(' · ') +
(res.autos ? ' · 普攻 ×' + res.autos : '') + ') ' : '') + '
';
var gains = [];
if (res.mpRegen > 0) gains.push('自然 +' + fmtInt(Math.round(res.mpRegen)));
if (res.mpFood > 0) gains.push('食物 +' + fmtInt(Math.round(res.mpFood)));
if (res.mpLeech > 0) gains.push('偷蓝 +' + fmtInt(Math.round(res.mpLeech)));
if (res.mpRipple > 0) gains.push('涟漪 +' + fmtInt(Math.round(res.mpRipple)));
html += '
回蓝 ' +
(gains.length ? gains.join(' · ') : '0') + '
';
var fp = simFoodUsedText(res, false);
if (fp.length) {
html += '
耗食物 ' +
fp.join(' · ') + ' / 300s
';
var out = Object.keys(res.foodsUsed).filter(function (h) {
return res.foodsLeft[h] === 0;
});
if (out.length) {
html += '
⚠ ' + out.map(function (h) { return esc(itemName(h)); }).join('、') +
' 在 300 秒推演内吃完 —— 之后净耗会变大,记得补货
';
}
}
var net = res.mpNet;
html += '
推演净耗 ' +
(net > 0.01 ? '-' + net.toFixed(2) + ' 蓝/秒' : '+' + Math.max(0, -net).toFixed(2) + ' 蓝/秒(盈余)') +
'
';
// 「满蓝可战」的口径:首次断蓝时刻 > 线性外推 > 不降。
// 线性外推(mpMax/net)在食物占收入大头时会误导(存货吃完后净耗跳变),
// 而 oomAt 是模拟器逐事件推演出来的真实断蓝点,优先展示。
html += '
满蓝可战(推演) ' +
'' +
(res.oomAt != null ? '约 ' + fmtFull(res.oomAt) + ' 首次断蓝(之后靠回蓝/食物断续) '
: (net > 0.01 ? fmtFull(res.selfMpMax / net) : '蓝量不降 ✅')) + '
';
if (measuredNet300 != null && measuredNet300 > 0) {
var simNet300 = net * res.durSec;
var diff = (simNet300 - measuredNet300) / measuredNet300 * 100;
html += '
推演 vs 实测 ' +
fmtInt(Math.round(simNet300)) + ' vs ' + fmtInt(Math.round(measuredNet300)) +
' 蓝/300s(' + (diff >= 0 ? '+' : '') + diff.toFixed(0) + '%)
';
}
html += simNoteHtml(res);
return html + '
';
}
// 血量页:伤害推演区块
function simHpHtml(res, measuredRate) {
var html = '伤害推演(敌人 × 你的配装 · 300 秒)
';
var mkeys = Object.keys(res.monDamage);
mkeys.forEach(function (h) {
html += '
' + esc(itemName(h)) + ' ' +
'' + (res.monActs[h] || 0) + ' 次攻击 ' +
'≈ ' + fmtInt(Math.round(res.monDamage[h])) + '
';
});
html += '
承受伤害 -' +
fmtInt(Math.round(res.hpTaken)) + ' 血 / 300s' +
(res.threatShare < 0.999
? '(按仇恨分摊 ' + Math.round(res.threatShare * 100) + '%) '
: '') + '
';
var heals = [];
if (res.hpRegen > 0) heals.push('自然 +' + fmtInt(Math.round(res.hpRegen)));
if (res.hpFood > 0) heals.push('食物 +' + fmtInt(Math.round(res.hpFood)));
if (res.hpHeal > 0) heals.push('技能 +' + fmtInt(Math.round(res.hpHeal)));
if (res.hpLeech > 0) heals.push('偷血 +' + fmtInt(Math.round(res.hpLeech)));
if (res.hpBloom > 0) heals.push('绽放 +' + fmtInt(Math.round(res.hpBloom)));
html += '
治疗 ' +
(heals.length ? heals.join(' · ') : '0') + '
';
var fp = simFoodUsedText(res, true);
if (fp.length) {
html += '
耗回血食物 ' +
fp.join(' · ') + ' / 300s
';
}
var net = res.hpNet;
html += '
推演净掉血 ' +
(net > 0.01 ? '-' + net.toFixed(2) + ' 血/秒'
: (net < -0.01 ? '+' + (-net).toFixed(2) + ' 血/秒(盈余)' : '≈ 0(收支相抵)')) +
'
';
html += '
推演存活(满血起) ' +
'' +
(res.deadAt != null ? fmtFull(res.deadAt) + ' 阵亡'
: (net > 0.01 ? fmtFull(res.selfHpMax / net) : '站得住 ✅')) + '
';
if (res.kills > 0) {
html += '
击杀节奏 ' +
'击杀 ×' + res.kills + ' · 怪物在场 ' +
Math.round(res.uptimeSec / res.durSec * 100) + '% 时间
';
}
if (measuredRate != null && measuredRate > 0) {
var simTaken300 = res.hpTakenRate * res.durSec;
var realTaken300 = measuredRate * res.durSec;
var diff = (simTaken300 - realTaken300) / realTaken300 * 100;
html += '
推演 vs 实测 ' +
fmtInt(Math.round(simTaken300)) + ' vs ' + fmtInt(Math.round(realTaken300)) +
' 血/300s(' + (diff >= 0 ? '+' : '') + diff.toFixed(0) + '%)
';
}
html += simNoteHtml(res);
return html + '
';
}
// —— 血量管家(★ v1.5.13 新增页签)——
function renderHp() {
var self = getSelf();
if (!self) {
if (state.players.length) {
return '未识别到自己的角色(刷新游戏页面通常可修复)
';
}
return '进入战斗后显示血量信息
';
}
var hpMax = num(self.hpMax);
var hp = num(self.hp);
var pct = hpMax > 0 ? Math.round(hp / hpMax * 100) : 0;
var html = '';
// 血条
html += '当前血量 ' +
fmtInt(hp) + (hpMax > 0 ? ' / ' + fmtInt(hpMax) + '(' + pct + '%)' : '') +
'
';
html += '
';
// 自然恢复(静态,来自 combatStats.hpRegenPer10)
var regenPer10 = num(self.hpRegenPer10);
html += '自然恢复 ' +
(regenPer10 > 0
? (regenPer10 * 100).toFixed(2) + '% / 10秒 ≈ ' + (hpMax * regenPer10).toFixed(1) + ' 点'
: '数据缺失') + '
';
var proj = projectHp(self);
var an = proj.analyze || {};
html += '生存续航(实测掉血 · 300 秒窗口)
';
// 食物侧(回血食物 / 食物恢复 / 食物还能撑)是**闭式**的 ——
// 只由库存 + 各自 CD 决定,不依赖掉血实测,所以采样没热时也照常显示。
// 这样用户一进战斗就能先看到「我带的这些回血食物够供多少血、能撑多久」,
// 而不是整块空着等采样。nd 为 null 时不做「够不够」的对比(那是实测量)。
function foodRows(nd) {
var out = '';
if (!proj.foodKinds) {
out += '
未携带回血食物 —— 只能靠自然恢复硬撑
';
return out;
}
out += '
回血食物 ' +
proj.foodKinds + ' 种 · ' + fmtInt(proj.foodCount) + ' 个
';
if (proj.foodSupply > 0) {
var known = (nd != null);
var need = known ? Math.max(0, nd) : 0;
var good = known ? (proj.foodSupply >= need) : true;
out += '
食物恢复 ' + proj.foodSupply.toFixed(2) + ' 血/秒' +
(!known ? '(供血上限)'
: (good ? '(够用)'
: ' < 净掉血 ' + nd.toFixed(2) + ' → 迟早倒地')) +
'
';
}
if (isFinite(proj.foodLastsSec)) {
out += '
食物还能撑 约 ' +
fmtFull(proj.foodLastsSec) + '(' + fmtInt(proj.foodStockCount) + ' 个)
';
if (proj.foodIsBottleneck) {
out += '
食物会先吃完 → 瓶颈是回血食物数量,补货即可延长
';
} else if (proj.ok && isFinite(proj.withFood)) {
out += '
食物还没吃完就先倒 → 瓶颈是回血跟不上掉血,得加防/减伤
';
}
} else {
out += '
食物还能撑 ' +
'不构成瓶颈(按此掉速吃不完)
';
}
return out;
}
if (!proj.ok) {
html += '
' + esc(proj.note || '采样中…') + '
';
html += '
掉血没有闭式解(无法推演 成公式)—— 实测最准,' +
'下方另有 v1.6.0 模拟器按 敌人×配装 做的期望推演可对照。' +
(an.samples ? '(已记录 ' + an.samples + ' 个血量采样点)' : '') + '
';
html += foodRows(null);
} else {
// 主结论 —— 满血基准,与蓝量侧同构
html += '
可战斗时长(满血起) ' +
'' +
(proj.sustainable ? '站得住 ✅' :
(isFinite(proj.withFood) ? fmtFull(proj.withFood) : '> ' + fmtFull(400 * 86400))) +
'
';
// 拆解:自然恢复能撑 X + 食物再补 Y
if (!proj.sustainable && isFinite(proj.naturalOnly) && isFinite(proj.withFood)
&& proj.naturalOnly < proj.withFood - 1) {
html += '
· 自然恢复能撑 ' +
'' + fmtFull(proj.naturalOnly) + '
';
html += '
· 食物再补 ' +
'+' + fmtFull(proj.withFood - proj.naturalOnly) + '
';
}
// 收支明细
html += '
掉血速率(实测) ' +
'' + proj.damageRate.toFixed(2) + ' 血/秒
';
html += '
自然恢复 +' +
proj.regenRate.toFixed(2) + ' 血/秒
';
var nd = proj.netDrain;
html += '
净掉血 ' +
(nd > 0 ? '-' + nd.toFixed(2) + ' 血/秒'
: '+' + (-nd).toFixed(2) + ' 血/秒(回血盈余)') + '
';
html += foodRows(nd);
// —— 每 300 秒实测消耗 ——
var p3 = proj.per300;
html += '
' +
'每 300 秒实测消耗
';
html += '
净掉血 ' +
fmtInt(Math.round(p3.netDrain)) + ' 血 / 300s
';
html += '
耗食物 ' +
(p3.foodCount > 0
? (Math.round(p3.foodCount * 10) / 10) + ' 个 / 300s'
: '0 个(不需要吃)') + '
';
if (nd <= 0 && p3.recovered > 0) {
html += '
净回血 +' +
fmtInt(Math.round(p3.recovered)) + ' 血 / 300s
';
}
// 采样透明度 —— 让用户知道这个数字有多可信
html += '
实测样本 ' + an.samples + ' 点 · 覆盖 ' +
Math.round(an.coverSec) + ' 秒 · 识别到 ' + an.healEvents +
' 次回血(掉血 = 实测变化 − 自然恢复,非推演)
';
}
html += '
';
// ★ v1.6.0:模拟器伤害推演(敌人 × 你的配装,不经实测)
var simH = simCached();
if (simH.ok) {
html += simHpHtml(simH, proj.ok ? proj.damageRate : null);
} else if (simH.reason && state.players.length) {
html += '模拟器:' + esc(simH.reason) + '
';
}
// 队友血量概览
var others = state.players.filter(function (p) { return !p.isSelf; });
if (others.length) {
html += '全队血量
';
sortedPlayers().forEach(function (p) {
var pm = num(p.hpMax);
var pp = pm > 0 ? Math.round(num(p.hp) / pm * 100) : 0;
html += '
' +
'' + (p.isSelf ? '★ ' : '') + esc(p.name) + ' ' +
' ' +
'' + pp + '%
';
});
html += '
';
}
return html;
}
// —— 蓝量管家 ——
function renderMana() {
var self = getSelf();
if (!self) {
if (state.players.length) {
// 有队伍但没识别出自己
var html2 = '未识别到自己的角色(刷新游戏页面通常可修复)
' + partyOverview();
return html2;
}
return '进入战斗后显示蓝量信息
';
}
var pct = self.mpMax > 0 ? Math.round(self.mp / self.mpMax * 100) : 0;
var ptsPer10 = self.mpMax * self.mpRegenPer10;
var ptsPerSec = ptsPer10 / 10;
var html = '';
// 蓝条
html += '当前蓝量 ' +
fmtInt(self.mp) + ' / ' + fmtInt(self.mpMax) + '(' + pct + '%)
';
html += '
';
// 回蓝
html += '回蓝速率 ' +
(self.mpRegenPer10 > 0
? (self.mpRegenPer10 * 100).toFixed(2) + '% / 10秒 ≈ ' + ptsPer10.toFixed(1) + ' 点'
: '数据缺失') + '
';
html += '回满还需 ' +
(self.mp >= self.mpMax ? '已回满 ✅'
: (ptsPerSec > 0 ? '计算中 ' : '数据缺失')) + '
';
// —— 蓝量续航推演(300 秒周期 · 含食物/自然回蓝)——
var proj = projectMana(self);
html += '蓝量续航(300 秒周期推演)
';
if (!proj.ok) {
html += '
等待蓝量数据…' +
(proj.note ? '(' + esc(proj.note) + ')' : '') + '
';
} else {
var cy = proj.cycle;
var net = cy.net;
// 主结论 —— 满蓝基准的稳态值,不随战斗中蓝量波动
html += '
可战斗时长(满蓝起) ' +
'' +
(proj.sustainable ? '无限续航 ✅' :
(isFinite(proj.timeToEmpty) ? fmtFull(proj.timeToEmpty) : '> ' + fmtFull(400 * 86400))) +
'
';
if (!proj.sustainable) {
html += '
' + esc(proj.note) +
' · 技能耗尽后蓝量归零
';
}
// ★ v1.5.4:把「可战斗时长」的两个来源拆开显示。
// 之前只给一个总数,容易被误读成「只算了自然回蓝」。这里明确:
// 自然回蓝能撑 X + 食物再补 Y = 可战斗时长 X+Y
if (!proj.sustainable && isFinite(proj.timeToEmpty) && isFinite(proj.naturalOnly)
&& proj.naturalOnly < proj.timeToEmpty - 1) {
html += '
· 自然回蓝能撑 ' +
'' + fmtFull(proj.naturalOnly) + '
';
html += '
· 食物再补 ' +
'+' + fmtFull(proj.timeToEmpty - proj.naturalOnly) + '
';
}
// 收支明细
html += '
技能耗蓝 ' +
cy.drain.toFixed(2) + ' 蓝/秒(' + fmtInt(cy.drainPerCycle) + ' 蓝 / 300s)
';
html += '
自然回蓝 +' +
cy.regen.toFixed(2) + ' 蓝/秒(' + fmtInt(cy.regen * CYCLE_SEC) + ' 蓝 / 300s)
';
html += '
净耗速率 ' +
(net > 0 ? '-' + net.toFixed(2) + ' 蓝/秒' : '+' + (-net).toFixed(2) + ' 蓝/秒(盈余)') +
'
';
// 食物
if (cy.foodKinds) {
html += '
回蓝食物 ' +
cy.foodKinds + ' 种 · ' + cy.foodCount + ' 个' +
(cy.foodsLeft < cy.foodCount ? '(推演后余 ' + cy.foodsLeft + ')' : '') +
'
';
// 食物供蓝能力:每种食物都受自身 CD 约束,这个上限决定稳态能否成立
if (cy.supply) {
html += '
食物供蓝 ' +
cy.supply.perSec.toFixed(2) + ' 蓝/秒' +
(cy.supply.enough
? '(够用)'
: ' < 净耗 ' + cy.supply.need.toFixed(2) + ' → 迟早断蓝') +
'
';
}
// ★ v1.5.4:食物何时耗尽 —— 直接回答「我这点食物够打多久」
if (isFinite(proj.foodLastsSec)) {
html += '
食物可撑 ' +
(proj.foodEstimated ? '约 ' : '') + fmtFull(proj.foodLastsSec) +
'(' + fmtInt(proj.foodStockCount) + ' 个)
';
if (proj.foodIsBottleneck) {
html += '
食物会先吃完 → 瓶颈是食物数量,补货即可延长
';
} else if (isFinite(proj.timeToEmpty)) {
html += '
蓝量先归零(食物还剩很多)→ 瓶颈是供蓝不足,加食物不会更久
';
}
} else {
html += '
食物可撑 ' +
'不构成瓶颈(按此耗速吃不完)
';
}
}
// —— 每 300 秒的实测消耗:蓝量 + 食物数量 ——
var p3 = cy.per300;
if (p3) {
// 折算而来的数量会带小数,统一格式化,避免显示成 3.3333333
var fmtCnt = function (v) {
if (!isFinite(v)) return '--';
var n = Math.round(v * 10) / 10;
return Math.abs(n - Math.round(n)) < 0.05 ? String(Math.round(n)) : n.toFixed(1);
};
html += '
' +
'每 300 秒实测消耗' + (p3.scaled
? ' · 续航不足 300 秒,按实际速率折算(已封顶至冷却上限)'
: '(推演窗口)') +
'
';
html += '
净耗蓝量 ' +
fmtInt(p3.drained) + ' 蓝 / 300s
';
html += '
耗食物 ' +
(p3.foodCount > 0
? fmtCnt(p3.foodCount) + ' 个 / 300s'
: '0 个(不需要吃)') +
'
';
if (p3.foodKinds.length) {
// 分种类:哪种食物被吃掉几个
p3.foodKinds.forEach(function (k) {
html += '
' +
esc(k.name) + ' ×' + fmtCnt(k.count) +
' 个 · 补 ' + fmtInt(k.mp * k.count) + ' 蓝
';
});
if (isFinite(p3.secPerFood)) {
html += '
食物续航 ' +
'平均 ' + Math.round(p3.secPerFood) + ' 秒 / 个
';
}
}
// 多窗口平滑值
if (cy.avgPer300 && cy.avgPer300.windows > 1) {
html += '
按 ' + cy.avgPer300.windows +
' 个窗口平均:' + fmtInt(cy.avgPer300.drained) + ' 蓝 + ' +
Math.round(cy.avgPer300.foodCount * 10) / 10 + ' 个 / 300s
';
}
// 总消耗汇总
if (proj.foodUsed > 0) {
html += '
累计消耗 ' +
fmtInt(proj.drainedTotal) + ' 蓝 · ' + proj.foodUsed + ' 个食物
';
}
}
// 对照:只靠自然回蓝的耗尽时间(= 上面的「· 自然回蓝能撑」)
// v1.5.4:拆解行已经给出同一数值,这里不再重复显示,避免面板冗余。
if (!proj.sustainable && !isFinite(proj.timeToEmpty)) {
html += '
不用食物的话 ' +
fmtFull(proj.naturalOnly) + '耗尽
';
}
// 技能耗蓝明细(按贡献降序)
if (cy.abilityDetails.length) {
html += '
' +
'技能耗蓝明细(每 300 秒)
';
cy.abilityDetails.forEach(function (d) {
if (d.special) {
html += '
' + esc(abilityName(d.hrid)) + ' ' +
'' + fmtInt(d.cost) + '蓝 · 按需施放 ' +
'未计入
';
return;
}
html += '
' + esc(abilityName(d.hrid)) + ' ' +
'' + fmtInt(d.cost) + '蓝 ×' + d.casts + '次 ' +
'' + fmtInt(d.perCycle) + '
';
});
}
}
html += '
';
// ★ v1.6.0:模拟器蓝耗推演(触发器 × 装备 × 敌人,不再只是冷却轮转估算)
// ※ 蓝量侧没有实测时间序列(那是血量管家的机制),旧面板的「每 300 秒实测消耗」
// 其实是旧解析模型自己推演出来的 —— 不能拿来当「实测」做对比,故这里不传对照值。
var simM = simCached();
if (simM.ok) {
html += simMpHtml(simM, null);
}
// —— 瞬时耗蓝(按当前可用技能)——
var drain = estimateManaDrain(self, ptsPerSec);
html += '瞬时状态 ' +
(drain.mode === 'idle' ? '技能冷却中,正在回蓝' :
'技能可用,瞬时耗 ' + drain.rate.toFixed(1) + ' 蓝/秒') + '
';
// —— 本次已获得经验 ——
var exp = sessionExperience();
html += '本次经验(面板加载后累计)
';
if (!exp.ready) {
html += '
等待经验数据…
';
} else {
html += '
战斗经验 ' +
'+' + fmtInt(exp.gained) + '
';
html += '
技能总经验 ' +
fmtInt(exp.total) + '
';
// 各技能增量明细
var lines = [];
var cur = state.expSkillsNow || {};
var base = state.expSkillsBaseline || {};
for (var hrid in cur) {
if (!Object.prototype.hasOwnProperty.call(cur, hrid)) continue;
var d = cur[hrid].exp - ((base[hrid] && base[hrid].exp) || 0);
if (d > 0) lines.push({ name: abilityName(hrid), d: d, level: cur[hrid].level });
}
if (lines.length) {
lines.sort(function (a, b) { return b.d - a.d; });
lines.forEach(function (l) {
html += '
' + esc(l.name) + ' ' +
(l.level > 0 ? 'Lv' + l.level + ' ' : '') +
'+' + fmtInt(l.d) + '
';
});
}
}
html += '
';
// 技能续航
html += '技能续航(按当前蓝量可施放次数)
';
if (!self.abilities.length) {
html += '
未装备战斗技能
';
} else {
self.abilities.forEach(function (a) {
var det = state.abilityDetailMap && state.abilityDetailMap[a.hrid];
var cost = det ? num(det.manaCost) : 0;
var casts = cost > 0 ? Math.floor(self.mp / cost) : null;
var cdText = cdHtml(a);
html += '
' +
'' + esc(abilityName(a.hrid)) + ' ' +
(a.level > 0 ? 'Lv' + a.level + ' ' : '') +
'' + (cost > 0 ? fmtInt(cost) + '蓝' : '') + ' ' +
'' + (casts !== null ? '×' + fmtInt(casts) : '') + ' ' +
'' + cdText + ' ' +
'
';
});
}
html += '
';
// 队友蓝量概览
var others = state.players.filter(function (p) { return !p.isSelf; });
if (others.length) html += partyOverview();
return html;
}
function partyOverview() {
var html = '全队蓝量
';
sortedPlayers().forEach(function (p) {
var pct = p.mpMax > 0 ? Math.round(p.mp / p.mpMax * 100) : 0;
html += '
' +
'' + (p.isSelf ? '★ ' : '') + esc(p.name) + ' ' +
' ' +
'' + pct + '%
';
});
html += '
';
return html;
}
function cdHtml(a) {
var t = Date.parse(a.availableTime);
if (!a.availableTime || !isFinite(t)) return '';
var ms = t - Date.now();
if (ms <= 0) return '✓ ';
return Math.ceil(ms / 1000) + 's';
}
// ==================== 蓝量续航:300 秒周期精确模拟 ====================
//
// 全部参数来自真实游戏数据(从客户端缓存实锤):
// abilityDetailMap[hrid] = { manaCost, cooldownDuration(纳秒), castDuration(纳秒) }
// 实测样本:aqua_arrow 35蓝/CD18s/吟唱0.5s、fireball 10蓝/CD0s/吟唱3s、
// critical_aura 100蓝/CD120s、frost_surge 75蓝/CD15s/吟唱2s
// → CD 为 0 的(fireball/entangle)是纯 GCD,按 3 秒吟唱算一个周期。
//
// itemDetailMap[hrid].consumableDetail = {
// manapointRestore, // 回蓝点数
// cooldownDuration, // 食物自身 CD(60s)
// defaultCombatTriggers:[{ conditionHrid:'/combat_trigger_conditions/missing_mp',
// comparatorHrid:'.../greater_than_equal', value:80 }]
// }
// 实测关键规律:**触发阈值 === 回蓝量**。
// apple_gummy 回 80 蓝 → 缺蓝 ≥80 时自动吃;dragon_fruit_yogurt 回 300 → 阈值 300。
// 所以食物是「掉到 X 蓝就补回 X 蓝」的锯齿状回补,净效果是把蓝量钉在 mpMax-value 上方。
//
// combatStats.mpRegenPer10 = 每 10 秒回复「最大蓝量」的百分比(小数)
//
// —— 300 秒为什么是自然周期 ——
// 最长技能 CD 是 critical_aura/fierce_aura/guardian_aura/mystic_aura 的 120 秒;
// 300 秒 = 2.5 个 120 秒周期,同时是所有常见 CD(15/18/20/30/90)的公共近似公倍数,
// 一个窗口内各技能的可用次数稳定收敛,得到稳定的平均耗蓝速率。
//
// —— 模型 ——
// 1) 技能耗蓝:按各自 (CD + 吟唱) 独立轮转,窗口内取整次数 → 求平均速率
// 2) 自然回蓝:mpRegenPer10 × mpMax / 10(受 mpMax 上限约束)
// 3) 食物回蓝:数量有限,按需消耗,模拟「吃到没为止」
// 4) 净速率 = 技能耗蓝 - 自然回蓝;再用食物能撑多久,算出总续航时间
var CYCLE_SEC = 300;
// 技能的施放周期(秒)= CD + 吟唱,CD 为 0 时退化为 GCD
function abilityCycle(det) {
var cd = num(det.cooldownDuration) / 1e9;
var cast = num(det.castDuration) / 1e9;
var cyc = cd + cast;
if (cyc < 3) cyc = 3; // GCD 下限:3 秒
return cyc;
}
// 一个 300 秒窗口内,技能能放几次
function castsPerCycle(cyc) {
return Math.max(1, Math.floor(CYCLE_SEC / cyc));
}
// 技能组合的平均耗蓝速率(点/秒)+ 明细
function abilityDrain(self) {
var details = [];
var totalPerCycle = 0;
var seen = Object.create(null);
(self.abilities || []).forEach(function (a) {
var h = a.hrid;
// 同名技能不重复计(多个槽装同一个技能不会叠加耗蓝)
if (seen[h]) return;
seen[h] = true;
var det = state.abilityDetailMap && state.abilityDetailMap[h];
if (!det) return;
var cost = num(det.manaCost);
if (cost <= 0) return;
// ★ v1.5.5:isSpecialAbility 的技能**不参与常规轮转** ——
// 它们只在触发条件满足时使用。典型:/abilities/revive(复活术),
// cd 300s、耗蓝 200、只有「队友阵亡」才会放。旧实现按 CD 一律计 1 次,
// 等于给每一场战斗都凭空加了 200 蓝固定开销。实测该技能真实数据就是
// isSpecialAbility:true,这里直接排除,单列出来仅供展示。
if (det.isSpecialAbility === true) {
details.push({
hrid: h, cost: cost, cyc: abilityCycle(det), casts: 0, perCycle: 0,
rate: 0, special: true
});
return;
}
var cyc = abilityCycle(det);
var n = castsPerCycle(cyc);
var perCycle = cost * n;
totalPerCycle += perCycle;
details.push({
hrid: h, cost: cost, cyc: cyc, casts: n, perCycle: perCycle,
rate: perCycle / CYCLE_SEC
});
});
details.sort(function (a, b) { return b.rate - a.rate; });
return { rate: totalPerCycle / CYCLE_SEC, perCycle: totalPerCycle, details: details };
}
// 回蓝食物清单:只取 manapointRestore > 0 的。
// 注意:consumables 来自 normalizePlayer 的归一化结构,字段名是 hrid(不是 itemHrid)。
function manaFoods(self) {
var out = [];
(self.consumables || []).forEach(function (c) {
if (!c || c.count <= 0) return;
var hr = c.hrid || c.itemHrid; // 兼容两种字段名
if (!hr) return;
var d = state.itemDetailMap && state.itemDetailMap[hr];
var cd = d && d.consumableDetail;
if (!cd) return;
var mp = num(cd.manapointRestore);
if (mp <= 0) return;
// 强化对恢复量的加成:未在客户端缓存中查到确切倍率,不做臆测,只用基础值
var cdSec = num(cd.cooldownDuration) / 1e9 || 60;
// 恢复方式分两类(实测自客户端缓存 91 个消耗品样本):
// recoveryDuration = 0 → 软糖 / 甜甜圈,吃下瞬间到账 mp 点
// recoveryDuration > 0 → 酸奶 / 蛋糕,在这段时长内**均匀**补完 mp 点(实测恒为 30 秒)
// 这不是可忽略的细节:按瞬回算会把酸奶的救急能力高估整整 30 秒,
// 推演会以为「一口下去立刻满蓝」,而实际上蓝量还在继续掉。
var recoverySec = num(cd.recoveryDuration) / 1e9;
// 自动触发阈值:缺蓝 ≥ value 时吃(实测:阈值 === 回蓝量)
var threshold = null;
var trigs = cd.defaultCombatTriggers;
if (trigs && trigs.length) {
for (var i = 0; i < trigs.length; i++) {
var t = trigs[i];
if (t && String(t.conditionHrid || '').indexOf('missing_mp') !== -1) {
threshold = num(t.value);
break;
}
}
}
if (!(threshold > 0)) threshold = mp; // 无有效触发配置时按回蓝量兜底
out.push({
hrid: hr, mp: mp, count: c.count, cdSec: cdSec, recoverySec: recoverySec,
threshold: threshold, enh: c.enh
});
});
// 阈值高的先用(先吃大补撑更久),同阈值按回蓝量降序
out.sort(function (a, b) { return b.threshold - a.threshold || b.mp - a.mp; });
return out;
}
// 完整续航推演
// 返回 { cycle:{drain, regen, foodRate, net}, sustainable, timeToEmpty, foodUsed, breakdown }
function projectMana(self) {
var mpMax = self.mpMax;
var result = {
ok: false, cycle: null, sustainable: false,
timeToEmpty: Infinity, naturalOnly: Infinity, foodUsed: 0,
// v1.5.4:食物耗尽相关(食物何时吃光 = 可战斗时长的另一半约束)
foodStockCount: 0, foodExhaustAt: null, foodLastsSec: Infinity,
foodEstimated: false, foodIsBottleneck: false,
note: ''
};
if (!(mpMax > 0)) { result.note = '蓝量上限未知'; return result; }
var regenPerSec = mpMax * self.mpRegenPer10 / 10;
var drain = abilityDrain(self);
var foods = manaFoods(self);
// —— 无食物时的净耗 ——
var netNatural = drain.rate - regenPerSec;
// —— 短路:自然回蓝已覆盖技能耗蓝 → 无限续航,不必推演 ——
if (netNatural <= 0) {
result.ok = true;
result.sustainable = true;
result.timeToEmpty = Infinity;
result.naturalOnly = Infinity;
result.foodUsed = 0;
result.drainedTotal = drain.perCycle;
result.foodsLeftCount = foods.reduce(function (s, f) { return s + f.count; }, 0);
// 自然回蓝已覆盖耗蓝 → 食物根本不必吃 → 永远不会耗尽
result.foodStockCount = result.foodsLeftCount;
result.foodExhaustAt = null;
result.foodLastsSec = Infinity;
result.foodEstimated = false;
result.foodIsBottleneck = false;
result.cycle = {
drain: drain.rate,
drainPerCycle: drain.perCycle,
regen: regenPerSec,
net: netNatural,
abilityDetails: drain.details,
foodKinds: foods.length,
foodCount: foods.reduce(function (s, f) { return s + f.count; }, 0),
foodsLeft: foods.reduce(function (s, f) { return s + f.count; }, 0),
supply: {
perSec: foods.reduce(function (s, f) { return s + f.mp / f.cdSec; }, 0),
need: netNatural,
enough: true,
gap: 0,
cdCapPer300: foods.reduce(function (s, f) { return s + CYCLE_SEC / f.cdSec; }, 0)
},
// 自然回蓝已覆盖耗蓝 → 稳定态每 300 秒的实测消耗:蓝量照耗,食物 0 消耗
per300: {
drained: drain.perCycle,
regen: regenPerSec * CYCLE_SEC,
foodCount: 0,
foodKinds: [],
drainedPerSec: drain.rate,
foodPerSec: 0,
secPerFood: Infinity
},
avgPer300: {
drained: drain.perCycle,
foodCount: 0,
windows: 1
}
};
result.buckets = [];
result.note = '自然回蓝 ≥ 技能耗蓝,可无限续航';
return result;
}
// —— 模拟:300 秒周期算稳定速率,10 秒细步长向前推演 ——
// 状态:当前蓝量 mp;食物库存 foodsLeft(每个食物维护自身 CD 就绪时刻 readyAt)
//
// ★ v1.5.6:起点固定为**满蓝 mpMax**,不再用当前蓝量。
// 旧实现从 self.mp 起算,而战斗中蓝量每几秒就变一次(battle_updated 一直推),
// 于是同一套配装每次刷新算出来的「可战斗时长」都不一样 —— 面板上的数字在跳,
// 用户根本没法据此判断。改成满蓝基准后,这个数字只由「配装 + 补给」决定,
// 是稳定的「这套配置能连续输出多久」;当前蓝量另有「当前蓝量」一行单独显示。
var mp = mpMax;
var foodsLeft = foods.map(function (f) {
return {
mp: f.mp, count: f.count, cdSec: f.cdSec, threshold: f.threshold,
hrid: f.hrid, readyAt: 0, recoverySec: f.recoverySec || 0
};
});
// 持续恢复流:酸奶/蛋糕这类吃下后不是瞬回,而是在 recoverySec 内匀速补蓝。
// 它们**已在恢复中的份额**与当前蓝量一起参与「缺蓝是否达阈值」的判定。
var recovering = [];
var elapsed = 0;
var totalFoodUsed = 0;
var trace = [];
// —— v1.5.4:食物库存追踪 ——
// foodStockCount:整场携带的回蓝食物总数(个)
// foodExhaustAt:食物被**真正吃光**的时刻(推演里发生了才有值,否则为 Infinity)
var foodStockCount = foods.reduce(function (s, f) { return s + f.count; }, 0);
var foodExhaustAt = Infinity;
var LIMIT_SEC = 400 * 86400; // 最多外推 400 天,避免极端数据卡死页面
// 步长:用 300 秒窗口算稳定速率,但**推进时间用细步长**(10 秒),
// 否则短命的耗尽时间会被 300 秒整步跳过(净耗 7 蓝/秒时 300 秒就见底了)。
var STEP = 10;
var drainedPerStep = drain.rate * STEP;
var drainedTotal = 0;
var regenPerStep = regenPerSec * STEP;
var depleted = false;
// —— 300 秒分桶:每个窗口独立记录「耗蓝量」与「耗食物数量」——
// 用于回答「每 300 秒到底吃几个食物、烧多少蓝」这个具体问题。
var buckets = [];
var cur = newBucket(0);
function newBucket(t) {
return { startT: t, t: t, drained: 0, regen: 0, food: 0, steps: 0, byHrid: {} };
}
function closeBucket(t) {
// 步数才是耗蓝/回蓝的真实计量基准:耗尽时刻是插值算出来的,会比整步小,
// 若拿它当 span 折算 300 秒消耗会把数字放大。
cur.t = t;
cur.spanSec = cur.steps * STEP;
buckets.push(cur);
cur = newBucket(t);
}
// 吃食物:缺蓝达到阈值就触发(阈值 === 回蓝量,来自真实触发配置)
// 约束一:食物自身 CD 未就绪不能吃(readyAt 逐点推进,不用取整近似)
// 约束二:一次只吃 1 个 —— 真实机制是喝一个即进 CD,不能连灌。
// (旧实现按 missing/mp 一次吃多个,会把耗食物量高估好几倍)
// ★ 必须在**步首与步末各判定一次**。只在步末判,会让「本步一开始就已经缺蓝」
// 的情况白白晚吃一整个步长;残血场景(mp 只剩几十点)会让整个 10 秒步的
// 消耗先于食物发生,续航被算少一半以上(实测 18 秒被算成 6 秒)。
function tryFeed(now) {
for (var i = 0; i < foodsLeft.length; i++) {
var f = foodsLeft[i];
if (f.count <= 0) continue;
if (now < f.readyAt) continue;
if (mpMax - mp < f.threshold) continue;
if (f.recoverySec > 0) {
// 持续恢复(酸奶 / 蛋糕):排队,在 recoverySec 内匀速补完,不瞬回
recovering.push({ perSec: f.mp / f.recoverySec, remaining: f.mp });
} else {
mp += f.mp; // 瞬时恢复(软糖 / 甜甜圈)
}
f.count -= 1;
f.readyAt = now + f.cdSec;
totalFoodUsed += 1;
cur.food += 1;
cur.byHrid[f.hrid] = (cur.byHrid[f.hrid] || 0) + 1;
}
foodsLeft = foodsLeft.filter(function (f) { return f.count > 0; });
// v1.5.4:食物刚刚被吃空的那一刻 → 记下「食物何时耗尽」
if (!foodsLeft.length && foods.length > 0 && foodExhaustAt === Infinity) {
foodExhaustAt = now;
}
}
while (elapsed < LIMIT_SEC) {
// 0) 步首:先把「进入这一步时就已经缺蓝」的食物吃掉
tryFeed(elapsed);
// 1) 自然收支:技能耗蓝 vs 自然回蓝
mp = mp + regenPerStep - drainedPerStep;
cur.drained += drainedPerStep;
cur.regen += regenPerStep;
drainedTotal += drainedPerStep;
cur.steps++;
// 2) 已吃下、仍在生效的持续恢复(酸奶 / 蛋糕)继续补蓝
for (var ri = 0; ri < recovering.length; ri++) {
var rf = recovering[ri];
if (rf.remaining <= 0) continue;
var amt = rf.perSec * STEP;
if (amt > rf.remaining) amt = rf.remaining;
mp += amt;
rf.remaining -= amt;
}
if (recovering.length) {
recovering = recovering.filter(function (r) { return r.remaining > 1e-9; });
}
// 3) 步末:吃掉这一步内掉出来的缺口
tryFeed(elapsed);
elapsed += STEP;
if (mp > mpMax) mp = mpMax;
// 记录耗尽时刻(更精确:用线性插值估算落在这一小步内的哪一点)
if (mp <= 0) {
// 耗尽时刻插值。设步首蓝量 M、净耗速率 netRate、步长 STEP:
// M = netPerStep - over (over = 步末超出 0 的量)
// 步内走完 t* = M / netRate 秒后见底
// → t* = STEP × (1 - over / netPerStep)
// ★ 分母必须是**净**耗(含自然回蓝)。旧实现用总耗 drainedPerStep 当分母,
// 在回蓝占比大时会把耗尽时刻算晚 —— 实测净耗 34.3/s、回蓝 2/s 时,
// 真实 16.9 秒被算成 12.9 秒,并连带破坏蓝量收支守恒。
var over = -mp;
var netPerStep = drainedPerStep - regenPerStep;
var frac = netPerStep > 0
? Math.max(0, Math.min(STEP, STEP * (1 - over / netPerStep)))
: 0;
elapsed -= STEP;
elapsed += frac;
depleted = true;
// 口径对齐:耗蓝/回蓝是按整步累加的,但这一步只走完 frac/STEP,按比例扣回。
var keep = Math.max(0, Math.min(1, frac / STEP));
cur.drained -= drainedPerStep * (1 - keep);
cur.regen -= regenPerStep * (1 - keep);
drainedTotal -= drainedPerStep * (1 - keep);
break;
}
if (trace.length < 14 && (elapsed % 300 === 0)) {
trace.push({ t: elapsed, mp: Math.round(mp), food: foodsLeft.reduce(function (s, x) { return s + x.count; }, 0) });
}
// 每满一个 300 秒窗口就封桶
if (elapsed % CYCLE_SEC === 0) closeBucket(elapsed);
}
// 收尾:若耗尽时 break 早于封桶检查,尾巴桶还没被收进buckets,这里补封一次,
// 保证耗蓝/食物总量闭合。
if (cur.steps > 0) closeBucket(elapsed);
result.ok = true;
// 食物供蓝能力:每种食物各自受自身 CD 约束,可持续供蓝速率 = Σ(mp / cdSec)。
// 这个值直接决定「稳态能否成立」—— 低于净耗速率时,食物只是拖延,断蓝是必然的。
var supplyPerSec = 0;
foods.forEach(function (f) { supplyPerSec += f.mp / f.cdSec; });
result.cycle = {
drain: drain.rate,
drainPerCycle: drain.perCycle,
regen: regenPerSec,
net: netNatural,
abilityDetails: drain.details,
foodKinds: foods.length,
foodCount: foods.reduce(function (s, f) { return s + f.count; }, 0),
foodsLeft: foodsLeft.reduce(function (s, x) { return s + x.count; }, 0),
supply: {
perSec: supplyPerSec, // 食物可持续供蓝速率
need: netNatural, // 需要补上的净耗速率
enough: supplyPerSec >= netNatural, // 能否长期维持
gap: netNatural - supplyPerSec, // 缺口(>0 表示撑不住)
cdCapPer300: foods.reduce(function (s, f) {
return s + CYCLE_SEC / f.cdSec; // 每 300 秒的物理消耗上限
}, 0)
}
};
// —— 把分桶汇总成「每 300 秒」的真实消耗(而不是理论速率)——
// 取**第一个走完的完整 300 秒窗口**作为稳态样本:它不含起手阶段的瞬时误差。
result.buckets = buckets;
// 完整窗口判定:按**实际推进的秒数**(步数×步长),不是插值后的时刻
var full = buckets.filter(function (b) {
return b.spanSec !== undefined ? Math.abs(b.spanSec - CYCLE_SEC) < 1e-6
: (b.t - b.startT === CYCLE_SEC);
});
// 优先取**第 2 个**完整窗口做样本:起手窗口里蓝量通常接近满值,要掉一会儿
// 才会触发食物,拿它当稳态会系统性低估消耗。只有一个窗口时退回第 1 个。
//
// ★ v1.5.4 修正:若食物在推演中途就吃光了(foodExhaustAt 有效),
// 「稳态」根本不存在 —— 吃光之后就是纯裸耗,再取第 2 个窗口会得到
// 「耗食物 0 个(不需要吃)」这种自相矛盾的显示。此时改取
// **吃掉食物最多的那个完整窗口**,才如实反映「有食物时每 300 秒吃几个」。
var sample;
if (foodExhaustAt !== Infinity && full.length) {
sample = full[0];
full.forEach(function (b) { if (b.food > sample.food) sample = b; });
} else {
sample = full.length > 1 ? full[1] : (full.length ? full[0] : null);
}
// 续航不足一个窗口(打得很快就没蓝了)时,按已推演的实际时长线性折算,
// 保证「每 300 秒」口径始终有值可显示,而不是直接空掉。
if (!sample && buckets.length) {
var last = buckets[buckets.length - 1];
var span = Math.max(1e-9, last.spanSec || (last.t - last.startT));
var scale = CYCLE_SEC / span;
var kinds2 = [];
var cappedAny = false;
Object.keys(last.byHrid).forEach(function (h) {
var one = foods.filter(function (f) { return f.hrid === h; })[0];
// 线性外推不能突破物理上限:同一种食物 300 秒内最多吃 300/cdSec 次
var cap = one && one.cdSec > 0 ? CYCLE_SEC / one.cdSec : Infinity;
var cnt = last.byHrid[h] * scale;
if (cnt > cap) { cnt = cap; cappedAny = true; }
kinds2.push({
hrid: h, name: itemName(h), mp: one ? one.mp : 0, count: cnt
});
});
kinds2.sort(function (a, b) { return b.count - a.count || b.mp - a.mp; });
sample = {
scaled: true, span: span, capped: cappedAny,
drained: last.drained * scale,
regen: last.regen * scale,
food: kinds2.reduce(function (s, k) { return s + k.count; }, 0),
byHrid: last.byHrid
};
sample.kinds = kinds2;
}
if (sample) {
// 同一窗口内按种类拆分的食物消耗(中文明细用)
var kinds = sample.kinds || [];
if (!kinds.length) {
Object.keys(sample.byHrid).forEach(function (h) {
var one = foods.filter(function (f) { return f.hrid === h; })[0];
kinds.push({
hrid: h, name: itemName(h), mp: one ? one.mp : 0,
count: sample.byHrid[h]
});
});
kinds.sort(function (a, b) { return b.count - a.count || b.mp - a.mp; });
}
result.cycle.per300 = {
drained: sample.drained, // 该窗口净耗蓝量(点)
regen: sample.regen,// 该窗口自然回蓝量(点)
foodCount: sample.food, // 该窗口吃掉的食物个数
foodKinds: kinds,
drainedPerSec: sample.drained / CYCLE_SEC,
foodPerSec: sample.food / CYCLE_SEC,
scaled: !!sample.scaled, // true = 由实际时长折算而来(续航 < 300s)
// 折算成「1 食物顶多少秒」
secPerFood: sample.food > 0 ? CYCLE_SEC / sample.food : Infinity
};
} else {
result.cycle.per300 = null;
}
// 全部完整窗口的平均(样本足够多时更平滑),至少给出总体口径
if (full.length > 1) {
var sumD = 0, sumF = 0;
full.forEach(function (b) { sumD += b.drained; sumF += b.food; });
result.cycle.avgPer300 = {
drained: sumD / full.length,
foodCount: sumF / full.length,
windows: full.length
};
} else if (result.cycle.per300) {
result.cycle.avgPer300 = {
drained: result.cycle.per300.drained,
foodCount: result.cycle.per300.foodCount,
windows: 1
};
} else {
result.cycle.avgPer300 = null;
}
result.foodUsed = totalFoodUsed;
result.drainedTotal = drainedTotal; // 推演窗口内累计耗蓝(点)
result.foodsLeftCount = result.cycle.foodsLeft;
result.sustainable = false;
// 耗尽时刻:优先用推演结果;若 400 天内未耗尽,视为可长期续航
if (depleted) {
result.timeToEmpty = elapsed;
result.exhausted = true;
} else {
result.timeToEmpty = Infinity;
result.exhausted = false;
}
// 纯自然回蓝(不吃食物)的耗尽时间,供对照(同样按满蓝基准,保证与主数字同口径)
result.naturalOnly = mpMax / netNatural;
// —— 食物何时耗尽(v1.5.6:改成闭式稳态,不再依赖会滑动的推演采样窗口)——
// 旧实现拿「每 300 秒实测吃几个」外推,而那个吃速取自推演样本窗口,
// 样本窗口会随当前蓝量/战斗状态滑动 → 「食物可撑多久」跟着一起跳,不可信。
// 正确口径只由「库存」和「冷却」决定,跟当前蓝量无关:
// 库存总补蓝量 = Σ(单个补蓝 × 数量)
// 吃速上限 = Σ(单个补蓝 ÷ 自身CD) ← 冷却硬约束
// 两者相除 = 按最快吃速把库存吃完需要多久 = 「这些食物能吃多久」的上限。
// 若推演里食物真的被吃光(少见),仍以实测时刻为准(更准)。
var foodTotalMp = 0; // 库存能提供的总蓝量
foods.forEach(function (f) { foodTotalMp += f.mp * f.count; });
var foodSupplyPerSec = 0; // 吃速上限(蓝/秒)
foods.forEach(function (f) { foodSupplyPerSec += f.mp / f.cdSec; });
var steadyFoodPerSec = (foodSupplyPerSec > 0 && foodStockCount > 0)
? foodSupplyPerSec / (foodTotalMp / foodStockCount) // 平均每秒吃几个(满载)
: 0;
var foodLastsSec;
if (foodExhaustAt !== Infinity) {
foodLastsSec = foodExhaustAt;
} else if (foodSupplyPerSec > 0 && foodTotalMp > 0) {
foodLastsSec = foodTotalMp / foodSupplyPerSec;
} else {
foodLastsSec = Infinity;
}
result.foodStockCount = foodStockCount;
result.foodExhaustAt = (foodExhaustAt === Infinity) ? null : foodExhaustAt;
result.foodLastsSec = foodLastsSec;
result.foodPerSec = steadyFoodPerSec;
// 是估算值(非推演实测)→ 面板上要标注「约」
result.foodEstimated = (foodExhaustAt === Infinity) && isFinite(foodLastsSec);
// 瓶颈判定:食物在蓝量归零之前就吃光了 → 瓶颈是「食物不够」,补货就能续
// 反之(食物还剩着就断蓝)→ 瓶颈是「供蓝不足」,加食物也没用
result.foodIsBottleneck = isFinite(foodLastsSec) && isFinite(result.timeToEmpty)
&& foodLastsSec < result.timeToEmpty - 1;
result.note = foods.length
? '已计入 ' + foods.length + ' 种回蓝食物共 ' + result.cycle.foodCount + ' 个'
: '未携带回蓝食物';
return result;
}
// 旧版瞬时估算(保留,供技能冷却显示参考)
function estimateManaDrain(self, regenPerSec) {
var abilities = self.abilities || [];
var cheapestReady = 0;
var now = Date.now();
var anyReady = false;
for (var i = 0; i < abilities.length; i++) {
var det = state.abilityDetailMap && state.abilityDetailMap[abilities[i].hrid];
var cost = det ? num(det.manaCost) : 0;
if (cost <= 0) continue;
var t = Date.parse(abilities[i].availableTime);
var ready = (!isFinite(t)) || t <= now;
if (ready) {
anyReady = true;
if (cost > cheapestReady) cheapestReady = cost;
}
}
if (!anyReady) return { rate: -regenPerSec, mode: 'idle' };
return { rate: cheapestReady - regenPerSec, mode: 'active' };
}
// 每秒更新的动态文本(回满时间、蓝量耗尽倒计时、技能冷却),不整页重绘
function updateDynTexts() {
var root = document.getElementById('mwi-adjutant-root');
if (!root || state.collapsed) return;
var self = getSelf();
// 回满还需
var ttfEl = root.querySelector('[data-ttf]');
if (ttfEl && self && self.mpRegenPer10 > 0 && self.mp < self.mpMax) {
var ptsPerSec = self.mpMax * self.mpRegenPer10 / 10;
ttfEl.textContent = fmtDur((self.mpMax - self.mp) / ptsPerSec);
}
// 蓝量续航倒计时:真实推演,随蓝量/食物消耗逐秒递减
var lifeEl = root.querySelector('[data-mana-life]');
if (lifeEl && self) {
var proj = projectMana(self);
if (proj.ok) {
if (proj.sustainable) {
lifeEl.textContent = '无限续航 ✅';
} else if (isFinite(proj.timeToEmpty)) {
lifeEl.textContent = fmtFull(proj.timeToEmpty);
// 剩余不足 1 小时时变红警示
if (proj.timeToEmpty < 3600) lifeEl.className = 'v warn';
} else {
lifeEl.textContent = '> ' + fmtFull(400 * 86400);
}
}
}
// ★ v1.5.13:血量管家动态刷新 —— 血条/血值随时在变,续航数字则随实测窗口滚动。
// 只在血量页签(元素存在)时才计算,避免其它页签白跑一遍 analyzeHp。
var hpLifeEl = root.querySelector('[data-hp-life]');
var hpTextEl = root.querySelector('#adj-hp-text');
var hpBarEl = root.querySelector('#adj-hp-bar');
if ((hpLifeEl || hpTextEl) && self) {
var hpMaxN = num(self.hpMax);
var hpN = num(self.hp);
if (hpTextEl && hpMaxN > 0) {
hpTextEl.textContent = fmtInt(hpN) + ' / ' + fmtInt(hpMaxN) +
'(' + Math.round(hpN / hpMaxN * 100) + '%)';
}
if (hpBarEl) {
var hpc = hpMaxN > 0 ? Math.round(hpN / hpMaxN * 100) : 0;
var fill = hpBarEl.querySelector('i');
if (fill) fill.style.width = Math.max(0, Math.min(100, hpc)) + '%';
}
if (hpLifeEl) {
var hpProj = projectHp(self);
if (hpProj.ok) {
if (hpProj.sustainable) {
hpLifeEl.textContent = '站得住 ✅';
} else if (isFinite(hpProj.withFood)) {
hpLifeEl.textContent = fmtFull(hpProj.withFood);
if (hpProj.withFood < 3600) hpLifeEl.className = 'v warn';
} else {
hpLifeEl.textContent = '> ' + fmtFull(400 * 86400);
}
}
}
}
// 技能冷却
var cds = root.querySelectorAll('[data-cd]');
for (var i = 0; i < cds.length; i++) {
var el = cds[i];
var at = el.dataset.at;
var t = Date.parse(at);
if (!at || !isFinite(t)) { el.innerHTML = ''; continue; }
var ms = t - Date.now();
el.innerHTML = ms <= 0 ? '✓ ' : Math.ceil(ms / 1000) + 's';
}
}
// ============================== 启动 ==============================
hookWS();
ensurePanel();
// 三张静态表(item/ability/buffType)必须从游戏内存读,WS 不下发。
// 先同步试一次,失败则退避轮询(拿到后会自动重渲染)。
ensureClientData();
scheduleClientDataRetry();
// 字典通常要等游戏 UI 挂载后才可取到,这里先试一次,失败则交给渲染重试
setTimeout(function () { if (ensureI18n()) scheduleRender(); else scheduleI18nRetry(); }, 1200);
// 心跳:刷新动态文本(回满倒计时、蓝量耗尽、冷却),经验靠 init_character_data 被动更新
setInterval(function () {
var root = document.getElementById('mwi-adjutant-root');
if (!root) return;
// 兜底:若静态表迟迟未到(游戏加载慢),继续补取
if (!clientDataComplete()) ensureClientData();
updateDynTexts();
}, 1000);
console.info('[战斗副官] v' + VERSION + ' 已加载(只读模式:不向游戏服务器发送任何请求)');
})();