// ==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 + ' 已加载(只读模式:不向游戏服务器发送任何请求)'); })();