rendered paste body// ==UserScript==// @name HV Monster PL Planner// @namespace https://hentaiverse.org/// @version 0.2.5// @description Standalone Monster Lab batch PL planner, upgrader and crystal buyer with HV Utils compatibility.// @author KirisameReiko// @include /^https:\/\/hentaiverse\.org\/\?s=Bazaar&ss=ml(?:&.*)?$/// @include /^https:\/\/alt\.hentaiverse\.org\/\?s=Bazaar&ss=ml(?:&.*)?$/// @grant GM_addStyle// @grant GM_getValue// @grant GM_setValue// @run-at document-end// ==/UserScript==(function () { 'use strict'; const query = Object.fromEntries(new URLSearchParams(location.search)); if ( location.protocol !== 'https:' || !['hentaiverse.org', 'alt.hentaiverse.org'].includes(location.hostname) || location.pathname !== '/' || query.s !== 'Bazaar' || query.ss !== 'ml' ) return; const STORE_KEY = 'hv_exact_pl_planner_v1'; const EPS = 1e-9; const PL_SCALE = 2; const PRIMARY_COST_BASE = 50; const PRIMARY_COST_RATE = 1.555079154; const ELEMENTAL_COST_BASE = 10; const ELEMENTAL_COST_RATE = 1.26485522; const REQUEST_DELAY_MS = 300; const DIRECT_BUY_RETRY_DELAY_MS = 5000; const DIRECT_BUY_MAX_RETRIES = 3; const MARKET_DETAIL_CONCURRENCY = 6; const primary = ['STR', 'DEX', 'AGI', 'END', 'INT', 'WIS']; const elemental = ['FIRE', 'COLD', 'ELEC', 'WIND', 'HOLY', 'DARK']; const all = [...primary, ...elemental]; const displayElemental = ['WIND', 'ELEC', 'FIRE', 'COLD', 'DARK', 'HOLY']; const displayAll = [...primary, ...displayElemental]; const primarySet = new Set(primary); // Add new Easter eggs here, one string per line. const easterEggMessages = [ 'Isekaijoucho', '🍜', '⑨', 'Dokidoki Wakuwaku', 'wawawaa', '( ・_ゝ・)', 'Rina Hidaka', 'How high can borrowed wings really fly?', 'Aizawa Nachu', 'Soyokaze Ame', 'Otherside Picnic', ]; const languageOptions = [ ['en', 'English'], ['zh-CN', '简体中文'], ]; const languageValues = languageOptions.map(([value]) => value); const crystalByAttr = { STR: 'Crystal of Vigor', DEX: 'Crystal of Finesse', AGI: 'Crystal of Swiftness', END: 'Crystal of Fortitude', INT: 'Crystal of Cunning', WIS: 'Crystal of Knowledge', FIRE: 'Crystal of Flames', COLD: 'Crystal of Frost', ELEC: 'Crystal of Lightning', WIND: 'Crystal of Tempest', HOLY: 'Crystal of Devotion', DARK: 'Crystal of Corruption', }; const upgradeQueryByAttr = { STR: 'pa_str', DEX: 'pa_dex', AGI: 'pa_agi', END: 'pa_end', INT: 'pa_int', WIS: 'pa_wis', FIRE: 'er_fire', COLD: 'er_cold', ELEC: 'er_elec', WIND: 'er_wind', HOLY: 'er_holy', DARK: 'er_dark', }; const crystalNames = all.map((a) => crystalByAttr[a]); const attrByCrystal = Object.fromEntries(all.map((attr) => [crystalByAttr[attr], attr])); const priceSourceValues = ['ask', 'bid', 'day', 'week', 'month', 'year', 'hvut']; const marketPriceSources = ['ask', 'bid', 'day', 'week', 'month', 'year']; const translations = { en: { plannerTitle: 'PL Planner', attr: { STR: 'STR', DEX: 'DEX', AGI: 'AGI', END: 'END', INT: 'INT', WIS: 'WIS', FIRE: 'FIRE', COLD: 'COLD', ELEC: 'ELEC', WIND: 'WIND', HOLY: 'HOLY', DARK: 'DARK', }, crystal: { STR: 'Crystal of Vigor', DEX: 'Crystal of Finesse', AGI: 'Crystal of Swiftness', END: 'Crystal of Fortitude', INT: 'Crystal of Cunning', WIS: 'Crystal of Knowledge', FIRE: 'Crystal of Flames', COLD: 'Crystal of Frost', ELEC: 'Crystal of Lightning', WIND: 'Crystal of Tempest', HOLY: 'Crystal of Devotion', DARK: 'Crystal of Corruption', }, labelCurrentPL: 'Current PL', labelTargetPL: 'Target PL', labelNeedPL: 'Needed', labelMaxPL: 'Max', labelTargetInput: 'Target PL', labelPriceSource: 'Market Price Source', labelOrderPriceSource: 'Buy Order Price Source', labelSelectedMonsters: 'Selected Monsters', labelLoadedCount: 'Cached', headingMonsterSelection: 'Monster Selection', headingCrystalNeeds: 'Crystal Requirements and Inventory', crystalPlanUnavailable: 'Crystal actions are unavailable until every selected monster has a valid plan. Review the monster errors below.', buttonFetchPrice: 'Refresh Prices', buttonLoadMonster: 'Refresh Monster Levels', buttonSelectAll: 'Select All', buttonClearSelection: 'Clear', buttonLoadStock: 'Read Crystal Inventory and Orders', buttonDirectBuy: 'Direct Buy Missing Crystals', buttonPlaceBuyOrders: 'Place Crystal Buy Orders', buttonRunUpgrade: 'Upgrade Selected Monsters by Plan', buttonRunningUpgrade: ({ current, total }) => `Upgrading ${current}/${total}`, buttonBuying: ({ current, total }) => `Buying ${current}/${total}`, totalCost: 'Total Cost:', totalMonsters: 'Monsters:', noUpgradeNeeded: 'No upgrade needed', tableAttr: 'Attribute', tableCurrentLevel: 'Current Level', tableTargetLevel: 'Upgrade To', tableIncrease: 'Increase', tableCost: 'Cost', tableCrystal: 'Crystal', tableRequired: 'Required', tableStock: 'Stock', tableShortage: 'Shortage', tableEstimatedCost: 'Estimated Spend', tableOrderPrice: 'Buy Price / Batch', stockUnknown: 'Not loaded', estimateUnavailable: 'Read order books first', estimateUsesSupply: ({ batches }) => `Includes ${batches} guaranteed-supply batch(es)`, estimateSummary: ({ cost, balance }) => `Order-book estimated spend: ${cost}. Estimated starting Market Balance required by whole-order maximum bids: ${balance}. Estimates use the current snapshot; final matches use the market response.`, listSeparator: ', ', priceSource: { ask: 'Ask', bid: 'Bid', day: 'Daily Avg', week: 'Weekly Avg', month: 'Monthly Avg', year: 'Yearly Avg', hvut: 'HV Utils Saved', }, errorTargetOverMax: ({ max }) => `Target exceeds max reachable PL: ${max}.`, errorTargetUnit: 'Cannot upgrade exactly to the target PL: the minimum PL unit is 0.5. Set the target PL to a multiple of 0.5.', errorTargetUnreachable: 'Cannot upgrade exactly to the target PL: this target is unreachable with the current levels and level caps.', errorCalculationPath: 'Calculation path error.', errorParseMonster: 'Could not parse attribute levels from the monster details page. Please confirm the page structure has not changed.', errorNoMonsterSelection: 'Select at least one monster first.', errorMonstersNotLoaded: 'Some selected monsters have no saved levels. Refresh their levels first.', errorNoValidPlan: 'Calculate a valid multi-monster plan first.', errorMarketItemForm: ({ crystal }) => `Could not parse the buy order form for ${crystal}. The market page structure may have changed. No order was submitted.`, errorCrystalBatch: ({ crystal }) => `Could not determine the live market batch size for ${crystal}. No order was submitted.`, errorCrystalBatchChanged: ({ crystal, expected, actual }) => `${crystal}'s market batch size changed from ${expected} to ${actual}. Recalculate before ordering.`, errorExistingBuyOrder: ({ crystals }) => `Direct buy stopped before submitting: an existing buy order was found for ${crystals}. Delete it manually or use persistent buy-order mode.`, errorDirectRemainder: ({ crystal, batches }) => `Direct buy stopped: ${crystal} left ${batches} high-price batch(es) unmatched and no usable delete control was returned. Check the market immediately.`, errorDirectRemainderCancelled: ({ crystal, batches }) => `Direct buy stopped after ${crystal}: ${batches} high-price unmatched batch(es) were automatically cancelled. No later crystal orders were submitted.`, errorOrderNotApplied: ({ crystal }) => `${crystal}'s response showed neither an inventory increase nor a remaining buy order. The market ignored the request; processing stopped.`, errorOrderResultMismatch: ({ crystal, submitted, matched, remaining }) => `${crystal}'s market response is inconsistent: submitted ${submitted}, matched ${matched}, remaining ${remaining}. Processing stopped.`, errorDirectPrice: ({ crystal }) => `Could not read the market's guaranteed direct-buy price for ${crystal}. No order was submitted.`, errorDirectBalance: ({ crystal, required, balance }) => `${crystal}'s single direct-buy order needs at least ${required} C of available Market Balance because it is submitted at the market's guaranteed ceiling price. This is a maximum-bid balance requirement, not the estimated or final cost; completed matches are charged at their actual sell prices. Current balance: ${balance} C. No order was submitted.`, statusLoadingMonsters: ({ done, total }) => `Refreshing and saving monster levels... ${done}/${total}`, statusLoadedMonsters: ({ total }) => `Refreshed and saved ${total} selected monster(s).`, statusLoadedCurrentMonster: 'Refreshed and saved levels from the current monster details page.', statusReadingMarket: ({ done = 0, total = 12 }) => done ? `Reading prices, inventory, and order books... ${done}/${total}` : 'Reading prices, inventory, and order books...', statusInventoryPartial: ({ failed }) => `Market snapshot loaded partially. Missing: ${failed}.`, statusMarketPartial: ({ updated, failed }) => `Saved price cache refreshed: ${updated}/12 items. Missing: ${failed}. Existing saved values were kept.`, statusMarketComplete: ({ source }) => `All price sources were refreshed and saved. Calculation source: ${source}.`, statusNoUpgrade: 'No upgrade is needed.', statusNoShortage: 'The loaded inventory covers this plan; no crystals need to be bought.', statusCrystalShortage: ({ failed }) => `Upgrade stopped because crystal inventory is short: ${failed}.`, statusOrderPricesComplete: ({ source }) => `Buy order source changed to ${source} and saved prices were applied; every row remains manually editable.`, statusOrderPricesPartial: ({ source, updated, failed }) => `Buy order source changed to ${source}; applied ${updated}/12 saved prices. Missing: ${failed}. Existing values were kept.`, statusCheckingMonsters: ({ done, total }) => `Checking selected monsters... ${done}/${total}`, statusBatchUpgradeCancelled: ({ slots }) => `Batch upgrade cancelled before making changes: current levels changed for monster(s) ${slots}. Please calculate again.`, statusExecutingUpgrade: ({ current, total, slot, attr, count }) => `Executing upgrade ${current}/${total}: monster #${slot}, ${attr} +${count}`, statusUpgradeComplete: ({ total }) => `Batch upgrade complete: ${total} monster(s) reached their plans.`, statusUpgradeMismatch: 'Upgrade requests completed, but the final levels do not exactly match the plan. Please check crystal stock or page messages.', statusCheckingPurchase: 'Refreshing inventory and validating crystal orders...', statusExecutingPurchase: ({ current, total, crystal, crystals, price }) => `Submitting crystal order ${current}/${total}: ${crystal}, ${crystals} crystals in one order @ ${price} C per market batch`, statusRetryingPurchase: ({ crystal, attempt }) => `${crystal}'s order was temporarily ignored; retrying after a cooldown (${attempt}/${DIRECT_BUY_MAX_RETRIES})...`, statusDirectBuyComplete: ({ total }) => `Direct-buy requests completed for ${total} crystal type(s); inventory has been refreshed.`, statusBuyOrdersComplete: ({ total }) => `Buy orders placed for ${total} crystal type(s).`, statusSavedPriceSource: ({ source }) => `Using saved ${source} prices for calculation.`, statusSavedPriceSourcePartial: ({ source, updated, failed }) => `Using saved ${source} prices for calculation: ${updated}/12 applied. Missing: ${failed}. Existing calculation prices were kept for those items.`, statusFetchPriceFailed: ({ message }) => `Price loading failed: ${message}`, statusLoadMonsterFailed: ({ message }) => `Monster level loading failed: ${message}`, statusUpgradeFailed: ({ message }) => `Upgrade failed: ${message}`, statusPurchaseFailed: ({ message }) => `Crystal purchase failed: ${message}`, statusLanguageSaved: 'Language saved.', confirmUpgrade: ({ monsters, requests, levels }) => `This will execute ${requests} upgrade requests for ${monsters} monster(s), increasing ${levels} total levels. Every monster and the crystal inventory will be checked again first. Continue?`, monsterFallback: ({ slot }) => `Monster ${slot}`, }, 'zh-CN': { plannerTitle: 'PL计划器', attr: { STR: '力量', DEX: '灵巧', AGI: '敏捷', END: '体质', INT: '智力', WIS: '智慧', FIRE: '火', COLD: '冰', ELEC: '雷', WIND: '风', HOLY: '圣', DARK: '暗', }, crystal: { STR: '力量水晶', DEX: '灵巧水晶', AGI: '敏捷水晶', END: '体质水晶', INT: '智力水晶', WIS: '智慧水晶', FIRE: '火焰水晶', COLD: '冰冻水晶', ELEC: '闪电水晶', WIND: '疾风水晶', HOLY: '神圣水晶', DARK: '暗黑水晶', }, labelCurrentPL: '当前 PL', labelTargetPL: '目标 PL', labelNeedPL: '还需', labelMaxPL: '最大', labelTargetInput: '目标 PL', labelPriceSource: '市场价来源', labelOrderPriceSource: '收购价来源', labelSelectedMonsters: '已选怪物', labelLoadedCount: '已缓存', headingMonsterSelection: '批量选择怪物', headingCrystalNeeds: '水晶需求、库存与缺口', crystalPlanUnavailable: '全部选中怪物都得到有效方案后才能使用水晶功能;请检查下方的怪物错误。', buttonFetchPrice: '刷新价格', buttonLoadMonster: '刷新怪物等级', buttonSelectAll: '全选', buttonClearSelection: '清空选择', buttonLoadStock: '读取水晶库存与订单', buttonDirectBuy: '直接买入缺少水晶', buttonPlaceBuyOrders: '挂水晶买单', buttonRunUpgrade: '按方案批量升级选中怪物', buttonRunningUpgrade: ({ current, total }) => `升级中 ${current}/${total}`, buttonBuying: ({ current, total }) => `买入中 ${current}/${total}`, totalCost: '总成本:', totalMonsters: '怪物数:', noUpgradeNeeded: '无需升级', tableAttr: '属性', tableCurrentLevel: '当前等级', tableTargetLevel: '需要提升到', tableIncrease: '提升', tableCost: '成本', tableCrystal: '水晶', tableRequired: '需要', tableStock: '库存', tableShortage: '缺口', tableEstimatedCost: '预估消耗', tableOrderPrice: '收购价 / 每批', stockUnknown: '未读取', estimateUnavailable: '请先读取订单簿', estimateUsesSupply: ({ batches }) => `含 ${batches} 批商店保证供货`, estimateSummary: ({ cost, balance }) => `订单簿预计支出:${cost};按整笔最高报价下单,预计所需初始市场余额:${balance}。预估使用当前快照,最终以市场响应为准。`, listSeparator: ',', priceSource: { ask: '卖价', bid: '买价', day: '日均价', week: '周均价', month: '月均价', year: '年均价', hvut: 'HV Utils 保存价', }, errorTargetOverMax: ({ max }) => `目标超过最大可达 PL:${max}。`, errorTargetUnit: '无法精确升级到指定 PL:PL 最小单位是 0.5,请把目标 PL 设为 0.5 的倍数。', errorTargetUnreachable: '无法精确升级到指定 PL:该目标在当前等级与等级上限下不可达。', errorCalculationPath: '计算路径异常。', errorParseMonster: '未能从怪物详情页解析属性等级。请确认怪物详情页结构未变化。', errorNoMonsterSelection: '请先至少选择一个怪物。', errorMonstersNotLoaded: '部分选中怪物没有已保存的等级,请先刷新怪物等级。', errorNoValidPlan: '请先计算出有效的多怪物升级方案。', errorMarketItemForm: ({ crystal }) => `无法解析 ${crystal} 的买单表单,市场页面结构可能已变化;本次没有提交该订单。`, errorCrystalBatch: ({ crystal }) => `无法从市场详情页确认 ${crystal} 的实际每批数量;本次没有提交该订单。`, errorCrystalBatchChanged: ({ crystal, expected, actual }) => `${crystal} 的市场批量已从 ${expected} 变为 ${actual},请重新计算后再下单。`, errorExistingBuyOrder: ({ crystals }) => `直接买入在提交前停止:${crystals} 已有买单。请手动删除,或改用“挂水晶买单”模式。`, errorDirectRemainder: ({ crystal, batches }) => `直接买入已停止:${crystal} 留下 ${batches} 批高价余单,且响应页没有可用的删除控件。请立即检查市场。`, errorDirectRemainderCancelled: ({ crystal, batches }) => `直接买入已在 ${crystal} 后停止:${batches} 批高价余单已自动撤销,后续水晶没有继续下单。`, errorOrderNotApplied: ({ crystal }) => `${crystal} 的响应既没有增加库存,也没有生成剩余买单;市场忽略了该请求,已停止后续处理。`, errorOrderResultMismatch: ({ crystal, submitted, matched, remaining }) => `${crystal} 的市场响应不一致:提交 ${submitted} 批、成交 ${matched} 批、剩余 ${remaining} 批,已停止后续处理。`, errorDirectPrice: ({ crystal }) => `无法读取 ${crystal} 的市场保证直接供货价,本次没有提交订单。`, errorDirectBalance: ({ crystal, required, balance }) => `${crystal} 的本次整笔直接买入按市场保证成交的最高报价提交,因此市场余额至少需要 ${required} C 才能下单。这是最高报价对应的可用余额要求,不是预计或最终花费;成交后按实际卖价扣款。当前余额为 ${balance} C,本次没有提交订单。`, statusLoadingMonsters: ({ done, total }) => `正在刷新并保存怪物等级... ${done}/${total}`, statusLoadedMonsters: ({ total }) => `已刷新并保存 ${total} 个选中怪物的等级。`, statusLoadedCurrentMonster: '已从当前怪物详情页刷新并保存等级。', statusReadingMarket: ({ done = 0, total = 12 }) => done ? `读取价格、库存和订单簿... ${done}/${total}` : '读取价格、库存和订单簿...', statusInventoryPartial: ({ failed }) => `市场快照仅部分读取成功,缺少:${failed}。`, statusMarketPartial: ({ updated, failed }) => `价格缓存已刷新 ${updated}/12 项,缺少:${failed};保留原有保存值。`, statusMarketComplete: ({ source }) => `所有价格来源均已刷新并保存;当前计算来源:${source}。`, statusNoUpgrade: '无需执行升级。', statusNoShortage: '当前库存足够执行方案,无需买入水晶。', statusCrystalShortage: ({ failed }) => `已停止升级,水晶库存仍有缺口:${failed}。`, statusOrderPricesComplete: ({ source }) => `收购价来源已改为 ${source},并自动应用保存价格;每种水晶仍可手动调整。`, statusOrderPricesPartial: ({ source, updated, failed }) => `收购价来源已改为 ${source},已应用 ${updated}/12 项保存价格;缺少:${failed},这些项目保留原值。`, statusCheckingMonsters: ({ done, total }) => `校验选中怪物... ${done}/${total}`, statusBatchUpgradeCancelled: ({ slots }) => `批量升级在产生变更前取消:怪物 ${slots} 的当前等级已变化,请重新读取并计算。`, statusExecutingUpgrade: ({ current, total, slot, attr, count }) => `执行升级 ${current}/${total}:怪物 #${slot},${attr} +${count}`, statusUpgradeComplete: ({ total }) => `批量升级完成:${total} 个怪物已达到各自方案。`, statusUpgradeMismatch: '升级请求已完成,但最终等级与方案不完全一致,请检查水晶库存或页面提示。', statusCheckingPurchase: '正在刷新库存并校验水晶订单...', statusExecutingPurchase: ({ current, total, crystal, crystals, price }) => `提交水晶订单 ${current}/${total}:${crystal},一次买入 ${crystals} 个,市场每组价格 ${price} C`, statusRetryingPurchase: ({ crystal, attempt }) => `${crystal} 的订单被市场临时忽略,冷却后重试(${attempt}/${DIRECT_BUY_MAX_RETRIES})...`, statusDirectBuyComplete: ({ total }) => `${total} 种水晶的直接买入请求已完成,库存已重新读取。`, statusBuyOrdersComplete: ({ total }) => `已为 ${total} 种水晶挂出买单。`, statusSavedPriceSource: ({ source }) => `计算已改用保存的 ${source} 价格。`, statusSavedPriceSourcePartial: ({ source, updated, failed }) => `计算已改用保存的 ${source} 价格:已应用 ${updated}/12 项;缺少 ${failed},这些项目保留原计算价格。`, statusFetchPriceFailed: ({ message }) => `读取价格失败:${message}`, statusLoadMonsterFailed: ({ message }) => `读取怪物等级失败:${message}`, statusUpgradeFailed: ({ message }) => `执行升级失败:${message}`, statusPurchaseFailed: ({ message }) => `买入水晶失败:${message}`, statusLanguageSaved: '语言已保存。', confirmUpgrade: ({ monsters, requests, levels }) => `将对 ${monsters} 个怪物执行 ${requests} 个升级请求,共提升 ${levels} 级。脚本会先重新校验全部怪物与水晶库存。是否继续?`, monsterFallback: ({ slot }) => `怪物 ${slot}`, }, }; const crystalNameAliases = new Map(); all.forEach((attr) => { const canonical = crystalByAttr[attr]; [canonical, translations.en.crystal[attr], translations['zh-CN'].crystal[attr]] .forEach((name) => crystalNameAliases.set(name, canonical)); }); const defaultPrice = { STR: 2.18, DEX: 1.52, AGI: 1.23, END: 1.95, INT: 1.25, WIS: 1.23, FIRE: 1.11, COLD: 1.09, ELEC: 1.11, WIND: 1.11, HOLY: 1.09, DARK: 1.10, }; function $(q, d = document) { return d.querySelector(q); } function $all(q, d = document) { return Array.from(d.querySelectorAll(q)); } function randomEasterEgg(current = '') { const candidates = easterEggMessages.filter((message) => message !== current); const pool = candidates.length ? candidates : easterEggMessages; return pool[Math.floor(Math.random() * pool.length)] || ''; } function htmlToDoc(html) { const doc = document.implementation.createHTMLDocument(''); doc.documentElement.innerHTML = html; return doc; } function elt(tag, attrs = {}, children = []) { const e = document.createElement(tag); Object.entries(attrs || {}).forEach(([k, v]) => { if (v === undefined || v === null) return; if (k === 'class') { e.className = v; } else if (k === 'style') { e.style.cssText = v; } else if (k === 'dataset') { Object.assign(e.dataset, v); } else if (k === 'text') { e.textContent = v; } else if (k in e) { e[k] = v; } else { e.setAttribute(k, v); } }); if (!Array.isArray(children)) children = [children]; children.forEach((c) => { if (c === null || c === undefined) return; e.appendChild( typeof c === 'string' || typeof c === 'number' ? document.createTextNode(String(c)) : c ); }); return e; } function parseNum(text) { const m = String(text || '').replace(/,/g, '').match(/-?\d+(?:\.\d+)?/); return m ? Number(m[0]) : NaN; } function positiveNumber(value) { const n = Number(value); return Number.isFinite(n) && n > 0 ? n : 0; } function clampInt(value, min, max) { const n = parseInt(value, 10); return Number.isFinite(n) ? Math.max(min, Math.min(max, n)) : min; } function gmGet(key, fallback) { try { const v = typeof GM_getValue === 'function' ? GM_getValue(key, null) : localStorage.getItem(key); return parseStoredJson(v, fallback); } catch (e) { return fallback; } } function gmSet(key, value) { try { if (typeof GM_setValue === 'function') { GM_setValue(key, JSON.stringify(value)); } else { localStorage.setItem(key, JSON.stringify(value)); } } catch (e) { console.warn('[PL Planner] Save failed:', e); } } function parseStoredJson(value, fallback = null) { if (value === null || value === undefined) return fallback; if (typeof value === 'string') { try { return JSON.parse(value); } catch (e) { return fallback; } } return value; } function isPlainObject(value) { return value && typeof value === 'object' && !Array.isArray(value); } function normalizeLanguage(language) { return languageValues.includes(language) ? language : 'en'; } function readTranslation(path, language = state.language) { const parts = path.split('.'); let value = translations[normalizeLanguage(language)]; for (const part of parts) { value = value?.[part]; } if (value !== undefined) return value; value = translations.en; for (const part of parts) { value = value?.[part]; } return value ?? path; } function t(path, params = {}) { const value = readTranslation(path); return typeof value === 'function' ? value(params) : value; } function attrLabel(attr) { return t(`attr.${attr}`); } function crystalLabel(crystalOrAttr) { const attr = crystalByAttr[crystalOrAttr] ? crystalOrAttr : attrByCrystal[crystalOrAttr]; return attr ? t(`crystal.${attr}`) : String(crystalOrAttr || ''); } function canonicalCrystalName(name) { return crystalNameAliases.get(String(name || '').trim()) || ''; } function priceSourceLabel(value) { return t(`priceSource.${value}`); } function joinList(values) { return values.join(t('listSeparator')); } function readHvutPriceStore() { try { const prices = parseStoredJson(localStorage.getItem('hvut_prices')); if (isPlainObject(prices)) return prices; } catch (e) { // fall through to this script's GM storage } try { if (typeof GM_getValue === 'function') { const prices = parseStoredJson(GM_getValue('hvut_prices', null)); if (isPlainObject(prices)) return prices; } } catch (e) { // ignore invalid external storage } return {}; } function writeHvutPrices(pricePatch) { const prices = Object.assign({}, readHvutPriceStore(), pricePatch); try { if (typeof GM_setValue === 'function') { GM_setValue('hvut_prices', prices); } } catch (e) { console.warn('[PL Planner] HV Utils GM price save failed:', e); } try { localStorage.setItem('hvut_prices', JSON.stringify(prices)); } catch (e) { console.warn('[PL Planner] HV Utils localStorage price save failed:', e); } } async function fetchText(url, data = null) { const full = new URL(url, location.origin).href; const response = await fetch(full, { method: data ? 'POST' : 'GET', body: data || undefined, credentials: 'same-origin', headers: data ? { 'Content-Type': 'application/x-www-form-urlencoded' } : undefined, redirect: 'follow', cache: 'no-store', }); const text = await response.text(); if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${full}`); if (text === 'state lock limiter in effect') throw new Error(text); return text; } function defaultLevels() { return Object.fromEntries(all.map((a) => [a, 0])); } function normalizeMonsterLevels(levels) { if (!isPlainObject(levels) || all.some((a) => !Number.isFinite(Number(levels[a])))) return null; return Object.fromEntries(all.map((a) => [a, clampInt(levels[a], 0, maxLevel(a))])); } function normalizeAttrPrices(value) { const source = isPlainObject(value) ? value : {}; return Object.fromEntries(all.map((attr) => [ attr, positiveNumber(source[attr]) || defaultPrice[attr], ])); } const savedState = gmGet(STORE_KEY, {}); const storedState = isPlainObject(savedState) ? savedState : {}; const state = { language: storedState.language ?? 'en', targetPL: storedState.targetPL ?? 750, priceSource: storedState.priceSource ?? 'ask', orderPriceSource: storedState.orderPriceSource ?? 'bid', prices: storedState.prices, priceCache: storedState.priceCache, orderUnitPrices: storedState.orderUnitPrices, levels: storedState.levels, monsterCache: storedState.monsterCache, selectedMonsterSlots: storedState.selectedMonsterSlots, lastMonsterSlot: storedState.lastMonsterSlot ?? '', }; state.prices = normalizeAttrPrices(state.prices); const storedPriceCache = isPlainObject(state.priceCache) ? state.priceCache : {}; state.priceCache = {}; state.levels = normalizeMonsterLevels(state.levels) || defaultLevels(); const normalizedMonsterCache = {}; if (isPlainObject(state.monsterCache)) { Object.entries(state.monsterCache).forEach(([slot, record]) => { const levels = normalizeMonsterLevels(record?.levels); if (!/^\d+$/.test(slot) || !levels) return; normalizedMonsterCache[slot] = { slot, name: String(record.name || `Monster ${slot}`), levels, }; }); } state.monsterCache = normalizedMonsterCache; const legacyMonsterLevels = normalizeMonsterLevels(storedState.levels); if (/^\d+$/.test(String(state.lastMonsterSlot)) && legacyMonsterLevels && !state.monsterCache[state.lastMonsterSlot]) { const slot = String(state.lastMonsterSlot); state.monsterCache[slot] = { slot, name: `Monster ${slot}`, levels: legacyMonsterLevels, }; } state.orderUnitPrices = normalizeAttrPrices(state.orderUnitPrices); if (!priceSourceValues.includes(state.priceSource)) { state.priceSource = 'ask'; } if (!priceSourceValues.includes(state.orderPriceSource)) { state.orderPriceSource = 'bid'; } if (!Array.isArray(state.selectedMonsterSlots)) { state.selectedMonsterSlots = []; } state.selectedMonsterSlots = Array.from(new Set( state.selectedMonsterSlots.map(String).filter((slot) => /^\d+$/.test(slot)) )); state.language = normalizeLanguage(state.language); const hvutSavedPrices = readHvutPriceStore(); for (const a of all) { const crystal = crystalByAttr[a]; const storedCache = isPlainObject(storedPriceCache[crystal]) ? storedPriceCache[crystal] : {}; const cached = {}; state.priceCache[crystal] = cached; marketPriceSources.forEach((source) => { const value = positiveNumber(storedCache[source]); if (value) cached[source] = value; }); cached.hvut = positiveNumber(storedCache.hvut) || positiveNumber(hvutSavedPrices[crystal]) || undefined; cached[state.priceSource] = positiveNumber(cached[state.priceSource]) || state.prices[a]; cached.batchSize = Math.floor(positiveNumber(storedCache.batchSize)) || undefined; state.levels[a] = clampInt(state.levels[a], 0, maxLevel(a)); } const runtime = { monsterList: [], monsters: new Map(Object.entries(state.monsterCache)), marketData: Object.fromEntries(crystalNames.map((name) => [name, Object.assign({}, state.priceCache[name])])), inventoryLoaded: false, orderBooksLoaded: false, lastPlan: null, busy: false, calculationTimer: null, calculationPending: false, }; const initialCachedMonster = runtime.monsters.get(String(state.lastMonsterSlot || state.selectedMonsterSlots[0] || '')); if (initialCachedMonster) state.levels = Object.assign({}, initialCachedMonster.levels); function saveState() { gmSet(STORE_KEY, state); } function isPrimary(a) { return primarySet.has(a); } function maxLevel(a) { return isPrimary(a) ? 25 : 50; } function primaryTotalPL(n) { return n * (6 + (n - 1) * 0.5) / 2; } function elementalTotalPL(n) { const t = Math.floor(n / 10); const r = n % 10; return (t + 1) * (t * 5 + r); } function deltaPL(a, n) { return isPrimary(a) ? 3 + n * 0.5 : Math.floor(1 + n * 0.1); } function crystalCost(a, n) { const primaryAttr = isPrimary(a); const base = primaryAttr ? PRIMARY_COST_BASE : ELEMENTAL_COST_BASE; const rate = primaryAttr ? PRIMARY_COST_RATE : ELEMENTAL_COST_RATE; return Math.round(base * Math.pow(rate, n)); } function totalPL(levels) { let pl = 0; primary.forEach((a) => { pl += primaryTotalPL(levels[a] || 0); }); elemental.forEach((a) => { pl += elementalTotalPL(levels[a] || 0); }); return pl; } const maxPL = primary.length * primaryTotalPL(25) + elemental.length * elementalTotalPL(50); function formatPL(v) { return Math.abs(v - Math.round(v)) < EPS ? String(Math.round(v)) : v.toFixed(2).replace(/\.?0+$/, ''); } function formatMoney(v) { if (!Number.isFinite(v)) return '-'; if (v >= 1000000) return (v / 1000000).toFixed(3) + ' MC'; if (v >= 1000) return (v / 1000).toFixed(3) + ' KC'; return v.toFixed(2) + ' C'; } function buildOptions(attr, startLevel, targetW) { const opts = [{ k: 0, w: 0, cost: 0, }]; let accW = 0; let accCrystals = 0; let accCost = 0; for (let n = startLevel; n < maxLevel(attr); n++) { const dpl = deltaPL(attr, n); const w = Math.round(dpl * PL_SCALE); const crystals = crystalCost(attr, n); const c = crystals * Number(state.prices[attr] || defaultPrice[attr]); accW += w; accCrystals += crystals; accCost += c; if (accW > targetW) break; opts.push({ attr, k: n - startLevel + 1, w: accW, crystals: accCrystals, cost: accCost, }); } return opts; } function solveExact(startLevels, target) { const currentPL = totalPL(startLevels); const effectiveTarget = Math.max(target, currentPL); if (effectiveTarget > maxPL + EPS) { return { ok: false, message: t('errorTargetOverMax', { max: formatPL(maxPL) }), }; } const need = effectiveTarget - currentPL; const needWFloat = need * PL_SCALE; const needW = Math.round(needWFloat); if (Math.abs(needWFloat - needW) > 1e-7) { return { ok: false, message: t('errorTargetUnit'), }; } let dp = new Array(needW + 1).fill(Infinity); dp[0] = 0; const parents = []; for (let gi = 0; gi < all.length; gi++) { const attr = all[gi]; const opts = buildOptions(attr, startLevels[attr], needW); const ndp = new Array(needW + 1).fill(Infinity); const parent = new Array(needW + 1).fill(null); for (let j = 0; j <= needW; j++) { if (!Number.isFinite(dp[j])) continue; for (const opt of opts) { const nj = j + opt.w; if (nj > needW) continue; const candidate = dp[j] + opt.cost; if (candidate + EPS < ndp[nj]) { ndp[nj] = candidate; parent[nj] = { prev: j, opt }; } } } dp = ndp; parents.push(parent); } if (!Number.isFinite(dp[needW])) { return { ok: false, message: t('errorTargetUnreachable'), }; } const chosen = []; let cur = needW; for (let gi = all.length - 1; gi >= 0; gi--) { const p = parents[gi][cur]; if (!p) { return { ok: false, message: t('errorCalculationPath'), }; } if (p.opt.k > 0) chosen.push(p.opt); cur = p.prev; } const finalLevels = Object.assign({}, startLevels); const agg = Object.fromEntries(all.map((attr) => [attr, { from: startLevels[attr], to: startLevels[attr], k: 0, crystals: 0, cost: 0, }])); let totalCost = 0; chosen.forEach((opt) => { finalLevels[opt.attr] += opt.k; const item = agg[opt.attr]; item.to += opt.k; item.k += opt.k; item.crystals += opt.crystals; item.cost += opt.cost; totalCost += opt.cost; }); return { ok: true, currentPL, targetPL: effectiveTarget, finalLevels, agg, totalCost, }; } function readInputs({ save = true } = {}) { all.forEach((a) => { const orderPrice = $(`#hvmepp-order-price-${a}`); if (orderPrice) { const crystal = crystalByAttr[a]; const batchSize = Math.floor(positiveNumber(runtime.marketData[crystal]?.batchSize)); const batchPrice = Math.max(1, Math.round(positiveNumber(orderPrice.value))); if (batchSize && batchPrice) { state.orderUnitPrices[a] = batchPrice / batchSize; orderPrice.value = batchPrice; } } }); const target = $('#hvmepp-target'); if (target) state.targetPL = parseFloat(target.value) || 0; const source = $('#hvmepp-source'); if (source) state.priceSource = source.value; const orderSource = $('#hvmepp-order-source'); if (orderSource) state.orderPriceSource = orderSource.value; if (save) saveState(); } function setLevels(levels, { save = true } = {}) { all.forEach((a) => { state.levels[a] = clampInt(levels[a], 0, maxLevel(a)); }); updateCurrentPL({ save: false }); if (save) saveState(); } function updateCurrentPL({ save = true } = {}) { readInputs({ save }); const current = totalPL(state.levels); const need = (Number(state.targetPL) || 0) - current; const node = $('#hvmepp-live'); if (node) { node.replaceChildren( `${t('labelCurrentPL')}: `, elt('b', { text: formatPL(current) }), ` ${t('labelTargetPL')}: `, elt('b', { text: formatPL(state.targetPL || 0) }), ` ${t('labelNeedPL')}: `, elt('b', { text: formatPL(need) }), ` ${t('labelMaxPL')}: `, elt('b', { text: formatPL(maxPL) }) ); } } function parseMonsterLevelsFromDoc(doc) { const stats = $all('#monsterstats_top td:nth-child(2)', doc) .map((td) => parseInt(td.textContent, 10)) .filter((n) => Number.isFinite(n)); if (stats.length < 12) return null; return Object.fromEntries(all.map((a, i) => [a, stats[i]])); } function extractSlotFromMonsterRow(row) { const candidates = [ row.getAttribute('onclick') || '', ...$all('a[href]', row).map((a) => a.getAttribute('href') || ''), ]; for (const text of candidates) { const slot = text.match(/[?&]slot=(\d+)/)?.[1]; if (slot) return slot; } return ''; } function parseMonsterName(row, slot) { const explicit = row.querySelector('[data-name], .hvmepp-monster-name')?.textContent?.trim(); if (explicit) return explicit; const cells = Array.from(row.children); const originalName = cells[1]?.textContent?.trim(); if (originalName) return originalName; const text = row.textContent.replace(/\s+/g, ' ').trim(); const withoutPL = text.replace(/\bPL\s*\d+\b/i, '').trim(); return withoutPL || t('monsterFallback', { slot }); } function parseMonsterPL(row) { const text = row.textContent.replace(/\s+/g, ' '); const fromText = text.match(/\bPL\s*(\d+)/i)?.[1]; if (fromText) return parseInt(fromText, 10); const structuredPL = parseNum(row.children[2]?.textContent); if (Number.isFinite(structuredPL)) return Math.floor(structuredPL); const originalPL = Array.from(row.children).slice(2) .map((child) => child.textContent || '') .map((text) => text.match(/^\s*(?:PL\s*)?(\d{1,4})\s*$/i)?.[1]) .find(Boolean); return originalPL ? parseInt(originalPL, 10) : 0; } function parseMonsterList() { return $all('#slot_pane > div').map((div) => { const onclick = div.getAttribute('onclick') || ''; if (onclick.includes('&create=new')) return null; const index = extractSlotFromMonsterRow(div); if (!index) return null; return { index, name: parseMonsterName(div, index), pl: parseMonsterPL(div), }; }).filter(Boolean); } async function fetchMonsterLevels(slot) { const html = await fetchText(`?s=Bazaar&ss=ml&slot=${encodeURIComponent(slot)}`); const doc = htmlToDoc(html); const levels = parseMonsterLevelsFromDoc(doc); if (!levels) { throw new Error(t('errorParseMonster')); } return levels; } function getMonsterMeta(slot) { const key = String(slot); return runtime.monsterList.find((monster) => String(monster.index) === key) || { name: t('monsterFallback', { slot: key }), }; } function cacheMonsterRecord(record, { save = true } = {}) { const slot = String(record?.slot || ''); const levels = normalizeMonsterLevels(record?.levels); if (!/^\d+$/.test(slot) || !levels) return null; const meta = getMonsterMeta(slot); const cached = { slot, name: String(record.name || meta.name || t('monsterFallback', { slot })), levels, }; state.monsterCache[slot] = cached; runtime.monsters.set(slot, cached); if (save) saveState(); return cached; } function getSelectedMonsterSlots() { const checkboxes = $all('.hvmepp-monster-check'); if (checkboxes.length) { return checkboxes.filter((checkbox) => checkbox.checked).map((checkbox) => String(checkbox.value)); } if (query.slot) return [String(query.slot)]; return state.selectedMonsterSlots.slice(); } function clearCurrentPlan() { runtime.lastPlan = null; const box = $('#hvmepp-result'); if (box) replacePlanContent(box); } function invalidateCurrentPlan() { runtime.lastPlan = null; const box = $('#hvmepp-result'); if (!box) return; box.setAttribute('aria-busy', 'true'); $all('button, input, select', box).forEach((control) => { control.disabled = true; }); } function clearScheduledCalculation() { if (runtime.calculationTimer !== null) clearTimeout(runtime.calculationTimer); runtime.calculationTimer = null; runtime.calculationPending = false; } function scheduleCalculation(delay = 0) { if (runtime.calculationTimer !== null) clearTimeout(runtime.calculationTimer); runtime.calculationPending = false; invalidateCurrentPlan(); runtime.calculationTimer = setTimeout(() => { runtime.calculationTimer = null; if (runtime.busy) { runtime.calculationPending = true; return; } calculate(); }, Math.max(0, Number(delay) || 0)); } function syncMonsterSelection() { if (runtime.busy) { const selected = new Set(state.selectedMonsterSlots.map(String)); $all('.hvmepp-monster-check').forEach((checkbox) => { checkbox.checked = selected.has(String(checkbox.value)); }); runtime.calculationPending = true; return; } state.selectedMonsterSlots = getSelectedMonsterSlots(); if (state.selectedMonsterSlots.length) { state.lastMonsterSlot = state.selectedMonsterSlots[0]; const loaded = runtime.monsters.get(state.lastMonsterSlot); setLevels(loaded?.levels || defaultLevels(), { save: false }); } else { state.lastMonsterSlot = ''; setLevels(defaultLevels(), { save: false }); } refreshMonsterSelectionSummary(); if (state.selectedMonsterSlots.length) scheduleCalculation(); else calculate(); } function refreshMonsterSelectionSummary() { const node = $('#hvmepp-selection-summary'); if (!node) return; const slots = getSelectedMonsterSlots(); const loaded = slots.filter((slot) => runtime.monsters.has(String(slot))).length; node.textContent = `${t('labelSelectedMonsters')}: ${slots.length} (${t('labelLoadedCount')}: ${loaded}/${slots.length})`; } async function mapLimit(values, limit, worker) { const items = Array.from(values); const results = new Array(items.length); let nextIndex = 0; async function run() { while (nextIndex < items.length) { const index = nextIndex++; results[index] = await worker(items[index], index); } } await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run)); return results; } async function loadSelectedMonsters() { const slots = getSelectedMonsterSlots(); if (!slots.length) throw new Error(t('errorNoMonsterSelection')); state.selectedMonsterSlots = slots; state.lastMonsterSlot = slots[0]; saveState(); let done = 0; const records = await mapLimit(slots, 4, async (slot) => { const levels = query.slot === slot ? parseMonsterLevelsFromDoc(document) || await fetchMonsterLevels(slot) : await fetchMonsterLevels(slot); const meta = getMonsterMeta(slot); const record = cacheMonsterRecord({ slot: String(slot), name: meta.name, levels, }, { save: false }); done++; setStatus(t('statusLoadingMonsters', { done, total: slots.length })); refreshMonsterSelectionSummary(); return record; }); if (records[0]?.levels) setLevels(records[0].levels, { save: false }); calculate(); setStatus(t('statusLoadedMonsters', { total: records.length })); return records; } function readCurrentMonsterIfAny({ recalculate = true } = {}) { const levels = parseMonsterLevelsFromDoc(document); if (!levels) return false; setLevels(levels, { save: false }); const slot = query.slot || state.lastMonsterSlot || ''; if (slot) { const meta = getMonsterMeta(slot); state.lastMonsterSlot = String(slot); state.selectedMonsterSlots = [String(slot)]; cacheMonsterRecord({ slot: String(slot), name: meta.name, levels, }, { save: false }); } saveState(); refreshMonsterSelectionSummary(); if (recalculate) calculate(); setStatus(t('statusLoadedCurrentMonster')); return true; } function parseMarketRows(doc, result) { const table = $('#market_itemlist table', doc); if (!table) return; const headers = Array.from(table.rows[0]?.cells || []) .map((cell) => cell.textContent.trim().replace(/\s+/g, ' ')); const findCol = (...names) => headers.findIndex((header) => names.includes(header)); const nameCol = Math.max(0, findCol('Item', '物品')); const stockCol = findCol('Your Stock', '你的库存'); const bidCol = findCol('Market Bid', '市场买价'); const askCol = findCol('Market Ask', '市场卖价'); Array.from(table.rows).forEach((tr, i) => { if (i === 0 || !tr.cells || tr.cells.length < 2) return; const name = canonicalCrystalName(tr.cells[nameCol]?.textContent); if (!name) return; const onclick = tr.getAttribute('onclick') || ''; const href = tr.querySelector('a[href*="itemid="]')?.getAttribute('href') || ''; const itemid = (onclick + ' ' + href).match(/itemid=(\d+)/)?.[1]; const stock = stockCol < 0 ? NaN : parseNum(tr.cells[stockCol]?.textContent); const bid = bidCol < 0 ? NaN : parseNum(tr.cells[bidCol]?.textContent); const ask = askCol < 0 ? NaN : parseNum(tr.cells[askCol]?.textContent); result[name] = Object.assign(result[name] || {}, { itemid, stock, bid, ask, unitBid: bid, unitAsk: ask, }); }); } async function fetchMarketCrystalIndex() { const result = {}; const urls = [ '?s=Bazaar&ss=mk&filter=mo&screen=browseitems', '?s=Bazaar&ss=mk&filter=ma&screen=browseitems', '?s=Bazaar&ss=mk&screen=browseitems', ]; for (const url of urls) { try { const html = await fetchText(url); parseMarketRows(htmlToDoc(html), result); if ( crystalNames.every((n) => result[n]?.itemid || Number.isFinite(result[n]?.ask) || Number.isFinite(result[n]?.bid) ) ) { break; } } catch (e) { console.warn('[PL Planner] Market browse failed:', url, e); } } return result; } function parseMarketBatchSize(doc) { const infoText = `${$('#market_iteminfo', doc)?.textContent || ''} ${$('.market_placeorder', doc)?.textContent || ''}`; const explicitBatch = infoText.match(/batches?\s+of\s+([\d,]+)/i)?.[1]; const localizedBatch = infoText.match(/每(?:组|組)\s*([\d,]+)\s*(?:件|個|个)/i)?.[1]; const xBatch = infoText.match(/[x×]\s*([\d,]+)/i)?.[1]; const batchSize = Math.floor(parseNum(explicitBatch || localizedBatch || xBatch)); return Number.isFinite(batchSize) && batchSize > 0 ? batchSize : 0; } function parseMarketDirectBuyPrice(doc) { const infoText = $('#market_iteminfo', doc)?.textContent || ''; const raw = infoText.match(/Can always be bought for\s+([\d,]+)\s*C/i)?.[1] || infoText.match(/(?:direct supply price|直接供貨價|直接供货价)[^\d]*([\d,]+)\s*C/i)?.[1]; const price = Math.floor(parseNum(raw)); return Number.isFinite(price) && price > 0 ? price : 0; } function parseMarketBalance(doc) { const candidates = [ ...$all('.credit_balance', doc), $('#market_xfer', doc), doc.body, ].filter(Boolean); for (const node of candidates) { const match = (node.textContent || '').match(/Market Balance[\s\S]*?([\d,]+)\s*C/i); const balance = parseNum(match?.[1]); if (Number.isFinite(balance)) return Math.max(0, Math.floor(balance)); } return null; } function parseOrderBook(doc, selector) { return $all(`${selector} .market_itemorders table tr`, doc).slice(1).map((row) => ({ crystals: Math.max(0, Math.floor(parseNum(row.cells?.[0]?.textContent) || 0)), batchPrice: Math.max(0, Math.round(parseNum(row.cells?.[1]?.textContent) || 0)), })).filter((order) => order.crystals > 0 && order.batchPrice > 0); } async function fetchMarketDetailData(itemid) { const html = await fetchText(`?s=Bazaar&ss=mk&screen=browseitems&filter=mo&itemid=${encodeURIComponent(itemid)}`); const doc = htmlToDoc(html); const name = canonicalCrystalName($('#market_itemheader', doc)?.children?.[1]?.textContent); const batchSize = parseMarketBatchSize(doc); const count = $('#buyorder_batchcount, input[name="buyorder_batchcount"]', doc); const rows = $('#market_price', doc)?.rows; if (!name) return null; const result = { batchSize, directBuyPrice: parseMarketDirectBuyPrice(doc), askOrders: parseOrderBook(doc, '#market_itemsell'), bidOrders: parseOrderBook(doc, '#market_itembuy'), currentOrder: { count: Math.max(0, Math.floor(parseNum(count?.value) || 0)), }, }; if (!batchSize || !rows || rows.length < 5) return result; function avg(rowIndex) { const text = rows[rowIndex]?.cells?.[3]?.textContent || ''; const v = parseNum(text); return v ? v / batchSize : 0; } return Object.assign(result, { day: avg(1), week: avg(2), month: avg(3), year: avg(4), }); } async function enrichCrystalMarketData(data) { const targets = crystalNames.filter((name) => data[name]?.itemid); let done = 0; await mapLimit(targets, MARKET_DETAIL_CONCURRENCY, async (name) => { let detail = null; try { detail = await fetchMarketDetailData(data[name].itemid); } catch (e) { console.warn('[PL Planner] Market crystal detail failed:', name, e); } done++; setStatus(t('statusReadingMarket', { done, total: targets.length })); if (detail) { const row = Object.assign(data[name], detail); const batchSize = positiveNumber(row.batchSize); row.batchBid = batchSize && positiveNumber(row.unitBid) ? Math.max(1, Math.round(row.unitBid * batchSize)) : 0; row.batchAsk = batchSize && positiveNumber(row.unitAsk) ? Math.max(1, Math.round(row.unitAsk * batchSize)) : 0; } }); } async function fetchCrystalMarketData() { const data = await fetchMarketCrystalIndex(); await enrichCrystalMarketData(data); runtime.marketData = Object.assign({}, runtime.marketData, data); return data; } function getCrystalPricesForSource(source, data = runtime.marketData) { const prices = {}; all.forEach((attr) => { const crystal = crystalByAttr[attr]; const value = positiveNumber(data[crystal]?.[source]); if (value) prices[crystal] = value; }); return prices; } function applyPricesByCrystal(priceByCrystal) { let updated = 0; const failed = []; displayAll.forEach((a) => { const crystal = crystalByAttr[a]; const v = positiveNumber(priceByCrystal[crystal]); if (v) { state.prices[a] = v; updated++; } else { failed.push(crystalLabel(crystal)); } }); return { updated, failed }; } function rebuildLastPlan() { if (!runtime.lastPlan?.results?.length) return; const monsters = runtime.lastPlan.results.map((result) => ({ slot: result.monsterSlot, name: result.monsterName, levels: result.startLevels, })); runtime.lastPlan = buildBatchPlan(monsters, Number(state.targetPL)); } function persistMarketPriceCache(data) { const hvutPrices = readHvutPriceStore(); let updated = 0; const failed = []; all.forEach((attr) => { const crystal = crystalByAttr[attr]; const market = data[crystal] || {}; const cached = state.priceCache[crystal] || (state.priceCache[crystal] = {}); marketPriceSources.forEach((source) => { const value = positiveNumber(market[source]); if (value) cached[source] = value; }); const hvut = positiveNumber(hvutPrices[crystal]); if (hvut) cached.hvut = hvut; if (positiveNumber(market.batchSize)) cached.batchSize = Math.floor(market.batchSize); if (marketPriceSources.every((source) => positiveNumber(cached[source]))) updated++; else failed.push(crystalLabel(crystal)); }); return { updated, failed }; } function applyCalculationPriceSource(source = state.priceSource) { return applyPricesByCrystal(getCrystalPricesForSource(source, state.priceCache)); } function applyOrderPricesFromCache({ save = true, notify = true } = {}) { const source = state.orderPriceSource || 'bid'; const prices = getCrystalPricesForSource(source, state.priceCache); let updated = 0; const failed = []; all.forEach((attr) => { const unitPrice = positiveNumber(prices[crystalByAttr[attr]]); if (!unitPrice) { failed.push(crystalLabel(attr)); return; } state.orderUnitPrices[attr] = unitPrice; const batchSize = Math.floor(positiveNumber( runtime.marketData[crystalByAttr[attr]]?.batchSize || state.priceCache[crystalByAttr[attr]]?.batchSize )); const input = $(`#hvmepp-order-price-${attr}`); if (input && batchSize) { input.value = Math.max(1, Math.round(unitPrice * batchSize)); input.disabled = false; } updated++; }); if (save) saveState(); if (notify) { setStatus(failed.length ? t('statusOrderPricesPartial', { source: priceSourceLabel(source), updated, failed: joinList(failed), }) : t('statusOrderPricesComplete', { source: priceSourceLabel(source) })); } return { updated, failed }; } async function refreshMarketSnapshot({ silent = false, rerender = true, applySavedSources = true } = {}) { readInputs({ save: false }); if (!silent) setStatus(t('statusReadingMarket')); const data = await fetchCrystalMarketData(); runtime.inventoryLoaded = true; runtime.orderBooksLoaded = true; const cacheResult = persistMarketPriceCache(data); if (applySavedSources) { applyCalculationPriceSource(); applyOrderPricesFromCache({ save: false, notify: false }); const selectedPrices = getCrystalPricesForSource(state.priceSource, state.priceCache); if (Object.keys(selectedPrices).length) { writeHvutPrices(selectedPrices); Object.entries(selectedPrices).forEach(([crystal, value]) => { state.priceCache[crystal].hvut = value; }); } rebuildLastPlan(); } saveState(); if (rerender && runtime.lastPlan) renderPlan(runtime.lastPlan); const snapshotMissing = crystalNames.filter((name) => !Number.isFinite(runtime.marketData[name]?.stock) || !positiveNumber(runtime.marketData[name]?.batchSize) || !Array.isArray(runtime.marketData[name]?.askOrders) || !Array.isArray(runtime.marketData[name]?.bidOrders) ); const failed = Array.from(new Set([...cacheResult.failed, ...snapshotMissing.map(crystalLabel)])); if (!silent) { setStatus(failed.length ? t('statusMarketPartial', { updated: cacheResult.updated, failed: joinList(failed) }) : t('statusMarketComplete', { source: priceSourceLabel(state.priceSource) })); } return runtime.marketData; } function levelsEqual(a, b) { return all.every((attr) => Number(a?.[attr]) === Number(b?.[attr])); } function getSelectedLoadedMonsters() { const slots = getSelectedMonsterSlots(); return slots.map((slot) => runtime.monsters.get(String(slot))).filter(Boolean); } function buildBatchPlan(monsters, target) { const results = monsters.map((monster) => { const levels = Object.assign({}, monster.levels); const result = solveExact(levels, target); return Object.assign(result, { startLevels: levels, monsterSlot: String(monster.slot), monsterName: monster.name || t('monsterFallback', { slot: monster.slot }), }); }); const requirements = Object.fromEntries(all.map((attr) => [attr, 0])); let totalCost = 0; results.forEach((result) => { if (!result.ok) return; totalCost += result.totalCost; all.forEach((attr) => { requirements[attr] += Number(result.agg[attr].crystals) || 0; }); }); return { ok: results.length > 0 && results.every((result) => result.ok), targetPL: target, results, requirements, totalCost, monsterCount: results.length, }; } function buildUpgradeRequests(result, slot) { const requests = []; all.forEach((attr) => { let count = result.agg[attr].k; const queryName = upgradeQueryByAttr[attr]; while (count > 0) { const step = Math.min(10, count); requests.push({ attr, count: step, slot: String(slot), url: `?s=Bazaar&ss=ml&slot=${encodeURIComponent(slot)}`, data: `crystal_upgrade=${encodeURIComponent(queryName)}&crystal_count=${step}`, }); count -= step; } }); return requests; } function requiredTradeBatches(shortage, batchSize) { const missing = Math.max(0, Math.ceil(Number(shortage) || 0)); if (!missing) return 0; const size = Math.floor(positiveNumber(batchSize)); return size ? Math.floor(missing / size) + 1 : null; } function estimateOrderBookPurchase(crystalsToBuy, batchSize, askOrders, directBuyPrice) { const size = Math.floor(positiveNumber(batchSize)); const totalBatches = size ? Math.floor(positiveNumber(crystalsToBuy) / size) : 0; if (!size || !totalBatches || !Array.isArray(askOrders)) return null; let remainingBatches = totalBatches; let estimatedCost = 0; let lowestBatchPrice = 0; let highestBatchPrice = 0; const sortedAsks = askOrders.slice().sort((a, b) => a.batchPrice - b.batchPrice); sortedAsks.forEach((order) => { if (!remainingBatches) return; const availableBatches = Math.floor(positiveNumber(order.crystals) / size); const batchPrice = Math.round(positiveNumber(order.batchPrice)); if (!availableBatches || !batchPrice) return; const matched = Math.min(remainingBatches, availableBatches); estimatedCost += matched * batchPrice; remainingBatches -= matched; if (!lowestBatchPrice) lowestBatchPrice = batchPrice; highestBatchPrice = batchPrice; }); const guaranteedPrice = Math.round(positiveNumber(directBuyPrice)); const guaranteedBatches = remainingBatches; if (remainingBatches && guaranteedPrice) { estimatedCost += remainingBatches * guaranteedPrice; if (!lowestBatchPrice) lowestBatchPrice = guaranteedPrice; highestBatchPrice = Math.max(highestBatchPrice, guaranteedPrice); remainingBatches = 0; } return { estimatedCost: remainingBatches ? null : estimatedCost, lowestBatchPrice, highestBatchPrice, guaranteedBatches, }; } function getPurchaseEstimateSummary(rows) { let estimatedCost = 0; let requiredStartingBalance = 0; const shortages = rows.filter((row) => row.shortage > 0 && row.batches > 0); if (!shortages.length || shortages.some((row) => !Number.isFinite(row.estimatedDirectCost))) return null; shortages.forEach((row) => { requiredStartingBalance = Math.max( requiredStartingBalance, estimatedCost + row.directBalanceRequired ); estimatedCost += row.estimatedDirectCost; }); return { estimatedCost, requiredStartingBalance }; } function getCrystalPlanRows(plan) { return displayAll.map((attr) => { const crystal = crystalByAttr[attr]; const market = runtime.marketData[crystal] || {}; const required = Math.max(0, Math.ceil(Number(plan?.requirements?.[attr]) || 0)); const rawStock = market.stock; const stock = runtime.inventoryLoaded && Number.isFinite(rawStock) ? Math.max(0, rawStock) : null; const shortage = stock === null ? null : Math.max(0, required - stock); const batchSize = Math.floor(positiveNumber(market.batchSize)) || null; const batches = shortage === null ? null : requiredTradeBatches(shortage, batchSize); const crystalsToBuy = batches === null || !batchSize ? null : batches * batchSize; const orderUnitPrice = positiveNumber(state.orderUnitPrices[attr]) || defaultPrice[attr]; const orderBatchPrice = batchSize ? Math.max(1, Math.round(orderUnitPrice * batchSize)) : null; const directBuyPrice = Math.floor(positiveNumber(market.directBuyPrice)) || 0; const estimate = runtime.orderBooksLoaded && crystalsToBuy ? estimateOrderBookPurchase(crystalsToBuy, batchSize, market.askOrders, directBuyPrice) : null; return { attr, crystal, label: crystalLabel(attr), itemid: market.itemid || '', required, stock, shortage, batchSize, batches, crystalsToBuy, orderBatchPrice, directBuyPrice, estimatedDirectCost: estimate?.estimatedCost ?? null, estimatedLowestBatchPrice: estimate?.lowestBatchPrice || 0, estimatedHighestBatchPrice: estimate?.highestBatchPrice || 0, guaranteedBatches: estimate?.guaranteedBatches || 0, directBalanceRequired: batches && directBuyPrice ? batches * directBuyPrice : 0, currentOrder: { count: Math.max(0, Math.floor(Number(market.currentOrder?.count) || 0)), }, }; }); } function getCrystalShortages(rows) { return rows.filter((row) => row.shortage > 0 && row.batches > 0); } function getUnknownCrystalRows(rows) { return rows.filter((row) => row.required > 0 && (row.stock === null || (row.shortage > 0 && (!row.itemid || !row.batchSize))) ); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function marketResponseError(doc) { const messages = $all('#messagebox_inner p', doc).map((node) => node.textContent.trim()).filter(Boolean); return messages.find((message) => /(?:error|invalid|cannot|not enough|insufficient|exceed|failed)/i.test(message)) || ''; } function orderNotAppliedError(crystal) { const error = new Error(t('errorOrderNotApplied', { crystal: crystalLabel(crystal) })); error.code = 'ORDER_NOT_APPLIED'; return error; } function parseCrystalBuyForm(doc, crystal) { const section = $('#market_itembuy', doc); if (!section) throw new Error(t('errorMarketItemForm', { crystal: crystalLabel(crystal) })); const controls = $all('input, button', section); const token = section.querySelector('input[name="marketoken"]'); const count = section.querySelector('#buyorder_batchcount, input[name="buyorder_batchcount"]') || controls.find((node) => /buyorder.*count/i.test(`${node.name || ''} ${node.id || ''}`)); const price = section.querySelector('#buyorder_batchprice, input[name="buyorder_batchprice"]') || controls.find((node) => /buyorder.*price/i.test(`${node.name || ''} ${node.id || ''}`)); const submit = section.querySelector('input[name="buyorder_update"], button[name="buyorder_update"]') || controls.find((node) => /buyorder.*(?:update|submit|place)/i.test(`${node.name || ''} ${node.id || ''}`)); const deleteControl = controls.find((node) => { const signature = `${node.name || ''} ${node.id || ''} ${node.value || ''} ${node.textContent || ''}`; return node !== submit && /buyorder.*(?:delete|cancel|remove)|(?:delete|cancel|remove).*buyorder/i.test(signature); }); const batchSize = parseMarketBatchSize(doc); const itemInfoText = $('#market_iteminfo', doc)?.textContent || ''; const inventoryStock = parseNum( itemInfoText.match(/You have\s+([\d,]+)\s+available/i)?.[1] || itemInfoText.match(/你有\s*([\d,]+)\s*(?:件|個|个)/i)?.[1] ); if (!token || !count || !price || !submit) { throw new Error(t('errorMarketItemForm', { crystal: crystalLabel(crystal) })); } if (!batchSize) { throw new Error(t('errorCrystalBatch', { crystal: crystalLabel(crystal) })); } function controlName(node) { return node.getAttribute('name') || node.id || ''; } const names = { token: controlName(token), count: controlName(count), price: controlName(price), submit: controlName(submit), }; if (Object.values(names).some((name) => !name)) { throw new Error(t('errorMarketItemForm', { crystal: crystalLabel(crystal) })); } return { names, token: token.value, submitValue: 'Update', batchSize, directBuyPrice: parseMarketDirectBuyPrice(doc), inventoryStock: Number.isFinite(inventoryStock) ? Math.max(0, Math.floor(inventoryStock)) : null, currentOrder: { count: Math.max(0, Math.floor(parseNum(count.value) || 0)), price: Math.max(0, Math.round(parseNum(price.value) || 0)), }, deleteControl: deleteControl ? { name: controlName(deleteControl), value: deleteControl.value || deleteControl.textContent?.trim() || 'Delete', } : null, }; } async function cancelCrystalBuyOrder(url, responseDoc, form, crystal) { if (!form.deleteControl?.name) return false; const params = new URLSearchParams(); params.set(form.names.token, form.token); params.set(form.deleteControl.name, form.deleteControl.value); const cancelDoc = htmlToDoc(await fetchText(url, params.toString())); const error = marketResponseError(cancelDoc); if (error) throw new Error(error); try { return parseCrystalBuyForm(cancelDoc, crystal).currentOrder.count === 0; } catch (e) { console.warn('[PL Planner] Could not verify automatic buy-order cancellation:', e); return false; } } async function placeCrystalBuyOrder(row, mode) { const label = crystalLabel(row.crystal); if (!row.itemid) throw new Error(t('errorMarketItemForm', { crystal: label })); const url = `?s=Bazaar&ss=mk&screen=browseitems&filter=mo&itemid=${encodeURIComponent(row.itemid)}`; const detailDoc = htmlToDoc(await fetchText(url)); const form = parseCrystalBuyForm(detailDoc, row.crystal); if (form.batchSize !== row.batchSize) { throw new Error(t('errorCrystalBatchChanged', { crystal: label, expected: row.batchSize, actual: form.batchSize, })); } if (mode === 'direct' && form.currentOrder.count > 0) { throw new Error(t('errorExistingBuyOrder', { crystals: label })); } const submittedPrice = mode === 'direct' ? form.directBuyPrice : row.orderBatchPrice; const marketBalance = mode === 'direct' ? parseMarketBalance(detailDoc) : null; const submittedBatches = row.batches; if (mode === 'direct' && !positiveNumber(submittedPrice)) { throw new Error(t('errorDirectPrice', { crystal: label })); } const reservedTotal = submittedBatches * submittedPrice; if (mode === 'direct' && (!Number.isFinite(marketBalance) || marketBalance < reservedTotal)) { throw new Error(t('errorDirectBalance', { crystal: label, required: reservedTotal.toLocaleString(), balance: Number.isFinite(marketBalance) ? marketBalance.toLocaleString() : '-', })); } const params = new URLSearchParams(); params.set(form.names.token, form.token); params.set(form.names.count, String(submittedBatches)); params.set(form.names.price, String(submittedPrice)); params.set(form.names.submit, form.submitValue); const responseDoc = htmlToDoc(await fetchText(url, params.toString())); const error = marketResponseError(responseDoc); if (error) throw new Error(error); const responseForm = parseCrystalBuyForm(responseDoc, row.crystal); if (responseForm.batchSize !== row.batchSize) { throw new Error(t('errorCrystalBatchChanged', { crystal: label, expected: row.batchSize, actual: responseForm.batchSize, })); } const remainingBatches = responseForm.currentOrder.count; const remainingPrice = responseForm.currentOrder.price; if (!Number.isFinite(form.inventoryStock) || !Number.isFinite(responseForm.inventoryStock)) { throw new Error(t('errorMarketItemForm', { crystal: label })); } const inventoryDelta = responseForm.inventoryStock - form.inventoryStock; const matchedBatches = inventoryDelta >= 0 && inventoryDelta % row.batchSize === 0 ? inventoryDelta / row.batchSize : -1; if (matchedBatches === 0 && remainingBatches === 0 && submittedBatches > 0) { throw orderNotAppliedError(row.crystal); } if (matchedBatches < 0 || matchedBatches + remainingBatches !== submittedBatches) { throw new Error(t('errorOrderResultMismatch', { crystal: label, submitted: submittedBatches, matched: Math.max(0, matchedBatches), remaining: remainingBatches, })); } const result = { matchedBatches, remainingBatches, responseStatus: remainingBatches > 0 ? 'pending' : (matchedBatches > 0 ? 'filled' : 'accepted'), cancelledRemainder: false, }; if (mode === 'order' && ( remainingBatches > submittedBatches || (remainingBatches > 0 && remainingPrice !== submittedPrice) )) { throw new Error(t('errorMarketItemForm', { crystal: label })); } if (mode === 'direct' && remainingBatches > 0) { const cancelled = await cancelCrystalBuyOrder(url, responseDoc, responseForm, row.crystal); if (!cancelled) { throw new Error(t('errorDirectRemainder', { crystal: label, batches: remainingBatches, })); } result.cancelledRemainder = true; result.responseStatus = 'remainder-cancelled'; } return result; } async function executeCrystalPurchase(mode, button) { const plan = runtime.lastPlan; if (!plan?.ok) { setStatus(t('errorNoValidPlan')); return; } setStatus(t('statusCheckingPurchase')); await refreshMarketSnapshot({ silent: true, rerender: false, applySavedSources: false }); const crystalRows = getCrystalPlanRows(plan); renderPlan(plan, crystalRows); const unknown = getUnknownCrystalRows(crystalRows); if (unknown.length) { throw new Error(t('statusInventoryPartial', { failed: joinList(unknown.map((row) => row.label)) })); } const rows = getCrystalShortages(crystalRows); if (!rows.length) { setStatus(t('statusNoShortage')); return; } if (mode === 'order' && rows.some((row) => !positiveNumber(row.orderBatchPrice))) { throw new Error(t('errorNoValidPlan')); } if (mode === 'direct') { const missingDirectPrice = rows.find((row) => !positiveNumber(row.directBuyPrice)); if (missingDirectPrice) { throw new Error(t('errorDirectPrice', { crystal: missingDirectPrice.label })); } const existing = rows.filter((row) => row.currentOrder.count > 0); if (existing.length) { throw new Error(t('errorExistingBuyOrder', { crystals: joinList(existing.map((row) => row.label)), })); } } for (let i = 0; i < rows.length; i++) { const row = rows[i]; const price = mode === 'direct' ? row.directBuyPrice : row.orderBatchPrice; button.textContent = t('buttonBuying', { current: i + 1, total: rows.length }); setStatus(t('statusExecutingPurchase', { current: i + 1, total: rows.length, crystal: row.label, crystals: row.crystalsToBuy.toLocaleString(), price: price.toLocaleString(), })); let ignoredRetries = 0; let result; while (!result) { try { result = await placeCrystalBuyOrder(row, mode); } catch (error) { if (mode !== 'direct' || error?.code !== 'ORDER_NOT_APPLIED' || ignoredRetries >= DIRECT_BUY_MAX_RETRIES) { throw error; } ignoredRetries++; setStatus(t('statusRetryingPurchase', { crystal: row.label, attempt: ignoredRetries, })); await sleep(DIRECT_BUY_RETRY_DELAY_MS * ignoredRetries); } } if (result.cancelledRemainder) { throw new Error(t('errorDirectRemainderCancelled', { crystal: row.label, batches: result.remainingBatches, })); } if (i + 1 < rows.length) await sleep(REQUEST_DELAY_MS); } if (mode === 'direct') { await refreshMarketSnapshot({ silent: true, rerender: false, applySavedSources: false }); renderPlan(plan); setStatus(t('statusDirectBuyComplete', { total: rows.length })); } else { setStatus(t('statusBuyOrdersComplete', { total: rows.length })); } } async function executeBatchUpgradePlan(plan, button) { if (!plan?.ok) { setStatus(t('errorNoValidPlan')); return; } const requests = plan.results.flatMap((result) => buildUpgradeRequests(result, result.monsterSlot)); if (!requests.length) { setStatus(t('statusNoUpgrade')); return; } let checked = 0; const currentRecords = await mapLimit(plan.results, 4, async (result) => { const levels = await fetchMonsterLevels(result.monsterSlot); checked++; setStatus(t('statusCheckingMonsters', { done: checked, total: plan.results.length })); const record = { slot: result.monsterSlot, name: result.monsterName, levels, }; return cacheMonsterRecord(record, { save: false }); }); saveState(); const changed = plan.results.filter((result, index) => !levelsEqual(currentRecords[index].levels, result.startLevels)); if (changed.length) { setLevels(currentRecords[0].levels); setStatus(t('statusBatchUpgradeCancelled', { slots: joinList(changed.map((result) => `#${result.monsterSlot}`)) })); return; } await refreshMarketSnapshot({ silent: true, rerender: false, applySavedSources: false }); const crystalRows = getCrystalPlanRows(plan); renderPlan(plan, crystalRows); const unknown = getUnknownCrystalRows(crystalRows); if (unknown.length) { setStatus(t('statusInventoryPartial', { failed: joinList(unknown.map((row) => row.label)) })); return; } const shortages = getCrystalShortages(crystalRows); if (shortages.length) { setStatus(t('statusCrystalShortage', { failed: joinList(shortages.map((row) => `${row.label} ${row.shortage.toLocaleString()}`)), })); return; } const totalLevels = requests.reduce((sum, request) => sum + request.count, 0); if (!confirm(t('confirmUpgrade', { monsters: plan.monsterCount, requests: requests.length, levels: totalLevels, }))) return; for (let i = 0; i < requests.length; i++) { const request = requests[i]; button.textContent = t('buttonRunningUpgrade', { current: i + 1, total: requests.length }); setStatus(t('statusExecutingUpgrade', { current: i + 1, total: requests.length, slot: request.slot, attr: attrLabel(request.attr), count: request.count, })); await fetchText(request.url, request.data); if (i + 1 < requests.length) await sleep(REQUEST_DELAY_MS); } const finalRecords = await mapLimit(plan.results, 4, async (result) => { const levels = await fetchMonsterLevels(result.monsterSlot); const record = { slot: result.monsterSlot, name: result.monsterName, levels }; return cacheMonsterRecord(record, { save: false }); }); saveState(); const complete = plan.results.every((result, index) => levelsEqual(finalRecords[index].levels, result.finalLevels)); if (finalRecords[0]?.levels) setLevels(finalRecords[0].levels); await refreshMarketSnapshot({ silent: true, rerender: false, applySavedSources: false }); runtime.lastPlan = buildBatchPlan(finalRecords, plan.targetPL); renderPlan(runtime.lastPlan); setStatus(complete ? t('statusUpgradeComplete', { total: finalRecords.length }) : t('statusUpgradeMismatch')); } function renderPriceSourceSelect(id, selectedValue) { const select = elt('select', { id }); priceSourceValues.forEach((value) => { select.appendChild(elt('option', { value, text: priceSourceLabel(value), dataset: { source: value }, selected: selectedValue === value, })); }); return select; } function renderButton(id, textKey) { return elt('button', { id, type: 'button', text: t(textKey), dataset: { i18n: textKey }, }); } function setRuntimeBusy(busy) { const nextBusy = Boolean(busy); const resumeCalculation = !nextBusy && runtime.calculationPending; runtime.busy = nextBusy; $all('.hvmepp-monster-check, #hvmepp-select-all-monsters, #hvmepp-clear-monsters') .forEach((control) => { control.disabled = nextBusy; }); if (resumeCalculation) scheduleCalculation(); } function bindManagedAction(button, textKey, errorKey, action) { button.addEventListener('click', async () => { if (runtime.busy) return; setRuntimeBusy(true); button.disabled = true; try { await action(button); } catch (error) { setStatus(t(errorKey, { message: error.message })); } finally { button.disabled = false; button.textContent = t(textKey); setRuntimeBusy(false); } }); } function setStatus(text) { const n = $('#hvmepp-status'); if (n) n.textContent = text || ''; } function refreshLanguageButtons() { $all('.hvmepp-lang-switch button').forEach((button) => { const active = button.dataset.lang === state.language; button.classList.toggle('hvmepp-lang-active', active); button.setAttribute('aria-pressed', active ? 'true' : 'false'); }); } function refreshPriceSourceOptions() { $all('#hvmepp-source option, #hvmepp-order-source option').forEach((option) => { const source = option.dataset.source || option.value; option.textContent = priceSourceLabel(source); }); } function refreshLocalizedText({ rerenderResult = true } = {}) { $all('[data-i18n]').forEach((node) => { node.textContent = t(node.dataset.i18n); }); $all('[data-i18n-value]').forEach((node) => { node.value = t(node.dataset.i18nValue); }); $all('[data-attr]').forEach((node) => { node.textContent = attrLabel(node.dataset.attr); }); refreshPriceSourceOptions(); refreshLanguageButtons(); updateCurrentPL({ save: false }); if (rerenderResult && runtime.lastPlan && $('#hvmepp-result')?.hasChildNodes()) { renderPlan(runtime.lastPlan); } } function setLanguage(language) { const nextLanguage = normalizeLanguage(language); readInputs({ save: false }); state.language = nextLanguage; rebuildLastPlan(); saveState(); refreshLocalizedText(); setStatus(t('statusLanguageSaved')); } function renderLanguageSwitcher() { const bar = elt('div', { class: 'hvmepp-lang-switch' }); languageOptions.forEach(([value, label]) => { const active = value === state.language; const button = elt('button', { type: 'button', text: label, class: active ? 'hvmepp-lang-active' : '', dataset: { lang: value }, ariaPressed: active ? 'true' : 'false', }); button.addEventListener('click', () => setLanguage(value)); bar.appendChild(button); }); return bar; } function renderSingleMonsterResult(result) { const details = elt('details', { class: 'hvmepp-monster-result' }); const summaryText = result.ok ? `#${result.monsterSlot || '-'} ${result.monsterName} / PL ${formatPL(result.currentPL)} → ${formatPL(result.targetPL)} / ${formatMoney(result.totalCost)}` : `#${result.monsterSlot || '-'} ${result.monsterName}`; details.appendChild(elt('summary', { text: summaryText })); if (!result.ok) { details.appendChild(elt('div', { class: 'hvmepp-alert', text: result.message })); return details; } const table = elt('table', { class: 'hvmepp-table' }); table.appendChild(elt('tr', {}, [ t('tableAttr'), t('tableCurrentLevel'), t('tableTargetLevel'), t('tableIncrease'), t('tableCost'), ].map((heading) => elt('th', { text: heading })))); const upgraded = displayAll.filter((attr) => result.agg[attr].k > 0); upgraded.forEach((attr) => { const item = result.agg[attr]; table.appendChild(elt('tr', { class: 'hvmepp-upgraded' }, [ elt('td', { text: attrLabel(attr) }), elt('td', { text: item.from }), elt('td', { text: item.to }), elt('td', { text: `+${item.k}` }), elt('td', { text: formatMoney(item.cost) }), ])); }); if (!upgraded.length) { table.appendChild(elt('tr', {}, [ elt('td', { colspan: 5, text: t('noUpgradeNeeded') }), ])); } details.appendChild(table); return details; } function renderCrystalPlan(plan, rows) { const card = elt('div', { class: 'hvmepp-card hvmepp-crystal-card' }); card.appendChild(elt('h3', { text: t('headingCrystalNeeds'), dataset: { i18n: 'headingCrystalNeeds' }, })); if (!plan.ok) { card.appendChild(elt('div', { class: 'hvmepp-alert', text: t('crystalPlanUnavailable') })); return card; } const orderSource = renderPriceSourceSelect('hvmepp-order-source', state.orderPriceSource); orderSource.addEventListener('change', () => { state.orderPriceSource = orderSource.value; applyOrderPricesFromCache(); }); card.appendChild(elt('div', { class: 'hvmepp-controls hvmepp-crystal-price-controls' }, [ elt('label', {}, [ elt('span', { text: t('labelOrderPriceSource'), dataset: { i18n: 'labelOrderPriceSource' } }), ' ', orderSource, ]), ])); const table = elt('table', { class: 'hvmepp-table hvmepp-crystal-table' }); table.appendChild(elt('tr', {}, [ t('tableCrystal'), t('tableRequired'), t('tableStock'), t('tableShortage'), t('tableEstimatedCost'), t('tableOrderPrice'), ].map((heading) => elt('th', { text: heading })))); rows.forEach((row) => { const shortage = row.shortage === null ? '-' : row.shortage.toLocaleString(); let estimatedCost = row.shortage > 0 ? t('estimateUnavailable') : formatMoney(0); let estimateTitle = ''; if (Number.isFinite(row.estimatedDirectCost)) { estimatedCost = formatMoney(row.estimatedDirectCost); const range = row.estimatedLowestBatchPrice === row.estimatedHighestBatchPrice ? `${row.estimatedLowestBatchPrice} C` : `${row.estimatedLowestBatchPrice}-${row.estimatedHighestBatchPrice} C`; estimateTitle = range; if (row.guaranteedBatches) { estimateTitle += ` / ${t('estimateUsesSupply', { batches: row.guaranteedBatches.toLocaleString() })}`; } } const tr = elt('tr', { class: row.shortage > 0 ? 'hvmepp-shortage' : '' }, [ elt('td', { text: row.label }), elt('td', { text: row.required.toLocaleString() }), elt('td', { text: row.stock === null ? t('stockUnknown') : row.stock.toLocaleString() }), elt('td', { text: shortage }), elt('td', { text: estimatedCost, title: estimateTitle }), elt('td', {}, elt('input', { id: `hvmepp-order-price-${row.attr}`, type: 'number', min: 1, step: 1, value: row.orderBatchPrice || '', disabled: !row.batchSize, title: row.label, })), ]); table.appendChild(tr); }); card.appendChild(table); const estimateSummary = getPurchaseEstimateSummary(rows); if (estimateSummary) { card.appendChild(elt('div', { class: 'hvmepp-estimate-summary', text: t('estimateSummary', { cost: formatMoney(estimateSummary.estimatedCost), balance: formatMoney(estimateSummary.requiredStartingBalance), }), })); } const actions = elt('div', { class: 'hvmepp-controls hvmepp-result-actions' }); const stockButton = renderButton('hvmepp-load-stock', 'buttonLoadStock'); const directButton = renderButton('hvmepp-direct-buy', 'buttonDirectBuy'); const orderButton = renderButton('hvmepp-place-buy-orders', 'buttonPlaceBuyOrders'); bindManagedAction(stockButton, 'buttonLoadStock', 'statusFetchPriceFailed', refreshMarketSnapshot); bindManagedAction(directButton, 'buttonDirectBuy', 'statusPurchaseFailed', (managedButton) => executeCrystalPurchase('direct', managedButton)); bindManagedAction(orderButton, 'buttonPlaceBuyOrders', 'statusPurchaseFailed', (managedButton) => executeCrystalPurchase('order', managedButton)); actions.append(stockButton, directButton, orderButton); card.appendChild(actions); return card; } function renderPlan(plan, crystalRows = null) { const box = $('#hvmepp-result'); if (!box) return; const fragment = document.createDocumentFragment(); if (!plan || plan.message) { fragment.appendChild(elt('div', { class: 'hvmepp-alert', text: plan?.message || t('errorNoValidPlan') })); replacePlanContent(box, fragment); return; } fragment.appendChild(elt('div', { class: 'hvmepp-total' }, [ `${t('totalMonsters')} `, elt('span', { class: 'hvmepp-good', text: String(plan.monsterCount) }), ` ${t('totalCost')} `, elt('span', { class: 'hvmepp-good', text: formatMoney(plan.totalCost) }), ])); const rows = crystalRows || getCrystalPlanRows(plan); fragment.appendChild(renderCrystalPlan(plan, rows)); if (plan.ok) { const requests = plan.results.flatMap((result) => buildUpgradeRequests(result, result.monsterSlot)); const executable = plan.results.every((result) => /^\d+$/.test(result.monsterSlot)); if (requests.length && executable) { const runButton = renderButton('hvmepp-run-upgrade', 'buttonRunUpgrade'); bindManagedAction(runButton, 'buttonRunUpgrade', 'statusUpgradeFailed', (managedButton) => executeBatchUpgradePlan(plan, managedButton)); fragment.appendChild(elt('div', { class: 'hvmepp-controls hvmepp-result-actions' }, [runButton])); } } plan.results.forEach((result) => { fragment.appendChild(renderSingleMonsterResult(result)); }); replacePlanContent(box, fragment); } function replacePlanContent(box, fragment = null) { const panel = $('#hvmepp-panel'); const scrollTop = panel?.scrollTop || 0; if (fragment) box.replaceChildren(fragment); else box.replaceChildren(); box.removeAttribute('aria-busy'); if (panel) panel.scrollTop = scrollTop; } function calculate() { clearScheduledCalculation(); readInputs(); updateCurrentPL({ save: false }); const selected = getSelectedMonsterSlots(); if (!selected.length) { clearCurrentPlan(); return null; } const monsters = getSelectedLoadedMonsters(); if (monsters.length !== selected.length) { const invalidPlan = { message: t('errorMonstersNotLoaded') }; runtime.lastPlan = null; renderPlan(invalidPlan); setStatus(invalidPlan.message); return invalidPlan; } const plan = buildBatchPlan(monsters, Number(state.targetPL)); runtime.lastPlan = plan; renderPlan(plan); return plan; } function renderMonsterSelection(monsterList) { const card = elt('div', { class: 'hvmepp-card hvmepp-monster-select-card' }); card.appendChild(elt('h3', { text: t('headingMonsterSelection'), dataset: { i18n: 'headingMonsterSelection' }, })); const toolbar = elt('div', { class: 'hvmepp-controls' }); const selectAllButton = renderButton('hvmepp-select-all-monsters', 'buttonSelectAll'); const clearButton = renderButton('hvmepp-clear-monsters', 'buttonClearSelection'); selectAllButton.disabled = runtime.busy; clearButton.disabled = runtime.busy; toolbar.append(selectAllButton, clearButton, elt('span', { id: 'hvmepp-selection-summary' })); card.appendChild(toolbar); const selected = new Set(state.selectedMonsterSlots.map(String)); const validSlots = new Set(monsterList.map((monster) => String(monster.index))); if (query.slot) selected.add(String(query.slot)); if (![...selected].some((slot) => validSlots.has(slot)) && validSlots.has(String(state.lastMonsterSlot))) { selected.add(String(state.lastMonsterSlot)); } const list = elt('div', { class: 'hvmepp-monster-checklist' }); monsterList.forEach((monster) => { const slot = String(monster.index); const checkbox = elt('input', { type: 'checkbox', value: slot, checked: selected.has(slot), disabled: runtime.busy, class: 'hvmepp-monster-check', }); checkbox.addEventListener('change', syncMonsterSelection); list.appendChild(elt('label', {}, [ checkbox, elt('span', { text: `#${slot} ${monster.name} / PL ${monster.pl}` }), ])); }); card.appendChild(list); selectAllButton.addEventListener('click', () => { if (runtime.busy) return; $all('.hvmepp-monster-check').forEach((checkbox) => { checkbox.checked = true; }); syncMonsterSelection(); }); clearButton.addEventListener('click', () => { if (runtime.busy) return; $all('.hvmepp-monster-check').forEach((checkbox) => { checkbox.checked = false; }); syncMonsterSelection(); }); queueMicrotask(syncMonsterSelection); return card; } function renderPanel() { let overlay = $('#hvmepp-overlay'); if (overlay) { overlay.classList.remove('hvmepp-hidden'); const easterEgg = $('#hvmepp-easter-egg'); if (easterEgg) easterEgg.textContent = randomEasterEgg(); refreshLocalizedText(); return; } overlay = elt('div', { id: 'hvmepp-overlay' }); const panel = elt('div', { id: 'hvmepp-panel' }); overlay.appendChild(panel); panel.appendChild(renderLanguageSwitcher()); const easterEgg = elt('button', { id: 'hvmepp-easter-egg', class: 'hvmepp-easter-egg', type: 'button', text: randomEasterEgg(), title: 'Click to refresh', }); easterEgg.addEventListener('click', () => { easterEgg.textContent = randomEasterEgg(easterEgg.textContent); }); const closeButton = elt('button', { text: '×', class: 'hvmepp-close' }); const title = elt('div', { class: 'hvmepp-title' }, [ elt('span', { text: t('plannerTitle'), dataset: { i18n: 'plannerTitle' } }), elt('div', { class: 'hvmepp-title-actions' }, [ easterEgg, closeButton, ]), ]); closeButton.addEventListener('click', () => { overlay.classList.add('hvmepp-hidden'); }); panel.appendChild(title); panel.appendChild(elt('div', { id: 'hvmepp-live', class: 'hvmepp-live', })); const controls = elt('div', { class: 'hvmepp-controls' }); controls.appendChild(elt('label', {}, [ elt('span', { text: t('labelTargetInput'), dataset: { i18n: 'labelTargetInput' } }), ' ', elt('input', { id: 'hvmepp-target', type: 'number', step: 0.5, value: state.targetPL, }), ])); controls.appendChild(elt('label', {}, [ elt('span', { text: t('labelPriceSource'), dataset: { i18n: 'labelPriceSource' } }), ' ', renderPriceSourceSelect('hvmepp-source', state.priceSource), ])); controls.appendChild(renderButton('hvmepp-fetch-price', 'buttonFetchPrice')); controls.appendChild(renderButton('hvmepp-load-monster', 'buttonLoadMonster')); panel.appendChild(controls); runtime.monsterList = parseMonsterList(); if (query.slot && !runtime.monsterList.some((monster) => String(monster.index) === String(query.slot))) { const currentLevels = parseMonsterLevelsFromDoc(document); runtime.monsterList.push({ index: String(query.slot), name: $('.msl > div:nth-child(2)')?.textContent?.trim() || t('monsterFallback', { slot: query.slot }), pl: currentLevels ? totalPL(currentLevels) : 0, }); } if (runtime.monsterList.length) { panel.appendChild(renderMonsterSelection(runtime.monsterList)); } panel.appendChild(elt('div', { id: 'hvmepp-status', class: 'hvmepp-status', })); panel.appendChild(elt('div', { id: 'hvmepp-result', })); document.body.appendChild(overlay); $('#hvmepp-target').addEventListener('input', () => scheduleCalculation(80)); $('#hvmepp-source').addEventListener('change', () => { readInputs({ save: false }); const result = applyCalculationPriceSource(); scheduleCalculation(); setStatus(result.failed.length ? t('statusSavedPriceSourcePartial', { source: priceSourceLabel(state.priceSource), updated: result.updated, failed: joinList(result.failed), }) : t('statusSavedPriceSource', { source: priceSourceLabel(state.priceSource) })); }); bindManagedAction($('#hvmepp-fetch-price'), 'buttonFetchPrice', 'statusFetchPriceFailed', refreshMarketSnapshot); bindManagedAction($('#hvmepp-load-monster'), 'buttonLoadMonster', 'statusLoadMonsterFailed', async () => { if (query.slot && readCurrentMonsterIfAny()) return; await loadSelectedMonsters(); }); readCurrentMonsterIfAny({ recalculate: false }); updateCurrentPL({ save: false }); } function findPowerLevelCalculatorButton(side) { const nodes = $all('input[type="button"], button', side); return nodes.find((n) => { const label = (n.value || n.textContent || '').trim(); return label === 'Power Level Calculator'; }); } function createEntryButton() { const btn = elt('input', { id: 'hvmepp-entry', type: 'button', value: t('plannerTitle'), dataset: { i18nValue: 'plannerTitle' }, }); btn.addEventListener('click', renderPanel); return btn; } function getOrCreateEntrySide(createFallback = false) { const hvutSide = $('.hvut-ml-side'); if (hvutSide) return hvutSide; if (!createFallback) return null; const monsterOuter = $('#monster_outer'); const host = monsterOuter || $('#mainpane') || document.body; const side = elt('div', { class: monsterOuter ? 'hvmepp-side-fallback' : 'hvmepp-side-fallback hvmepp-side-fallback-static', }); if (monsterOuter) { monsterOuter.classList.add('hvmepp-ml-host-fallback'); } else if (host && getComputedStyle(host).position === 'static') { host.style.position = 'relative'; } host.appendChild(side); if (monsterOuter) { requestAnimationFrame(() => clampFallbackSide(side)); window.addEventListener('resize', () => clampFallbackSide(side)); } return side; } function clampFallbackSide(side) { if (getComputedStyle(side).position !== 'absolute') { side.style.left = ''; return; } side.style.left = ''; const rect = side.getBoundingClientRect(); const min = 8; const max = window.innerWidth - 8; let left = parseFloat(getComputedStyle(side).left) || 0; if (rect.left < min) { left += min - rect.left; } else if (rect.right > max) { left -= rect.right - max; } else { return; } side.style.left = `${Math.round(left)}px`; } function mountEntryButton(createFallback = false) { if ($('#hvmepp-entry')) return true; const side = getOrCreateEntrySide(createFallback); if (!side) return false; const btn = createEntryButton(); const plcButton = findPowerLevelCalculatorButton(side); if (plcButton) { plcButton.insertAdjacentElement('afterend', btn); } else { side.appendChild(btn); } return true; } function scheduleEntryButton() { let fallbackTimer = null; let observer = null; function cleanup() { if (fallbackTimer) clearTimeout(fallbackTimer); if (observer) observer.disconnect(); } if (mountEntryButton()) return; observer = new MutationObserver(() => { if (mountEntryButton()) { cleanup(); } }); observer.observe(document.body, { childList: true, subtree: true }); fallbackTimer = setTimeout(() => { mountEntryButton(true); cleanup(); }, 3000); } GM_addStyle(` #hvmepp-entry { cursor: pointer; display: block; box-sizing: border-box; } .hvmepp-ml-host-fallback { margin-left: 150px; position: relative; } .hvmepp-side-fallback { position: absolute; top: 38px; left: -110px; width: 100px; display: flex; flex-direction: column; z-index: 10; } .hvmepp-side-fallback input { margin: 3px 0; padding: 1px; white-space: normal; } .hvmepp-side-fallback-static { position: static; width: 100px; margin: 4px 0; } #hvmepp-overlay { position: fixed; inset: 0; z-index: 9999; background: rgba(0,0,0,.45); display: flex; align-items: flex-start; justify-content: center; box-sizing: border-box; padding: 8px; color: #222; } #hvmepp-overlay.hvmepp-hidden { display: none; } #hvmepp-panel { width: min(1120px, calc(100vw - 16px)); max-height: calc(100vh - 16px); overflow: auto; overflow-anchor: none; background: #EDEBDF; border: 1px solid #5C0D11; padding: 8px; box-shadow: 0 6px 20px rgba(0,0,0,.35); font: 9pt/1.25 Arial, sans-serif; text-align: left; box-sizing: border-box; } .hvmepp-lang-switch { display: flex; justify-content: center; align-items: center; gap: 6px; margin: 0 32px 5px; } .hvmepp-lang-switch button { min-width: 76px; padding: 2px 8px; border: 1px solid #8f806c; background: #f7f1d9; cursor: pointer; font: inherit; } .hvmepp-lang-switch button.hvmepp-lang-active { border-color: #5C0D11; background: #edb; font-weight: bold; } .hvmepp-title { display: flex; justify-content: space-between; align-items: center; font-weight: bold; font-size: 11pt; margin-bottom: 4px; } .hvmepp-title-actions { display: flex; align-items: center; gap: 8px; } .hvmepp-easter-egg { padding: 0; border: 0; background: none; color: #7a5a3a; cursor: pointer; font-family: inherit; font-size: 9pt; font-weight: normal; white-space: nowrap; } .hvmepp-close { width: 24px; height: 22px; cursor: pointer; } .hvmepp-live, .hvmepp-status { margin: 4px 0; padding: 4px 6px; background: #fff8; border: 1px solid #b9aa99; } .hvmepp-controls { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; margin: 5px 0; } .hvmepp-controls input, .hvmepp-controls select { font-size: 9pt; } .hvmepp-controls button { cursor: pointer; font: inherit; padding: 2px 6px; } .hvmepp-card { border: 1px solid #b9aa99; background: #fff8; padding: 5px; } .hvmepp-card h3 { margin: 0 0 4px; font-size: 10pt; } .hvmepp-monster-select-card, .hvmepp-crystal-card { margin: 6px 0; } .hvmepp-monster-checklist { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 2px 8px; max-height: 170px; overflow: auto; padding: 4px; border: 1px solid #b9aa99; background: #fff8; } .hvmepp-monster-checklist label { display: flex; align-items: center; gap: 4px; min-width: 0; white-space: nowrap; } .hvmepp-monster-checklist label span { overflow: hidden; text-overflow: ellipsis; } .hvmepp-table { width: 100%; border-collapse: collapse; margin: 4px 0; table-layout: fixed; font-size: 9pt; } .hvmepp-table th, .hvmepp-table td { border: 1px solid #b9aa99; padding: 2px 3px; text-align: center; word-break: keep-all; } .hvmepp-table th { background: #edb; } .hvmepp-table input { width: 58px; text-align: right; box-sizing: border-box; } .hvmepp-total { margin: 5px 0; padding: 5px 6px; border: 1px solid #b9aa99; background: #fff; line-height: 1.4; font-weight: bold; } .hvmepp-monster-result { margin: 5px 0; border: 1px solid #b9aa99; background: #fff8; } .hvmepp-monster-result > summary { padding: 5px 6px; cursor: pointer; font-weight: bold; background: #edb; } .hvmepp-monster-result > .hvmepp-table, .hvmepp-monster-result > .hvmepp-alert { width: calc(100% - 8px); margin: 4px; } .hvmepp-crystal-table td:first-child, .hvmepp-crystal-table th:first-child { width: 230px; text-align: left; } .hvmepp-crystal-table td:last-child, .hvmepp-crystal-table th:last-child { width: 105px; } .hvmepp-crystal-table td:last-child input { width: 96px; } .hvmepp-crystal-price-controls { margin: 2px 0 4px; } .hvmepp-estimate-summary { margin: 4px 0; padding: 5px 6px; border: 1px solid #8f806c; background: #fffbe8; line-height: 1.4; font-weight: bold; } .hvmepp-shortage { color: #b00020; background: #fff2f2; font-weight: bold; } .hvmepp-good { color: #006400; font-weight: bold; } .hvmepp-alert { margin: 5px 0; padding: 5px 6px; border: 1px solid #b00020; color: #b00020; background: #fff2f2; font-weight: bold; } .hvmepp-upgraded { background: #f4fff4; } @media (max-width: 900px) { #hvmepp-panel { width: calc(100vw - 10px); max-height: calc(100vh - 10px); padding: 6px; } .hvmepp-lang-switch { margin: 0 28px 4px; gap: 4px; } .hvmepp-lang-switch button { min-width: 68px; padding: 2px 4px; } .hvmepp-table input { width: 50px; } .hvmepp-side-fallback { position: static; margin: 4px 0; } .hvmepp-ml-host-fallback { margin-left: 0; } } `); scheduleEntryButton();})();