// ==UserScript== // @name MWIAlchemyCalc // @namespace http://tampermonkey.net/ // @version 20250425.5 // @description 显示炼金收益和产出统计 milkywayidle 银河奶牛放置 // @author IOMisaka // @match https://www.milkywayidle.com/* // @match https://test.milkywayidle.com/* // @icon https://www.milkywayidle.com/favicon.svg // @grant none // @license MIT // @downloadURL none // ==/UserScript== (function () { 'use strict'; if (!window.mwi) { console.error("MWIAlchemyCalc需要安装mooket才能使用"); return; } ////////////////code////////////////// function hookWS() { const dataProperty = Object.getOwnPropertyDescriptor(MessageEvent.prototype, "data"); const oriGet = dataProperty.get; dataProperty.get = hookedGet; Object.defineProperty(MessageEvent.prototype, "data", dataProperty); function hookedGet() { const socket = this.currentTarget; if (!(socket instanceof WebSocket)) { return oriGet.call(this); } if (socket.url.indexOf("api.milkywayidle.com/ws") <= -1 && socket.url.indexOf("api-test.milkywayidle.com/ws") <= -1) { return oriGet.call(this); } const message = oriGet.call(this); Object.defineProperty(this, "data", { value: message }); // Anti-loop handleMessage(message); return message; } } let clientData = null; let characterData = null; function loadClientData() { if (localStorage.getItem("initClientData")) { const obj = JSON.parse(localStorage.getItem("initClientData")); clientData = obj; } } function handleMessage(message) { let obj = JSON.parse(message); if (obj) { if (obj.type === "init_character_data") { characterData = obj; } else if (obj.type === "action_type_consumable_slots_updated") {//更新饮料和食物槽数据 characterData.actionTypeDrinkSlotsMap = obj.actionTypeDrinkSlotsMap; characterData.actionTypeFoodSlotsMap = obj.actionTypeFoodSlotsMap; handleAlchemyDetailChanged(); } else if (obj.type === "consumable_buffs_updated") { characterData.consumableActionTypeBuffsMap = obj.consumableActionTypeBuffsMap; handleAlchemyDetailChanged(); } else if (obj.type === "community_buffs_updated") { characterData.communityActionTypeBuffsMap = obj.communityActionTypeBuffsMap; handleAlchemyDetailChanged(); } else if (obj.type === "equipment_buffs_updated") {//装备buff characterData.equipmentActionTypeBuffsMap = obj.equipmentActionTypeBuffsMap; characterData.equipmentTaskActionBuffs = obj.equipmentTaskActionBuffs; handleAlchemyDetailChanged(); } else if (obj.type === "house_rooms_updated") {//房屋更新 characterData.characterHouseRoomMap = obj.characterHouseRoomMap; characterData.houseActionTypeBuffsMap = obj.houseActionTypeBuffsMap; } else if (obj.type === "actions_updated") { obj.endCharacterActions?.forEach( action => { if (action.actionHrid.startsWith("/actions/alchemy")) { updateAlchemyAction(action); } } ); } else if (obj.type === "action_completed") {//更新技能等级和经验 if (obj.endCharacterItems) {//道具更新 //炼金统计 try { if (obj.endCharacterAction.actionHrid.startsWith("/actions/alchemy")) {//炼金统计 let outputHashCount = {}; let inputHashCount = {}; let tempItems = {}; obj.endCharacterItems.forEach( item => { let existItem = tempItems[item.id] || characterData.characterItems.find(x => x.id === item.id); //console.log("炼金(old):",existItem.id,existItem.itemHrid, existItem.count); //console.log("炼金(new):", item.id,item.itemHrid, item.count); let delta = (item.count - (existItem?.count || 0));//计数 if (delta < 0) {//数量减少 inputHashCount[item.hash] = (inputHashCount[item.hash] || 0) + delta;//可能多次发送同一个物品 tempItems[item.id] = item;//替换旧的物品计数 } else if (delta > 0) {//数量增加 outputHashCount[item.hash] = (outputHashCount[item.hash] || 0) + delta;//可能多次发送同一个物品 tempItems[item.id] = item;//替换旧的物品计数 } else { console.log("炼金统计出错?不应该为0", item); } } ); let index = [ "/actions/alchemy/coinify", "/actions/alchemy/decompose", "/actions/alchemy/transmute" ].findIndex(x => x === obj.endCharacterAction.actionHrid); countAlchemyOutput(inputHashCount, outputHashCount, index); } } catch (e) { } let newIds = obj.endCharacterItems.map(i => i.id); characterData.characterItems = characterData.characterItems.filter(e => !newIds.includes(e.id));//移除存在的物品 characterData.characterItems.push(...mergeObjectsById(obj.endCharacterItems));//放入新物品 } if (obj.endCharacterSkills) { for (let newSkill of obj.endCharacterSkills) { let oldSkill = characterData.characterSkills.find(skill => skill.skillHrid === newSkill.skillHrid); oldSkill.level = newSkill.level; oldSkill.experience = newSkill.experience; } } } else if (obj.type === "items_updated") { if (obj.endCharacterItems) {//道具更新 let newIds = obj.endCharacterItems.map(i => i.id); characterData.characterItems = characterData.characterItems.filter(e => !newIds.includes(e.id));//移除存在的物品 characterData.characterItems.push(...mergeObjectsById(obj.endCharacterItems));//放入新物品 } } } return message; } function mergeObjectsById(list) { return Object.values(list.reduce((acc, obj) => { const id = obj.id; acc[id] = { ...acc[id], ...obj }; // 后面的对象会覆盖前面的 return acc; }, {})); } /////////辅助函数,角色动态数据/////////// // skillHrid = "/skills/alchemy" function getSkillLevel(skillHrid, withBuff = false) { let skill = characterData.characterSkills.find(skill => skill.skillHrid === skillHrid); let level = skill?.level || 0; if (withBuff) {//计算buff加成 level += getBuffValueByType( skillHrid.replace("/skills/", "/action_types/"), skillHrid.replace("/skills/", "/buff_types/") + "_level" ); } return level; } /// actionTypeHrid = "/action_types/alchemy" /// buffTypeHrid = "/buff_types/alchemy_level" function getBuffValueByType(actionTypeHrid, buffTypeHrid) { let returnValue = 0; //社区buff for (let buff of characterData.communityActionTypeBuffsMap[actionTypeHrid] || []) { if (buff.typeHrid === buffTypeHrid) returnValue += buff.flatBoost; } //装备buff for (let buff of characterData.equipmentActionTypeBuffsMap[actionTypeHrid] || []) { if (buff.typeHrid === buffTypeHrid) returnValue += buff.flatBoost; } //房屋buff for (let buff of characterData.houseActionTypeBuffsMap[actionTypeHrid] || []) { if (buff.typeHrid === buffTypeHrid) returnValue += buff.flatBoost; } //茶饮buff for (let buff of characterData.consumableActionTypeBuffsMap[actionTypeHrid] || []) { if (buff.typeHrid === buffTypeHrid) returnValue += buff.flatBoost; } return returnValue; } /** * 获取角色ID * * @returns {string|null} 角色ID,如果不存在则返回null */ function getCharacterId() { return characterData?.character.id; } /** * 获取指定物品的数量 * * @param itemHrid 物品的唯一标识 * @param enhancementLevel 物品强化等级,默认为0 * @returns 返回指定物品的数量,如果未找到该物品则返回0 */ function getItemCount(itemHrid, enhancementLevel = 0) { return characterData.characterItems.find(item => item.itemHrid === itemHrid && item.itemLocationHrid === "/item_locations/inventory" && item.enhancementLevel === enhancementLevel)?.count || 0;//背包里面的物品 } //获取饮料状态,传入类型/action_types/brewing,返回列表 function getDrinkSlots(actionTypeHrid) { return characterData.actionTypeDrinkSlotsMap[actionTypeHrid] } /////////游戏静态数据//////////// //中英文都有可能 function getItemHridByShowName(showName) { return window.mwi.ensureItemHrid(showName) } //类似这样的名字blackberry_donut,knights_ingot function getItemDataByHridName(hrid_name) { return clientData.itemDetailMap["/items/" + hrid_name]; } //类似这样的名字/items/blackberry_donut,/items/knights_ingot function getItemDataByHrid(itemHrid) { return clientData.itemDetailMap[itemHrid]; } //类似这样的名字Blackberry Donut,Knight's Ingot function getItemDataByName(name) { return Object.entries(clientData.itemDetailMap).find(([k, v]) => v.name == name); } function getOpenableItems(itemHrid) { let items = []; for (let openItem of clientData.openableLootDropMap[itemHrid]) { items.push({ itemHrid: openItem.itemHrid, count: (openItem.minCount + openItem.maxCount) / 2 * openItem.dropRate }); } return items; } ////////////观察节点变化///////////// function observeNode(nodeSelector, rootSelector, addFunc = null, updateFunc = null, removeFunc = null) { const rootNode = document.querySelector(rootSelector); if (!rootNode) { //console.error(`Root node with selector "${rootSelector}" not found.wait for 1s to try again...`); setTimeout(() => observeNode(nodeSelector, rootSelector, addFunc, updateFunc, removeFunc), 1000); return; } console.info(`observing "${rootSelector}"`); function delayCall(func, observer, delay = 100) { //判断func是function类型 if (typeof func !== 'function') return; // 延迟执行,如果再次调用则在原有基础上继续延时 func.timeout && clearTimeout(func.timeout); func.timeout = setTimeout(() => func(observer), delay); } const observer = new MutationObserver((mutationsList, observer) => { mutationsList.forEach((mutation) => { mutation.addedNodes.forEach((addedNode) => { if (addedNode.matches && addedNode.matches(nodeSelector)) { addFunc?.(observer); } }); mutation.removedNodes.forEach((removedNode) => { if (removedNode.matches && removedNode.matches(nodeSelector)) { removeFunc?.(observer); } }); // 处理子节点变化 if (mutation.type === 'childList') { let node = mutation.target.matches(nodeSelector) ? mutation.target : mutation.target.closest(nodeSelector); if (node) { delayCall(updateFunc, observer); // 延迟 100ms 合并变动处理,避免频繁触发 } } else if (mutation.type === 'characterData') { // 文本内容变化(如文本节点修改) delayCall(updateFunc, observer); } }); }); const config = { childList: true, subtree: true, characterData: true }; observer.reobserve = function () { observer.observe(rootNode, config); }//重新观察 observer.observe(rootNode, config); return observer; } loadClientData();//加载游戏数据 hookWS();//hook收到角色信息 //模块逻辑代码 const MARKET_API_URL = "https://raw.githubusercontent.com/holychikenz/MWIApi/main/milkyapi.json"; let marketData = JSON.parse(localStorage.getItem("MWIAPI_JSON") || localStorage.getItem("MWITools_marketAPI_json") || "{}");//Use MWITools的API数据 if (!(marketData?.time > Date.now() / 1000 - 86400)) {//如果本地缓存数据过期,则重新获取 fetch(MARKET_API_URL).then(res => { res.json().then(data => { marketData = data; //更新本地缓存数据 localStorage.setItem("MWIAPI_JSON", JSON.stringify(data));//更新本地缓存数据 console.info("MWIAPI_JSON updated:", new Date(marketData.time * 1000).toLocaleString()); }) }); } //返回[买,卖] function getPrice(itemHrid, enhancementLevel = 0) { return mwi.coreMarket.getItemPrice(itemHrid, enhancementLevel); } let includeRare = false; //计算每次的收益 function calculateProfit(data) { let profit = 0; let input = 0; let output = 0; let essence = 0; let rare = 0; let tea = 0; let catalyst = 0; for (let item of data.inputItems) {//消耗物品每次必定消耗 input -= getPrice(item.itemHrid).ask * item.count;//买入材料价格*数量 } for (let item of data.teaUsage) {//茶每次必定消耗 tea -= getPrice(item.itemHrid).ask * item.count;//买入材料价格*数量 } for (let item of data.outputItems) {//产出物品每次不一定产出,需要计算成功率 output += getPrice(item.itemHrid).bid * item.count * data.successRate;//卖出产出价格*数量*成功率 } if (data.inputItems[0].itemHrid !== "/items/task_crystal") {//任务水晶有问题,暂时不计算 for (let item of data.essenceDrops) {//精华和宝箱与成功率无关 消息id,10211754失败出精华! essence += getPrice(item.itemHrid).bid * item.count;//采集数据的地方已经算进去了 } if (includeRare) {//排除宝箱,因为几率过低,严重影响收益显示 for (let item of data.rareDrops) {//宝箱也是按自己的几率出! // getOpenableItems(item.itemHrid).forEach(openItem => { // rare += getPrice(openItem.itemHrid).bid * openItem.count * item.count;//已折算 // }); rare += getPrice(item.itemHrid).bid * item.count;//失败要出箱子,消息id,2793104转化,工匠茶失败出箱子了 } } } //催化剂 for (let item of data.catalystItems) {//催化剂,成功才会用 catalyst -= getPrice(item.itemHrid).ask * item.count * data.successRate;//买入材料价格*数量 } profit = input + tea + output + essence + rare + catalyst; let description = `Last Update:${new Date(marketData.time * 1000).toLocaleString()}\n(效率+${(data.effeciency * 100).toFixed(2)}%)每次收益${profit}=\n\t材料(${input})\n\t茶(${tea})\n\t催化剂(${catalyst})\n\t产出(${output})\n\t精华(${essence})\n\t稀有(${rare})`; //console.info(description); return [profit, description];//再乘以次数 } function showNumber(num) { if (isNaN(num)) return num; if (num === 0) return "0";// 单独处理0的情况 const sign = num > 0 ? '+' : ''; const absNum = Math.abs(num); return absNum >= 1e10 ? `${sign}${(num / 1e9).toFixed(1)}B` : absNum >= 1e7 ? `${sign}${(num / 1e6).toFixed(1)}M` : absNum >= 1e5 ? `${sign}${Math.floor(num / 1e3)}K` : `${sign}${Math.floor(num)}`; } function parseNumber(str) { return parseInt(str.replaceAll("/", "").replaceAll(",", "").replaceAll(" ", "")); } let predictPerDay = {}; function handleAlchemyDetailChanged(observer) { let inputItems = []; let outputItems = []; let essenceDrops = []; let rareDrops = []; let teaUsage = []; let catalystItems = []; let costNodes = document.querySelector(".AlchemyPanel_skillActionDetailContainer__o9SsW .SkillActionDetail_itemRequirements__3SPnA"); if (!costNodes) return;//没有炼金详情就不处理 let costs = Array.from(costNodes.children); //每三个元素取textContent拼接成一个字符串,用空格和/分割 for (let i = 0; i < costs.length; i += 3) { let need = parseNumber(costs[i + 1].textContent); let nameArr = costs[i + 2].textContent.split("+"); let itemHrid = getItemHridByShowName(nameArr[0]); let enhancementLevel = nameArr.length > 1 ? parseNumber(nameArr[1]) : 0; inputItems.push({ itemHrid: itemHrid, enhancementLevel: enhancementLevel, count: need }); } //炼金输出 for (let line of document.querySelectorAll(".SkillActionDetail_alchemyOutput__6-92q .SkillActionDetail_drop__26KBZ")) { let count = parseFloat(line.children[0].textContent.replaceAll(",", "")); let itemName = line.children[1].textContent; let rate = line.children[2].textContent ? parseFloat(line.children[2].textContent.substring(1, line.children[2].textContent.length - 1) / 100.0) : 1;//默认1 outputItems.push({ itemHrid: getItemHridByShowName(itemName), count: count * rate }); } //精华输出 for (let line of document.querySelectorAll(".SkillActionDetail_essenceDrops__2skiB .SkillActionDetail_drop__26KBZ")) { let count = parseFloat(line.children[0].textContent); let itemName = line.children[1].textContent; let rate = line.children[2].textContent ? parseFloat(line.children[2].textContent.substring(1, line.children[2].textContent.length - 1) / 100.0) : 1;//默认1 essenceDrops.push({ itemHrid: getItemHridByShowName(itemName), count: count * rate }); } //稀有输出 for (let line of document.querySelectorAll(".SkillActionDetail_rareDrops__3OTzu .SkillActionDetail_drop__26KBZ")) { let count = parseFloat(line.children[0].textContent); let itemName = line.children[1].textContent; let rate = line.children[2].textContent ? parseFloat(line.children[2].textContent.substring(1, line.children[2].textContent.length - 1) / 100.0) : 1;//默认1 rareDrops.push({ itemHrid: getItemHridByShowName(itemName), count: count * rate }); } //成功率 let successRateStr = document.querySelector(".SkillActionDetail_successRate__2jPEP .SkillActionDetail_value__dQjYH").textContent; let successRate = parseFloat(successRateStr.substring(0, successRateStr.length - 1)) / 100.0; //消耗时间 let costTimeStr = document.querySelector(".SkillActionDetail_timeCost__1jb2x .SkillActionDetail_value__dQjYH").textContent; let costSeconds = parseFloat(costTimeStr.substring(0, costTimeStr.length - 1));//秒,有分再改 //催化剂 let catalystItem = document.querySelector(".SkillActionDetail_catalystItemInput__2ERjq .Icon_icon__2LtL_") || document.querySelector(".SkillActionDetail_catalystItemInputContainer__5zmou .Item_iconContainer__5z7j4 .Icon_icon__2LtL_");//过程中是另一个框 if (catalystItem) { catalystItems = [{ itemHrid: getItemHridByShowName(catalystItem.getAttribute("aria-label")), count: 1 }]; } //计算效率 let effeciency = getBuffValueByType("/action_types/alchemy", "/buff_types/efficiency"); let skillLevel = getSkillLevel("/skills/alchemy", true); let mainItem = getItemDataByHrid(inputItems[0].itemHrid); if (mainItem.itemLevel) { effeciency += Math.max(0, skillLevel - mainItem.itemLevel) / 100;//等级加成 } //costSeconds = costSeconds * (1 - effeciency);//效率,相当于减少每次的时间 costSeconds = costSeconds / (1 + effeciency); //茶饮,茶饮的消耗就减少了 let teas = getDrinkSlots("/action_types/alchemy");//炼金茶配置 for (let tea of teas) { if (tea) {//有可能空位 teaUsage.push({ itemHrid: tea.itemHrid, count: costSeconds / 300 });//300秒消耗一个茶 } } console.info("效率", effeciency); //返回结果 let ret = { inputItems: inputItems, outputItems: outputItems, essenceDrops: essenceDrops, rareDrops: rareDrops, successRate: successRate, costTime: costSeconds, teaUsage: teaUsage, catalystItems: catalystItems, effeciency: effeciency, } //次数,收益 let result = calculateProfit(ret); let profit = result[0]; let desc = result[1]; let timesPerHour = 3600 / costSeconds;//加了效率相当于增加了次数 let profitPerHour = profit * timesPerHour; let timesPerDay = 24 * timesPerHour; let profitPerDay = profit * timesPerDay; const buttons = document.querySelectorAll(".AlchemyPanel_tabsComponentContainer__1f7FY .MuiButtonBase-root.MuiTab-root.MuiTab-textColorPrimary.css-1q2h7u5"); const selectedIndex = Array.from(buttons).findIndex(button => button.classList.contains('Mui-selected') ); predictPerDay[selectedIndex] = profitPerDay;//记录第几个对应的每日收益 observer?.disconnect();//断开观察 //显示位置 let showParent = document.querySelector(".SkillActionDetail_notes__2je2F"); let label = showParent.querySelector("#alchemoo"); if (!label) { label = document.createElement("div"); label.id = "alchemoo"; showParent.appendChild(label); } let color = "white"; if (profitPerHour > 0) { color = "lime"; } else if (profitPerHour < 0) { color = "red"; } label.innerHTML = `