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