skip to main content
paste
bin
.ca
type · paste · share
⌘
K
Family
The bin family
pastebin.ca
hub
Share text and code with expiry and privacy controls.
imagebin.ca
Upload and share images with direct links.
filebin.ca
Drop a file and get a shareable link.
notebin.ca
Write Markdown notes with durable links.
turl.ca
Short, reputation-checked links.
attn.ca
Notifications and alerts for your services.
voicebin.ca
Record and share short voice clips.
dnsbin.ca
Inspect DNS and debug records.
Docs
Sign in
?
← back to paste
›
Edit / fork
HV Monster PL Planner
#5kPLpH6qr8
public / public
new version
anonymous
created 3 days ago
Expires in 3 months
103.6 KB
syntax:
javascript
Your changes create a new paste linked to this one — the original is untouched.
new version
Your changes create a new paste linked to this one — the original is untouched.
Title (optional)
Filename
Syntax
javascript
text
bash
c
cpp
css
diff
dockerfile
go
html
ini
java
javascript
json
kotlin
lua
makefile
markdown
nginx
php
python
ruby
rust
shellscript
sql
swift
toml
typescript
xml
yaml
Visibility
Public feed
Access
public
Expires
90 days
10 min
1 hour
1 day
7 days
30 days
90 days
custom…
Custom expiry
Change note
(optional)
This paste will be listed on the public feed. Change Visibility if you only want people with the link to see it.
Create new version
Cancel
Paste or type…
// ==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(); })();