NOTICE: By continued use of this site you understand and agree to the binding Uvjeti usluge and Politika privatnosti.
// ==UserScript==
// @name 迷宫胜率计算器
// @name:en Labyrinth Clear Rate Calculator
// @name:zh-CN 迷宫胜率计算器
// @namespace http://tampermonkey.net/
// @version 1.5.7
// @description Show skilling/combat room clear chance and expected clear seconds (including failed runs) on each labyrinth tile.
// @description:en Show skilling/combat room clear chance and expected clear seconds (including failed runs) on each labyrinth tile.
// @description:zh-CN 在迷宫每个房间格子上显示生活/战斗房间胜率与期望耗时(包含失败场次)。
// @author dakonglong
// @license MIT
// @match https://www.milkywayidle.com/*
// @match https://test.milkywayidle.com/*
// @match https://www.milkywayidlecn.com/*
// @match https://test.milkywayidlecn.com/*
// @match https://shykai.github.io/MWICombatSimulatorTest/dist/*
// @grant none
// @run-at document-idle
// @require https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js
// ==/UserScript==
(function () {
"use strict";
if (window.__MWI_LAB_CLEAR_RATE_OVERLAY__) {
return;
}
window.__MWI_LAB_CLEAR_RATE_OVERLAY__ = true;
window.__MWI_LAB_CLEAR_RATE_OVERLAY_VERSION__ = "1.5.7";
const ROOM_DURATION_SECONDS = 120;
const ROOM_ENTRY_SECONDS = 1;
const BASE_ACTION_SECONDS = 10;
const BASE_ENHANCING_ACTION_SECONDS = 8;
const LABYRINTH_ENHANCING_BASE_TARGET_LEVEL = 3;
const LABYRINTH_ENHANCING_LEVEL_STEP = 50;
const LABYRINTH_COMBAT_ROOM_TYPE = "/labyrinth_room_types/combat";
const LABYRINTH_SKILLING_ROOM_TYPE = "/labyrinth_room_types/skilling";
const LABYRINTH_TREASURE_ROOM_TYPE = "/labyrinth_room_types/treasure";
const LABYRINTH_DESCEND_ROOM_TYPE = "/labyrinth_room_types/descend";
const DEFAULT_COMBAT_SIM_TRIALS = 100;
const DEFAULT_AUTOMATION_COMBAT_SIM_TRIALS = 500;
const DEFAULT_AUTOMATION_TARGET_WIN_RATE = 70;
const MIN_COMBAT_SIM_TRIALS = 1;
const MAX_COMBAT_SIM_TRIALS = 2000;
const COMBAT_SIM_TRIALS_STORAGE_KEY = "mwi_lab_clear_rate_combat_trials";
const AUTOMATION_COMBAT_SIM_TRIALS_STORAGE_KEY = "mwi_lab_auto_combat_trials";
const AUTOMATION_TARGET_WIN_RATE_STORAGE_KEY = "mwi_lab_auto_target_win_rate";
const COMBAT_SIM_CACHE_LIMIT = 256;
const COMBAT_ONE_SECOND_NS = 1e9;
const COMBAT_SLOT_COUNT = 5;
const SIMULATOR_SUPPORTED_EQUIPMENT_TYPES = new Set([
"/equipment_types/head",
"/equipment_types/body",
"/equipment_types/legs",
"/equipment_types/feet",
"/equipment_types/hands",
"/equipment_types/main_hand",
"/equipment_types/two_hand",
"/equipment_types/off_hand",
"/equipment_types/pouch",
"/equipment_types/back",
"/equipment_types/neck",
"/equipment_types/earrings",
"/equipment_types/ring",
"/equipment_types/charm",
]);
const COMBAT_SIM_CHUNK_CACHE_BUST = "20260309-1.5.7";
const COMBAT_SIM_VENDOR_CHUNK_URL = `https://shykai.github.io/MWICombatSimulatorTest/dist/vendors-node_modules_heap-js_dist_heap-js_es5_js.bundle.js?v=${COMBAT_SIM_CHUNK_CACHE_BUST}`;
const COMBAT_SIM_WORKER_CHUNK_URL = `https://shykai.github.io/MWICombatSimulatorTest/dist/src_worker_js.bundle.js?v=${COMBAT_SIM_CHUNK_CACHE_BUST}`;
const COMBAT_MAZE_PLAYER_LEVEL_BONUS = 15;
const COMBAT_MAZE_PLAYER_ATTACK_SPEED_BONUS = 0.15;
const COMBAT_MAZE_PLAYER_REGEN_BONUS = 0.06;
const COMBAT_MAZE_PLAYER_CRIT_RATE_BONUS = 0.06;
const COMBAT_MAZE_PLAYER_CRIT_DAMAGE_BONUS = 0.1;
const COMBAT_MODEL_SIGNATURE = "official-simulator-core-v2";
const LABYRINTH_ABILITY_NAME_ZH_MAP = {
"/abilities/critical_aura": "暴击光环",
"/abilities/elusiveness": "闪避",
"/abilities/rain_of_arrows": "箭雨",
"/abilities/penetrating_shot": "贯穿射击",
"/abilities/steady_shot": "稳定射击",
"/abilities/precision": "精确",
"/abilities/flame_arrow": "烈焰箭",
"/abilities/silencing_shot": "沉默之箭",
"/abilities/aqua_arrow": "流水箭",
"/abilities/quick_shot": "快速射击",
"/abilities/mystic_aura": "元素光环",
"/abilities/elemental_affinity": "元素增幅",
"/abilities/frost_surge": "冰霜爆裂",
"/abilities/mana_spring": "法力喷泉",
"/abilities/water_strike": "流水冲击",
"/abilities/firestorm": "火焰风暴",
"/abilities/smoke_burst": "烟爆灭影",
"/abilities/fireball": "火球",
"/abilities/guardian_aura": "守护光环",
"/abilities/toxic_pollen": "剧毒粉尘",
"/abilities/natures_veil": "自然菌幕",
"/abilities/life_drain": "生命吸取",
"/abilities/entangle": "缠绕",
"/abilities/fierce_aura": "物理光环",
"/abilities/berserk": "狂暴",
"/abilities/impale": "透骨之刺",
"/abilities/puncture": "破甲之刺",
"/abilities/penetrating_strike": "贯心之刺",
"/abilities/cleave": "分裂斩",
"/abilities/maim": "血刃斩",
"/abilities/crippling_slash": "致残斩",
"/abilities/toughness": "坚韧",
"/abilities/sweep": "重扫",
"/abilities/stunning_blow": "重锤",
"/abilities/fracturing_impact": "碎裂冲击",
"/abilities/speed_aura": "速度光环",
"/abilities/frenzy": "狂速",
};
const LABYRINTH_MONSTER_NAME_ZH_MAP = {
"/monsters/shadow_archer": "暗影弓手",
"/monsters/pyre_hunter": "火焰猎手",
"/monsters/frost_sniper": "霜冻狙击手",
"/monsters/siren": "海妖",
"/monsters/salamander": "火蜥蜴",
"/monsters/dryad": "树精",
"/monsters/giant_scorpion": "巨蝎",
"/monsters/giant_mantis": "巨螳螂",
"/monsters/cyclops": "独眼巨人",
"/monsters/mimic": "宝箱怪",
};
const LABYRINTH_MONSTER_NAME_ZH_BY_TAIL = {
shadow_archer: "暗影弓手",
pyre_hunter: "火焰猎手",
frost_sniper: "霜冻狙击手",
siren: "海妖",
salamander: "火蜥蜴",
dryad: "树精",
giant_scorpion: "巨蝎",
giant_mantis: "巨螳螂",
cyclops: "独眼巨人",
mimic: "宝箱怪",
};
const COMBAT_SKILL_HRID_BY_KEY = {
stamina: "/skills/stamina",
intelligence: "/skills/intelligence",
attack: "/skills/attack",
melee: "/skills/melee",
defense: "/skills/defense",
ranged: "/skills/ranged",
magic: "/skills/magic",
};
const COMBAT_LEVEL_SKILL_HRID = "/skills/combat";
const BADGE_CLASS = "mwi-lab-clear-rate-badge";
const STYLE_ID = "mwi-lab-clear-rate-style";
const CONTROL_ID = "mwi-lab-clear-rate-control";
const CONTROL_CLASS = "mwi-lab-clear-rate-control";
const CONTROL_LOAN_TOGGLE_CLASS = "mwi-lab-clear-rate-control__loan-toggle";
const CONTROL_LOAN_PANEL_CLASS = "mwi-lab-clear-rate-control__loan-panel";
const CONTROL_LOAN_LIST_CLASS = "mwi-lab-clear-rate-control__loan-list";
const CONTROL_LOAN_ITEM_CLASS = "mwi-lab-clear-rate-control__loan-item";
const CONTROL_LOAN_ITEM_STATUS_CLASS = "mwi-lab-clear-rate-control__loan-item-status";
const CONTROL_LOAN_CALC_CLASS = "mwi-lab-clear-rate-control__loan-calc";
const CONTROL_LOG_TOGGLE_CLASS = "mwi-lab-clear-rate-control__log-toggle";
const CONTROL_LOG_PANEL_CLASS = "mwi-lab-clear-rate-control__log-panel";
const CONTROL_LOG_LIST_CLASS = "mwi-lab-clear-rate-control__log-list";
const CONTROL_LOG_ITEM_CLASS = "mwi-lab-clear-rate-control__log-item";
const CONTROL_LOG_ACTION_CLASS = "mwi-lab-clear-rate-control__log-action";
const CONTROL_LOG_META_CLASS = "mwi-lab-clear-rate-control__log-meta";
const CONTROL_LOG_INCOMPLETE_CLASS = "mwi-lab-clear-rate-control__log-incomplete";
const ROOM_LOG_FLOAT_ID = "mwi-lab-room-log-floating";
const ROOM_LOG_FLOAT_CLASS = "mwi-lab-room-log-floating";
const ROOM_LOG_FLOAT_HEADER_CLASS = "mwi-lab-room-log-floating__header";
const ROOM_LOG_FLOAT_TITLE_CLASS = "mwi-lab-room-log-floating__title";
const ROOM_LOG_FLOAT_ACTIONS_CLASS = "mwi-lab-room-log-floating__actions";
const ROOM_LOG_FLOAT_CLEAR_CLASS = "mwi-lab-room-log-floating__clear";
const ROOM_LOG_FLOAT_CLOSE_CLASS = "mwi-lab-room-log-floating__close";
const ROOM_LOG_STORAGE_KEY = "mwi_lab_skilling_room_logs_v1";
const ROOM_LOG_POSITION_STORAGE_KEY = "mwi_lab_skilling_room_logs_position_v1";
const ROOM_LOG_MAX_SESSIONS = 30;
const ROOM_LOG_ACTION_OUTCOME_SUCCESS = "success";
const ROOM_LOG_ACTION_OUTCOME_FAIL = "fail";
const ROOM_LOG_ACTION_OUTCOME_DOUBLE = "double";
const ROOM_LOG_ACTION_OUTCOME_UNKNOWN = "unknown";
const LIVE_ACTION_RATE_ID = "mwi-lab-live-action-rate";
const LIVE_ACTION_RATE_CLASS = "mwi-lab-live-action-rate";
const LIVE_ACTION_RATE_MWITOOLS_COLOR = "#ffffff";
const LIVE_ACTION_RATE_MWITOOLS_FONT_SIZE = "0.875rem";
const CONTROL_SCHEMA_ATTR = "data-mwi-lab-clear-schema";
const CONTROL_SCHEMA_VERSION = "6";
const CONTROL_BOUND_FLAG = "__mwiLabControlBound";
const PREVIEW_TOOLTIP_ID = "mwi-lab-clear-rate-preview";
const PREVIEW_TOOLTIP_CLASS = "mwi-lab-clear-rate-preview";
const PREVIEW_CELL_BOUND_FLAG = "__mwiLabPreviewBound";
const AUTOMATION_ESTIMATE_CONTROL_ID = "mwi-lab-auto-estimate-control";
const AUTOMATION_ESTIMATE_CONTROL_CLASS = "mwi-lab-auto-estimate-control";
const AUTOMATION_ESTIMATE_CONTROL_SCHEMA_ATTR = "data-mwi-lab-auto-schema";
const AUTOMATION_ESTIMATE_CONTROL_SCHEMA_VERSION = "4";
const AUTOMATION_ESTIMATE_CONTROL_TRIALS_INPUT_CLASS = "mwi-lab-auto-estimate-control__trials-input";
const AUTOMATION_ESTIMATE_CONTROL_TARGET_RATE_INPUT_CLASS = "mwi-lab-auto-estimate-control__target-rate-input";
const AUTOMATION_ESTIMATE_CONTROL_TARGET_RATE_LABEL_CLASS = "mwi-lab-auto-estimate-control__target-rate-label";
const AUTOMATION_ESTIMATE_CONTROL_RECOMMEND_BUTTON_CLASS = "mwi-lab-auto-estimate-control__recommend-button";
const AUTOMATION_MAX_FLOOR_TABLE_HEADER_CLASS = "mwi-lab-auto-floor-header";
const AUTOMATION_MAX_FLOOR_CELL_CLASS = "mwi-lab-auto-floor-cell";
const AUTOMATION_ESTIMATE_TABLE_HEADER_CLASS = "mwi-lab-auto-estimate-header";
const AUTOMATION_ESTIMATE_CELL_CLASS = "mwi-lab-auto-estimate-cell";
const AUTOMATION_ESTIMATE_CELL_CHANCE_CLASS = "mwi-lab-auto-estimate-cell__chance";
const AUTOMATION_ESTIMATE_CELL_ETA_CLASS = "mwi-lab-auto-estimate-cell__eta";
const AUTOMATION_ESTIMATE_CELL_ETA_DANGER_CLASS = "mwi-lab-auto-estimate-cell__eta--danger";
const AUTOMATION_ESTIMATE_CELL_BOUND_FLAG = "__mwiLabAutoEstimateCellBound";
const AUTOMATION_ESTIMATE_CELL_RENDER_TOKEN_ATTR = "data-mwi-auto-render-token";
const AUTOMATION_RECOMMEND_TABLE_HEADER_CLASS = "mwi-lab-auto-recommend-header";
const AUTOMATION_RECOMMEND_CELL_CLASS = "mwi-lab-auto-recommend-cell";
const AUTOMATION_RECOMMEND_CELL_RENDER_TOKEN_ATTR = "data-mwi-auto-recommend-token";
const AUTOMATION_RECOMMEND_CELL_BOUND_FLAG = "__mwiLabAutoRecommendCellBound";
const AUTOMATION_RECOMMEND_MIN_DELTA = -300;
const AUTOMATION_RECOMMEND_MAX_DELTA = 300;
const AUTOMATION_RECOMMEND_COMBAT_TRIALS = Math.max(
MIN_COMBAT_SIM_TRIALS,
Math.min(MAX_COMBAT_SIM_TRIALS, Math.floor(3600 / ROOM_DURATION_SECONDS))
);
const AUTOMATION_RECOMMEND_ACCEPTABLE_DIFF = 0.005;
const AUTOMATION_ESTIMATE_DEFAULT_SKIP_THRESHOLD = 100;
const LABYRINTH_UPGRADE_LEVELS_STORAGE_KEY = "mwi_labyrinth_upgrade_levels_v1";
const LABYRINTH_UPGRADE_STEP_RATIO = 0.01;
const LABYRINTH_UPGRADE_SKILLING_SUCCESS_STEP_RATIO = 0.005;
const LABYRINTH_UPGRADE_KEY_SKILL_ACTION_SPEED = "/labyrinth_upgrades/skill_action_speed";
const LABYRINTH_UPGRADE_KEY_SKILLING_EFFICIENCY = "/labyrinth_upgrades/skilling_efficiency";
const LABYRINTH_UPGRADE_KEY_SKILLING_SUCCESS = "/labyrinth_upgrades/skilling_success";
const LABYRINTH_UPGRADE_KEY_SKILLING_DOUBLE_PROGRESS = "/labyrinth_upgrades/skilling_double_progress";
const LABYRINTH_UPGRADE_KEY_COMBAT_DAMAGE = "/labyrinth_upgrades/combat_damage";
const LABYRINTH_UPGRADE_KEY_ATTACK_SPEED = "/labyrinth_upgrades/attack_speed";
const LABYRINTH_UPGRADE_KEY_CAST_SPEED = "/labyrinth_upgrades/cast_speed";
const LABYRINTH_UPGRADE_KEY_CRITICAL_RATE = "/labyrinth_upgrades/critical_rate";
const LABYRINTH_UPGRADE_KEY_LABYRINTH_EXPERIENCE = "/labyrinth_upgrades/labyrinth_experience";
const LABYRINTH_UPGRADE_KEYS = [
LABYRINTH_UPGRADE_KEY_SKILL_ACTION_SPEED,
LABYRINTH_UPGRADE_KEY_SKILLING_EFFICIENCY,
LABYRINTH_UPGRADE_KEY_SKILLING_SUCCESS,
LABYRINTH_UPGRADE_KEY_SKILLING_DOUBLE_PROGRESS,
LABYRINTH_UPGRADE_KEY_COMBAT_DAMAGE,
LABYRINTH_UPGRADE_KEY_ATTACK_SPEED,
LABYRINTH_UPGRADE_KEY_CAST_SPEED,
LABYRINTH_UPGRADE_KEY_CRITICAL_RATE,
LABYRINTH_UPGRADE_KEY_LABYRINTH_EXPERIENCE,
];
/* Legacy labyrinth shop DOM scan path removed in favor of characterInfo-backed upgrade levels.
const LABYRINTH_UPGRADE_TEXT_KEY_MAP_LEGACY = [
[/迷宫冷却/i, LABYRINTH_UPGRADE_KEY_COOLDOWN],
[/火把容量/i, LABYRINTH_UPGRADE_KEY_TORCH_CAP],
[/斗篷容量/i, LABYRINTH_UPGRADE_KEY_SHROUD_CAP],
[/探照灯容量/i, LABYRINTH_UPGRADE_KEY_BEACON_CAP],
[/完全自动化/i, LABYRINTH_UPGRADE_KEY_FULL_AUTO],
[/专业速度/i, LABYRINTH_UPGRADE_KEY_SKILL_ACTION_SPEED],
[/专业效率/i, LABYRINTH_UPGRADE_KEY_SKILLING_EFFICIENCY],
[/专业成功率/i, LABYRINTH_UPGRADE_KEY_SKILLING_SUCCESS],
[/专业双倍进度/i, LABYRINTH_UPGRADE_KEY_SKILLING_DOUBLE_PROGRESS],
[/战斗伤害/i, LABYRINTH_UPGRADE_KEY_COMBAT_DAMAGE],
[/攻击速度/i, LABYRINTH_UPGRADE_KEY_ATTACK_SPEED],
[/施法速度/i, LABYRINTH_UPGRADE_KEY_CAST_SPEED],
[/暴击率/i, LABYRINTH_UPGRADE_KEY_CRITICAL_RATE],
[/迷宫经验/i, LABYRINTH_UPGRADE_KEY_LABYRINTH_EXPERIENCE],
];
const LABYRINTH_UPGRADE_TEXT_KEY_MAP = [
[/\u8ff7\u5bab\u51b7\u5374|cooldown/i, LABYRINTH_UPGRADE_KEY_COOLDOWN],
[/\u706b\u628a\u5bb9\u91cf|torch/i, LABYRINTH_UPGRADE_KEY_TORCH_CAP],
[/\u6597\u7bf7\u5bb9\u91cf|shroud/i, LABYRINTH_UPGRADE_KEY_SHROUD_CAP],
[/\u63a2\u7167\u706f|\u63a2\u7167\u706f\u5bb9\u91cf|beacon/i, LABYRINTH_UPGRADE_KEY_BEACON_CAP],
[/\u5b8c\u5168\u81ea\u52a8\u5316|full\s*auto/i, LABYRINTH_UPGRADE_KEY_FULL_AUTO],
[/\u4e13\u4e1a\u901f\u5ea6|skill(?:ing)?\s*(?:action\s*)?speed/i, LABYRINTH_UPGRADE_KEY_SKILL_ACTION_SPEED],
[/\u4e13\u4e1a\u6548\u7387|skill(?:ing)?\s*efficiency/i, LABYRINTH_UPGRADE_KEY_SKILLING_EFFICIENCY],
[/\u4e13\u4e1a\u6210\u529f\u7387|skill(?:ing)?\s*success/i, LABYRINTH_UPGRADE_KEY_SKILLING_SUCCESS],
[/\u4e13\u4e1a\u53cc\u500d\u8fdb\u5ea6|skill(?:ing)?\s*double\s*progress/i, LABYRINTH_UPGRADE_KEY_SKILLING_DOUBLE_PROGRESS],
[/\u6218\u6597\u4f24\u5bb3|combat\s*damage/i, LABYRINTH_UPGRADE_KEY_COMBAT_DAMAGE],
[/\u653b\u51fb\u901f\u5ea6|attack\s*speed/i, LABYRINTH_UPGRADE_KEY_ATTACK_SPEED],
[/\u65bd\u6cd5\u901f\u5ea6|cast\s*speed/i, LABYRINTH_UPGRADE_KEY_CAST_SPEED],
[/\u66b4\u51fb\u7387|critical\s*rate|crit\s*rate/i, LABYRINTH_UPGRADE_KEY_CRITICAL_RATE],
[/(?:\u8ff7\u5bab)?\u7ecf\u9a8c|labyrinth\s*experience|experience/i, LABYRINTH_UPGRADE_KEY_LABYRINTH_EXPERIENCE],
];
*/
const LABYRINTH_AUTOMATION_SKILL_ROOM_TYPES = [
{ key: "milking", skillHrid: "/skills/milking" },
{ key: "foraging", skillHrid: "/skills/foraging" },
{ key: "woodcutting", skillHrid: "/skills/woodcutting" },
{ key: "cheesesmithing", skillHrid: "/skills/cheesesmithing" },
{ key: "crafting", skillHrid: "/skills/crafting" },
{ key: "tailoring", skillHrid: "/skills/tailoring" },
{ key: "cooking", skillHrid: "/skills/cooking" },
{ key: "brewing", skillHrid: "/skills/brewing" },
{ key: "alchemy", skillHrid: "/skills/alchemy" },
{ key: "enhancing", skillHrid: "/skills/enhancing" },
];
const LABYRINTH_AUTOMATION_COMBAT_ROOM_TYPES = [
{ key: "shadow_archer", monsterHrid: "/monsters/shadow_archer" },
{ key: "pyre_hunter", monsterHrid: "/monsters/pyre_hunter" },
{ key: "frost_sniper", monsterHrid: "/monsters/frost_sniper" },
{ key: "siren", monsterHrid: "/monsters/siren" },
{ key: "salamander", monsterHrid: "/monsters/salamander" },
{ key: "dryad", monsterHrid: "/monsters/dryad" },
{ key: "giant_scorpion", monsterHrid: "/monsters/giant_scorpion" },
{ key: "giant_mantis", monsterHrid: "/monsters/giant_mantis" },
{ key: "cyclops", monsterHrid: "/monsters/cyclops" },
{ key: "mimic", monsterHrid: "/monsters/mimic" },
];
const LABYRINTH_AUTOMATION_SKILL_NAME_ZH_BY_KEY = {
milking: "挤奶",
foraging: "采摘",
woodcutting: "伐木",
cheesesmithing: "奶酪锻造",
crafting: "制作",
tailoring: "缝纫",
cooking: "烹饪",
brewing: "冲泡",
alchemy: "炼金",
enhancing: "强化",
};
const LABYRINTH_AUTOMATION_SKILL_NAME_EN_BY_KEY = {
milking: "Milking",
foraging: "Foraging",
woodcutting: "Woodcutting",
cheesesmithing: "Cheesesmithing",
crafting: "Crafting",
tailoring: "Tailoring",
cooking: "Cooking",
brewing: "Brewing",
alchemy: "Alchemy",
enhancing: "Enhancing",
};
const LABYRINTH_LOAN_SEAL_EFFECTS = [
{
itemHrid: "/items/seal_of_efficiency",
buffTypeHrid: "/buff_types/efficiency",
amount: 0.14,
boostMode: "flat",
isCombat: false,
},
{
itemHrid: "/items/seal_of_action_speed",
buffTypeHrid: "/buff_types/action_speed",
amount: 0.15,
boostMode: "flat",
isCombat: false,
},
{
itemHrid: "/items/seal_of_gourmet",
buffTypeHrid: "/buff_types/gourmet",
amount: 0.1,
boostMode: "flat",
isCombat: false,
},
{
itemHrid: "/items/seal_of_gathering",
buffTypeHrid: "/buff_types/gathering",
amount: 0.18,
boostMode: "flat",
isCombat: false,
},
{
itemHrid: "/items/seal_of_damage",
buffTypeHrid: "/buff_types/damage",
amount: 0.08,
boostMode: "ratio",
isCombat: true,
},
{
itemHrid: "/items/seal_of_attack_speed",
buffTypeHrid: "/buff_types/attack_speed",
amount: 0.15,
boostMode: "ratio",
isCombat: true,
},
{
itemHrid: "/items/seal_of_cast_speed",
buffTypeHrid: "/buff_types/cast_speed",
amount: 0.15,
boostMode: "flat",
isCombat: true,
},
{
itemHrid: "/items/seal_of_critical_rate",
buffTypeHrid: "/buff_types/critical_rate",
amount: 0.1,
boostMode: "flat",
isCombat: true,
},
];
const SIMULATOR_PERSONAL_BUFF_ITEM_HRIDS = new Set([
"/items/seal_of_combat_drop",
"/items/seal_of_attack_speed",
"/items/seal_of_cast_speed",
"/items/seal_of_damage",
"/items/seal_of_critical_rate",
"/items/seal_of_wisdom",
"/items/seal_of_rare_find",
]);
const SIMULATOR_COMBAT_PERSONAL_SEAL_ITEM_HRIDS = new Set([
"/items/seal_of_combat_drop",
"/items/seal_of_attack_speed",
"/items/seal_of_cast_speed",
"/items/seal_of_damage",
"/items/seal_of_critical_rate",
"/items/seal_of_wisdom",
"/items/seal_of_rare_find",
]);
const LABYRINTH_SEAL_NAME_ZH_BY_ITEM_HRID = {
"/items/seal_of_gathering": "卷轴·采集",
"/items/seal_of_gourmet": "卷轴·美食",
"/items/seal_of_processing": "卷轴·加工",
"/items/seal_of_efficiency": "卷轴·效率",
"/items/seal_of_action_speed": "卷轴·行动速度",
"/items/seal_of_combat_drop": "卷轴·战利品",
"/items/seal_of_attack_speed": "卷轴·攻击速度",
"/items/seal_of_cast_speed": "卷轴·施法速度",
"/items/seal_of_damage": "卷轴·伤害",
"/items/seal_of_critical_rate": "卷轴·暴击率",
"/items/seal_of_wisdom": "卷轴·智慧",
"/items/seal_of_rare_find": "卷轴·稀有掉落",
};
const SIMULATOR_BRIDGE_URL_STORAGE_KEY = "mwi_lab_simulator_bridge_url";
const SIMULATOR_BRIDGE_DEFAULT_URL = "https://shykai.github.io/MWICombatSimulatorTest/dist/";
const SIMULATOR_BRIDGE_LEGACY_URL_PREFIXES = [
"https://amvoidguy.github.io/MWICombatSimulatorTest/",
"https://shykai.github.io/MWICombatSimulatorTest/",
"https://shykai.github.io/mwisim/",
"https://truthligh.github.io/MWICombatSimulator/",
];
const SIMULATOR_BRIDGE_PAYLOAD_PARAM = "mwiLabBridge";
const SIMULATOR_BRIDGE_SOURCE = "mwi-lab-clear-rate-overlay";
const SIMULATOR_BRIDGE_VERSION = 1;
const ETA_INFINITE_TEXT = "999+";
const PANEL_REFRESH_POLL_MS = 1000;
const PANEL_REFRESH_DEBOUNCE_MS = 120;
const AUTO_RECALC_DEBOUNCE_MS = 600;
const UI_LANGUAGE_STORAGE_KEY = "i18nextLng";
const UI_LANGUAGE_EN = "en";
const UI_LANGUAGE_ZH = "zh";
const UI_TEXT = {
en: {
pending: "Pending",
tokenExpected: "Token Expected",
experiencePerAction: "EXP / Action",
experiencePerRoom: "EXP / Room",
experiencePerHour: "EXP / Hour",
skillingBoxExpected: "Skilling Box Expected",
combatBoxExpected: "Combat Box Expected",
refiningChestExpected: "Refining Chest Expected",
skillingRoomPreview: "Skilling Room Preview",
combatRoomPreview: "Combat Room Preview",
treasureRoomPreview: "Treasure Room Preview",
floorExitPreview: "Floor Exit Preview",
roomPreview: "Room Preview",
styleStab: "Stab",
styleSlash: "Slash",
styleSmash: "Smash",
styleRanged: "Ranged",
styleMagic: "Magic",
unknown: "Unknown",
accuracySuffix: "Accuracy",
damageSuffix: "Damage",
evasionSuffix: "Evasion",
water: "Water",
nature: "Nature",
fire: "Fire",
physical: "Physical",
waterResistance: "Water Resistance",
natureResistance: "Nature Resistance",
fireResistance: "Fire Resistance",
armor: "Armor",
failureDefense: "Insufficient Defense",
failureDamage: "Insufficient Damage",
targetEnhancement: "Target Enhancement",
successRate: "Success Rate",
doubleProgress: "Double Progress",
twoMinuteActions: "Actions in 2m",
actionDuration: "Action Duration",
needSpeedForOneMoreAction: "Speed for +1 Action",
workPower: "Work Power",
needEfficiencyForOneLessProgress: "Efficiency for -1 Progress",
alreadyOptimal: "Already Optimal",
combatStyle: "Combat Style",
damageType: "Damage Type",
attackInterval: "Attack Interval",
castSpeed: "Cast Speed",
maxHp: "Max HP",
operation: "Action",
rightClickOpenSimulator: "Right-click to open simulator",
failureReason: "Failure Reason",
accuracyDefault: "Accuracy",
damageDefault: "Damage",
evasionDefault: "Evasion",
mitigationDefault: "Armor",
automationPreview: "Automation Preview",
status: "Status",
level: "Level",
maxFloor: "Max Floor",
chanceEta: "Chance / ETA",
targetWinRate: "Target Win %",
recommendDelta: "Recommend Level",
recommendSettingLevel: "Recommend Setting Level",
skipLevel: "Skip Level",
skipLevelLong: "Skip if above level",
combatTrials: "Combat Trials",
calcChance: "Calculate",
calculating: "Calculating...",
calcMaze: "Calculate Labyrinth",
automationListNotFound: "Automation list not found",
noCalculableRooms: "No calculable rooms",
missingClientData: "Missing client data",
preparing: "Preparing...",
calculatingProgressFmt: "Calculating {current}/{total}",
skipRoom: "Skip Room",
calcFailed: "Calculation failed",
calcDone: "Calculation complete",
calcDoneWithPersonalBuffs: "Calculation complete (including personal buffs)",
loanSeal: "Scroll Loan",
loanPanelTitle: "Available Scroll Effects",
loanCalc: "Loan Calculate",
loanNoOptions: "No usable scrolls",
loanAlreadyActive: "Active",
loanCannotApply: "No effect data",
roomLog: "Logs",
roomLogTitleFmt: "Room Logs (Last {count})",
roomLogEmpty: "No logs yet",
roomLogIncomplete: "Incomplete",
roomLogModeSkilling: "Skilling",
roomLogModeEnhancing: "Enhancing",
roomLogModeCombat: "Combat",
roomLogActionGap: "Missed action records",
roomLogComingSoon: "Coming Soon",
roomLogRateFmt: "Success {success}% / Double {double}%",
roomLogWorkFmt: "Work {value}",
roomLogExpFmt: "EXP {value}",
roomLogProgressFmt: "Progress {current}% / {target}%",
roomLogEnhFmt: "Enh +{current}/+{target}",
roomLogDurationFmt: "{seconds}s",
roomLogClear: "Clear",
roomLogClose: "Close",
skippedRooms: "Rooms skipped",
noSupplies: "No supply crates equipped",
partialSkipped: "Some rooms skipped",
missingCoffeeCrate: "Coffee Crate",
missingFoodCrate: "Food Crate",
missingTeaCrate: "Tea Crate",
missingCrateFmt: "Missing {crates}",
progressFmt: "Progress {percent}%",
notInLabyrinth: "Not in labyrinth",
noLabyrinthData: "No labyrinth data",
cellsNotFound: "Grid cells not found",
noNewTiles: "No new tiles",
roomFmt: "Room {current}/{total}",
combatFmt: "Combat {current}/{total}",
autoNewTiles: "New tiles found, auto calculating",
readGameDataFailed: "Unable to read game data. Refresh and try again.",
exportableCombatRoomNotFound: "Could not identify an exportable combat room.",
skippedCannotExport: "This room is skipped and cannot be exported.",
simulatorExportNoLoadout: "Simulator export failed: missing usable loadout.",
combatFlowFailedFmt: "Labyrinth combat full-flow calculation failed: {message}",
liveEnhFmt: " [Clear {chance}% | +{current}/+{target} | {left} left]",
liveBasicFmt: " [Clear {chance}% | {left} left]",
liveSuccessFmt: "Success {chance}%",
liveDoubleFmt: "Double {chance}%",
liveActionsFmt: "Actions {current}/{total}",
liveEnhTitleFmt: "Enhance +{current}/+{target}",
liveProgressFmt: "Progress {current}/{target}",
waitingOptimization: "(Pending optimization)",
},
zh: {
pending: "待计算",
tokenExpected: "代币期望",
experiencePerAction: "每次经验",
experiencePerRoom: "每场经验",
experiencePerHour: "每小时经验",
skillingBoxExpected: "生活紫盒期望",
combatBoxExpected: "战斗紫盒期望",
refiningChestExpected: "精炼宝箱期望",
skillingRoomPreview: "生活房间预览",
combatRoomPreview: "战斗房间预览",
treasureRoomPreview: "宝箱房间预览",
floorExitPreview: "楼层出口预览",
roomPreview: "房间预览",
styleStab: "刺击",
styleSlash: "斩击",
styleSmash: "钝击",
styleRanged: "远程",
styleMagic: "魔法",
unknown: "未知",
accuracySuffix: "精准度",
damageSuffix: "伤害",
evasionSuffix: "闪避",
water: "水系",
nature: "自然系",
fire: "火系",
physical: "物理",
waterResistance: "水系抗性",
natureResistance: "自然系抗性",
fireResistance: "火系抗性",
armor: "护甲",
failureDefense: "防御不足",
failureDamage: "伤害不足",
targetEnhancement: "目标强化",
successRate: "成功率",
doubleProgress: "双倍进度",
twoMinuteActions: "2分钟次数",
actionDuration: "单次时长",
needSpeedForOneMoreAction: "多1次行动需速度",
workPower: "工作能力",
needEfficiencyForOneLessProgress: "减1次进度需效率",
alreadyOptimal: "已最优",
combatStyle: "战斗风格",
damageType: "伤害类型",
attackInterval: "攻击间隔",
castSpeed: "施法速度",
maxHp: "最大HP",
operation: "操作",
rightClickOpenSimulator: "右键打开模拟器",
failureReason: "失败原因",
accuracyDefault: "精准度",
damageDefault: "伤害",
evasionDefault: "闪避",
mitigationDefault: "护甲",
automationPreview: "自动化预览",
status: "状态",
level: "等级",
maxFloor: "最高层数",
chanceEta: "胜率/耗时",
targetWinRate: "目标胜率",
recommendDelta: "推荐等级",
recommendSettingLevel: "推荐设置等级",
skipLevel: "跳过等级",
skipLevelLong: "跳过如果高出等级",
combatTrials: "战斗次数",
calcChance: "计算胜率",
calculating: "计算中...",
calcMaze: "计算迷宫",
automationListNotFound: "未识别到自动化列表",
noCalculableRooms: "无可计算房间",
missingClientData: "缺少客户端数据",
preparing: "准备中...",
calculatingProgressFmt: "计算中 {current}/{total}",
skipRoom: "跳过房间",
calcFailed: "计算失败",
calcDone: "计算完成",
calcDoneWithPersonalBuffs: "计算完成(包含个人增益)",
loanSeal: "贷款卷轴",
loanPanelTitle: "可用卷轴效果",
loanCalc: "贷款计算",
loanNoOptions: "没有可用卷轴",
loanAlreadyActive: "已生效",
loanCannotApply: "无效果数据",
roomLog: "日志",
roomLogTitleFmt: "房间日志(最近{count}场)",
roomLogEmpty: "暂无日志",
roomLogIncomplete: "不完整",
roomLogModeSkilling: "技能",
roomLogModeEnhancing: "强化",
roomLogModeCombat: "战斗",
roomLogActionGap: "行动记录缺失",
roomLogComingSoon: "敬请期待",
roomLogRateFmt: "成功率 {success}% / 双倍 {double}%",
roomLogWorkFmt: "工作能力 {value}",
roomLogExpFmt: "经验 {value}",
roomLogProgressFmt: "进度 {current}% / {target}%",
roomLogEnhFmt: "强化 +{current}/+{target}",
roomLogDurationFmt: "持续{seconds}秒",
roomLogClear: "清理",
roomLogClose: "关闭",
skippedRooms: "已跳过房间",
noSupplies: "未携带补给箱",
partialSkipped: "部分房间已跳过",
missingCoffeeCrate: "咖啡箱",
missingFoodCrate: "食物箱",
missingTeaCrate: "茶叶箱",
missingCrateFmt: "未携带{crates}",
progressFmt: "进度 {percent}%",
notInLabyrinth: "不在迷宫",
noLabyrinthData: "无迷宫数据",
cellsNotFound: "未找到格子",
noNewTiles: "无新增地块",
roomFmt: "房间 {current}/{total}",
combatFmt: "战斗 {current}/{total}",
autoNewTiles: "发现新地块,自动计算",
readGameDataFailed: "无法读取游戏数据,请刷新页面后重试。",
exportableCombatRoomNotFound: "未识别到可导出的战斗房间。",
skippedCannotExport: "该房间当前设置会跳过,无法导出模拟器。",
simulatorExportNoLoadout: "导出模拟器数据失败:缺少可用配装。",
combatFlowFailedFmt: "迷宫战斗全流程计算失败:{message}",
liveEnhFmt: " [胜率 {chance}% | +{current}/+{target} | 剩余{left}次]",
liveBasicFmt: " [胜率 {chance}% | 剩余{left}次]",
liveSuccessFmt: "成功率 {chance}%",
liveDoubleFmt: "双倍 {chance}%",
liveActionsFmt: "已行动 {current}/{total}",
liveEnhTitleFmt: "强化 +{current}/+{target}",
liveProgressFmt: "进度 {current}/{target}",
waitingOptimization: "(等待优化)",
},
};
function normalizeUiLanguage(value) {
const raw = String(value || "").trim().toLowerCase();
if (raw.startsWith("zh")) {
return UI_LANGUAGE_ZH;
}
if (raw.startsWith("en")) {
return UI_LANGUAGE_EN;
}
return "";
}
function getUiLanguage() {
try {
const fromStorage = normalizeUiLanguage(localStorage.getItem(UI_LANGUAGE_STORAGE_KEY));
if (fromStorage) {
return fromStorage;
}
} catch (_error) {
// Ignore storage read errors.
}
return UI_LANGUAGE_EN;
}
function isChineseUi() {
return getUiLanguage() === UI_LANGUAGE_ZH;
}
function t(key, vars) {
const lang = getUiLanguage();
const template = (UI_TEXT[lang] && UI_TEXT[lang][key]) || UI_TEXT.en[key] || key;
if (!vars || typeof vars !== "object") {
return template;
}
return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, name) => {
if (Object.prototype.hasOwnProperty.call(vars, name)) {
return String(vars[name]);
}
return "";
});
}
let cachedInitClientDataRaw = "";
let cachedInitClientData = null;
let combatEstimateCache = new Map();
let manualUpdateRunning = false;
let combatWorkerScriptPromise = null;
let combatSimulatorWorker = null;
let combatSimulatorWorkerUrl = "";
let combatWorkerRequestId = 0;
let lastLabyrinthDisplaySignature = "";
let lastProgressRoomKey = "";
let wasRoomChallengeRunning = false;
let lastObservedPathRoomKey = "";
let autoRecalcArmed = false;
let autoRecalcLabyrinthSignature = "";
let lastCalculatedCalculableRoomSignature = "";
let lastCalculatedCalculableRoomCount = 0;
let lastCalculatedCalculableRoomEntries = new Set();
let pendingAutoRecalcRoomKeys = new Set();
let autoRecalcTimerId = 0;
let panelRefreshTimerId = 0;
const combatWorkerPendingRequests = new Map();
let skillingPreviewByCell = new WeakMap();
let combatPreviewByCell = new WeakMap();
let latestRoomEstimateByRoomKey = new Map();
let lastLiveActionRateToken = "";
let liveActionRateWsHookInstalled = false;
let automationEstimateByRoomTypeKey = new Map();
let automationEstimateSignatureByRoomTypeKey = new Map();
let automationRecommendByRoomTypeKey = new Map();
let automationRecommendSignatureByRoomTypeKey = new Map();
let automationEstimateRunning = false;
let automationEstimateRunningMode = "";
let automationEstimateStatusText = t("pending");
let automationEstimateColumnEnabled = false;
let automationRecommendColumnEnabled = false;
let automationWideLayoutNodes = [];
let lastLabyrinthCalcDoneMessage = "";
let loanSealSelectionByItemHrid = new Map();
let activeLoanSimulationOptions = null;
let latestLabyrinthUpgradeLevels = null;
let roomLogSessions = [];
let activeRoomLogSession = null;
function clamp01(value) {
if (!Number.isFinite(value)) {
return 0;
}
if (value < 0) {
return 0;
}
if (value > 1) {
return 1;
}
return value;
}
function finiteNumber(value, fallback = 0) {
return Number.isFinite(value) ? Number(value) : fallback;
}
function positiveNumber(value, fallback = 0) {
const n = finiteNumber(value, fallback);
return n > 0 ? n : fallback;
}
function createEmptyLabyrinthUpgradeLevels() {
const result = {};
for (const key of LABYRINTH_UPGRADE_KEYS) {
result[key] = 0;
}
return result;
}
function normalizeLabyrinthUpgradeLevels(raw) {
const result = createEmptyLabyrinthUpgradeLevels();
if (!raw || typeof raw !== "object") {
return result;
}
for (const key of LABYRINTH_UPGRADE_KEYS) {
result[key] = Math.max(0, Math.floor(finiteNumber(raw[key], 0)));
}
return result;
}
function mergeLabyrinthUpgradeLevels(base, override) {
const result = normalizeLabyrinthUpgradeLevels(base);
if (!override || typeof override !== "object") {
return result;
}
for (const key of LABYRINTH_UPGRADE_KEYS) {
if (Object.prototype.hasOwnProperty.call(override, key)) {
result[key] = Math.max(0, Math.floor(finiteNumber(override[key], result[key])));
}
}
return result;
}
function loadStoredLabyrinthUpgradeLevels() {
try {
return normalizeLabyrinthUpgradeLevels(JSON.parse(localStorage.getItem(LABYRINTH_UPGRADE_LEVELS_STORAGE_KEY) || "null"));
} catch (_error) {
return createEmptyLabyrinthUpgradeLevels();
}
}
function saveLabyrinthUpgradeLevels(levels) {
const normalized = normalizeLabyrinthUpgradeLevels(levels);
try {
localStorage.setItem(LABYRINTH_UPGRADE_LEVELS_STORAGE_KEY, JSON.stringify(normalized));
} catch (_error) {
// Ignore storage errors.
}
latestLabyrinthUpgradeLevels = normalized;
return normalized;
}
function readLabyrinthUpgradeLevelsFromState(state) {
const info = state?.characterInfo;
if (!info || typeof info !== "object") {
return null;
}
const result = {};
let matched = 0;
const assign = (key, value) => {
if (!Number.isFinite(Number(value))) {
return;
}
result[key] = Math.max(0, Math.floor(Number(value)));
matched += 1;
};
assign(LABYRINTH_UPGRADE_KEY_SKILL_ACTION_SPEED, info.labyrinthSkillActionSpeedLevel);
assign(LABYRINTH_UPGRADE_KEY_SKILLING_EFFICIENCY, info.labyrinthSkillingEfficiencyLevel);
assign(LABYRINTH_UPGRADE_KEY_SKILLING_SUCCESS, info.labyrinthSkillingSuccessLevel);
assign(LABYRINTH_UPGRADE_KEY_SKILLING_DOUBLE_PROGRESS, info.labyrinthSkillingDoubleProgressLevel);
assign(LABYRINTH_UPGRADE_KEY_COMBAT_DAMAGE, info.labyrinthCombatDamageLevel);
assign(LABYRINTH_UPGRADE_KEY_ATTACK_SPEED, info.labyrinthAttackSpeedLevel);
assign(LABYRINTH_UPGRADE_KEY_CAST_SPEED, info.labyrinthCastSpeedLevel);
assign(LABYRINTH_UPGRADE_KEY_CRITICAL_RATE, info.labyrinthCriticalRateLevel);
assign(LABYRINTH_UPGRADE_KEY_LABYRINTH_EXPERIENCE, info.labyrinthExperienceLevel);
return matched > 0 ? result : null;
}
function areLabyrinthUpgradeLevelsEqual(left, right) {
for (const key of LABYRINTH_UPGRADE_KEYS) {
if (Math.max(0, Math.floor(finiteNumber(left?.[key], 0))) !== Math.max(0, Math.floor(finiteNumber(right?.[key], 0)))) {
return false;
}
}
return true;
}
/* Legacy labyrinth shop DOM scan helpers kept disabled after switching upgrade reads to characterInfo only.
function getReactFiberNodeFromElement(element) {
if (!element || typeof element !== "object") {
return null;
}
const fiberKey = Object.getOwnPropertyNames(element).find((key) => key.startsWith("__reactFiber$"));
return fiberKey ? element[fiberKey] || null : null;
}
function resolveLabyrinthUpgradeKeyFromElement(element) {
let currentElement = element;
for (let depth = 0; currentElement && depth < 4; depth += 1, currentElement = currentElement.parentElement) {
let fiber = getReactFiberNodeFromElement(currentElement);
while (fiber) {
const upgradeKey = String(fiber.key || "");
if (upgradeKey.startsWith("/labyrinth_upgrades/")) {
return upgradeKey;
}
fiber = fiber.return || null;
}
}
const text = String(element?.innerText || element?.textContent || "");
for (const [pattern, upgradeKey] of LABYRINTH_UPGRADE_TEXT_KEY_MAP) {
if (pattern.test(text)) {
return upgradeKey;
}
}
return "";
}
function scanLabyrinthUpgradeLevelsFromDom() {
const result = createEmptyLabyrinthUpgradeLevels();
let matched = 0;
for (const element of Array.from(document.querySelectorAll(LABYRINTH_UPGRADE_CARD_SELECTOR))) {
const upgradeKey = resolveLabyrinthUpgradeKeyFromElement(element);
if (!upgradeKey || !Object.prototype.hasOwnProperty.call(result, upgradeKey)) {
continue;
}
const text = String(element.innerText || element.textContent || "");
const countMatch = text.match(/(\d+)\s*\/\s*(\d+)/);
if (!countMatch) {
continue;
}
result[upgradeKey] = Math.max(0, Math.floor(finiteNumber(Number(countMatch[1]), 0)));
matched += 1;
}
return matched > 0 ? result : null;
}
*/
function getLabyrinthUpgradeLevels(forceRefresh = false) {
if (!latestLabyrinthUpgradeLevels) {
latestLabyrinthUpgradeLevels = loadStoredLabyrinthUpgradeLevels();
}
const fromState = readLabyrinthUpgradeLevelsFromState(getGameState());
const resolved = fromState ? mergeLabyrinthUpgradeLevels(latestLabyrinthUpgradeLevels, fromState) : latestLabyrinthUpgradeLevels;
return areLabyrinthUpgradeLevelsEqual(resolved, latestLabyrinthUpgradeLevels)
? latestLabyrinthUpgradeLevels
: saveLabyrinthUpgradeLevels(resolved);
}
function syncVisibleLabyrinthUpgradeLevelsCache() {
if (!latestLabyrinthUpgradeLevels) {
latestLabyrinthUpgradeLevels = loadStoredLabyrinthUpgradeLevels();
}
const fromState = readLabyrinthUpgradeLevelsFromState(getGameState());
const resolved = fromState ? mergeLabyrinthUpgradeLevels(latestLabyrinthUpgradeLevels, fromState) : latestLabyrinthUpgradeLevels;
if (!areLabyrinthUpgradeLevelsEqual(resolved, latestLabyrinthUpgradeLevels)) {
return saveLabyrinthUpgradeLevels(resolved);
}
latestLabyrinthUpgradeLevels = normalizeLabyrinthUpgradeLevels(resolved);
return latestLabyrinthUpgradeLevels;
}
function getLabyrinthUpgradeLevel(levels, upgradeKey) {
return Math.max(0, Math.floor(finiteNumber(levels?.[upgradeKey], 0)));
}
function resolveLabyrinthUpgradeLevels(options = null) {
if (options?.labyrinthUpgradeLevels && typeof options.labyrinthUpgradeLevels === "object") {
return normalizeLabyrinthUpgradeLevels(options.labyrinthUpgradeLevels);
}
return getLabyrinthUpgradeLevels();
}
function normalizeChance(value) {
const n = finiteNumber(value, 0);
if (n > 1 && n <= 100) {
return clamp01(n / 100);
}
return clamp01(n);
}
function nowIsoString(timestamp = Date.now()) {
try {
return new Date(timestamp).toISOString();
} catch (_error) {
return "";
}
}
function formatRoomLogPercent(percentValue) {
const value = Math.max(0, finiteNumber(percentValue, 0));
const oneDecimal = Math.round(value * 10) / 10;
if (Math.abs(oneDecimal - Math.round(oneDecimal)) < 0.0001) {
return String(Math.round(oneDecimal));
}
return oneDecimal.toFixed(1);
}
function formatRoomLogExperience(value) {
const n = Math.max(0, finiteNumber(value, 0));
if (Math.abs(n - Math.round(n)) < 1e-9) {
return `${Math.round(n)}`;
}
return n.toFixed(1).replace(/\.0$/, "");
}
function getSkillNameByHrid(skillHrid) {
const hrid = String(skillHrid || "");
const key = hrid.split("/").pop() || "";
if (isChineseUi()) {
return LABYRINTH_AUTOMATION_SKILL_NAME_ZH_BY_KEY[key] || key || "--";
}
return LABYRINTH_AUTOMATION_SKILL_NAME_EN_BY_KEY[key] || key || "--";
}
function getSkillExperienceValue(skillMap, skillHrid) {
const hrid = String(skillHrid || "");
if (!skillMap || !hrid) {
return NaN;
}
if (skillMap instanceof Map) {
const entry = skillMap.get(hrid);
if (Number.isFinite(entry?.experience)) {
return Number(entry.experience);
}
return NaN;
}
const entry = skillMap[hrid];
if (entry && Number.isFinite(entry.experience)) {
return Number(entry.experience);
}
return NaN;
}
function buildRoomLogContextFromSession(session) {
if (!session || typeof session !== "object") {
return null;
}
const skillHrid = String(session.skillHrid || "");
if (!skillHrid) {
return null;
}
return {
roomKey: String(session.roomKey || ""),
roomType: LABYRINTH_SKILLING_ROOM_TYPE,
skillHrid,
skillName: String(session.skillName || getSkillNameByHrid(skillHrid)),
recommendedLevel: Math.max(0, Math.floor(finiteNumber(session.recommendedLevel, 0))),
};
}
function createEmptyRoomLogStorage() {
return {
sessions: [],
active: null,
};
}
function trimRoomLogSessions(sessions) {
if (!Array.isArray(sessions)) {
return [];
}
return sessions
.filter((entry) => entry && typeof entry === "object")
.slice(0, ROOM_LOG_MAX_SESSIONS);
}
function sanitizeRoomLogAction(action, fallbackCounter = 0) {
if (!action || typeof action !== "object") {
return {
counter: Math.max(0, Math.floor(finiteNumber(fallbackCounter, 0))),
outcome: ROOM_LOG_ACTION_OUTCOME_UNKNOWN,
text: "?",
missing: true,
};
}
return {
counter: Math.max(0, Math.floor(finiteNumber(action.counter, fallbackCounter))),
outcome: String(action.outcome || ROOM_LOG_ACTION_OUTCOME_UNKNOWN),
text: String(action.text || "?"),
missing: action.missing === true,
};
}
function sanitizeRoomLogSession(session) {
if (!session || typeof session !== "object") {
return null;
}
const startedAt = Math.max(0, Math.floor(finiteNumber(session.startedAt, 0)));
const actions = Array.isArray(session.actions) ? session.actions.map((action) => sanitizeRoomLogAction(action)) : [];
return {
id: String(session.id || `room-log-${startedAt}`),
startedAt,
endedAt: Math.max(0, Math.floor(finiteNumber(session.endedAt, 0))),
runKey: String(session.runKey || ""),
roomKey: String(session.roomKey || ""),
mode: String(session.mode || "skilling"),
skillHrid: String(session.skillHrid || ""),
skillName: String(session.skillName || "--"),
recommendedLevel: Math.max(0, Math.floor(finiteNumber(session.recommendedLevel, 0))),
successRate: clamp01(finiteNumber(session.successRate, 0)),
doubleChance: clamp01(finiteNumber(session.doubleChance, 0)),
progressPerAction: Math.max(0, finiteNumber(session.progressPerAction, 0)),
experiencePerAction: Math.max(0, finiteNumber(session.experiencePerAction, 0)),
predictedExperience: Math.max(
0,
finiteNumber(session.predictedExperience, finiteNumber(session.experiencePerAction, 0))
),
actualExperienceGain: Math.max(0, finiteNumber(session.actualExperienceGain, 0)),
startSkillExperience: Math.max(0, finiteNumber(session.startSkillExperience, 0)),
endSkillExperience: Math.max(0, finiteNumber(session.endSkillExperience, 0)),
totalExperience: Math.max(0, finiteNumber(session.totalExperience, 0)),
targetWorkValue: Math.max(0, finiteNumber(session.targetWorkValue, 0)),
currentWorkValue: Math.max(0, finiteNumber(session.currentWorkValue, 0)),
currentProgressPct: Math.max(0, finiteNumber(session.currentProgressPct, 0)),
targetLevel: Math.max(0, Math.floor(finiteNumber(session.targetLevel, 0))),
currentEnhLevel: Math.max(0, Math.floor(finiteNumber(session.currentEnhLevel, 0))),
actions,
incomplete: session.incomplete === true,
incompleteReasons: Array.isArray(session.incompleteReasons)
? Array.from(new Set(session.incompleteReasons.map((reason) => String(reason || "").trim()).filter(Boolean)))
: [],
completed: session.completed === true,
};
}
function persistRoomLogStorage() {
const payload = {
sessions: trimRoomLogSessions(roomLogSessions),
active: sanitizeRoomLogSession(activeRoomLogSession),
};
try {
localStorage.setItem(ROOM_LOG_STORAGE_KEY, JSON.stringify(payload));
} catch (_error) {
// Ignore storage errors.
}
}
function loadRoomLogStorage() {
const fallback = createEmptyRoomLogStorage();
try {
const raw = localStorage.getItem(ROOM_LOG_STORAGE_KEY);
if (!raw) {
return fallback;
}
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") {
return fallback;
}
const sessions = Array.isArray(parsed.sessions)
? trimRoomLogSessions(parsed.sessions.map((entry) => sanitizeRoomLogSession(entry)).filter(Boolean))
: [];
const active = sanitizeRoomLogSession(parsed.active);
return {
sessions,
active,
};
} catch (_error) {
return fallback;
}
}
function getRoomLogFloatingPanel() {
return document.getElementById(ROOM_LOG_FLOAT_ID);
}
function refreshRoomLogPanelIfVisible() {
const panel = getRoomLogFloatingPanel();
if (!panel || panel.hasAttribute("hidden")) {
return;
}
renderRoomLogPanel();
}
function markRoomLogSessionIncomplete(session, reason) {
if (!session || typeof session !== "object") {
return;
}
session.incomplete = true;
if (!Array.isArray(session.incompleteReasons)) {
session.incompleteReasons = [];
}
const normalized = String(reason || "").trim();
if (normalized && !session.incompleteReasons.includes(normalized)) {
session.incompleteReasons.push(normalized);
}
}
function getRoomLogRunKey(state) {
const labyrinth = state?.characterLabyrinth;
if (!labyrinth) {
return "";
}
const startedAt = String(labyrinth.startedAt || "");
const floor = Math.max(0, Math.floor(finiteNumber(labyrinth.currentFloor, 0)));
return `${startedAt}|${floor}`;
}
function buildRoomLogSessionKey(state, roomContext, mode) {
if (!roomContext) {
return "";
}
return [
getRoomLogRunKey(state),
String(roomContext.roomKey || ""),
String(roomContext.skillHrid || ""),
String(mode || "skilling"),
].join("|");
}
function getCurrentSkillingRoomContext(state) {
const roomKey = getCurrentPathRoomKey(state);
if (!roomKey) {
return null;
}
const room = getRoomAtKey(state, roomKey);
if (!room || room.roomType !== LABYRINTH_SKILLING_ROOM_TYPE) {
return null;
}
const skillHrid = String(room.skillHrid || "");
return {
roomKey,
roomType: String(room.roomType || ""),
skillHrid,
skillName: getSkillNameByHrid(skillHrid),
recommendedLevel: Math.max(0, Math.floor(finiteNumber(room.recommendedLevel, 0))),
};
}
function getCombatMonsterDisplayName(monsterHrid, initClientData) {
const hrid = String(monsterHrid || "");
if (!hrid) {
return t("roomLogModeCombat");
}
const tail = hrid.split("/").pop() || hrid;
const monster = initClientData?.combatMonsterDetailMap?.[hrid];
if (isChineseUi()) {
const localizedByTail = LABYRINTH_MONSTER_NAME_ZH_BY_TAIL[tail];
if (localizedByTail) {
return String(localizedByTail);
}
const localized = LABYRINTH_MONSTER_NAME_ZH_MAP[hrid];
if (localized) {
return String(localized);
}
}
return String(monster?.name || tail || hrid);
}
function getCurrentCombatRoomContext(state) {
const roomKey = getCurrentPathRoomKey(state);
if (!roomKey) {
return null;
}
const room = getRoomAtKey(state, roomKey);
if (!room || room.roomType !== LABYRINTH_COMBAT_ROOM_TYPE) {
return null;
}
const initClientData = getInitClientData();
const monsterHrid = String(room.monsterHrid || "");
return {
roomKey,
roomType: String(room.roomType || ""),
skillHrid: "",
skillName: getCombatMonsterDisplayName(monsterHrid, initClientData),
recommendedLevel: Math.max(0, Math.floor(finiteNumber(room.recommendedLevel, 0))),
};
}
function buildRoomLogSnapshot(roomProgress) {
if (!roomProgress || typeof roomProgress !== "object") {
return null;
}
const isEnhancing = roomProgress.targetLevel !== null && roomProgress.targetLevel !== undefined;
const targetWorkValue = Math.max(0, finiteNumber(roomProgress.targetWorkValue, 0));
let currentProgressRatio = clamp01(finiteNumber(roomProgress.currentProgress, 0));
let currentWorkValue = Math.max(0, finiteNumber(roomProgress.currentWorkValue, 0));
if (targetWorkValue > 0 && currentWorkValue <= 0 && currentProgressRatio > 0) {
currentWorkValue = targetWorkValue * currentProgressRatio;
}
if (targetWorkValue > 0) {
currentProgressRatio = clamp01(currentWorkValue / targetWorkValue);
}
return {
isEnhancing,
actionCounter: Math.max(0, Math.floor(finiteNumber(roomProgress.actionCounter, 0))),
successRate: normalizeChance(roomProgress.successRate),
doubleChance: normalizeChance(roomProgress.doubleProgressChance),
progressPerAction: Math.max(0, finiteNumber(roomProgress.progressPerAction, 0)),
targetWorkValue,
currentWorkValue,
currentProgressRatio,
targetLevel: Math.max(0, Math.floor(finiteNumber(roomProgress.targetLevel, 0))),
currentEnhLevel: Math.max(0, Math.floor(finiteNumber(roomProgress.currentEnhLevel, 0))),
actionTimeMs: Math.max(1, finiteNumber(roomProgress.actionTimeMs, 1)),
};
}
function computeSkillingRoomExperiencePerRoom(state, _initClientData, roomContext) {
if (!state || !roomContext?.skillHrid) {
return 0;
}
const skillId = skillHridToSkillId(roomContext.skillHrid);
if (!skillId) {
return 0;
}
const actionTypeHrid = skillIdToActionTypeHrid(skillId);
if (!actionTypeHrid) {
return 0;
}
const liveRoom = getRoomAtKey(state, roomContext.roomKey);
const room = liveRoom || {
roomType: LABYRINTH_SKILLING_ROOM_TYPE,
skillHrid: roomContext.skillHrid,
recommendedLevel: roomContext.recommendedLevel,
roomKey: roomContext.roomKey,
};
const roomLevel = Math.max(0, finiteNumber(room.recommendedLevel, roomContext.recommendedLevel));
const baseExperiencePerRoom = roomLevel * 50;
if (baseExperiencePerRoom <= 0) {
return 0;
}
const equipmentMetrics = getSkillingActionMetricsFromState(
state,
skillId,
actionTypeHrid,
"equipmentActionTypeBuffsDict"
);
const consumableMetrics = getSkillingActionMetricsFromState(
state,
skillId,
actionTypeHrid,
"consumableActionTypeBuffsDict"
);
const labyrinthUpgradeLevels = getLabyrinthUpgradeLevels();
const labyrinthExperienceBonus = getLabyrinthUpgradeExperienceBonus(labyrinthUpgradeLevels);
const experienceBonusDetail = computeSkillingExperienceBonusForRoom(
state,
skillId,
actionTypeHrid,
equipmentMetrics,
consumableMetrics,
createEmptySkillingMetrics(),
true,
labyrinthExperienceBonus
);
const experienceMultiplier = Math.max(0, finiteNumber(experienceBonusDetail?.multiplier, 1));
return Math.max(0, baseExperiencePerRoom * experienceMultiplier);
}
function computeSkillingRoomExperiencePerAction(state, initClientData, roomContext) {
return computeSkillingRoomExperiencePerRoom(state, initClientData, roomContext);
}
function refreshRoomLogSessionActualExperience(session, state) {
if (!session || !state || !session.skillHrid) {
return NaN;
}
const latestSkillExperience = getSkillExperienceValue(state.characterSkillMap, session.skillHrid);
if (Number.isFinite(latestSkillExperience)) {
session.endSkillExperience = latestSkillExperience;
}
const startSkillExperience = finiteNumber(session.startSkillExperience, NaN);
const endSkillExperience = finiteNumber(session.endSkillExperience, NaN);
if (!Number.isFinite(startSkillExperience) || !Number.isFinite(endSkillExperience)) {
return NaN;
}
const gained = Math.max(0, endSkillExperience - startSkillExperience);
session.actualExperienceGain = gained;
return gained;
}
function resolveRoomLogTotalExperience(session, actualGain) {
const safeActual = finiteNumber(actualGain, NaN);
if (Number.isFinite(safeActual) && (safeActual > 0 || session?.completed)) {
return Math.max(0, safeActual);
}
return Math.max(
0,
finiteNumber(session?.predictedExperience, finiteNumber(session?.experiencePerAction, finiteNumber(session?.totalExperience, 0)))
);
}
function refreshRoomLogSessionExperience(session, state, roomContext, snapshot) {
if (!session || !roomContext) {
return;
}
const initClientData = getInitClientData();
// Keep the legacy field name for backward compatibility, but this value is room-completion EXP.
const predicted = computeSkillingRoomExperiencePerRoom(state, initClientData, roomContext);
session.experiencePerAction = Math.max(0, finiteNumber(predicted, 0));
session.predictedExperience = session.experiencePerAction;
const actualGain = refreshRoomLogSessionActualExperience(session, state);
session.totalExperience = resolveRoomLogTotalExperience(session, actualGain);
}
function createRoomLogUnknownAction(counter) {
return {
counter: Math.max(0, Math.floor(finiteNumber(counter, 0))),
outcome: ROOM_LOG_ACTION_OUTCOME_UNKNOWN,
text: "?",
missing: true,
};
}
function deriveRoomLogAction(prevSnapshot, nextSnapshot, actionCounter) {
if (!nextSnapshot || typeof nextSnapshot !== "object") {
return createRoomLogUnknownAction(actionCounter);
}
if (nextSnapshot.isEnhancing) {
const previousLevel = Math.max(0, Math.floor(finiteNumber(prevSnapshot?.currentEnhLevel, 0)));
const nextLevel = Math.max(0, Math.floor(finiteNumber(nextSnapshot.currentEnhLevel, 0)));
const levelDelta = nextLevel - previousLevel;
let outcome = ROOM_LOG_ACTION_OUTCOME_FAIL;
if (levelDelta >= 2) {
outcome = ROOM_LOG_ACTION_OUTCOME_DOUBLE;
} else if (levelDelta >= 1) {
outcome = ROOM_LOG_ACTION_OUTCOME_SUCCESS;
}
const text = `+${nextLevel}`;
return {
counter: Math.max(0, Math.floor(finiteNumber(actionCounter, 0))),
outcome,
text,
missing: false,
};
}
const prevWork = Math.max(0, finiteNumber(prevSnapshot?.currentWorkValue, 0));
const nextWork = Math.max(0, finiteNumber(nextSnapshot.currentWorkValue, 0));
const workDelta = nextWork - prevWork;
const prevProgress = clamp01(finiteNumber(prevSnapshot?.currentProgressRatio, 0));
const nextProgress = clamp01(finiteNumber(nextSnapshot.currentProgressRatio, 0));
const progressDelta = nextProgress - prevProgress;
const expectedSingle = Math.max(0, finiteNumber(prevSnapshot?.progressPerAction, 0));
let outcome = ROOM_LOG_ACTION_OUTCOME_FAIL;
if (workDelta > 0.0001 || progressDelta > 0.0001) {
if (expectedSingle > 0 && workDelta >= expectedSingle * 1.8) {
outcome = ROOM_LOG_ACTION_OUTCOME_DOUBLE;
} else {
outcome = ROOM_LOG_ACTION_OUTCOME_SUCCESS;
}
}
return {
counter: Math.max(0, Math.floor(finiteNumber(actionCounter, 0))),
outcome,
text: `${formatRoomLogPercent(nextProgress * 100)}%`,
missing: false,
};
}
function applyRoomLogSnapshotToSession(session, snapshot) {
if (!session || !snapshot) {
return;
}
session.successRate = snapshot.successRate;
session.doubleChance = snapshot.doubleChance;
session.progressPerAction = snapshot.progressPerAction;
session.targetWorkValue = snapshot.targetWorkValue;
session.currentWorkValue = snapshot.currentWorkValue;
session.currentProgressPct = snapshot.currentProgressRatio * 100;
session.targetLevel = snapshot.targetLevel;
session.currentEnhLevel = snapshot.currentEnhLevel;
session.totalExperience = Math.max(0, finiteNumber(session.totalExperience, finiteNumber(session.experiencePerAction, 0)));
}
function appendRoomLogAction(session, nextSnapshot) {
if (!session || !nextSnapshot) {
return;
}
const prevSnapshot = session.lastSnapshot || null;
const prevCounter = Math.max(0, Math.floor(finiteNumber(prevSnapshot?.actionCounter, 0)));
const nextCounter = Math.max(0, Math.floor(finiteNumber(nextSnapshot.actionCounter, 0)));
if (!prevSnapshot) {
session.lastSnapshot = nextSnapshot;
session.lastActionCounter = nextCounter;
applyRoomLogSnapshotToSession(session, nextSnapshot);
return;
}
if (nextCounter <= prevCounter) {
session.lastSnapshot = nextSnapshot;
session.lastActionCounter = nextCounter;
applyRoomLogSnapshotToSession(session, nextSnapshot);
return;
}
if (!Array.isArray(session.actions)) {
session.actions = [];
}
if (nextCounter - prevCounter > 1) {
markRoomLogSessionIncomplete(session, "action_gap");
for (let counter = prevCounter + 1; counter < nextCounter; counter += 1) {
session.actions.push(createRoomLogUnknownAction(counter));
}
}
const action = deriveRoomLogAction(prevSnapshot, nextSnapshot, nextCounter);
session.actions.push(action);
if (session.actions.length > 200) {
markRoomLogSessionIncomplete(session, "action_overflow");
session.actions = session.actions.slice(session.actions.length - 200);
}
session.lastSnapshot = nextSnapshot;
session.lastActionCounter = nextCounter;
applyRoomLogSnapshotToSession(session, nextSnapshot);
}
function isRoomLogSessionComplete(session) {
if (!session || typeof session !== "object") {
return false;
}
if (session.mode === "combat") {
return true;
}
if (session.mode === "enhancing") {
const targetLevel = Math.max(0, Math.floor(finiteNumber(session.targetLevel, 0)));
const currentLevel = Math.max(0, Math.floor(finiteNumber(session.currentEnhLevel, 0)));
return targetLevel > 0 && currentLevel >= targetLevel;
}
const targetWork = Math.max(0, finiteNumber(session.targetWorkValue, 0));
const currentWork = Math.max(0, finiteNumber(session.currentWorkValue, 0));
const progressPct = Math.max(0, finiteNumber(session.currentProgressPct, 0));
if (targetWork > 0) {
return currentWork >= targetWork - 0.0001 || progressPct >= 99.9;
}
return progressPct >= 99.9;
}
function finalizeActiveRoomLogSession(options = {}) {
if (!activeRoomLogSession) {
return;
}
const session = activeRoomLogSession;
const latestState = getGameState();
const fallbackContext = buildRoomLogContextFromSession(session);
if (latestState && fallbackContext) {
refreshRoomLogSessionExperience(session, latestState, fallbackContext, session.lastSnapshot || null);
}
if (options && options.forceIncompleteReason) {
markRoomLogSessionIncomplete(session, options.forceIncompleteReason);
}
let completed;
if (options && typeof options.completed === "boolean") {
completed = options.completed;
} else {
completed = isRoomLogSessionComplete(session);
}
if (!completed) {
markRoomLogSessionIncomplete(session, "not_complete");
}
session.endedAt = Date.now();
session.completed = completed && !session.incomplete;
const stored = sanitizeRoomLogSession(session);
if (stored) {
roomLogSessions.unshift(stored);
roomLogSessions = trimRoomLogSessions(roomLogSessions);
}
activeRoomLogSession = null;
persistRoomLogStorage();
refreshRoomLogPanelIfVisible();
}
function ensureRoomLogSession(state, roomContext, snapshot) {
if (!roomContext || !snapshot) {
return null;
}
const mode = snapshot.isEnhancing ? "enhancing" : "skilling";
const sessionKey = buildRoomLogSessionKey(state, roomContext, mode);
if (!sessionKey) {
return null;
}
if (activeRoomLogSession && activeRoomLogSession.sessionKey !== sessionKey) {
finalizeActiveRoomLogSession({ forceIncompleteReason: "room_switch" });
}
if (activeRoomLogSession) {
return activeRoomLogSession;
}
const now = Date.now();
const startSkillExperience = getSkillExperienceValue(state?.characterSkillMap, roomContext.skillHrid);
activeRoomLogSession = {
id: `room-log-${now}-${Math.random().toString(36).slice(2, 8)}`,
startedAt: now,
endedAt: 0,
runKey: getRoomLogRunKey(state),
sessionKey,
roomKey: String(roomContext.roomKey || ""),
mode,
skillHrid: String(roomContext.skillHrid || ""),
skillName: String(roomContext.skillName || "--"),
recommendedLevel: Math.max(0, Math.floor(finiteNumber(roomContext.recommendedLevel, 0))),
successRate: snapshot.successRate,
doubleChance: snapshot.doubleChance,
progressPerAction: snapshot.progressPerAction,
experiencePerAction: 0,
predictedExperience: 0,
actualExperienceGain: 0,
startSkillExperience: Number.isFinite(startSkillExperience) ? startSkillExperience : 0,
endSkillExperience: Number.isFinite(startSkillExperience) ? startSkillExperience : 0,
totalExperience: 0,
targetWorkValue: snapshot.targetWorkValue,
currentWorkValue: snapshot.currentWorkValue,
currentProgressPct: snapshot.currentProgressRatio * 100,
targetLevel: snapshot.targetLevel,
currentEnhLevel: snapshot.currentEnhLevel,
actions: [],
lastActionCounter: snapshot.actionCounter,
lastSnapshot: snapshot,
incomplete: false,
incompleteReasons: [],
completed: false,
};
refreshRoomLogSessionExperience(activeRoomLogSession, state, roomContext, snapshot);
if (snapshot.actionCounter > 0) {
markRoomLogSessionIncomplete(activeRoomLogSession, "start_midway");
}
persistRoomLogStorage();
return activeRoomLogSession;
}
function ensureCombatRoomLogSession(state, roomContext) {
if (!roomContext) {
return null;
}
const mode = "combat";
const sessionKey = buildRoomLogSessionKey(state, roomContext, mode);
if (!sessionKey) {
return null;
}
if (activeRoomLogSession && activeRoomLogSession.sessionKey !== sessionKey) {
finalizeActiveRoomLogSession({ forceIncompleteReason: "room_switch" });
}
if (activeRoomLogSession) {
return activeRoomLogSession;
}
const now = Date.now();
activeRoomLogSession = {
id: `room-log-${now}-${Math.random().toString(36).slice(2, 8)}`,
startedAt: now,
endedAt: 0,
runKey: getRoomLogRunKey(state),
sessionKey,
roomKey: String(roomContext.roomKey || ""),
mode,
skillHrid: "",
skillName: String(roomContext.skillName || t("roomLogModeCombat")),
recommendedLevel: Math.max(0, Math.floor(finiteNumber(roomContext.recommendedLevel, 0))),
successRate: 0,
doubleChance: 0,
progressPerAction: 0,
experiencePerAction: 0,
predictedExperience: 0,
actualExperienceGain: 0,
startSkillExperience: 0,
endSkillExperience: 0,
totalExperience: 0,
targetWorkValue: 0,
currentWorkValue: 0,
currentProgressPct: 0,
targetLevel: 0,
currentEnhLevel: 0,
actions: [],
lastActionCounter: 0,
lastSnapshot: null,
incomplete: false,
incompleteReasons: [],
completed: false,
};
persistRoomLogStorage();
refreshRoomLogPanelIfVisible();
return activeRoomLogSession;
}
function handleRoomLogProgressMessage(roomProgressMessage) {
if (!roomProgressMessage || typeof roomProgressMessage !== "object") {
return;
}
const state = getGameState();
const roomContext = getCurrentSkillingRoomContext(state);
if (!roomContext) {
return;
}
const snapshot = buildRoomLogSnapshot(roomProgressMessage);
if (!snapshot) {
return;
}
const session = ensureRoomLogSession(state, roomContext, snapshot);
if (!session) {
return;
}
appendRoomLogAction(session, snapshot);
refreshRoomLogSessionExperience(session, state, roomContext, snapshot);
persistRoomLogStorage();
refreshRoomLogPanelIfVisible();
}
function syncRoomLogSessionState(state) {
if (!state?.characterLabyrinth) {
if (activeRoomLogSession) {
finalizeActiveRoomLogSession({ forceIncompleteReason: "left_labyrinth", completed: false });
}
return;
}
const combatRoomContext = getCurrentCombatRoomContext(state);
const isCombatRunning = Boolean(
combatRoomContext && Array.isArray(state.labyrinthBattleMonsters) && state.labyrinthBattleMonsters.length > 0
);
if (isCombatRunning) {
ensureCombatRoomLogSession(state, combatRoomContext);
return;
}
if (!activeRoomLogSession) {
return;
}
if (activeRoomLogSession.mode === "combat") {
finalizeActiveRoomLogSession({ completed: true });
return;
}
const roomContext = getCurrentSkillingRoomContext(state);
if (!roomContext || !state.labyrinthRoomProgress) {
finalizeActiveRoomLogSession();
return;
}
const snapshot = buildRoomLogSnapshot(state.labyrinthRoomProgress);
if (!snapshot) {
return;
}
const mode = snapshot.isEnhancing ? "enhancing" : "skilling";
const activeSessionKey = buildRoomLogSessionKey(state, roomContext, mode);
if (activeRoomLogSession.sessionKey !== activeSessionKey) {
finalizeActiveRoomLogSession({ forceIncompleteReason: "room_switch" });
return;
}
activeRoomLogSession.lastSnapshot = snapshot;
activeRoomLogSession.lastActionCounter = snapshot.actionCounter;
applyRoomLogSnapshotToSession(activeRoomLogSession, snapshot);
refreshRoomLogSessionExperience(activeRoomLogSession, state, roomContext, snapshot);
}
function initializeRoomLogState() {
const loaded = loadRoomLogStorage();
roomLogSessions = trimRoomLogSessions(loaded.sessions);
activeRoomLogSession = null;
const staleActive = loaded.active;
if (staleActive) {
markRoomLogSessionIncomplete(staleActive, "reload_recovered");
staleActive.completed = false;
staleActive.endedAt = Date.now();
const stored = sanitizeRoomLogSession(staleActive);
if (stored) {
roomLogSessions.unshift(stored);
roomLogSessions = trimRoomLogSessions(roomLogSessions);
}
}
persistRoomLogStorage();
}
function isSimulatorBridgePage() {
const host = String(window.location.host || "").toLowerCase();
const path = String(window.location.pathname || "");
return host === "shykai.github.io" && path.includes("/MWICombatSimulatorTest/dist");
}
function normalizeSimulatorBridgeUrl(value) {
const raw = String(value || "").trim();
if (!raw) {
return SIMULATOR_BRIDGE_DEFAULT_URL;
}
try {
const url = new URL(raw, window.location.href);
if (!/^https?:$/i.test(url.protocol)) {
return SIMULATOR_BRIDGE_DEFAULT_URL;
}
return url.toString();
} catch (_error) {
return SIMULATOR_BRIDGE_DEFAULT_URL;
}
}
function getSimulatorBridgeUrl() {
try {
const fromStorage = localStorage.getItem(SIMULATOR_BRIDGE_URL_STORAGE_KEY);
return normalizeSimulatorBridgeUrl(fromStorage);
} catch (_error) {
return SIMULATOR_BRIDGE_DEFAULT_URL;
}
}
function migrateLegacySimulatorBridgeUrl() {
try {
const raw = localStorage.getItem(SIMULATOR_BRIDGE_URL_STORAGE_KEY);
if (!raw) {
return;
}
const normalized = normalizeSimulatorBridgeUrl(raw);
const normalizedLower = normalized.toLowerCase();
const defaultLower = String(SIMULATOR_BRIDGE_DEFAULT_URL || "").toLowerCase();
if (defaultLower && normalizedLower.startsWith(defaultLower)) {
return;
}
const shouldMigrate = SIMULATOR_BRIDGE_LEGACY_URL_PREFIXES.some((legacyPrefix) => {
return normalizedLower.startsWith(String(legacyPrefix || "").toLowerCase());
});
if (shouldMigrate) {
localStorage.setItem(SIMULATOR_BRIDGE_URL_STORAGE_KEY, SIMULATOR_BRIDGE_DEFAULT_URL);
}
} catch (_error) {
// Ignore storage write errors.
}
}
function encodeSimulatorBridgePayload(payload) {
const json = JSON.stringify(payload || {});
const lz = typeof LZString !== "undefined" ? LZString : null;
if (lz && typeof lz.compressToEncodedURIComponent === "function") {
const encoded = lz.compressToEncodedURIComponent(json);
if (encoded) {
return encoded;
}
}
return encodeURIComponent(json);
}
function decodeSimulatorBridgePayload(encoded) {
const raw = String(encoded || "");
if (!raw) {
return null;
}
const lz = typeof LZString !== "undefined" ? LZString : null;
if (lz && typeof lz.decompressFromEncodedURIComponent === "function") {
try {
const decompressed = lz.decompressFromEncodedURIComponent(raw);
if (decompressed) {
return JSON.parse(decompressed);
}
} catch (_error) {
// Fall back to plain decoding.
}
}
try {
return JSON.parse(decodeURIComponent(raw));
} catch (_error) {
return null;
}
}
function extractSimulatorBridgePayloadFromLocation() {
try {
const searchParams = new URLSearchParams(window.location.search || "");
const searchValue = searchParams.get(SIMULATOR_BRIDGE_PAYLOAD_PARAM);
if (searchValue) {
return decodeSimulatorBridgePayload(searchValue);
}
} catch (_error) {
// Continue to hash parsing.
}
try {
const hashRaw = String(window.location.hash || "").replace(/^#/, "");
if (!hashRaw) {
return null;
}
const hashParams = new URLSearchParams(hashRaw);
const hashValue = hashParams.get(SIMULATOR_BRIDGE_PAYLOAD_PARAM);
if (hashValue) {
return decodeSimulatorBridgePayload(hashValue);
}
} catch (_error) {
return null;
}
return null;
}
function clearSimulatorBridgePayloadFromLocation() {
try {
const url = new URL(window.location.href);
const searchParams = new URLSearchParams(url.search);
searchParams.delete(SIMULATOR_BRIDGE_PAYLOAD_PARAM);
url.search = searchParams.toString();
const hashRaw = String(url.hash || "").replace(/^#/, "");
if (hashRaw) {
const hashParams = new URLSearchParams(hashRaw);
hashParams.delete(SIMULATOR_BRIDGE_PAYLOAD_PARAM);
const hash = hashParams.toString();
url.hash = hash ? `#${hash}` : "";
}
history.replaceState(null, "", url.toString());
} catch (_error) {
// Ignore URL cleanup errors.
}
}
function buildSimulatorBridgeLaunchUrl(payload) {
const baseUrl = normalizeSimulatorBridgeUrl(getSimulatorBridgeUrl());
const encoded = encodeSimulatorBridgePayload(payload);
const url = new URL(baseUrl, window.location.href);
url.hash = `${SIMULATOR_BRIDGE_PAYLOAD_PARAM}=${encoded}`;
return url.toString();
}
function normalizeCombatSimTrials(value) {
const n = Math.floor(Number(value));
if (!Number.isFinite(n)) {
return DEFAULT_COMBAT_SIM_TRIALS;
}
if (n < MIN_COMBAT_SIM_TRIALS) {
return MIN_COMBAT_SIM_TRIALS;
}
if (n > MAX_COMBAT_SIM_TRIALS) {
return MAX_COMBAT_SIM_TRIALS;
}
return n;
}
function loadCombatSimTrialsSetting() {
try {
const raw = localStorage.getItem(COMBAT_SIM_TRIALS_STORAGE_KEY);
if (raw === null || raw === undefined) {
return DEFAULT_COMBAT_SIM_TRIALS;
}
const trimmed = String(raw).trim();
if (!trimmed) {
return DEFAULT_COMBAT_SIM_TRIALS;
}
return normalizeCombatSimTrials(trimmed);
} catch (_error) {
return DEFAULT_COMBAT_SIM_TRIALS;
}
}
function saveCombatSimTrialsSetting(value) {
const normalized = normalizeCombatSimTrials(Number(value));
try {
localStorage.setItem(COMBAT_SIM_TRIALS_STORAGE_KEY, String(normalized));
} catch (_error) {
// Ignore storage errors and keep runtime value.
}
return normalized;
}
function loadAutomationCombatSimTrialsSetting() {
try {
const raw = localStorage.getItem(AUTOMATION_COMBAT_SIM_TRIALS_STORAGE_KEY);
if (raw === null || raw === undefined) {
return DEFAULT_AUTOMATION_COMBAT_SIM_TRIALS;
}
const trimmed = String(raw).trim();
if (!trimmed) {
return DEFAULT_AUTOMATION_COMBAT_SIM_TRIALS;
}
return normalizeCombatSimTrials(trimmed);
} catch (_error) {
return DEFAULT_AUTOMATION_COMBAT_SIM_TRIALS;
}
}
function saveAutomationCombatSimTrialsSetting(value) {
const normalized = normalizeCombatSimTrials(Number(value));
try {
localStorage.setItem(AUTOMATION_COMBAT_SIM_TRIALS_STORAGE_KEY, String(normalized));
} catch (_error) {
// Ignore storage errors and keep runtime value.
}
return normalized;
}
function normalizeAutomationTargetWinRate(value) {
let n = Number(value);
if (!Number.isFinite(n)) {
return DEFAULT_AUTOMATION_TARGET_WIN_RATE;
}
if (n >= 0 && n <= 1) {
n *= 100;
}
if (n < 0) {
n = 0;
}
if (n > 100) {
n = 100;
}
return Math.round(n * 10) / 10;
}
function loadAutomationTargetWinRateSetting() {
try {
const raw = localStorage.getItem(AUTOMATION_TARGET_WIN_RATE_STORAGE_KEY);
if (raw === null || raw === undefined) {
return DEFAULT_AUTOMATION_TARGET_WIN_RATE;
}
const trimmed = String(raw).trim();
if (!trimmed) {
return DEFAULT_AUTOMATION_TARGET_WIN_RATE;
}
return normalizeAutomationTargetWinRate(trimmed);
} catch (_error) {
return DEFAULT_AUTOMATION_TARGET_WIN_RATE;
}
}
function saveAutomationTargetWinRateSetting(value) {
const normalized = normalizeAutomationTargetWinRate(value);
try {
localStorage.setItem(AUTOMATION_TARGET_WIN_RATE_STORAGE_KEY, String(normalized));
} catch (_error) {
// Ignore storage errors and keep runtime value.
}
return normalized;
}
function roundForSignature(value) {
const n = finiteNumber(value, 0);
return Math.round(n * 1000) / 1000;
}
function isLikelyGameState(state) {
return Boolean(
state &&
typeof state === "object" &&
(Object.prototype.hasOwnProperty.call(state, "characterLabyrinth") ||
Object.prototype.hasOwnProperty.call(state, "combatUnit") ||
Object.prototype.hasOwnProperty.call(state, "gameConn"))
);
}
function findGameStateFromFiber(rootFiber) {
if (!rootFiber || typeof rootFiber !== "object") {
return null;
}
const queue = [rootFiber];
const visited = new Set();
let steps = 0;
while (queue.length > 0 && steps < 20000) {
const fiber = queue.shift();
if (!fiber || typeof fiber !== "object" || visited.has(fiber)) {
continue;
}
visited.add(fiber);
steps += 1;
const state = fiber.stateNode?.state;
if (isLikelyGameState(state)) {
return state;
}
if (fiber.child) {
queue.push(fiber.child);
}
if (fiber.sibling) {
queue.push(fiber.sibling);
}
}
return null;
}
function getGameState() {
const gamePage = document.querySelector('[class^="GamePage"]');
if (gamePage) {
const reactKey = Object.keys(gamePage).find((key) => key.startsWith("__reactFiber$"));
if (reactKey) {
const fiberNode = gamePage[reactKey];
const directState = fiberNode?.return?.stateNode?.state || null;
if (isLikelyGameState(directState)) {
return directState;
}
}
}
const rootElement = document.getElementById("root");
let rootContainer = rootElement?._reactRootContainer || null;
if (!rootContainer) {
const fallbackRoot = Array.from(document.querySelectorAll("div")).find((el) =>
Object.prototype.hasOwnProperty.call(el, "_reactRootContainer")
);
rootContainer = fallbackRoot?._reactRootContainer || null;
}
return findGameStateFromFiber(rootContainer?.current || null);
}
function getInitClientData() {
const raw = localStorage.getItem("initClientData");
if (!raw) {
return null;
}
if (cachedInitClientData && cachedInitClientDataRaw === raw) {
return cachedInitClientData;
}
const lz = typeof LZString !== "undefined" ? LZString : null;
const parsers = [
() => JSON.parse(raw),
() => {
if (!lz || typeof lz.decompressFromUTF16 !== "function") {
return null;
}
const decompressed = lz.decompressFromUTF16(raw);
return decompressed ? JSON.parse(decompressed) : null;
},
() => {
if (!lz || typeof lz.decompressFromBase64 !== "function") {
return null;
}
const decompressed = lz.decompressFromBase64(raw);
return decompressed ? JSON.parse(decompressed) : null;
},
];
for (const parser of parsers) {
try {
const parsed = parser();
if (parsed && typeof parsed === "object") {
cachedInitClientData = parsed;
cachedInitClientDataRaw = raw;
return cachedInitClientData;
}
} catch (_error) {
// Try next parser.
}
}
console.error("[Lab Clear Rate] Failed to parse initClientData with all parsers.");
return null;
}
function getContainerValue(container, key) {
if (!container || key === undefined || key === null) {
return undefined;
}
if (container instanceof Map) {
return container.get(key);
}
return container[key];
}
function getContainerEntries(container) {
if (!container) {
return [];
}
if (container instanceof Map) {
return Array.from(container.entries());
}
if (typeof container === "object") {
return Object.entries(container);
}
return [];
}
function deepCloneJson(value) {
if (value === null || value === undefined) {
return value;
}
if (typeof structuredClone === "function") {
return structuredClone(value);
}
try {
return JSON.parse(JSON.stringify(value));
} catch (_error) {
return value;
}
}
function stableStringify(value) {
if (value === null || value === undefined) {
return String(value);
}
const type = typeof value;
if (type === "number" || type === "boolean") {
return JSON.stringify(value);
}
if (type === "string") {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
}
if (value instanceof Map) {
const entries = Array.from(value.entries()).sort((a, b) => String(a[0]).localeCompare(String(b[0])));
return stableStringify(entries);
}
if (type === "object") {
const keys = Object.keys(value).sort();
const body = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",");
return `{${body}}`;
}
return JSON.stringify(String(value));
}
function hashString(text) {
const source = String(text || "");
let hash = 2166136261;
for (let i = 0; i < source.length; i += 1) {
hash ^= source.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(36);
}
function normalizeTriggerList(rawList) {
if (!Array.isArray(rawList)) {
return [];
}
return rawList
.map((trigger) => ({
dependencyHrid: String(trigger?.dependencyHrid || ""),
conditionHrid: String(trigger?.conditionHrid || ""),
comparatorHrid: String(trigger?.comparatorHrid || ""),
value: finiteNumber(trigger?.value, 0),
}))
.filter((trigger) => trigger.dependencyHrid && trigger.conditionHrid && trigger.comparatorHrid);
}
function skillHridToSkillId(skillHrid) {
if (!skillHrid || typeof skillHrid !== "string") {
return "";
}
const parts = skillHrid.split("/");
return parts[parts.length - 1] || "";
}
function skillIdToActionTypeHrid(skillId) {
return skillId ? `/action_types/${skillId}` : "";
}
function isGatheringSkillId(skillId) {
return skillId === "milking" || skillId === "foraging" || skillId === "woodcutting";
}
function isGourmetSkillId(skillId) {
return skillId === "cooking" || skillId === "brewing";
}
function isDoubleProgressBuffApplicable(skillId, buffTypeHrid) {
if (buffTypeHrid === "/buff_types/gathering") {
return isGatheringSkillId(skillId);
}
if (buffTypeHrid === "/buff_types/gourmet") {
return isGourmetSkillId(skillId);
}
return false;
}
function sumBuffValue(buffs, predicate) {
let total = 0;
for (const buff of buffs) {
if (!buff || !predicate(buff)) {
continue;
}
total += Number(buff.flatBoost || 0);
total += Number(buff.flatBoostLevelBonus || 0);
total += Number(buff.ratioBoost || 0);
total += Number(buff.ratioBoostLevelBonus || 0);
}
return total;
}
function getBuffAmount(buff) {
if (!buff) {
return 0;
}
return (
finiteNumber(buff.flatBoost, 0) +
finiteNumber(buff.flatBoostLevelBonus, 0) +
finiteNumber(buff.ratioBoost, 0) +
finiteNumber(buff.ratioBoostLevelBonus, 0)
);
}
function createEmptySkillingMetrics() {
return {
skillLevelBonus: 0,
efficiencyBonus: 0,
actionSpeedBonus: 0,
successBonus: 0,
experienceBonus: 0,
genericExperienceBonus: 0,
skillingExperienceBonus: 0,
skillExperienceBonus: 0,
crateDoubleProgressBonus: 0,
gatheringBonus: 0,
};
}
function cloneSkillingMetrics(metrics) {
return {
skillLevelBonus: finiteNumber(metrics?.skillLevelBonus, 0),
efficiencyBonus: finiteNumber(metrics?.efficiencyBonus, 0),
actionSpeedBonus: finiteNumber(metrics?.actionSpeedBonus, 0),
successBonus: finiteNumber(metrics?.successBonus, 0),
experienceBonus: finiteNumber(metrics?.experienceBonus, 0),
genericExperienceBonus: finiteNumber(metrics?.genericExperienceBonus, 0),
skillingExperienceBonus: finiteNumber(metrics?.skillingExperienceBonus, 0),
skillExperienceBonus: finiteNumber(metrics?.skillExperienceBonus, 0),
crateDoubleProgressBonus: finiteNumber(metrics?.crateDoubleProgressBonus, 0),
gatheringBonus: finiteNumber(metrics?.gatheringBonus, 0),
};
}
function addSkillingMetrics(target, source) {
if (!target || !source) {
return target;
}
target.skillLevelBonus += finiteNumber(source.skillLevelBonus, 0);
target.efficiencyBonus += finiteNumber(source.efficiencyBonus, 0);
target.actionSpeedBonus += finiteNumber(source.actionSpeedBonus, 0);
target.successBonus += finiteNumber(source.successBonus, 0);
target.experienceBonus += finiteNumber(source.experienceBonus, 0);
target.genericExperienceBonus += finiteNumber(source.genericExperienceBonus, 0);
target.skillingExperienceBonus += finiteNumber(source.skillingExperienceBonus, 0);
target.skillExperienceBonus += finiteNumber(source.skillExperienceBonus, 0);
target.crateDoubleProgressBonus += finiteNumber(source.crateDoubleProgressBonus, 0);
target.gatheringBonus += finiteNumber(source.gatheringBonus, 0);
return target;
}
function buildLabyrinthUpgradeSkillingMetrics(levels) {
const metrics = createEmptySkillingMetrics();
metrics.actionSpeedBonus += getLabyrinthUpgradeLevel(levels, LABYRINTH_UPGRADE_KEY_SKILL_ACTION_SPEED) * LABYRINTH_UPGRADE_STEP_RATIO;
metrics.efficiencyBonus += getLabyrinthUpgradeLevel(levels, LABYRINTH_UPGRADE_KEY_SKILLING_EFFICIENCY) * LABYRINTH_UPGRADE_STEP_RATIO;
metrics.successBonus +=
getLabyrinthUpgradeLevel(levels, LABYRINTH_UPGRADE_KEY_SKILLING_SUCCESS) *
LABYRINTH_UPGRADE_SKILLING_SUCCESS_STEP_RATIO;
metrics.crateDoubleProgressBonus +=
getLabyrinthUpgradeLevel(levels, LABYRINTH_UPGRADE_KEY_SKILLING_DOUBLE_PROGRESS) * LABYRINTH_UPGRADE_STEP_RATIO;
return metrics;
}
function getLabyrinthUpgradeExperienceBonus(levels) {
return getLabyrinthUpgradeLevel(levels, LABYRINTH_UPGRADE_KEY_LABYRINTH_EXPERIENCE) * LABYRINTH_UPGRADE_STEP_RATIO;
}
function subtractSkillingMetrics(target, source) {
if (!target || !source) {
return target;
}
target.skillLevelBonus -= finiteNumber(source.skillLevelBonus, 0);
target.efficiencyBonus -= finiteNumber(source.efficiencyBonus, 0);
target.actionSpeedBonus -= finiteNumber(source.actionSpeedBonus, 0);
target.successBonus -= finiteNumber(source.successBonus, 0);
target.experienceBonus -= finiteNumber(source.experienceBonus, 0);
target.genericExperienceBonus -= finiteNumber(source.genericExperienceBonus, 0);
target.skillingExperienceBonus -= finiteNumber(source.skillingExperienceBonus, 0);
target.skillExperienceBonus -= finiteNumber(source.skillExperienceBonus, 0);
target.crateDoubleProgressBonus -= finiteNumber(source.crateDoubleProgressBonus, 0);
target.gatheringBonus -= finiteNumber(source.gatheringBonus, 0);
return target;
}
function getSkillingBuffMetrics(skillId, buffs) {
const metrics = createEmptySkillingMetrics();
const skillLevelType = `/buff_types/${skillId}_level`;
const skillSuccessType = `/buff_types/${skillId}_success`;
const skillExperienceType = `/buff_types/${skillId}_experience`;
for (const buff of Array.isArray(buffs) ? buffs : []) {
if (!buff?.typeHrid) {
continue;
}
const amount = getBuffAmount(buff);
if (!Number.isFinite(amount) || amount === 0) {
continue;
}
if (buff.typeHrid === skillLevelType) {
metrics.skillLevelBonus += amount;
} else if (buff.typeHrid === "/buff_types/efficiency") {
metrics.efficiencyBonus += amount;
} else if (buff.typeHrid === "/buff_types/action_speed") {
metrics.actionSpeedBonus += amount;
} else if (buff.typeHrid === "/buff_types/labyrinth_double_progress") {
metrics.crateDoubleProgressBonus += amount;
} else if (
buff.typeHrid === "/buff_types/experience" ||
buff.typeHrid === "/buff_types/wisdom"
) {
metrics.experienceBonus += amount;
metrics.genericExperienceBonus += amount;
} else if (buff.typeHrid === "/buff_types/skilling_experience") {
metrics.experienceBonus += amount;
metrics.skillingExperienceBonus += amount;
} else if (buff.typeHrid === skillExperienceType) {
metrics.experienceBonus += amount;
metrics.skillExperienceBonus += amount;
} else if (isDoubleProgressBuffApplicable(skillId, buff.typeHrid)) {
metrics.gatheringBonus += amount;
} else if (buff.typeHrid === "/buff_types/success_rate" || buff.typeHrid === skillSuccessType) {
metrics.successBonus += amount;
}
}
return metrics;
}
function getSkillingActionMetricsFromState(state, skillId, actionTypeHrid, stateKey) {
const actionTypeBuffs = state?.[stateKey];
const buffs = actionTypeBuffs?.[actionTypeHrid];
return getSkillingBuffMetrics(skillId, Array.isArray(buffs) ? buffs : []);
}
function getSkillingGlobalMetrics(state, skillId, actionTypeHrid, options = {}) {
const includePersonalBuffs = !(options && options.includePersonalBuffs === false);
const equipmentMetrics = getSkillingActionMetricsFromState(
state,
skillId,
actionTypeHrid,
"equipmentActionTypeBuffsDict"
);
const hasTotalBuffs = Array.isArray(state?.skillingActionTypeBuffsDict?.[actionTypeHrid]);
if (!hasTotalBuffs) {
const fallbackGlobalMetrics = createEmptySkillingMetrics();
const fallbackGlobalStateKeys = [
"communityActionTypeBuffsDict",
"houseActionTypeBuffsDict",
"achievementActionTypeBuffsDict",
"mooPassActionTypeBuffsDict",
];
if (includePersonalBuffs) {
fallbackGlobalStateKeys.unshift("personalActionTypeBuffsDict");
}
for (const stateKey of fallbackGlobalStateKeys) {
addSkillingMetrics(
fallbackGlobalMetrics,
getSkillingActionMetricsFromState(state, skillId, actionTypeHrid, stateKey)
);
}
return {
equipmentMetrics,
globalMetrics: fallbackGlobalMetrics,
};
}
const totalMetrics = getSkillingActionMetricsFromState(
state,
skillId,
actionTypeHrid,
"skillingActionTypeBuffsDict"
);
const consumableMetrics = getSkillingActionMetricsFromState(
state,
skillId,
actionTypeHrid,
"consumableActionTypeBuffsDict"
);
const globalMetrics = cloneSkillingMetrics(totalMetrics);
subtractSkillingMetrics(globalMetrics, equipmentMetrics);
subtractSkillingMetrics(globalMetrics, consumableMetrics);
if (!includePersonalBuffs) {
const personalMetrics = getSkillingActionMetricsFromState(
state,
skillId,
actionTypeHrid,
"personalActionTypeBuffsDict"
);
subtractSkillingMetrics(globalMetrics, personalMetrics);
}
return {
equipmentMetrics,
globalMetrics,
};
}
function hasMeaningfulSkillingMetrics(metrics) {
const epsilon = 1e-9;
return (
Math.abs(finiteNumber(metrics?.skillLevelBonus, 0)) > epsilon ||
Math.abs(finiteNumber(metrics?.efficiencyBonus, 0)) > epsilon ||
Math.abs(finiteNumber(metrics?.actionSpeedBonus, 0)) > epsilon ||
Math.abs(finiteNumber(metrics?.successBonus, 0)) > epsilon ||
Math.abs(finiteNumber(metrics?.experienceBonus, 0)) > epsilon ||
Math.abs(finiteNumber(metrics?.genericExperienceBonus, 0)) > epsilon ||
Math.abs(finiteNumber(metrics?.skillingExperienceBonus, 0)) > epsilon ||
Math.abs(finiteNumber(metrics?.skillExperienceBonus, 0)) > epsilon ||
Math.abs(finiteNumber(metrics?.crateDoubleProgressBonus, 0)) > epsilon ||
Math.abs(finiteNumber(metrics?.gatheringBonus, 0)) > epsilon
);
}
function hasActivePersonalSkillingBuffForRoom(state, room) {
if (!state || !room || room.roomType !== LABYRINTH_SKILLING_ROOM_TYPE || !room.skillHrid) {
return false;
}
const skillId = skillHridToSkillId(room.skillHrid);
if (!skillId) {
return false;
}
const actionTypeHrid = skillIdToActionTypeHrid(skillId);
const personalBuffs = state?.personalActionTypeBuffsDict?.[actionTypeHrid];
if (!Array.isArray(personalBuffs) || personalBuffs.length === 0) {
return false;
}
return hasMeaningfulSkillingMetrics(getSkillingBuffMetrics(skillId, personalBuffs));
}
function hasActivePersonalCombatBuff(state) {
const personalBuffs = state?.personalActionTypeBuffsDict?.["/action_types/combat"];
if (!Array.isArray(personalBuffs) || personalBuffs.length === 0) {
return false;
}
const epsilon = 1e-9;
for (const buff of personalBuffs) {
if (Math.abs(getBuffAmount(buff)) > epsilon) {
return true;
}
}
return false;
}
function buildLabyrinthCalcDoneMessage(state, flatRooms, targetIndexes) {
return t("calcDone");
}
function getCharacterItemValues(state) {
const itemMap = state?.characterItemMap;
if (itemMap instanceof Map) {
return Array.from(itemMap.values());
}
if (itemMap && typeof itemMap === "object") {
return Object.values(itemMap);
}
return [];
}
function formatLoanSealPercent(amount) {
const percent = Math.max(0, finiteNumber(amount, 0)) * 100;
if (Math.abs(percent - Math.round(percent)) < 1e-9) {
return `${Math.round(percent)}`;
}
return percent.toFixed(1).replace(/\.0$/, "");
}
function getPersonalBuffHridFromSealItemHrid(itemHrid) {
const tail = String(itemHrid || "").split("/").pop() || "";
if (!tail.startsWith("seal_of_")) {
return "";
}
return `/personal_buff_types/${tail.slice("seal_of_".length)}`;
}
function getActivePersonalBuffByHrid(state) {
const result = new Map();
const buffs = Array.isArray(state?.characterBuffs) ? state.characterBuffs : [];
for (const buff of buffs) {
const hrid = String(buff?.hrid || "");
if (!hrid.startsWith("/personal_buff_types/")) {
continue;
}
result.set(hrid, buff);
}
return result;
}
function formatLoanSealDisplayName(name) {
const rawName = String(name || "").trim();
if (!rawName) {
return "";
}
return rawName.replace(/^卷轴[·..]/, "");
}
function buildLoanSealEffectCatalog(state, initClientData = null) {
const itemDetails = state?.itemDetailDict || initClientData?.itemDetailMap || {};
const buffTypeDetails = state?.buffTypeDetailDict || initClientData?.buffTypeDetailMap || {};
const itemCountByHrid = new Map();
for (const item of getCharacterItemValues(state)) {
const itemHrid = String(item?.itemHrid || item?.hrid || "");
if (!itemHrid.startsWith("/items/seal_of_")) {
continue;
}
const count = Math.max(0, Math.floor(finiteNumber(item?.count ?? item?.quantity ?? item?.amount, 0)));
itemCountByHrid.set(itemHrid, (itemCountByHrid.get(itemHrid) || 0) + count);
}
const activePersonalBuffByHrid = getActivePersonalBuffByHrid(state);
const catalog = [];
for (const effect of LABYRINTH_LOAN_SEAL_EFFECTS) {
const itemHrid = String(effect?.itemHrid || "");
const quantity = Math.max(0, Math.floor(finiteNumber(itemCountByHrid.get(itemHrid), 0)));
const itemDetail = getContainerValue(itemDetails, itemHrid) || null;
const buffTypeHrid = String(effect?.buffTypeHrid || "");
const buffTypeDetail = buffTypeHrid ? getContainerValue(buffTypeDetails, buffTypeHrid) : null;
const personalBuffHrid = getPersonalBuffHridFromSealItemHrid(itemHrid);
const activeBuff = personalBuffHrid ? activePersonalBuffByHrid.get(personalBuffHrid) || null : null;
const localizedName = isChineseUi() ? LABYRINTH_SEAL_NAME_ZH_BY_ITEM_HRID[itemHrid] || "" : "";
const displayName = formatLoanSealDisplayName(
localizedName || itemDetail?.name || buffTypeDetail?.name || itemHrid.split("/").pop() || itemHrid
);
const sortIndex = Math.max(0, Math.floor(finiteNumber(itemDetail?.sortIndex, 9999)));
const amount = Math.max(0, finiteNumber(effect?.amount, 0));
catalog.push({
itemHrid,
displayName,
sortIndex,
quantity,
buffTypeHrid,
amount,
boostMode: String(effect?.boostMode || "flat") === "ratio" ? "ratio" : "flat",
isCombat: effect?.isCombat === true,
personalBuffHrid,
isActive: Boolean(activeBuff),
activeBuff,
canApply: Boolean(buffTypeHrid) && amount > 0,
labelText: `${displayName} (+${formatLoanSealPercent(amount)}%)`,
});
}
catalog.sort((a, b) => {
if (a.sortIndex !== b.sortIndex) {
return a.sortIndex - b.sortIndex;
}
return a.displayName.localeCompare(b.displayName);
});
return catalog;
}
function createLoanSealBuffEntry(effect, index = 0) {
const amount = Math.max(0, finiteNumber(effect?.amount, 0));
if (!effect?.buffTypeHrid || amount <= 0) {
return null;
}
const useRatio = effect?.boostMode === "ratio";
return {
uniqueHrid: `/buff_uniques/loan_seal_${String(effect.itemHrid || "").split("/").pop() || index}`,
typeHrid: String(effect.buffTypeHrid),
ratioBoost: useRatio ? amount : 0,
ratioBoostLevelBonus: 0,
flatBoost: useRatio ? 0 : amount,
flatBoostLevelBonus: 0,
startTime: "0001-01-01T00:00:00Z",
duration: 0,
};
}
function getLoanSkillingActionTypeHrids(state) {
const source = state?.personalActionTypeBuffsDict || state?.skillingActionTypeBuffsDict || {};
const values = [];
for (const key of Object.keys(source)) {
const actionType = String(key || "");
if (!actionType.startsWith("/action_types/")) {
continue;
}
if (
actionType === "/action_types/combat" ||
actionType === "/action_types/labyrinth" ||
actionType === "/action_types/special"
) {
continue;
}
values.push(actionType);
}
if (values.length > 0) {
values.sort();
return values;
}
return LABYRINTH_AUTOMATION_SKILL_ROOM_TYPES.map((entry) => `/action_types/${entry.key}`);
}
function buildLoanSimulationOptions(state, catalog, selectedItemHrids) {
if (!Array.isArray(catalog) || catalog.length === 0 || !Array.isArray(selectedItemHrids) || selectedItemHrids.length === 0) {
return null;
}
const selectedSet = new Set(selectedItemHrids.map((itemHrid) => String(itemHrid || "")));
const selectedEffects = catalog.filter(
(effect) => selectedSet.has(String(effect?.itemHrid || "")) && !effect?.isActive && effect?.canApply
);
if (!selectedEffects.length) {
return null;
}
const skillingActionTypes = getLoanSkillingActionTypeHrids(state);
const loanPersonalActionTypeBuffsDict = {};
for (let i = 0; i < selectedEffects.length; i += 1) {
const effect = selectedEffects[i];
if (effect.isCombat) {
continue;
}
const buffEntry = createLoanSealBuffEntry(effect, i);
if (!buffEntry) {
continue;
}
for (const actionTypeHrid of skillingActionTypes) {
if (!loanPersonalActionTypeBuffsDict[actionTypeHrid]) {
loanPersonalActionTypeBuffsDict[actionTypeHrid] = [];
}
loanPersonalActionTypeBuffsDict[actionTypeHrid].push({
...buffEntry,
});
}
}
return {
loanPersonalActionTypeBuffsDict,
selectedSealItemHrids: selectedEffects.map((effect) => String(effect?.itemHrid || "")).filter(Boolean),
selectedEffects,
};
}
function normalizeLoanSimulationOptions(rawOptions) {
return null;
}
function getActivePersonalSealItemHrids(state) {
const result = [];
const buffs = Array.isArray(state?.characterBuffs) ? state.characterBuffs : [];
for (const buff of buffs) {
const hrid = String(buff?.hrid || "");
if (!hrid.startsWith("/personal_buff_types/")) {
continue;
}
const tail = hrid.slice("/personal_buff_types/".length);
if (!tail) {
continue;
}
const itemHrid = `/items/seal_of_${tail}`;
if (!SIMULATOR_PERSONAL_BUFF_ITEM_HRIDS.has(itemHrid)) {
continue;
}
result.push(itemHrid);
}
return Array.from(new Set(result));
}
function getSimulatorPersonalBuffItemHrids(state) {
return [];
}
function getCombatSimulatorPersonalSealItemHrids(state, options = {}) {
return [];
}
function getSkillLevel(skillMap, skillHrid) {
if (!skillMap) {
return 0;
}
if (skillMap instanceof Map) {
return Number(skillMap.get(skillHrid)?.level || 0);
}
return Number(skillMap[skillHrid]?.level || 0);
}
function getCrateBuffs(initClientData, teaCrateItemHrid) {
if (!initClientData || !teaCrateItemHrid) {
return [];
}
const map = initClientData.labyrinthCrateDetailMap || {};
const buffs = map[teaCrateItemHrid];
return Array.isArray(buffs) ? buffs : [];
}
function normalizeCombatBuffEntry(buff) {
if (!buff?.typeHrid) {
return null;
}
return {
uniqueHrid: String(buff.uniqueHrid || ""),
typeHrid: String(buff.typeHrid || ""),
ratioBoost: finiteNumber(buff.ratioBoost, 0),
ratioBoostLevelBonus: finiteNumber(buff.ratioBoostLevelBonus, 0),
flatBoost: finiteNumber(buff.flatBoost, 0),
flatBoostLevelBonus: finiteNumber(buff.flatBoostLevelBonus, 0),
startTime: String(buff.startTime || "0001-01-01T00:00:00Z"),
duration: Math.max(0, finiteNumber(buff.duration, 0)),
};
}
function createLabyrinthCombatBuff(uniqueKey, typeHrid, level, valueKey) {
const normalizedLevel = Math.max(0, Math.floor(finiteNumber(level, 0)));
if (!typeHrid || normalizedLevel <= 0) {
return null;
}
return normalizeCombatBuffEntry({
uniqueHrid: `/buff_uniques/labyrinth_upgrade_${uniqueKey}`,
typeHrid,
[valueKey]: normalizedLevel * LABYRINTH_UPGRADE_STEP_RATIO,
startTime: "0001-01-01T00:00:00Z",
duration: 0,
});
}
function buildLabyrinthCombatBuffs(levels) {
const buffs = [];
const definitions = [
[LABYRINTH_UPGRADE_KEY_COMBAT_DAMAGE, "combat_damage", "/buff_types/damage", "ratioBoost"],
[LABYRINTH_UPGRADE_KEY_ATTACK_SPEED, "attack_speed", "/buff_types/attack_speed", "ratioBoost"],
[LABYRINTH_UPGRADE_KEY_CAST_SPEED, "cast_speed", "/buff_types/cast_speed", "flatBoost"],
[LABYRINTH_UPGRADE_KEY_CRITICAL_RATE, "critical_rate", "/buff_types/critical_rate", "flatBoost"],
];
for (const [upgradeKey, uniqueKey, typeHrid, valueKey] of definitions) {
const buff = createLabyrinthCombatBuff(uniqueKey, typeHrid, getLabyrinthUpgradeLevel(levels, upgradeKey), valueKey);
if (buff) {
buffs.push(buff);
}
}
return buffs;
}
function appendNormalizedCombatBuffs(target, seen, rawBuffs) {
if (!Array.isArray(rawBuffs)) {
return target;
}
const dedupeSet = seen instanceof Set ? seen : new Set();
for (const buff of rawBuffs) {
const normalized = normalizeCombatBuffEntry(buff);
if (!normalized || !normalized.typeHrid) {
continue;
}
const dedupeKey = [
normalized.typeHrid,
normalized.ratioBoost,
normalized.ratioBoostLevelBonus,
normalized.flatBoost,
normalized.flatBoostLevelBonus,
normalized.duration,
].join("|");
if (dedupeSet.has(dedupeKey)) {
continue;
}
dedupeSet.add(dedupeKey);
target.push(normalized);
}
return target;
}
function getCombatPersonalBuffs(state) {
const actionTypeHrid = "/action_types/combat";
const personalBuffs = state?.personalActionTypeBuffsDict?.[actionTypeHrid];
const combatBuffs = [];
appendNormalizedCombatBuffs(combatBuffs, new Set(), Array.isArray(personalBuffs) ? personalBuffs : []);
return combatBuffs;
}
function isLabyrinthRunActiveForCrateSelection(state) {
if (!state?.characterLabyrinth) {
return false;
}
const labyrinth = state.characterLabyrinth;
if (Array.isArray(labyrinth.roomData) && labyrinth.roomData.length > 0) {
return true;
}
const path = parseLabyrinthPathData(labyrinth.pathData);
if (Array.isArray(path) && path.length > 0) {
return true;
}
if (state.labyrinthRoomProgress) {
return true;
}
return Array.isArray(state.labyrinthBattleMonsters) && state.labyrinthBattleMonsters.length > 0;
}
function getLabyrinthCrateSelection(state) {
const labyrinth = state?.characterLabyrinth || {};
const setting = state?.characterSetting || {};
const fromLabyrinth = {
teaCrateItemHrid: String(labyrinth?.teaCrateItemHrid || ""),
coffeeCrateItemHrid: String(labyrinth?.coffeeCrateItemHrid || ""),
foodCrateItemHrid: String(labyrinth?.foodCrateItemHrid || ""),
source: "labyrinth",
};
const runActive = isLabyrinthRunActiveForCrateSelection(state);
const hasSettingCrateKeys =
Object.prototype.hasOwnProperty.call(setting, "labyrinthTeaCrateHrid") ||
Object.prototype.hasOwnProperty.call(setting, "labyrinthCoffeeCrateHrid") ||
Object.prototype.hasOwnProperty.call(setting, "labyrinthFoodCrateHrid");
if (!runActive && hasSettingCrateKeys) {
return {
teaCrateItemHrid: String(setting?.labyrinthTeaCrateHrid || ""),
coffeeCrateItemHrid: String(setting?.labyrinthCoffeeCrateHrid || ""),
foodCrateItemHrid: String(setting?.labyrinthFoodCrateHrid || ""),
source: "setting",
};
}
return fromLabyrinth;
}
function getCombatCrateBuffs(state, initClientData) {
const crateSelection = getLabyrinthCrateSelection(state);
const teaCrateItemHrid = String(crateSelection?.teaCrateItemHrid || "");
const coffeeCrateItemHrid = String(crateSelection?.coffeeCrateItemHrid || "");
const foodCrateItemHrid = String(crateSelection?.foodCrateItemHrid || "");
const combatCrateItemHrids = [];
if (coffeeCrateItemHrid) {
combatCrateItemHrids.push(coffeeCrateItemHrid);
}
if (foodCrateItemHrid) {
combatCrateItemHrids.push(foodCrateItemHrid);
}
// Compatibility fallback: old states may only expose one crate field.
if (!combatCrateItemHrids.length && teaCrateItemHrid) {
if (teaCrateItemHrid.includes("coffee_crate") || teaCrateItemHrid.includes("food_crate")) {
combatCrateItemHrids.push(teaCrateItemHrid);
}
}
const combatBuffs = [];
const seen = new Set();
for (const itemHrid of combatCrateItemHrids) {
const crateBuffs = getCrateBuffs(initClientData, itemHrid);
appendNormalizedCombatBuffs(combatBuffs, seen, crateBuffs);
}
const combatCrateSignature = combatCrateItemHrids.join("+");
return {
teaCrateItemHrid,
coffeeCrateItemHrid,
foodCrateItemHrid,
combatCrateItemHrids,
combatCrateSignature,
combatBuffs,
};
}
function computeNonEnhancingClearStats(params) {
const { attempts, successChance, doubleChance, progressPerSuccess, targetProgress } = params;
if (!Number.isFinite(targetProgress) || targetProgress <= 0) {
return { clearChance: 1, expectedAttemptsOnClear: 0 };
}
if (!Number.isFinite(attempts) || attempts <= 0) {
return { clearChance: 0, expectedAttemptsOnClear: null };
}
if (!Number.isFinite(progressPerSuccess) || progressPerSuccess <= 0) {
return { clearChance: 0, expectedAttemptsOnClear: null };
}
const p = clamp01(successChance);
const d = clamp01(doubleChance);
if (p <= 0) {
return { clearChance: 0, expectedAttemptsOnClear: null };
}
const neededUnits = Math.ceil(targetProgress / progressPerSuccess - 1e-9);
if (neededUnits <= 0) {
return { clearChance: 1, expectedAttemptsOnClear: 0 };
}
if (neededUnits > attempts * 2) {
return { clearChance: 0, expectedAttemptsOnClear: null };
}
const q0 = 1 - p;
const q1 = p * (1 - d);
const q2 = p * d;
let stateDist = new Float64Array(neededUnits + 1);
stateDist[0] = 1;
let expectedAttemptsNumerator = 0;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const nextDist = new Float64Array(neededUnits + 1);
for (let units = 0; units <= neededUnits; units += 1) {
const prob = stateDist[units];
if (prob <= 0) {
continue;
}
if (units === neededUnits) {
nextDist[neededUnits] += prob;
continue;
}
nextDist[units] += prob * q0;
nextDist[Math.min(neededUnits, units + 1)] += prob * q1;
nextDist[Math.min(neededUnits, units + 2)] += prob * q2;
}
const reachedNow = nextDist[neededUnits] - stateDist[neededUnits];
if (reachedNow > 0) {
expectedAttemptsNumerator += attempt * reachedNow;
}
stateDist = nextDist;
}
const clearChance = clamp01(stateDist[neededUnits]);
const expectedAttemptsOnClear = clearChance > 0 ? expectedAttemptsNumerator / clearChance : null;
return {
clearChance,
expectedAttemptsOnClear,
};
}
function computeEnhancingClearStats(params) {
const { attempts, successChance, doubleChance, targetLevel, startLevel } = params;
const target = Math.max(0, Math.floor(finiteNumber(targetLevel, 0)));
const start = Math.max(0, Math.floor(finiteNumber(startLevel, 0)));
if (target <= 0) {
return { clearChance: 1, expectedAttemptsOnClear: 0 };
}
if (!Number.isFinite(attempts) || attempts <= 0) {
return { clearChance: 0, expectedAttemptsOnClear: null };
}
const p = clamp01(successChance);
const d = clamp01(doubleChance);
const failChance = 1 - p;
const singleSuccessChance = p * (1 - d);
const doubleSuccessChance = p * d;
let stateDist = new Float64Array(target + 1);
stateDist[Math.min(start, target)] = 1;
let expectedAttemptsNumerator = 0;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const nextDist = new Float64Array(target + 1);
for (let level = 0; level <= target; level += 1) {
const prob = stateDist[level];
if (prob <= 0) {
continue;
}
if (level === target) {
nextDist[target] += prob;
continue;
}
nextDist[Math.max(0, level - 1)] += prob * failChance;
nextDist[Math.min(target, level + 1)] += prob * singleSuccessChance;
nextDist[Math.min(target, level + 2)] += prob * doubleSuccessChance;
}
const reachedNow = nextDist[target] - stateDist[target];
if (reachedNow > 0) {
expectedAttemptsNumerator += attempt * reachedNow;
}
stateDist = nextDist;
}
const clearChance = clamp01(stateDist[target]);
const expectedAttemptsOnClear = clearChance > 0 ? expectedAttemptsNumerator / clearChance : null;
return {
clearChance,
expectedAttemptsOnClear,
};
}
function getEnhancingTargetLevelByRoomLevel(roomLevel) {
return 5;
}
function getBadgeColor(probability) {
if (probability >= 0.95) {
return "#1fbf60";
}
if (probability >= 0.8) {
return "#77b82a";
}
if (probability >= 0.6) {
return "#d2ac19";
}
if (probability >= 0.4) {
return "#d27a1f";
}
return "#d84b4b";
}
function ensureStyle() {
if (document.getElementById(STYLE_ID)) {
return;
}
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
.${BADGE_CLASS} {
position: absolute;
right: 1px;
bottom: 1px;
z-index: 9;
max-width: calc(100% - 2px);
max-height: 24%;
padding: 1px 3px;
border-radius: 3px;
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: flex-end;
gap: 2px;
white-space: nowrap;
background: rgba(0, 0, 0, 0.35);
color: #ffffff;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.55);
pointer-events: none;
user-select: none;
}
.${BADGE_CLASS}__chance {
font-size: 9px;
font-weight: 700;
line-height: 1;
flex: 0 0 auto;
}
.${BADGE_CLASS}__eta {
font-size: 8px;
font-weight: 600;
line-height: 1;
opacity: 0.95;
flex: 0 0 auto;
}
.${LIVE_ACTION_RATE_CLASS} {
display: inline-block;
margin-left: 6px;
font-size: ${LIVE_ACTION_RATE_MWITOOLS_FONT_SIZE};
font-weight: 500;
line-height: 1.2;
color: ${LIVE_ACTION_RATE_MWITOOLS_COLOR};
white-space: nowrap;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.35);
}
.${CONTROL_CLASS} {
position: relative;
z-index: 2;
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: 4px;
row-gap: 2px;
width: min(372px, calc(100vw - 16px));
box-sizing: border-box;
padding: 3px 6px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.62);
color: #f0f4ff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28);
user-select: none;
overflow: visible;
}
.${CONTROL_CLASS}--inline {
margin-left: auto;
flex: 0 0 auto;
}
.${CONTROL_CLASS}--block {
margin: 0 0 8px 0;
}
.${CONTROL_CLASS}__button {
min-width: 96px;
width: auto;
padding: 0 10px;
height: 18px;
border: 0;
border-radius: 5px;
background: #3a88ff;
color: #ffffff;
font-size: 11px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
cursor: pointer;
flex: 0 0 auto;
}
.${CONTROL_CLASS}__button:disabled {
opacity: 0.75;
cursor: wait;
}
.${CONTROL_LOAN_TOGGLE_CLASS} {
min-width: 74px;
width: auto;
padding: 0 10px;
height: 18px;
border: 0;
border-radius: 5px;
background: rgba(84, 126, 224, 0.95);
color: #ffffff;
font-size: 11px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
cursor: pointer;
flex: 0 0 auto;
margin-left: auto;
}
.${CONTROL_LOAN_TOGGLE_CLASS}:disabled {
opacity: 0.75;
cursor: wait;
}
.${CONTROL_LOAN_PANEL_CLASS} {
position: absolute;
top: 0;
left: calc(100% + 8px);
width: 250px;
max-height: 320px;
box-sizing: border-box;
padding: 8px;
border: 1px solid rgba(128, 170, 255, 0.45);
border-radius: 6px;
background: rgba(12, 16, 24, 0.96);
color: #f2f7ff;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.45);
z-index: 2147483645;
pointer-events: auto;
}
.${CONTROL_LOAN_PANEL_CLASS}[hidden] {
display: none !important;
}
.${CONTROL_LOAN_PANEL_CLASS} * {
pointer-events: auto;
}
.${CONTROL_LOAN_PANEL_CLASS}__title {
margin-bottom: 6px;
font-size: 12px;
font-weight: 700;
color: #9ec4ff;
}
.${CONTROL_LOAN_LIST_CLASS} {
max-height: 230px;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
gap: 4px;
padding-right: 2px;
}
.${CONTROL_LOAN_ITEM_CLASS} {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 6px;
align-items: center;
font-size: 11px;
line-height: 1.2;
}
.${CONTROL_LOAN_ITEM_CLASS} input[type="checkbox"] {
margin: 0;
}
.${CONTROL_LOAN_ITEM_CLASS}__name {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.${CONTROL_LOAN_ITEM_STATUS_CLASS} {
font-size: 10px;
opacity: 0.85;
white-space: nowrap;
}
.${CONTROL_LOAN_ITEM_STATUS_CLASS}--warn {
color: #ff7b7b;
opacity: 1;
font-weight: 700;
}
.${CONTROL_LOAN_CALC_CLASS} {
margin-top: 8px;
width: 100%;
height: 22px;
border: 0;
border-radius: 5px;
background: #3a88ff;
color: #ffffff;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.${CONTROL_LOAN_CALC_CLASS}:disabled {
opacity: 0.72;
cursor: not-allowed;
}
.${CONTROL_LOG_TOGGLE_CLASS} {
min-width: 54px;
width: auto;
padding: 0 10px;
height: 18px;
border: 0;
border-radius: 5px;
background: rgba(77, 151, 255, 0.95);
color: #ffffff;
font-size: 11px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
cursor: pointer;
flex: 0 0 auto;
}
.${CONTROL_LOG_TOGGLE_CLASS}:disabled {
opacity: 0.75;
cursor: wait;
}
.${ROOM_LOG_FLOAT_CLASS} {
position: fixed;
top: 92px;
right: 14px;
width: 340px;
max-height: min(62vh, 470px);
box-sizing: border-box;
border: 1px solid rgba(128, 170, 255, 0.5);
border-radius: 8px;
background: rgba(10, 14, 22, 0.97);
color: #f2f7ff;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.55);
z-index: 2147483646;
pointer-events: auto;
user-select: none;
}
.${ROOM_LOG_FLOAT_CLASS}[hidden] {
display: none !important;
}
.${ROOM_LOG_FLOAT_HEADER_CLASS} {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px 6px;
border-bottom: 1px solid rgba(146, 182, 255, 0.24);
cursor: move;
}
.${ROOM_LOG_FLOAT_TITLE_CLASS} {
min-width: 0;
color: #9ec4ff;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.${ROOM_LOG_FLOAT_ACTIONS_CLASS} {
display: inline-flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
}
.${ROOM_LOG_FLOAT_CLEAR_CLASS} {
min-width: 40px;
height: 18px;
border: 0;
border-radius: 4px;
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
font-size: 10px;
line-height: 1;
cursor: pointer;
padding: 0 6px;
}
.${ROOM_LOG_FLOAT_CLEAR_CLASS}:hover {
background: rgba(255, 181, 94, 0.45);
}
.${ROOM_LOG_FLOAT_CLOSE_CLASS} {
width: 18px;
height: 18px;
border: 0;
border-radius: 4px;
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
font-size: 13px;
line-height: 1;
cursor: pointer;
flex: 0 0 auto;
}
.${ROOM_LOG_FLOAT_CLOSE_CLASS}:hover {
background: rgba(255, 108, 108, 0.45);
}
.${CONTROL_LOG_PANEL_CLASS} {
display: flex;
flex-direction: column;
gap: 6px;
box-sizing: border-box;
padding: 8px;
}
.${CONTROL_LOG_PANEL_CLASS}__title {
display: none;
}
.${CONTROL_LOG_LIST_CLASS} {
max-height: calc(min(62vh, 470px) - 60px);
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
gap: 6px;
padding-right: 2px;
}
.${CONTROL_LOG_ITEM_CLASS} {
border: 1px solid rgba(146, 182, 255, 0.25);
border-radius: 5px;
background: rgba(22, 31, 45, 0.92);
padding: 6px 7px;
font-size: 11px;
line-height: 1.25;
}
.${CONTROL_LOG_ITEM_CLASS}__header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
font-weight: 700;
color: #eef6ff;
}
.${CONTROL_LOG_ITEM_CLASS}__time {
opacity: 0.85;
font-weight: 600;
}
.${CONTROL_LOG_META_CLASS} {
font-size: 10px;
color: rgba(221, 232, 255, 0.9);
margin-bottom: 4px;
}
.${CONTROL_LOG_INCOMPLETE_CLASS} {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0 6px;
background: rgba(244, 124, 71, 0.22);
color: #ffba92;
font-size: 10px;
font-weight: 700;
line-height: 1.4;
}
.${CONTROL_LOG_ITEM_CLASS}__actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
}
.${CONTROL_LOG_ITEM_CLASS}__sep {
opacity: 0.65;
}
.${CONTROL_LOG_ACTION_CLASS} {
font-weight: 700;
}
.${CONTROL_LOG_ACTION_CLASS}--success {
color: #3ddc84;
}
.${CONTROL_LOG_ACTION_CLASS}--fail {
color: #ff6464;
}
.${CONTROL_LOG_ACTION_CLASS}--double {
color: #ffcf5c;
background-image: linear-gradient(90deg, #ff5e7e 0%, #ffb55e 24%, #ffe45e 42%, #63e67c 60%, #59c8ff 78%, #d78bff 100%);
background-size: 100% 100%;
background-repeat: no-repeat;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.${CONTROL_LOG_ACTION_CLASS}--unknown {
color: #9ab0d8;
}
.${CONTROL_CLASS}__settings {
display: flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.${CONTROL_CLASS}__settings-label {
font-size: 10px;
line-height: 1;
opacity: 0.92;
}
.${CONTROL_CLASS}__settings-input {
width: 46px;
height: 18px;
box-sizing: border-box;
border: 1px solid rgba(150, 190, 255, 0.45);
border-radius: 4px;
background: rgba(20, 28, 42, 0.9);
color: #ffffff;
font-size: 11px;
font-weight: 700;
text-align: center;
outline: none;
}
.${CONTROL_CLASS}__settings-input:disabled {
opacity: 0.75;
cursor: wait;
}
.${CONTROL_CLASS}__progress {
flex: 1 1 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.${CONTROL_CLASS}__track {
width: 100%;
height: 5px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.2);
overflow: hidden;
}
.${CONTROL_CLASS}__bar {
width: 0%;
height: 100%;
background: linear-gradient(90deg, #57d08a 0%, #8ed447 100%);
transition: width 0.08s linear;
}
.${CONTROL_CLASS}__text {
display: none;
}
.${AUTOMATION_ESTIMATE_CONTROL_CLASS} {
margin: 6px 0 8px 0;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.${AUTOMATION_ESTIMATE_CONTROL_CLASS}__settings {
display: inline-flex;
align-items: center;
gap: 6px;
}
.${AUTOMATION_ESTIMATE_CONTROL_CLASS}__settings-label {
font-size: 11px;
color: rgba(240, 244, 255, 0.92);
}
.${AUTOMATION_ESTIMATE_CONTROL_TRIALS_INPUT_CLASS} {
width: 68px;
height: 22px;
box-sizing: border-box;
border: 1px solid rgba(150, 190, 255, 0.45);
border-radius: 4px;
background: rgba(20, 28, 42, 0.9);
color: #ffffff;
font-size: 11px;
font-weight: 700;
text-align: center;
outline: none;
}
.${AUTOMATION_ESTIMATE_CONTROL_TARGET_RATE_LABEL_CLASS} {
font-size: 11px;
color: rgba(240, 244, 255, 0.92);
}
.${AUTOMATION_ESTIMATE_CONTROL_TARGET_RATE_INPUT_CLASS} {
width: 64px;
height: 22px;
box-sizing: border-box;
border: 1px solid rgba(150, 190, 255, 0.45);
border-radius: 4px;
background: rgba(20, 28, 42, 0.9);
color: #ffffff;
font-size: 11px;
font-weight: 700;
text-align: center;
outline: none;
}
.${AUTOMATION_ESTIMATE_CONTROL_CLASS}__button {
min-width: 74px;
width: auto;
padding: 0 10px;
height: 24px;
border: 0;
border-radius: 5px;
background: #3a88ff;
color: #ffffff;
font-size: 11px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
cursor: pointer;
}
.${AUTOMATION_ESTIMATE_CONTROL_CLASS}__button:disabled {
opacity: 0.75;
cursor: wait;
}
.${AUTOMATION_ESTIMATE_CONTROL_RECOMMEND_BUTTON_CLASS} {
min-width: 88px;
}
.${AUTOMATION_ESTIMATE_CONTROL_CLASS}__status {
font-size: 12px;
line-height: 1.2;
color: rgba(240, 244, 255, 0.9);
}
.${AUTOMATION_MAX_FLOOR_TABLE_HEADER_CLASS} {
width: 64px;
min-width: 64px;
max-width: 64px;
white-space: nowrap;
text-align: right;
}
.${AUTOMATION_MAX_FLOOR_CELL_CLASS} {
width: 64px;
min-width: 64px;
max-width: 64px;
color: rgba(240, 244, 255, 0.95);
font-size: 11px;
font-weight: 700;
white-space: nowrap;
text-align: right;
padding-right: 6px;
}
.${AUTOMATION_ESTIMATE_TABLE_HEADER_CLASS} {
width: 84px;
min-width: 84px;
max-width: 84px;
white-space: nowrap;
text-align: right;
}
.${AUTOMATION_RECOMMEND_TABLE_HEADER_CLASS} {
width: 96px;
min-width: 96px;
max-width: 96px;
white-space: nowrap;
text-align: right;
}
.${AUTOMATION_ESTIMATE_CELL_CLASS} {
width: 84px;
min-width: 84px;
max-width: 84px;
color: #ffffff;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
cursor: default;
text-align: right;
padding-right: 6px;
}
.${AUTOMATION_RECOMMEND_CELL_CLASS} {
width: 96px;
min-width: 96px;
max-width: 96px;
color: rgba(240, 244, 255, 0.95);
font-size: 11px;
font-weight: 700;
white-space: nowrap;
text-align: right;
padding-right: 6px;
}
.${AUTOMATION_ESTIMATE_CELL_CLASS}__text {
color: rgba(240, 244, 255, 0.95);
font-weight: 600;
}
.${AUTOMATION_ESTIMATE_CELL_CHANCE_CLASS} {
font-size: 11px;
font-weight: 700;
}
.${AUTOMATION_ESTIMATE_CELL_ETA_CLASS} {
font-size: 11px;
font-weight: 600;
opacity: 0.92;
}
.${AUTOMATION_ESTIMATE_CELL_ETA_DANGER_CLASS} {
color: #ff5a5a;
opacity: 1;
font-weight: 700;
}
div[class*="LabyrinthPanel_automationSection"] table[class*="LabyrinthPanel_automationTable"] {
width: 100%;
max-width: 100%;
}
table[class*="LabyrinthPanel_automationTable"] thead th:nth-child(3),
table[class*="LabyrinthPanel_automationTable"] tbody td:nth-child(3) {
white-space: nowrap;
}
.${PREVIEW_TOOLTIP_CLASS} {
position: fixed;
z-index: 2147483646;
min-width: 140px;
max-width: 220px;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid rgba(128, 170, 255, 0.45);
background: rgba(12, 16, 24, 0.95);
color: #f2f7ff;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.45);
font-size: 11px;
line-height: 1.35;
pointer-events: none;
display: none;
}
.${PREVIEW_TOOLTIP_CLASS}__title {
margin-bottom: 4px;
font-size: 11px;
font-weight: 700;
color: #9ec4ff;
}
.${PREVIEW_TOOLTIP_CLASS}__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
white-space: nowrap;
}
.${PREVIEW_TOOLTIP_CLASS}__label {
opacity: 0.78;
}
.${PREVIEW_TOOLTIP_CLASS}__value {
font-weight: 700;
color: #ffffff;
}
`;
document.head.appendChild(style);
}
function getPreviewTooltip() {
return document.getElementById(PREVIEW_TOOLTIP_ID);
}
function ensurePreviewTooltip() {
let tooltip = getPreviewTooltip();
if (tooltip) {
return tooltip;
}
tooltip = document.createElement("div");
tooltip.id = PREVIEW_TOOLTIP_ID;
tooltip.className = PREVIEW_TOOLTIP_CLASS;
document.body.appendChild(tooltip);
return tooltip;
}
function hidePreviewTooltip() {
const tooltip = getPreviewTooltip();
if (!tooltip) {
return;
}
tooltip.style.display = "none";
tooltip.style.left = "-9999px";
tooltip.style.top = "-9999px";
tooltip.textContent = "";
}
function formatPreviewPercent(value) {
return `${(clamp01(value) * 100).toFixed(1)}%`;
}
function formatPreviewDeltaPercent(value, digits = 2) {
if (!Number.isFinite(value)) {
return "--";
}
const percent = Math.max(0, finiteNumber(value, 0)) * 100;
return `+${percent.toFixed(Math.max(0, Math.floor(digits)))}%`;
}
function computeEfficiencyDeltaForOneLessProgressUnit(targetProgress, effectiveSkillLevel, currentEfficiencyBonus, neededUnits) {
const target = finiteNumber(targetProgress, 0);
const skillLevel = finiteNumber(effectiveSkillLevel, 0);
const efficiency = finiteNumber(currentEfficiencyBonus, 0);
const units = Math.floor(finiteNumber(neededUnits, 0));
if (!(target > 0) || !(skillLevel > 0) || units <= 1) {
return null;
}
const requiredEfficiency = target / (skillLevel * (units - 1)) - 1;
if (!Number.isFinite(requiredEfficiency)) {
return null;
}
return Math.max(0, requiredEfficiency - efficiency);
}
function computeActionSpeedDeltaForOneMoreAttempt(baseActionSeconds, currentActionSpeedBonus, attempts) {
const baseSeconds = finiteNumber(baseActionSeconds, 0);
const speed = finiteNumber(currentActionSpeedBonus, 0);
const currentAttempts = Math.max(0, Math.floor(finiteNumber(attempts, 0)));
if (!(baseSeconds > 0)) {
return null;
}
const requiredSpeed = (baseSeconds * (currentAttempts + 1)) / ROOM_DURATION_SECONDS - 1;
if (!Number.isFinite(requiredSpeed)) {
return null;
}
return Math.max(0, requiredSpeed - speed);
}
function formatExpectedRewardCount(value) {
const n = Math.max(0, finiteNumber(value, 0));
if (Math.abs(n - Math.round(n)) < 1e-9) {
return `${Math.round(n)}`;
}
return n.toFixed(2).replace(/\.?0+$/, "");
}
function getCurrentLabyrinthFloor(state) {
const floor = Math.floor(finiteNumber(state?.characterLabyrinth?.currentFloor, 1));
return Math.max(1, floor);
}
function createRoomRewardPreview(state, room) {
if (!room || !room.roomType) {
return null;
}
const roomType = String(room.roomType || "");
const floor = getCurrentLabyrinthFloor(state);
const rows = [];
if (roomType === LABYRINTH_COMBAT_ROOM_TYPE || roomType === LABYRINTH_SKILLING_ROOM_TYPE) {
const tokenChance = Math.min(floor * 0.05, 0.5);
const boxChance = Math.min(floor * 0.01, 0.1);
const boxLabel = roomType === LABYRINTH_SKILLING_ROOM_TYPE ? t("skillingBoxExpected") : t("combatBoxExpected");
rows.push({
label: t("tokenExpected"),
value: formatExpectedRewardCount(tokenChance),
});
rows.push({
label: boxLabel,
value: formatExpectedRewardCount(boxChance),
});
} else if (roomType === LABYRINTH_TREASURE_ROOM_TYPE) {
const tokenCount = Math.min(floor, 10);
const boxChance = Math.min(floor * 0.05, 0.5);
rows.push({
label: t("tokenExpected"),
value: formatExpectedRewardCount(tokenCount),
});
rows.push({
label: t("skillingBoxExpected"),
value: formatExpectedRewardCount(boxChance),
});
rows.push({
label: t("combatBoxExpected"),
value: formatExpectedRewardCount(boxChance),
});
} else if (roomType === LABYRINTH_DESCEND_ROOM_TYPE) {
const tokenCount = 5 * floor;
const boxAverage = floor >= 4 ? (floor - 3) / 2 : 0;
const refineAverage = floor >= 6 ? (floor - 4) / 2 : 0;
rows.push({
label: t("tokenExpected"),
value: formatExpectedRewardCount(tokenCount),
});
rows.push({
label: t("skillingBoxExpected"),
value: formatExpectedRewardCount(boxAverage),
});
rows.push({
label: t("combatBoxExpected"),
value: formatExpectedRewardCount(boxAverage),
});
rows.push({
label: t("refiningChestExpected"),
value: formatExpectedRewardCount(refineAverage),
});
} else {
return null;
}
return {
floor,
rows,
};
}
function appendRewardPreviewRows(tooltip, rewardPreview) {
if (!tooltip || !rewardPreview || !Array.isArray(rewardPreview.rows) || rewardPreview.rows.length === 0) {
return;
}
for (const row of rewardPreview.rows) {
if (!row || !row.label) {
continue;
}
appendPreviewRow(tooltip, String(row.label), String(row.value ?? "--"));
}
}
function getRoomPreviewTitle(roomType) {
const type = String(roomType || "");
if (type === LABYRINTH_SKILLING_ROOM_TYPE) {
return t("skillingRoomPreview");
}
if (type === LABYRINTH_COMBAT_ROOM_TYPE) {
return t("combatRoomPreview");
}
if (type === LABYRINTH_TREASURE_ROOM_TYPE) {
return t("treasureRoomPreview");
}
if (type === LABYRINTH_DESCEND_ROOM_TYPE) {
return t("floorExitPreview");
}
return t("roomPreview");
}
function buildRewardOnlyPreview(state, room) {
const rewardPreview = createRoomRewardPreview(state, room);
if (!rewardPreview) {
return null;
}
return {
type: "reward-only",
title: getRoomPreviewTitle(room?.roomType),
rewardPreview,
};
}
function formatPreviewDecimal(value, digits = 1) {
const n = finiteNumber(value, 0);
return n.toFixed(digits);
}
function formatPreviewInteger(value) {
return `${Math.round(finiteNumber(value, 0))}`;
}
function formatPreviewExperience(value) {
const n = Math.max(0, finiteNumber(value, 0));
if (Math.abs(n - Math.round(n)) < 1e-9) {
return `${Math.round(n)}`;
}
return n.toFixed(1).replace(/\.0$/, "");
}
function formatPreviewExperiencePerHourK(value) {
const n = Math.max(0, finiteNumber(value, 0));
return `${(n / 1000).toFixed(1)}K`;
}
function formatPreviewPercentValue(value, digits = 0) {
const percent = finiteNumber(value, 0) * 100;
if (digits <= 0) {
return `${Math.round(percent)}%`;
}
return `${percent.toFixed(digits)}%`;
}
function getCombatStylePreviewLabel(styleHrid) {
switch (styleHrid) {
case "/combat_styles/stab":
return t("styleStab");
case "/combat_styles/slash":
return t("styleSlash");
case "/combat_styles/smash":
return t("styleSmash");
case "/combat_styles/ranged":
return t("styleRanged");
case "/combat_styles/magic":
return t("styleMagic");
default:
return t("unknown");
}
}
function getCombatStyleAccuracyLabel(styleHrid) {
const joiner = isChineseUi() ? "" : " ";
return `${getCombatStylePreviewLabel(styleHrid)}${joiner}${t("accuracySuffix")}`;
}
function getCombatStyleDamageLabel(styleHrid) {
const joiner = isChineseUi() ? "" : " ";
return `${getCombatStylePreviewLabel(styleHrid)}${joiner}${t("damageSuffix")}`;
}
function getCombatStyleEvasionLabel(styleHrid) {
const joiner = isChineseUi() ? "" : " ";
return `${getCombatStylePreviewLabel(styleHrid)}${joiner}${t("evasionSuffix")}`;
}
function getDamageTypePreviewLabel(damageTypeHrid) {
switch (damageTypeHrid) {
case "/damage_types/water":
return t("water");
case "/damage_types/nature":
return t("nature");
case "/damage_types/fire":
return t("fire");
case "/damage_types/physical":
return t("physical");
default:
return t("unknown");
}
}
function getDamageTypeMitigationLabel(damageTypeHrid) {
switch (damageTypeHrid) {
case "/damage_types/water":
return t("waterResistance");
case "/damage_types/nature":
return t("natureResistance");
case "/damage_types/fire":
return t("fireResistance");
case "/damage_types/physical":
default:
return t("armor");
}
}
function appendPreviewRow(tooltip, label, value) {
const row = document.createElement("div");
row.className = `${PREVIEW_TOOLTIP_CLASS}__row`;
const labelNode = document.createElement("span");
labelNode.className = `${PREVIEW_TOOLTIP_CLASS}__label`;
labelNode.textContent = label;
const valueNode = document.createElement("span");
valueNode.className = `${PREVIEW_TOOLTIP_CLASS}__value`;
valueNode.textContent = value;
row.appendChild(labelNode);
row.appendChild(valueNode);
tooltip.appendChild(row);
}
function deriveCombatFailureReasonFromCounts(totalFailures, failedByTimeout, failedByDeath) {
const failures = Math.max(0, Math.floor(finiteNumber(totalFailures, 0)));
if (failures <= 0) {
return "";
}
const timeoutCount = Math.max(0, Math.floor(finiteNumber(failedByTimeout, 0)));
const deathCount = Math.max(0, Math.floor(finiteNumber(failedByDeath, 0)));
if (deathCount > timeoutCount) {
return t("failureDefense");
}
return t("failureDamage");
}
function renderSkillingPreviewTooltip(preview) {
const tooltip = ensurePreviewTooltip();
tooltip.textContent = "";
const titleNode = document.createElement("div");
titleNode.className = `${PREVIEW_TOOLTIP_CLASS}__title`;
titleNode.textContent = t("skillingRoomPreview");
tooltip.appendChild(titleNode);
if (preview.type === "enhancing") {
appendPreviewRow(tooltip, t("targetEnhancement"), `+${Math.max(0, Math.floor(finiteNumber(preview.targetLevel, 0)))}`);
appendPreviewRow(tooltip, t("successRate"), formatPreviewPercent(preview.successChance));
appendPreviewRow(tooltip, t("doubleProgress"), formatPreviewPercent(preview.doubleChance));
appendPreviewRow(tooltip, t("twoMinuteActions"), `${Math.max(0, Math.floor(finiteNumber(preview.attempts, 0)))}`);
appendPreviewRow(tooltip, t("actionDuration"), `${formatPreviewDecimal(preview.actionSeconds, 2)}s`);
appendPreviewRow(tooltip, t("experiencePerRoom"), formatPreviewExperience(preview.experiencePerRoom));
appendPreviewRow(tooltip, t("experiencePerHour"), formatPreviewExperiencePerHourK(preview.experiencePerHour));
appendPreviewRow(tooltip, t("needSpeedForOneMoreAction"), formatPreviewDeltaPercent(preview.speedDeltaForOneMoreAttempt, 2));
} else {
appendPreviewRow(tooltip, t("workPower"), formatPreviewDecimal(preview.workPower, 2));
appendPreviewRow(tooltip, t("successRate"), formatPreviewPercent(preview.successChance));
appendPreviewRow(tooltip, t("doubleProgress"), formatPreviewPercent(preview.doubleChance));
appendPreviewRow(tooltip, t("twoMinuteActions"), `${Math.max(0, Math.floor(finiteNumber(preview.attempts, 0)))}`);
appendPreviewRow(tooltip, t("actionDuration"), `${formatPreviewDecimal(preview.actionSeconds, 2)}s`);
appendPreviewRow(tooltip, t("experiencePerRoom"), formatPreviewExperience(preview.experiencePerRoom));
appendPreviewRow(tooltip, t("experiencePerHour"), formatPreviewExperiencePerHourK(preview.experiencePerHour));
appendPreviewRow(
tooltip,
t("needEfficiencyForOneLessProgress"),
preview.efficiencyDeltaForOneLessProgressUnit === null
? t("alreadyOptimal")
: formatPreviewDeltaPercent(preview.efficiencyDeltaForOneLessProgressUnit, 2)
);
appendPreviewRow(tooltip, t("needSpeedForOneMoreAction"), formatPreviewDeltaPercent(preview.speedDeltaForOneMoreAttempt, 2));
}
appendRewardPreviewRows(tooltip, preview.rewardPreview);
return tooltip;
}
function renderCombatPreviewTooltip(preview) {
const tooltip = ensurePreviewTooltip();
tooltip.textContent = "";
const titleNode = document.createElement("div");
titleNode.className = `${PREVIEW_TOOLTIP_CLASS}__title`;
titleNode.textContent = String(preview.monsterName || "--");
tooltip.appendChild(titleNode);
appendPreviewRow(tooltip, t("combatStyle"), String(preview.styleLabel || "--"));
appendPreviewRow(tooltip, t("damageType"), String(preview.damageTypeLabel || "--"));
appendPreviewRow(tooltip, t("attackInterval"), `${formatPreviewDecimal(preview.attackIntervalSeconds, 2)}s`);
appendPreviewRow(tooltip, t("castSpeed"), formatPreviewPercentValue(preview.totalCastSpeed, 0));
appendPreviewRow(tooltip, String(preview.styleAccuracyLabel || t("accuracyDefault")), formatPreviewInteger(preview.styleAccuracy));
appendPreviewRow(tooltip, String(preview.styleDamageLabel || t("damageDefault")), formatPreviewInteger(preview.styleDamage));
appendPreviewRow(tooltip, t("maxHp"), formatPreviewInteger(preview.maxHitpoints));
appendPreviewRow(tooltip, String(preview.targetEvasionLabel || t("evasionDefault")), formatPreviewInteger(preview.targetEvasionRating));
appendPreviewRow(tooltip, String(preview.targetMitigationLabel || t("mitigationDefault")), formatPreviewInteger(preview.targetMitigationValue));
if (Array.isArray(preview.abilities) && preview.abilities.length > 0) {
for (const ability of preview.abilities) {
appendPreviewRow(
tooltip,
`Lv.${Math.max(1, Math.floor(finiteNumber(ability.level, 1)))}`,
String(ability.name || "--")
);
}
}
appendRewardPreviewRows(tooltip, preview.rewardPreview);
appendPreviewRow(tooltip, t("operation"), t("rightClickOpenSimulator"));
if (preview.failureReason) {
appendPreviewRow(tooltip, t("failureReason"), String(preview.failureReason));
}
return tooltip;
}
function renderRewardOnlyPreviewTooltip(preview) {
const tooltip = ensurePreviewTooltip();
tooltip.textContent = "";
const titleNode = document.createElement("div");
titleNode.className = `${PREVIEW_TOOLTIP_CLASS}__title`;
titleNode.textContent = String(preview?.title || t("roomPreview"));
tooltip.appendChild(titleNode);
appendRewardPreviewRows(tooltip, preview?.rewardPreview || null);
return tooltip;
}
function buildCombatPreview(state, room, initClientData, result = null) {
if (!room || room.roomType !== LABYRINTH_COMBAT_ROOM_TYPE || !room.monsterHrid) {
return null;
}
const monster = initClientData?.combatMonsterDetailMap?.[room.monsterHrid];
const baseDetails = monster?.combatDetails;
if (!baseDetails || !baseDetails.combatStats) {
return null;
}
const scaledTemplate = createMonsterCombatTemplate(initClientData, room);
const details = scaledTemplate?.combatDetails || baseDetails;
const stats = details.combatStats || baseDetails.combatStats;
const roomLevel = positiveNumber(room?.recommendedLevel, positiveNumber(baseDetails.combatLevel, 100));
const combatLevel = Math.max(1, Math.floor(roomLevel));
const baseCombatLevel = positiveNumber(baseDetails.combatLevel, combatLevel);
const abilityLevelScale = roomLevel / Math.max(1, baseCombatLevel);
const styleHrid = getCombatStyleHrid(stats);
const damageTypeHrid = getDamageTypeHrid(stats);
const loadoutWeaponMeta = getLoadoutWeaponCombatMetaForRoom(state, initClientData, room);
const currentPlayerCombatStats = state?.combatUnit?.combatDetails?.combatStats || null;
const targetStyleHrid = loadoutWeaponMeta?.styleHrid || getCombatStyleHrid(currentPlayerCombatStats);
const targetDamageTypeHrid = loadoutWeaponMeta?.damageTypeHrid || getDamageTypeHrid(currentPlayerCombatStats);
const rewardPreview = createRoomRewardPreview(state, room);
const attackIntervalNs = positiveNumber(details.attackInterval || stats.attackInterval, COMBAT_ONE_SECOND_NS);
const abilityMap = initClientData?.abilityDetailMap || {};
const abilities = [];
if (Array.isArray(monster?.abilities)) {
for (const rawAbility of monster.abilities) {
if (!rawAbility || !rawAbility.abilityHrid) {
continue;
}
const abilityHrid = String(rawAbility.abilityHrid);
const abilityDetail = getAbilityDetailByHrid(state, initClientData, abilityHrid) || getContainerValue(abilityMap, abilityHrid);
const fallbackName = abilityHrid.split("/").pop() || abilityHrid;
const localizedName = isChineseUi() ? LABYRINTH_ABILITY_NAME_ZH_MAP[abilityHrid] : "";
const baseAbilityLevel = positiveNumber(rawAbility.level, 1);
abilities.push({
level: Math.max(1, Math.round(baseAbilityLevel * abilityLevelScale)),
name: String(localizedName || abilityDetail?.name || fallbackName),
});
}
}
return {
type: "combat",
monsterName: String(
(isChineseUi() &&
(LABYRINTH_MONSTER_NAME_ZH_BY_TAIL[String(room.monsterHrid || "").split("/").pop() || ""] ||
LABYRINTH_MONSTER_NAME_ZH_MAP[room.monsterHrid])) ||
monster?.name ||
room.monsterHrid.split("/").pop() ||
room.monsterHrid
),
baseCombatLevel: combatLevel,
styleLabel: getCombatStylePreviewLabel(styleHrid),
damageTypeLabel: getDamageTypePreviewLabel(damageTypeHrid),
attackIntervalSeconds: attackIntervalNs / COMBAT_ONE_SECOND_NS,
totalCastSpeed: finiteNumber(details.totalCastSpeed, finiteNumber(baseDetails.totalCastSpeed, 0)),
styleAccuracyLabel: getCombatStyleAccuracyLabel(styleHrid),
styleDamageLabel: getCombatStyleDamageLabel(styleHrid),
maxHitpoints: positiveNumber(details.maxHitpoints, 1),
maxManapoints: positiveNumber(details.maxManapoints, 1),
styleAccuracy: getAccuracyRating(details, styleHrid),
styleDamage: getMaxDamage(details, styleHrid),
defensiveMaxDamage: positiveNumber(details.defensiveMaxDamage, 0),
targetEvasionLabel: getCombatStyleEvasionLabel(targetStyleHrid),
targetEvasionRating: getEvasionRating(details, targetStyleHrid),
targetMitigationLabel: getDamageTypeMitigationLabel(targetDamageTypeHrid),
targetMitigationValue: getResistance(details, targetDamageTypeHrid),
totalThreat: positiveNumber(details.totalThreat, 100),
rewardPreview,
failureReason: String(result?.combatMeta?.failureReason || ""),
abilities,
};
}
function getLoadoutWeaponCombatMetaForRoom(state, initClientData, room) {
const loadoutInfo = resolveCombatRoomLoadout(state, room);
const loadout = loadoutInfo?.loadout;
if (!isCombatLoadout(loadout)) {
return null;
}
const wearableMap = loadout.wearableMap || {};
const weaponSlots = ["/item_locations/two_hand", "/item_locations/main_hand"];
let styleHrid = "";
let damageTypeHrid = "";
for (const slotKey of weaponSlots) {
const entry = parseWearableReference(wearableMap[slotKey]);
if (!entry || !entry.itemHrid) {
continue;
}
const itemDetail = getItemDetailByHrid(state, initClientData, entry.itemHrid);
const baseStats = itemDetail?.equipmentDetail?.combatStats || {};
if (!styleHrid) {
if (Array.isArray(baseStats.combatStyleHrids) && baseStats.combatStyleHrids.length > 0) {
styleHrid = String(baseStats.combatStyleHrids[0] || "");
} else if (typeof baseStats.combatStyleHrid === "string" && baseStats.combatStyleHrid) {
styleHrid = baseStats.combatStyleHrid;
}
}
if (!damageTypeHrid && typeof baseStats.damageType === "string" && baseStats.damageType) {
damageTypeHrid = baseStats.damageType;
}
if (styleHrid && damageTypeHrid) {
break;
}
}
if (!styleHrid && !damageTypeHrid) {
return null;
}
return {
styleHrid,
damageTypeHrid,
};
}
function getCellPreview(cell) {
if (!cell) {
return null;
}
const combatPreview = combatPreviewByCell.get(cell);
if (combatPreview) {
return combatPreview;
}
return skillingPreviewByCell.get(cell) || null;
}
function renderCellPreviewTooltip(preview) {
if (preview?.type === "combat") {
return renderCombatPreviewTooltip(preview);
}
if (preview?.type === "reward-only") {
return renderRewardOnlyPreviewTooltip(preview);
}
return renderSkillingPreviewTooltip(preview);
}
function positionPreviewTooltip(tooltip, x, y) {
const offset = 12;
const margin = 8;
tooltip.style.display = "block";
tooltip.style.left = "0px";
tooltip.style.top = "0px";
const width = tooltip.offsetWidth || 180;
const height = tooltip.offsetHeight || 100;
let left = x + offset;
let top = y + offset;
if (left + width + margin > window.innerWidth) {
left = Math.max(margin, x - width - offset);
}
if (top + height + margin > window.innerHeight) {
top = Math.max(margin, y - height - offset);
}
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
}
function showSkillingPreviewTooltip(cell, event) {
const preview = getCellPreview(cell);
if (!preview) {
hidePreviewTooltip();
return;
}
const tooltip = renderCellPreviewTooltip(preview);
positionPreviewTooltip(tooltip, event.clientX, event.clientY);
}
function bindSkillingPreviewEvents(cell) {
if (!cell || cell[PREVIEW_CELL_BOUND_FLAG]) {
return;
}
cell[PREVIEW_CELL_BOUND_FLAG] = true;
cell.addEventListener("mouseenter", (event) => {
showSkillingPreviewTooltip(cell, event);
});
cell.addEventListener("mousemove", (event) => {
showSkillingPreviewTooltip(cell, event);
});
cell.addEventListener("mouseleave", () => {
hidePreviewTooltip();
});
cell.addEventListener("contextmenu", (event) => {
const preview = getCellPreview(cell);
if (!preview || preview.type !== "combat") {
return;
}
event.preventDefault();
event.stopPropagation();
openCombatRoomSimulatorFromCell(cell);
});
}
function setCellSkillingPreview(cell, preview) {
if (!cell || !preview) {
return;
}
bindSkillingPreviewEvents(cell);
skillingPreviewByCell.set(cell, preview);
}
function setCellCombatPreview(cell, preview) {
if (!cell || !preview) {
return;
}
bindSkillingPreviewEvents(cell);
combatPreviewByCell.set(cell, preview);
}
function clearCellSkillingPreview(cell) {
if (!cell) {
return;
}
skillingPreviewByCell.delete(cell);
}
function clearCellCombatPreview(cell) {
if (!cell) {
return;
}
combatPreviewByCell.delete(cell);
}
function isLabyrinthPanelInstance(value) {
return Boolean(
value &&
typeof value === "object" &&
typeof value.getSkillingRoomTypes === "function" &&
typeof value.getCombatRoomTypes === "function" &&
typeof value.getEffectiveLevelForRoom === "function"
);
}
function findLabyrinthPanelInstanceFromFiber(rootFiber) {
if (!rootFiber || typeof rootFiber !== "object") {
return null;
}
const queue = [rootFiber];
const visited = new Set();
let steps = 0;
while (queue.length > 0 && steps < 12000) {
const fiber = queue.shift();
if (!fiber || typeof fiber !== "object" || visited.has(fiber)) {
continue;
}
visited.add(fiber);
steps += 1;
if (isLabyrinthPanelInstance(fiber.stateNode)) {
return fiber.stateNode;
}
if (fiber.child) {
queue.push(fiber.child);
}
if (fiber.sibling) {
queue.push(fiber.sibling);
}
if (fiber.return) {
queue.push(fiber.return);
}
}
return null;
}
function getLabyrinthPanelInstance() {
const panelElement = document.querySelector('div[class*="LabyrinthPanel_labyrinthPanel"]');
if (!panelElement) {
return null;
}
const reactKey = Object.keys(panelElement).find((key) => key.startsWith("__reactFiber$"));
if (!reactKey) {
return null;
}
const fiberNode = panelElement[reactKey];
return findLabyrinthPanelInstanceFromFiber(fiberNode);
}
function getAutomationEstimateTable() {
const direct = document.querySelector('table[class*="LabyrinthPanel_automationTable"]');
if (direct) {
return direct;
}
const panel = document.querySelector('div[class*="LabyrinthPanel_labyrinthPanel"]');
if (!panel) {
return null;
}
const tables = Array.from(panel.querySelectorAll("table"));
for (const table of tables) {
const headerCells = Array.from(table.querySelectorAll("thead th")).map((th) =>
String(th.textContent || "").trim()
);
if (!headerCells.length) {
continue;
}
const hasRoomType = headerCells.some((text) => /房间类型|room\s*type/i.test(text));
const hasSkip = headerCells.some((text) => /跳过如果高出等级|跳过等级|skip.*level|skip\s*if/i.test(text));
if (hasRoomType && hasSkip) {
return table;
}
}
return null;
}
function getAutomationEstimateSection(table) {
if (!table) {
return null;
}
return table.closest('div[class*="LabyrinthPanel_automationSection"]') || table.parentElement || null;
}
function clearAutomationWideLayout() {
if (!Array.isArray(automationWideLayoutNodes) || automationWideLayoutNodes.length === 0) {
return;
}
for (const node of automationWideLayoutNodes) {
if (!node || !node.style) {
continue;
}
node.style.removeProperty("width");
node.style.removeProperty("max-width");
node.style.removeProperty("min-width");
}
automationWideLayoutNodes = [];
}
function applyAutomationWideLayout(section, table) {
clearAutomationWideLayout();
if (!section) {
return;
}
const currentWidth = Math.max(0, Math.floor(section.getBoundingClientRect().width));
if (!currentWidth) {
return;
}
const maxAllowed = Math.max(320, Math.floor(window.innerWidth - 24));
const targetWidth = Math.min(maxAllowed, currentWidth + 100);
section.style.setProperty("width", `${targetWidth}px`, "important");
section.style.setProperty("max-width", `${targetWidth}px`, "important");
section.style.setProperty("min-width", "0px", "important");
if (table && table.style) {
table.style.setProperty("width", "100%", "important");
table.style.setProperty("max-width", "100%", "important");
}
automationWideLayoutNodes = table ? [section, table] : [section];
}
function isAutomationEstimatePanelVisible() {
return Boolean(getAutomationEstimateTable());
}
function hasAnyLabyrinthCrateSelected(state) {
const selection = getLabyrinthCrateSelection(state);
const tea = String(selection?.teaCrateItemHrid || "");
const coffee = String(selection?.coffeeCrateItemHrid || "");
const food = String(selection?.foodCrateItemHrid || "");
return Boolean(tea || coffee || food);
}
function getAutomationMissingCratesForEntry(entry, crateSelection) {
const tea = String(crateSelection?.teaCrateItemHrid || "");
const coffee = String(crateSelection?.coffeeCrateItemHrid || "");
const food = String(crateSelection?.foodCrateItemHrid || "");
if (entry?.isCombat) {
const missing = [];
if (!coffee) {
missing.push(t("missingCoffeeCrate"));
}
if (!food) {
missing.push(t("missingFoodCrate"));
}
return missing;
}
return tea ? [] : [t("missingTeaCrate")];
}
function formatAutomationMissingCratesMessage(missingCrates) {
if (!Array.isArray(missingCrates) || missingCrates.length === 0) {
return "";
}
const joiner = isChineseUi() ? "、" : " / ";
return t("missingCrateFmt", { crates: missingCrates.join(joiner) });
}
function normalizeAutomationRoomTypeEntry(rawEntry, isCombat) {
const key = String(rawEntry?.key || "");
if (!key) {
return null;
}
if (isCombat) {
const monsterHrid = String(rawEntry?.monsterHrid || "");
if (!monsterHrid) {
return null;
}
return {
key,
isCombat: true,
monsterHrid,
};
}
const skillHrid = String(rawEntry?.skillHrid || "");
if (!skillHrid) {
return null;
}
return {
key,
isCombat: false,
skillHrid,
};
}
function getAutomationRoomTypeEntries(panelInstance) {
const skillingFromPanel =
typeof panelInstance?.getSkillingRoomTypes === "function" ? panelInstance.getSkillingRoomTypes() : null;
const combatFromPanel =
typeof panelInstance?.getCombatRoomTypes === "function" ? panelInstance.getCombatRoomTypes() : null;
const skillingRaw =
Array.isArray(skillingFromPanel) && skillingFromPanel.length > 0
? skillingFromPanel
: LABYRINTH_AUTOMATION_SKILL_ROOM_TYPES;
const combatRaw =
Array.isArray(combatFromPanel) && combatFromPanel.length > 0
? combatFromPanel
: LABYRINTH_AUTOMATION_COMBAT_ROOM_TYPES;
const result = [];
for (const rawEntry of Array.isArray(skillingRaw) ? skillingRaw : []) {
const normalized = normalizeAutomationRoomTypeEntry(rawEntry, false);
if (normalized) {
result.push(normalized);
}
}
for (const rawEntry of Array.isArray(combatRaw) ? combatRaw : []) {
const normalized = normalizeAutomationRoomTypeEntry(rawEntry, true);
if (normalized) {
result.push(normalized);
}
}
return result;
}
function getAutomationRoomTypeEntryByKey(roomTypeKey, panelInstance = null) {
const key = String(roomTypeKey || "");
if (!key) {
return null;
}
const panel = panelInstance || getLabyrinthPanelInstance();
const entries = getAutomationRoomTypeEntries(panel);
for (const entry of entries) {
if (String(entry?.key || "") === key) {
return entry;
}
}
return null;
}
function createAutomationRoomFromEntry(entry, roomLevel) {
const level = Math.max(1, Math.floor(finiteNumber(roomLevel, 1)));
if (entry?.isCombat) {
return {
roomType: LABYRINTH_COMBAT_ROOM_TYPE,
monsterHrid: String(entry.monsterHrid || ""),
recommendedLevel: level,
};
}
return {
roomType: LABYRINTH_SKILLING_ROOM_TYPE,
skillHrid: String(entry?.skillHrid || ""),
recommendedLevel: level,
};
}
function resolveAutomationRoomLoadoutId(panelInstance, key, state) {
let value = null;
if (typeof panelInstance?.getLoadoutIdForRoomType === "function") {
value = panelInstance.getLoadoutIdForRoomType(key);
}
if (!Number.isFinite(Number(value))) {
const settingKey = `labyrinthLoadout${toPascalCase(key)}`;
value = state?.characterSetting?.[settingKey];
}
return Math.max(0, Math.floor(finiteNumber(value, 0)));
}
function resolveAutomationSkipThreshold(panelInstance, key, state, skipThresholdOverrides = null) {
let value = null;
if (skipThresholdOverrides instanceof Map && skipThresholdOverrides.has(String(key || ""))) {
value = skipThresholdOverrides.get(String(key || ""));
}
if (typeof panelInstance?.getSkipThresholdForRoomType === "function") {
const panelValue = panelInstance.getSkipThresholdForRoomType(key);
if (!Number.isFinite(Number(value))) {
value = panelValue;
}
}
if (!Number.isFinite(Number(value))) {
const settingKey = `labyrinthSkip${toPascalCase(key)}`;
value = state?.characterSetting?.[settingKey];
}
if (!Number.isFinite(Number(value))) {
value = AUTOMATION_ESTIMATE_DEFAULT_SKIP_THRESHOLD;
}
return Math.floor(finiteNumber(value, AUTOMATION_ESTIMATE_DEFAULT_SKIP_THRESHOLD));
}
function getAutomationCombatBaseLevel(state) {
const fromCombatSkill = finiteNumber(getSkillLevel(state?.characterSkillMap, COMBAT_LEVEL_SKILL_HRID), NaN);
if (Number.isFinite(fromCombatSkill) && fromCombatSkill > 0) {
return fromCombatSkill;
}
const fromCombatDetails = finiteNumber(state?.combatUnit?.combatDetails?.combatLevel, NaN);
if (Number.isFinite(fromCombatDetails) && fromCombatDetails > 0) {
return fromCombatDetails;
}
const fromCombatUnit = finiteNumber(state?.combatUnit?.combatLevel, NaN);
if (Number.isFinite(fromCombatUnit) && fromCombatUnit > 0) {
return fromCombatUnit;
}
const fromCharacter = finiteNumber(state?.character?.combatLevel, NaN);
if (Number.isFinite(fromCharacter) && fromCharacter > 0) {
return fromCharacter;
}
const levels = getCombatSkillLevelsFromState(state);
const avg =
(finiteNumber(levels.stamina, 1) +
finiteNumber(levels.intelligence, 1) +
finiteNumber(levels.attack, 1) +
finiteNumber(levels.defense, 1) +
finiteNumber(levels.melee, 1) +
finiteNumber(levels.ranged, 1) +
finiteNumber(levels.magic, 1)) /
7;
return Math.max(0, avg);
}
function getAutomationCombatCrateLevelBonus(state, initClientData) {
if (!state || !initClientData) {
return 0;
}
const combatCrate = getCombatCrateBuffs(state, initClientData);
const buffs = Array.isArray(combatCrate?.combatBuffs) ? combatCrate.combatBuffs : [];
if (!buffs.length) {
return 0;
}
const skillLevelTypes = new Set([
"/buff_types/stamina_level",
"/buff_types/intelligence_level",
"/buff_types/attack_level",
"/buff_types/defense_level",
"/buff_types/melee_level",
"/buff_types/ranged_level",
"/buff_types/magic_level",
]);
let directLevelBonus = 0;
let skillLevelSum = 0;
let skillLevelCount = 0;
for (const buff of buffs) {
const typeHrid = String(buff?.typeHrid || "");
if (!typeHrid) {
continue;
}
const amount = getBuffAmount(buff);
if (!Number.isFinite(amount) || amount === 0) {
continue;
}
if (typeHrid === "/buff_types/combat_level" || typeHrid === "/buff_types/action_level") {
directLevelBonus += amount;
continue;
}
if (skillLevelTypes.has(typeHrid)) {
skillLevelSum += amount;
skillLevelCount += 1;
}
}
const averagedSkillLevelBonus = skillLevelCount > 0 ? skillLevelSum / skillLevelCount : 0;
return Math.max(0, directLevelBonus + averagedSkillLevelBonus);
}
function resolveAutomationEffectiveLevel(panelInstance, entry, state, initClientData, options = {}) {
const includePersonalBuffs = !(options && options.includePersonalBuffs === false);
if (!entry?.isCombat) {
if (includePersonalBuffs) {
const testRoom = createAutomationRoomFromEntry(entry, 1);
const fromPanel =
typeof panelInstance?.getEffectiveLevelForRoom === "function"
? finiteNumber(panelInstance.getEffectiveLevelForRoom(testRoom), NaN)
: NaN;
if (Number.isFinite(fromPanel)) {
return Math.max(0, fromPanel);
}
}
const baseLevel = positiveNumber(getSkillLevel(state?.characterSkillMap, entry.skillHrid), 1);
const selection = getLabyrinthCrateSelection(state);
const teaCrateItemHrid = String(selection?.teaCrateItemHrid || "");
const skillId = skillHridToSkillId(entry.skillHrid);
const crateMetrics = getSkillingBuffMetrics(skillId, getCrateBuffs(initClientData, teaCrateItemHrid));
return Math.max(0, baseLevel + finiteNumber(crateMetrics.skillLevelBonus, 0));
}
const baseCombatLevel = getAutomationCombatBaseLevel(state);
const crateLevelBonus = getAutomationCombatCrateLevelBonus(state, initClientData);
return Math.max(0, baseCombatLevel + crateLevelBonus);
}
function computeAutomationTargetRoomLevel(effectiveLevel, skipThreshold) {
const effective = finiteNumber(effectiveLevel, 0);
const threshold = Math.floor(finiteNumber(skipThreshold, AUTOMATION_ESTIMATE_DEFAULT_SKIP_THRESHOLD));
return Math.floor(effective + threshold - 1);
}
function computeAutomationMaxFloorByRoomLevel(roomLevel) {
const level = Math.max(0, Math.floor(finiteNumber(roomLevel, 0)));
if (level <= 0) {
return 0;
}
return Math.max(0, Math.floor(level / 20));
}
function getAutomationRoomLabel(entry, initClientData = null) {
if (entry?.isCombat) {
const monsterHrid = String(entry?.monsterHrid || "");
const monsterDetail = monsterHrid ? getContainerValue(initClientData?.combatMonsterDetailMap, monsterHrid) : null;
return String(
(isChineseUi() &&
(LABYRINTH_MONSTER_NAME_ZH_MAP[monsterHrid] || LABYRINTH_MONSTER_NAME_ZH_BY_TAIL[monsterHrid.split("/").pop() || ""])) ||
monsterDetail?.name ||
monsterHrid.split("/").pop() ||
monsterHrid ||
"--"
);
}
const key = String(entry?.key || "");
return isChineseUi()
? LABYRINTH_AUTOMATION_SKILL_NAME_ZH_BY_KEY[key] || key || "--"
: LABYRINTH_AUTOMATION_SKILL_NAME_EN_BY_KEY[key] || key || "--";
}
function parseAutomationSkipThresholdFromRow(row) {
if (!row) {
return NaN;
}
const cells = Array.from(row.querySelectorAll("td"));
const skipCell = cells[2] || null;
const rawText = String(skipCell?.textContent || "");
const match = rawText.match(/-?\d+/);
if (!match) {
return NaN;
}
return Math.floor(finiteNumber(Number(match[0]), NaN));
}
function buildAutomationSkipThresholdOverrideMap(entries) {
const overrides = new Map();
if (!Array.isArray(entries) || entries.length === 0) {
return overrides;
}
const table = getAutomationEstimateTable();
if (!table) {
return overrides;
}
const rows = Array.from(table.querySelectorAll("tbody tr"));
const count = Math.min(entries.length, rows.length);
for (let i = 0; i < count; i += 1) {
const entry = entries[i];
if (!entry?.key) {
continue;
}
const parsed = parseAutomationSkipThresholdFromRow(rows[i]);
if (!Number.isFinite(parsed)) {
continue;
}
overrides.set(String(entry.key), parsed);
}
return overrides;
}
function buildAutomationEstimateSharedSignature(state) {
if (!state || !state.characterLabyrinth) {
return "";
}
const selection = getLabyrinthCrateSelection(state);
const crateSignature = [
String(selection?.teaCrateItemHrid || ""),
String(selection?.coffeeCrateItemHrid || ""),
String(selection?.foodCrateItemHrid || ""),
].join("|");
const trials = getSelectedAutomationCombatTrials();
return `crate=${crateSignature}|trials=${trials}`;
}
function buildAutomationRecommendSharedSignature(state, targetWinRate) {
if (!state || !state.characterLabyrinth) {
return "";
}
const selection = getLabyrinthCrateSelection(state);
const crateSignature = [
String(selection?.teaCrateItemHrid || ""),
String(selection?.coffeeCrateItemHrid || ""),
String(selection?.foodCrateItemHrid || ""),
].join("|");
const trials = getSelectedAutomationCombatTrials();
const target = normalizeAutomationTargetWinRate(targetWinRate);
return `crate=${crateSignature}|trials=${trials}|target=${target}`;
}
function buildAutomationEstimateEntrySignature(panelInstance, entry, state, sharedSignature, skipThresholdOverrides) {
if (!entry?.key || !state) {
return "";
}
const threshold = resolveAutomationSkipThreshold(panelInstance, entry.key, state, skipThresholdOverrides);
const loadoutId = resolveAutomationRoomLoadoutId(panelInstance, entry.key, state);
const effective = resolveAutomationEffectiveLevel(panelInstance, entry, state, null, {
includePersonalBuffs: false,
});
const roundedEffective = Math.round(effective * 100) / 100;
return `${sharedSignature}|${entry.key}:${threshold}:${loadoutId}:${roundedEffective}`;
}
function buildAutomationRecommendEntrySignature(panelInstance, entry, state, sharedSignature) {
if (!entry?.key || !state) {
return "";
}
const loadoutId = resolveAutomationRoomLoadoutId(panelInstance, entry.key, state);
const effective = resolveAutomationEffectiveLevel(panelInstance, entry, state, null, {
includePersonalBuffs: false,
});
const roundedEffective = Math.round(effective * 100) / 100;
return `${sharedSignature}|${entry.key}:${loadoutId}:${roundedEffective}`;
}
function renderAutomationEstimateTooltip(estimate) {
if (!estimate || estimate.status !== "ready") {
const tooltip = ensurePreviewTooltip();
tooltip.textContent = "";
const titleNode = document.createElement("div");
titleNode.className = `${PREVIEW_TOOLTIP_CLASS}__title`;
titleNode.textContent = String(estimate?.roomLabel || t("automationPreview"));
tooltip.appendChild(titleNode);
appendPreviewRow(tooltip, t("status"), String(estimate?.message || t("pending")));
return tooltip;
}
const tooltip = estimate.detailPreview ? renderCellPreviewTooltip(estimate.detailPreview) : ensurePreviewTooltip();
const hiddenLabels = new Set([t("combatTrials"), "Combat Trials", "战斗次数", t("successRate"), "胜率", "ETA", "耗时", t("tokenExpected"), "代币期望"]);
const hiddenLabelIncludes = ["Box Expected", "盒子期望", "盒期望", "紫盒期望", "宝箱期望"];
const rows = Array.from(tooltip.querySelectorAll(`.${PREVIEW_TOOLTIP_CLASS}__row`));
for (const row of rows) {
const labelNode = row.querySelector(`.${PREVIEW_TOOLTIP_CLASS}__label`);
const labelText = String(labelNode?.textContent || "").trim();
if (!labelText) {
continue;
}
if (hiddenLabels.has(labelText) || hiddenLabelIncludes.some((part) => labelText.includes(part))) {
row.remove();
}
}
if (!estimate.detailPreview) {
tooltip.textContent = "";
const titleNode = document.createElement("div");
titleNode.className = `${PREVIEW_TOOLTIP_CLASS}__title`;
titleNode.textContent = String(estimate.roomLabel || t("automationPreview"));
tooltip.appendChild(titleNode);
}
appendPreviewRow(tooltip, t("level"), `Lv.${Math.max(1, Math.floor(finiteNumber(estimate.roomLevel, 1)))}`);
if (!estimate.detailPreview && estimate.isCombat && estimate.failureReason) {
appendPreviewRow(tooltip, t("failureReason"), String(estimate.failureReason));
}
return tooltip;
}
function getAutomationEstimateFromCell(cell) {
if (!cell) {
return null;
}
const roomTypeKey = String(cell.getAttribute("data-mwi-auto-room-key") || "");
if (!roomTypeKey) {
return null;
}
return automationEstimateByRoomTypeKey.get(roomTypeKey) || null;
}
function getAutomationRecommendFromCell(cell) {
if (!cell) {
return null;
}
const roomTypeKey = String(cell.getAttribute("data-mwi-auto-room-key") || "");
if (!roomTypeKey) {
return null;
}
return automationRecommendByRoomTypeKey.get(roomTypeKey) || null;
}
function showAutomationEstimateTooltip(cell, event) {
const estimate = getAutomationEstimateFromCell(cell);
if (!estimate) {
hidePreviewTooltip();
return;
}
const tooltip = renderAutomationEstimateTooltip(estimate);
positionPreviewTooltip(tooltip, event.clientX, event.clientY);
}
function showAutomationRecommendTooltip(cell, event) {
const estimate = getAutomationRecommendFromCell(cell);
if (!estimate) {
hidePreviewTooltip();
return;
}
const tooltip = renderAutomationEstimateTooltip(estimate);
positionPreviewTooltip(tooltip, event.clientX, event.clientY);
}
function bindAutomationEstimateCellEvents(cell) {
if (!cell || cell[AUTOMATION_ESTIMATE_CELL_BOUND_FLAG]) {
return;
}
cell[AUTOMATION_ESTIMATE_CELL_BOUND_FLAG] = true;
cell.addEventListener("mouseenter", (event) => {
showAutomationEstimateTooltip(cell, event);
});
cell.addEventListener("mousemove", (event) => {
showAutomationEstimateTooltip(cell, event);
});
cell.addEventListener("mouseleave", () => {
hidePreviewTooltip();
});
cell.addEventListener("contextmenu", (event) => {
const roomTypeKey = String(cell.getAttribute("data-mwi-auto-room-key") || "");
if (!roomTypeKey) {
return;
}
const estimate = getAutomationEstimateFromCell(cell);
const entry = getAutomationRoomTypeEntryByKey(roomTypeKey);
const isCombat = Boolean(estimate?.isCombat || entry?.isCombat);
if (!isCombat) {
return;
}
event.preventDefault();
event.stopPropagation();
openCombatRoomSimulatorFromAutomationCell(cell);
});
}
function bindAutomationRecommendCellEvents(cell) {
if (!cell || cell[AUTOMATION_RECOMMEND_CELL_BOUND_FLAG]) {
return;
}
cell[AUTOMATION_RECOMMEND_CELL_BOUND_FLAG] = true;
cell.addEventListener("mouseenter", (event) => {
showAutomationRecommendTooltip(cell, event);
});
cell.addEventListener("mousemove", (event) => {
showAutomationRecommendTooltip(cell, event);
});
cell.addEventListener("mouseleave", () => {
hidePreviewTooltip();
});
cell.addEventListener("contextmenu", (event) => {
const roomTypeKey = String(cell.getAttribute("data-mwi-auto-room-key") || "");
if (!roomTypeKey) {
return;
}
const recommend = getAutomationRecommendFromCell(cell);
const entry = getAutomationRoomTypeEntryByKey(roomTypeKey);
const isCombat = Boolean(recommend?.isCombat || entry?.isCombat);
if (!isCombat) {
return;
}
event.preventDefault();
event.stopPropagation();
openCombatRoomSimulatorFromAutomationCell(cell);
});
}
function ensureAutomationMaxFloorHeader(table) {
const headRow = table?.querySelector("thead tr");
if (!headRow) {
return;
}
if (headRow.querySelector(`.${AUTOMATION_MAX_FLOOR_TABLE_HEADER_CLASS}`)) {
return;
}
const th = document.createElement("th");
th.className = AUTOMATION_MAX_FLOOR_TABLE_HEADER_CLASS;
th.textContent = t("maxFloor");
const estimateHeader = headRow.querySelector(`.${AUTOMATION_ESTIMATE_TABLE_HEADER_CLASS}`);
if (estimateHeader) {
headRow.insertBefore(th, estimateHeader);
} else {
headRow.appendChild(th);
}
}
function ensureAutomationEstimateHeader(table) {
const headRow = table?.querySelector("thead tr");
if (!headRow) {
return;
}
if (headRow.querySelector(`.${AUTOMATION_ESTIMATE_TABLE_HEADER_CLASS}`)) {
return;
}
const th = document.createElement("th");
th.className = AUTOMATION_ESTIMATE_TABLE_HEADER_CLASS;
th.textContent = t("chanceEta");
headRow.appendChild(th);
}
function ensureAutomationRecommendHeader(table) {
const headRow = table?.querySelector("thead tr");
if (!headRow) {
return;
}
const existingHeader = headRow.querySelector(`.${AUTOMATION_RECOMMEND_TABLE_HEADER_CLASS}`);
if (existingHeader) {
if (existingHeader.textContent !== t("recommendSettingLevel")) {
existingHeader.textContent = t("recommendSettingLevel");
}
return;
}
const th = document.createElement("th");
th.className = AUTOMATION_RECOMMEND_TABLE_HEADER_CLASS;
th.textContent = t("recommendSettingLevel");
headRow.appendChild(th);
}
function updateAutomationSkipHeaderText(table, compact = false) {
const headers = Array.from(table?.querySelectorAll("thead th") || []);
if (headers.length === 0) {
return;
}
const nextText = compact ? t("skipLevel") : t("skipLevelLong");
for (const th of headers) {
const text = String(th?.textContent || "").trim();
if (!text) {
continue;
}
if (text === nextText) {
return;
}
if (/跳过如果高出等级|跳过等级|skip.*level|skip\s*if/i.test(text)) {
th.textContent = nextText;
return;
}
}
}
function removeAutomationEstimateColumn(table) {
if (!table) {
return;
}
const floorHeader = table.querySelector(`thead th.${AUTOMATION_MAX_FLOOR_TABLE_HEADER_CLASS}`);
if (floorHeader) {
floorHeader.remove();
}
const header = table.querySelector(`thead th.${AUTOMATION_ESTIMATE_TABLE_HEADER_CLASS}`);
if (header) {
header.remove();
}
const floorCells = Array.from(table.querySelectorAll(`tbody td.${AUTOMATION_MAX_FLOOR_CELL_CLASS}`));
for (const cell of floorCells) {
cell.remove();
}
const cells = Array.from(table.querySelectorAll(`tbody td.${AUTOMATION_ESTIMATE_CELL_CLASS}`));
for (const cell of cells) {
cell.remove();
}
}
function removeAutomationRecommendColumn(table) {
if (!table) {
return;
}
const header = table.querySelector(`thead th.${AUTOMATION_RECOMMEND_TABLE_HEADER_CLASS}`);
if (header) {
header.remove();
}
const cells = Array.from(table.querySelectorAll(`tbody td.${AUTOMATION_RECOMMEND_CELL_CLASS}`));
for (const cell of cells) {
cell.remove();
}
}
function getAutomationEstimateCellRenderToken(estimate) {
if (!estimate) {
return "none";
}
if (estimate.status !== "ready") {
return `status:${String(estimate.status || "")}:${String(estimate.message || "")}`;
}
const roomLevel = Math.max(1, Math.floor(finiteNumber(estimate.roomLevel, 1)));
const chancePercent = Math.round(clamp01(estimate.clearChance) * 100);
const etaText = String(estimate.etaText || ETA_INFINITE_TEXT);
return `ready:${roomLevel}:${chancePercent}:${etaText}`;
}
function getAutomationMaxFloorText(estimate) {
if (!estimate) {
return "--";
}
if (String(estimate.status || "") === "skipped") {
return t("skipRoom");
}
const roomLevel = finiteNumber(estimate?.roomLevel, NaN);
if (!Number.isFinite(roomLevel) || roomLevel <= 0) {
return t("skipRoom");
}
const floor = computeAutomationMaxFloorByRoomLevel(roomLevel);
if (!Number.isFinite(floor) || floor < 1) {
return t("skipRoom");
}
return String(floor);
}
function upsertAutomationMaxFloorCellContent(cell, estimate) {
if (!cell) {
return;
}
const nextText = getAutomationMaxFloorText(estimate);
if (cell.textContent !== nextText) {
cell.textContent = nextText;
}
cell.classList.add(AUTOMATION_MAX_FLOOR_CELL_CLASS);
cell.removeAttribute("title");
}
function upsertAutomationEstimateCellContent(cell, estimate) {
if (!cell) {
return;
}
const nextToken = getAutomationEstimateCellRenderToken(estimate);
const currentToken = String(cell.getAttribute(AUTOMATION_ESTIMATE_CELL_RENDER_TOKEN_ATTR) || "");
if (currentToken === nextToken) {
return;
}
cell.setAttribute(AUTOMATION_ESTIMATE_CELL_RENDER_TOKEN_ATTR, nextToken);
cell.classList.add(AUTOMATION_ESTIMATE_CELL_CLASS);
cell.removeAttribute("title");
cell.textContent = "";
const statusText = document.createElement("span");
statusText.className = `${AUTOMATION_ESTIMATE_CELL_CLASS}__text`;
if (!estimate) {
statusText.textContent = "--";
cell.appendChild(statusText);
return;
}
if (estimate.status !== "ready") {
statusText.textContent = String(estimate.message || t("pending"));
cell.appendChild(statusText);
return;
}
const chanceNode = document.createElement("span");
chanceNode.className = AUTOMATION_ESTIMATE_CELL_CHANCE_CLASS;
chanceNode.textContent = `${Math.round(clamp01(estimate.clearChance) * 100)}%`;
const etaNode = document.createElement("span");
etaNode.className = AUTOMATION_ESTIMATE_CELL_ETA_CLASS;
const etaText = String(estimate.etaText || ETA_INFINITE_TEXT);
etaNode.textContent = ` / ${etaText}`;
const expectedSeconds = finiteNumber(estimate.expectedSeconds, Infinity);
const etaIsDanger = etaText === ETA_INFINITE_TEXT || expectedSeconds > 999;
chanceNode.classList.toggle(AUTOMATION_ESTIMATE_CELL_ETA_DANGER_CLASS, etaIsDanger);
etaNode.classList.toggle(AUTOMATION_ESTIMATE_CELL_ETA_DANGER_CLASS, etaIsDanger);
cell.appendChild(chanceNode);
cell.appendChild(etaNode);
}
function getAutomationRecommendCellRenderToken(estimate) {
if (!estimate) {
return "none";
}
if (estimate.status !== "ready") {
return `status:${String(estimate.status || "")}:${String(estimate.message || "")}`;
}
const delta = Math.floor(finiteNumber(estimate.levelDelta, 0));
const roomLevel = Math.max(1, Math.floor(finiteNumber(estimate.roomLevel, 1)));
const chancePercent = Math.round(clamp01(finiteNumber(estimate.clearChance, 0)) * 1000) / 10;
return `ready:${delta}:${roomLevel}:${chancePercent}`;
}
function formatAutomationSignedDelta(value) {
const n = Math.floor(finiteNumber(value, 0));
if (n > 0) {
return `+${n}`;
}
return String(n);
}
function upsertAutomationRecommendCellContent(cell, estimate) {
if (!cell) {
return;
}
const nextToken = getAutomationRecommendCellRenderToken(estimate);
const currentToken = String(cell.getAttribute(AUTOMATION_RECOMMEND_CELL_RENDER_TOKEN_ATTR) || "");
if (currentToken === nextToken) {
return;
}
cell.setAttribute(AUTOMATION_RECOMMEND_CELL_RENDER_TOKEN_ATTR, nextToken);
cell.classList.add(AUTOMATION_RECOMMEND_CELL_CLASS);
cell.removeAttribute("title");
cell.textContent = "";
if (!estimate) {
cell.textContent = "--";
return;
}
if (estimate.status !== "ready") {
cell.textContent = String(estimate.message || t("pending"));
return;
}
const settingLevel = Math.floor(finiteNumber(estimate.levelDelta, 0)) + 1;
cell.textContent = formatAutomationSignedDelta(settingLevel);
}
function ensureAutomationEstimateControl(section) {
if (!section) {
return null;
}
let root = section.querySelector(`#${AUTOMATION_ESTIMATE_CONTROL_ID}`);
if (root && root.getAttribute(AUTOMATION_ESTIMATE_CONTROL_SCHEMA_ATTR) !== AUTOMATION_ESTIMATE_CONTROL_SCHEMA_VERSION) {
root.remove();
root = null;
}
if (!root) {
const initialTrials = loadAutomationCombatSimTrialsSetting();
const initialTargetWinRate = loadAutomationTargetWinRateSetting();
root = document.createElement("div");
root.id = AUTOMATION_ESTIMATE_CONTROL_ID;
root.className = AUTOMATION_ESTIMATE_CONTROL_CLASS;
root.setAttribute(AUTOMATION_ESTIMATE_CONTROL_SCHEMA_ATTR, AUTOMATION_ESTIMATE_CONTROL_SCHEMA_VERSION);
root.innerHTML = `
<div class="${AUTOMATION_ESTIMATE_CONTROL_CLASS}__settings">
<span class="${AUTOMATION_ESTIMATE_CONTROL_CLASS}__settings-label">${t("combatTrials")}</span>
<input type="number" class="${AUTOMATION_ESTIMATE_CONTROL_TRIALS_INPUT_CLASS}" min="${MIN_COMBAT_SIM_TRIALS}" max="${MAX_COMBAT_SIM_TRIALS}" step="1" value="${initialTrials}" inputmode="numeric" />
</div>
<button type="button" class="${AUTOMATION_ESTIMATE_CONTROL_CLASS}__button">${t("calcChance")}</button>
<div class="${AUTOMATION_ESTIMATE_CONTROL_CLASS}__settings">
<span class="${AUTOMATION_ESTIMATE_CONTROL_TARGET_RATE_LABEL_CLASS}">${t("targetWinRate")}</span>
<input type="number" class="${AUTOMATION_ESTIMATE_CONTROL_TARGET_RATE_INPUT_CLASS}" min="0" max="100" step="0.1" value="${initialTargetWinRate}" inputmode="decimal" />
</div>
<button type="button" class="${AUTOMATION_ESTIMATE_CONTROL_CLASS}__button ${AUTOMATION_ESTIMATE_CONTROL_RECOMMEND_BUTTON_CLASS}">${t("recommendDelta")}</button>
<span class="${AUTOMATION_ESTIMATE_CONTROL_CLASS}__status">${t("pending")}</span>`;
section.insertBefore(root, section.firstChild);
}
const settingsLabel = root.querySelector(`.${AUTOMATION_ESTIMATE_CONTROL_CLASS}__settings-label`);
if (settingsLabel) {
settingsLabel.textContent = t("combatTrials");
}
const targetRateLabel = root.querySelector(`.${AUTOMATION_ESTIMATE_CONTROL_TARGET_RATE_LABEL_CLASS}`);
if (targetRateLabel) {
targetRateLabel.textContent = t("targetWinRate");
}
const statusNode = root.querySelector(`.${AUTOMATION_ESTIMATE_CONTROL_CLASS}__status`);
if (statusNode && !String(statusNode.textContent || "").trim()) {
statusNode.textContent = t("pending");
}
const button = root.querySelector(`.${AUTOMATION_ESTIMATE_CONTROL_CLASS}__button`);
if (button && !button.dataset.bound) {
button.dataset.bound = "1";
button.addEventListener("click", () => {
runAutomationEstimateCalculation();
});
}
if (button) {
button.textContent = t("calcChance");
}
const recommendButton = root.querySelector(`.${AUTOMATION_ESTIMATE_CONTROL_RECOMMEND_BUTTON_CLASS}`);
if (recommendButton && !recommendButton.dataset.bound) {
recommendButton.dataset.bound = "1";
recommendButton.addEventListener("click", () => {
runAutomationRecommendCalculation();
});
}
if (recommendButton) {
recommendButton.textContent = t("recommendDelta");
}
const trialsInput = root.querySelector(`.${AUTOMATION_ESTIMATE_CONTROL_TRIALS_INPUT_CLASS}`);
if (trialsInput && !trialsInput.dataset.bound) {
trialsInput.dataset.bound = "1";
trialsInput.value = String(loadAutomationCombatSimTrialsSetting());
const syncTrials = (finalize = false) => {
const raw = String(trialsInput.value ?? "").trim();
if (!raw) {
if (finalize) {
const fallback = saveAutomationCombatSimTrialsSetting(DEFAULT_AUTOMATION_COMBAT_SIM_TRIALS);
trialsInput.value = String(fallback);
}
return;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed)) {
if (finalize) {
const fallback = saveAutomationCombatSimTrialsSetting(DEFAULT_AUTOMATION_COMBAT_SIM_TRIALS);
trialsInput.value = String(fallback);
}
return;
}
const normalized = saveAutomationCombatSimTrialsSetting(parsed);
if (finalize || /^\d+$/.test(raw)) {
trialsInput.value = String(normalized);
}
};
trialsInput.addEventListener("input", () => {
syncTrials(false);
});
trialsInput.addEventListener("change", () => {
syncTrials(true);
});
trialsInput.addEventListener("blur", () => {
syncTrials(true);
});
}
const targetRateInput = root.querySelector(`.${AUTOMATION_ESTIMATE_CONTROL_TARGET_RATE_INPUT_CLASS}`);
if (targetRateInput && !targetRateInput.dataset.bound) {
targetRateInput.dataset.bound = "1";
targetRateInput.value = String(loadAutomationTargetWinRateSetting());
const syncTargetRate = (finalize = false) => {
const raw = String(targetRateInput.value ?? "").trim();
if (!raw) {
if (finalize) {
const fallback = saveAutomationTargetWinRateSetting(DEFAULT_AUTOMATION_TARGET_WIN_RATE);
targetRateInput.value = String(fallback);
}
return;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed)) {
if (finalize) {
const fallback = saveAutomationTargetWinRateSetting(DEFAULT_AUTOMATION_TARGET_WIN_RATE);
targetRateInput.value = String(fallback);
}
return;
}
const normalized = saveAutomationTargetWinRateSetting(parsed);
if (finalize) {
targetRateInput.value = String(normalized);
}
};
targetRateInput.addEventListener("input", () => {
syncTargetRate(false);
});
targetRateInput.addEventListener("change", () => {
syncTargetRate(true);
});
targetRateInput.addEventListener("blur", () => {
syncTargetRate(true);
});
}
return root;
}
function getAutomationEstimateTrialsInput() {
const root = document.getElementById(AUTOMATION_ESTIMATE_CONTROL_ID);
if (!root) {
return null;
}
return root.querySelector(`.${AUTOMATION_ESTIMATE_CONTROL_TRIALS_INPUT_CLASS}`);
}
function getAutomationTargetWinRateInput() {
const root = document.getElementById(AUTOMATION_ESTIMATE_CONTROL_ID);
if (!root) {
return null;
}
return root.querySelector(`.${AUTOMATION_ESTIMATE_CONTROL_TARGET_RATE_INPUT_CLASS}`);
}
function getSelectedAutomationCombatTrials() {
const input = getAutomationEstimateTrialsInput();
if (!input) {
return loadAutomationCombatSimTrialsSetting();
}
const normalized = normalizeCombatSimTrials(Number(input.value));
if (input.value !== String(normalized)) {
input.value = String(normalized);
}
return normalized;
}
function getSelectedAutomationTargetWinRate() {
const input = getAutomationTargetWinRateInput();
if (!input) {
return loadAutomationTargetWinRateSetting();
}
const normalized = normalizeAutomationTargetWinRate(Number(input.value));
if (input.value !== String(normalized)) {
input.value = String(normalized);
}
return normalized;
}
function setAutomationEstimateControlStatus(status = {}) {
const root = document.getElementById(AUTOMATION_ESTIMATE_CONTROL_ID);
if (!root) {
return;
}
const button = root.querySelector(`.${AUTOMATION_ESTIMATE_CONTROL_CLASS}__button`);
const recommendButton = root.querySelector(`.${AUTOMATION_ESTIMATE_CONTROL_RECOMMEND_BUTTON_CLASS}`);
const text = root.querySelector(`.${AUTOMATION_ESTIMATE_CONTROL_CLASS}__status`);
const trialsInput = root.querySelector(`.${AUTOMATION_ESTIMATE_CONTROL_TRIALS_INPUT_CLASS}`);
const targetRateInput = root.querySelector(`.${AUTOMATION_ESTIMATE_CONTROL_TARGET_RATE_INPUT_CLASS}`);
if (button && Object.prototype.hasOwnProperty.call(status, "running")) {
const running = Boolean(status.running);
const runningMode = String(automationEstimateRunningMode || "");
const nextCalcText = running && runningMode === "estimate" ? t("calculating") : t("calcChance");
const nextRecommendText = running && runningMode === "recommend" ? t("calculating") : t("recommendDelta");
if (button.disabled !== running) {
button.disabled = running;
}
if (recommendButton && recommendButton.disabled !== running) {
recommendButton.disabled = running;
}
if (trialsInput && trialsInput.disabled !== running) {
trialsInput.disabled = running;
}
if (targetRateInput && targetRateInput.disabled !== running) {
targetRateInput.disabled = running;
}
if (button.textContent !== nextCalcText) {
button.textContent = nextCalcText;
}
if (recommendButton && recommendButton.textContent !== nextRecommendText) {
recommendButton.textContent = nextRecommendText;
}
}
if (text && typeof status.message === "string" && text.textContent !== status.message) {
text.textContent = status.message;
}
}
function renderAutomationEstimateTable(entries) {
const table = getAutomationEstimateTable();
if (!table) {
return;
}
const showEstimateColumns = automationEstimateColumnEnabled;
const showRecommendColumn = automationRecommendColumnEnabled;
updateAutomationSkipHeaderText(table, showEstimateColumns || showRecommendColumn);
if (!showEstimateColumns) {
removeAutomationEstimateColumn(table);
}
if (!showRecommendColumn) {
removeAutomationRecommendColumn(table);
}
if (!showEstimateColumns && !showRecommendColumn) {
return;
}
if (!Array.isArray(entries) || entries.length === 0) {
removeAutomationEstimateColumn(table);
removeAutomationRecommendColumn(table);
return;
}
const rowList = Array.from(table.querySelectorAll("tbody tr"));
if (showEstimateColumns) {
ensureAutomationMaxFloorHeader(table);
ensureAutomationEstimateHeader(table);
for (let i = 0; i < rowList.length; i += 1) {
const row = rowList[i];
const entry = entries[i] || null;
const estimate = entry ? automationEstimateByRoomTypeKey.get(entry.key) || null : null;
let estimateCell = row.querySelector(`td.${AUTOMATION_ESTIMATE_CELL_CLASS}`);
let floorCell = row.querySelector(`td.${AUTOMATION_MAX_FLOOR_CELL_CLASS}`);
if (!floorCell) {
floorCell = document.createElement("td");
floorCell.className = AUTOMATION_MAX_FLOOR_CELL_CLASS;
if (estimateCell) {
row.insertBefore(floorCell, estimateCell);
} else {
row.appendChild(floorCell);
}
}
if (!estimateCell) {
estimateCell = document.createElement("td");
estimateCell.className = AUTOMATION_ESTIMATE_CELL_CLASS;
row.appendChild(estimateCell);
}
upsertAutomationMaxFloorCellContent(floorCell, estimate);
if (!entry) {
if (estimateCell.getAttribute("data-mwi-auto-room-key") !== "") {
estimateCell.setAttribute("data-mwi-auto-room-key", "");
}
upsertAutomationEstimateCellContent(estimateCell, null);
continue;
}
if (estimateCell.getAttribute("data-mwi-auto-room-key") !== entry.key) {
estimateCell.setAttribute("data-mwi-auto-room-key", entry.key);
}
bindAutomationEstimateCellEvents(estimateCell);
upsertAutomationEstimateCellContent(estimateCell, estimate);
}
}
if (showRecommendColumn) {
ensureAutomationRecommendHeader(table);
for (let i = 0; i < rowList.length; i += 1) {
const row = rowList[i];
const entry = entries[i] || null;
const recommend = entry ? automationRecommendByRoomTypeKey.get(entry.key) || null : null;
let recommendCell = row.querySelector(`td.${AUTOMATION_RECOMMEND_CELL_CLASS}`);
if (!recommendCell) {
recommendCell = document.createElement("td");
recommendCell.className = AUTOMATION_RECOMMEND_CELL_CLASS;
row.appendChild(recommendCell);
}
if (!entry) {
if (recommendCell.getAttribute("data-mwi-auto-room-key") !== "") {
recommendCell.setAttribute("data-mwi-auto-room-key", "");
}
upsertAutomationRecommendCellContent(recommendCell, null);
continue;
}
if (recommendCell.getAttribute("data-mwi-auto-room-key") !== entry.key) {
recommendCell.setAttribute("data-mwi-auto-room-key", entry.key);
}
bindAutomationRecommendCellEvents(recommendCell);
upsertAutomationRecommendCellContent(recommendCell, recommend);
}
}
}
async function computeAutomationEntryClearChanceByRoomLevel(params, roomLevel) {
const safeRoomLevel = Math.max(1, Math.floor(finiteNumber(roomLevel, 1)));
const room = createAutomationRoomFromEntry(params.entry, safeRoomLevel);
const combatTrials = normalizeCombatSimTrials(
params.entry?.isCombat ? params.recommendCombatTrials : params.combatTrials
);
const result = params.entry.isCombat
? await computeCombatRoomClearChance(
params.state,
params.initClientData,
room,
params.maxEnhancementByItem,
null,
combatTrials,
`auto:recommend:${params.entry.key}:${safeRoomLevel}:target${Math.round(params.targetChance * 1000)}`,
{ includePersonalBuffs: false }
)
: computeRoomClearChance(params.state, params.initClientData, room, params.maxEnhancementByItem, {
includePersonalBuffs: false,
});
if (!result) {
return null;
}
return {
room,
roomLevel: safeRoomLevel,
clearChance: clamp01(finiteNumber(result.clearChance, 0)),
result,
};
}
function getAutomationRecommendStepByChanceDiff(chanceDiff) {
const diffPercent = Math.abs(clamp01(finiteNumber(chanceDiff, 0))) * 100;
if (diffPercent >= 70) {
return 64;
}
if (diffPercent >= 55) {
return 48;
}
if (diffPercent >= 40) {
return 32;
}
if (diffPercent >= 25) {
return 20;
}
if (diffPercent >= 15) {
return 12;
}
if (diffPercent >= 8) {
return 6;
}
if (diffPercent >= 4) {
return 3;
}
if (diffPercent >= 2) {
return 2;
}
return 1;
}
async function findAutomationRecommendedLevelDelta(params) {
const targetChance = clamp01(finiteNumber(params.targetChance, 0));
const targetPercent = targetChance * 100;
const effectiveLevel = Math.max(0, finiteNumber(params.effectiveLevel, 0));
const maxDelta = Math.max(0, Math.floor(AUTOMATION_RECOMMEND_MAX_DELTA));
const minDelta = Math.min(-1, Math.floor(AUTOMATION_RECOMMEND_MIN_DELTA));
const isCombatEntry = Boolean(params?.entry?.isCombat);
const evalCacheByDelta = new Map();
let bestMatch = null;
const getDisplayPercentFromChance = (chance) => {
// Keep recommendation threshold aligned with "计算胜率" table display (integer percent).
return Math.round(clamp01(finiteNumber(chance, 0)) * 100);
};
const isAtOrAboveTarget = (sample) => {
if (!sample) {
return false;
}
return getDisplayPercentFromChance(sample.clearChance) >= targetPercent;
};
const shouldValidateHighTarget = targetPercent >= 99;
const upwardValidationMaxSteps = isCombatEntry ? 14 : 48;
const upwardValidationFailStreak = isCombatEntry ? 5 : 14;
const sweepUpwardForHighestPass = async (startDelta) => {
let highestPassDelta = Math.max(minDelta, Math.min(maxDelta, Math.floor(finiteNumber(startDelta, minDelta))));
let failStreak = 0;
let steps = 0;
let probeDelta = highestPassDelta + 1;
while (probeDelta <= maxDelta && steps < upwardValidationMaxSteps) {
const sample = await evaluateDelta(probeDelta);
steps += 1;
if (isAtOrAboveTarget(sample)) {
highestPassDelta = probeDelta;
failStreak = 0;
} else {
failStreak += 1;
if (failStreak >= upwardValidationFailStreak) {
break;
}
}
probeDelta += 1;
}
return highestPassDelta;
};
const getInitialProbeStep = (chanceDiff) => {
const dynamicStep = Math.max(1, Math.floor(getAutomationRecommendStepByChanceDiff(chanceDiff)));
const minStep = isCombatEntry ? 8 : 4;
return Math.max(minStep, dynamicStep);
};
const getNextProbeStep = (currentStep, chanceDiff) => {
const dynamicStep = Math.max(1, Math.floor(getAutomationRecommendStepByChanceDiff(chanceDiff)));
const doubledStep = Math.max(1, currentStep * 2);
const maxStep = isCombatEntry ? 96 : 64;
return Math.max(dynamicStep, Math.min(maxStep, doubledStep));
};
const rememberBest = (sample) => {
if (!sample) {
return;
}
const sampleDisplayPercent = getDisplayPercentFromChance(sample.clearChance);
const diff = Math.abs(sampleDisplayPercent - targetPercent);
const sampleDelta = Math.floor(finiteNumber(sample.levelDelta, 0));
const bestDelta = bestMatch ? Math.floor(finiteNumber(bestMatch.levelDelta, 0)) : -Infinity;
if (
!bestMatch ||
diff < bestMatch.diff - 1e-9 ||
(Math.abs(diff - bestMatch.diff) <= 1e-9 && sampleDelta > bestDelta)
) {
bestMatch = {
...sample,
displayChancePercent: sampleDisplayPercent,
diff,
};
}
};
const evaluateDelta = async (rawDelta) => {
const levelDelta = Math.max(minDelta, Math.min(maxDelta, Math.floor(finiteNumber(rawDelta, 0))));
if (evalCacheByDelta.has(levelDelta)) {
return evalCacheByDelta.get(levelDelta);
}
const roomLevel = Math.max(1, Math.floor(effectiveLevel + levelDelta));
const computed = await computeAutomationEntryClearChanceByRoomLevel(params, roomLevel);
const sample = computed
? {
levelDelta,
roomLevel,
clearChance: computed.clearChance,
result: computed.result,
}
: null;
evalCacheByDelta.set(levelDelta, sample);
rememberBest(sample);
return sample;
};
const base = await evaluateDelta(0);
if (!base) {
return null;
}
if (isAtOrAboveTarget(base)) {
// Find a [pass, fail] bracket quickly with exponential probing, then binary-search the highest pass level.
let passDelta = 0;
let failDelta = maxDelta + 1;
let probeStep = getInitialProbeStep(base.clearChance - targetChance);
let probeDelta = Math.min(maxDelta, passDelta + probeStep);
while (probeDelta <= maxDelta) {
const probeSample = await evaluateDelta(probeDelta);
if (!probeSample) {
failDelta = probeDelta;
break;
}
if (isAtOrAboveTarget(probeSample)) {
passDelta = probeDelta;
if (passDelta >= maxDelta) {
break;
}
probeStep = getNextProbeStep(probeStep, probeSample.clearChance - targetChance);
probeDelta = Math.min(maxDelta, passDelta + probeStep);
if (probeDelta <= passDelta) {
break;
}
continue;
}
failDelta = probeDelta;
break;
}
if (failDelta <= maxDelta) {
while (failDelta - passDelta > 1) {
const mid = Math.floor((passDelta + failDelta) / 2);
const midSample = await evaluateDelta(mid);
if (!midSample) {
failDelta = mid;
continue;
}
if (isAtOrAboveTarget(midSample)) {
passDelta = mid;
} else {
failDelta = mid;
}
}
await evaluateDelta(passDelta);
await evaluateDelta(failDelta);
} else {
await evaluateDelta(passDelta);
if (passDelta < maxDelta) {
await evaluateDelta(maxDelta);
}
}
if (shouldValidateHighTarget && passDelta < maxDelta) {
passDelta = await sweepUpwardForHighestPass(passDelta);
await evaluateDelta(passDelta);
}
} else {
// Base is below target, probe downward quickly to find first pass, then binary-search the highest pass.
let passDelta = minDelta - 1;
let failDelta = 0;
let probeStep = getInitialProbeStep(base.clearChance - targetChance);
let probeDelta = Math.max(minDelta, failDelta - probeStep);
while (probeDelta >= minDelta) {
const probeSample = await evaluateDelta(probeDelta);
if (!probeSample) {
break;
}
if (isAtOrAboveTarget(probeSample)) {
passDelta = probeDelta;
break;
}
if (probeDelta === minDelta) {
break;
}
failDelta = probeDelta;
probeStep = getNextProbeStep(probeStep, probeSample.clearChance - targetChance);
probeDelta = Math.max(minDelta, failDelta - probeStep);
if (probeDelta >= failDelta) {
break;
}
}
if (passDelta >= minDelta) {
while (failDelta - passDelta > 1) {
const mid = Math.floor((passDelta + failDelta) / 2);
const midSample = await evaluateDelta(mid);
if (!midSample) {
failDelta = mid;
continue;
}
if (isAtOrAboveTarget(midSample)) {
passDelta = mid;
} else {
failDelta = mid;
}
}
await evaluateDelta(passDelta);
await evaluateDelta(failDelta);
if (shouldValidateHighTarget && passDelta < maxDelta) {
passDelta = await sweepUpwardForHighestPass(passDelta);
await evaluateDelta(passDelta);
}
} else {
await evaluateDelta(failDelta);
if (failDelta > minDelta) {
await evaluateDelta(minDelta);
}
}
}
return bestMatch;
}
async function runAutomationRecommendCalculation() {
if (automationEstimateRunning) {
return;
}
automationEstimateRunningMode = "";
const state = getGameState();
const panelInstance = getLabyrinthPanelInstance();
const table = getAutomationEstimateTable();
if (!state?.characterLabyrinth || !table) {
automationEstimateStatusText = t("automationListNotFound");
setAutomationEstimateControlStatus({
running: false,
message: automationEstimateStatusText,
});
return;
}
const entries = getAutomationRoomTypeEntries(panelInstance);
if (!entries.length) {
automationEstimateStatusText = t("noCalculableRooms");
setAutomationEstimateControlStatus({
running: false,
message: automationEstimateStatusText,
});
return;
}
automationEstimateColumnEnabled = false;
automationRecommendColumnEnabled = true;
renderAutomationEstimateTable(entries);
const initClientData = getInitClientData();
if (!initClientData) {
automationEstimateStatusText = t("missingClientData");
setAutomationEstimateControlStatus({
running: false,
message: automationEstimateStatusText,
});
return;
}
automationEstimateRunning = true;
automationEstimateRunningMode = "recommend";
setAutomationEstimateControlStatus({
running: true,
message: t("preparing"),
});
try {
const maxEnhancementByItem = buildMaxEnhancementByItem(state);
const recommendCombatTrials = normalizeCombatSimTrials(AUTOMATION_RECOMMEND_COMBAT_TRIALS);
const targetWinRate = saveAutomationTargetWinRateSetting(getSelectedAutomationTargetWinRate());
const targetChance = clamp01(targetWinRate / 100);
automationRecommendByRoomTypeKey.clear();
let computedCount = 0;
for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i];
automationEstimateStatusText = t("calculatingProgressFmt", { current: i + 1, total: entries.length });
setAutomationEstimateControlStatus({
running: true,
message: automationEstimateStatusText,
});
const roomLabel = getAutomationRoomLabel(entry, initClientData);
const effectiveLevel = resolveAutomationEffectiveLevel(panelInstance, entry, state, initClientData, {
includePersonalBuffs: false,
});
try {
const match = await findAutomationRecommendedLevelDelta({
state,
initClientData,
entry,
maxEnhancementByItem,
recommendCombatTrials,
effectiveLevel,
targetChance,
});
if (!match) {
automationRecommendByRoomTypeKey.set(entry.key, {
status: "error",
roomLabel,
isCombat: entry.isCombat,
message: t("calcFailed"),
});
} else {
const recommendedRoomLevel = Math.max(1, Math.floor(finiteNumber(match.roomLevel, 1)));
const recommendedRoom = createAutomationRoomFromEntry(entry, recommendedRoomLevel);
const detailPreview = entry.isCombat
? buildCombatPreview(state, recommendedRoom, initClientData, match.result)
: match.result?.skillingPreview || null;
automationRecommendByRoomTypeKey.set(entry.key, {
status: "ready",
roomLabel,
isCombat: entry.isCombat,
levelDelta: Math.floor(finiteNumber(match.levelDelta, 0)),
roomLevel: recommendedRoomLevel,
clearChance: clamp01(finiteNumber(match.clearChance, 0)),
detailPreview,
failureReason: String(match.result?.failureReason || ""),
});
computedCount += 1;
}
} catch (error) {
console.error("[Lab Clear Rate] automation recommend failed:", error);
automationRecommendByRoomTypeKey.set(entry.key, {
status: "error",
roomLabel,
isCombat: entry.isCombat,
message: t("calcFailed"),
});
}
renderAutomationEstimateTable(entries);
if ((i + 1) % 2 === 0) {
await nextFrame();
}
}
if (computedCount > 0) {
automationEstimateStatusText = t("calcDone");
} else {
automationEstimateStatusText = t("calcDone");
}
setAutomationEstimateControlStatus({
running: false,
message: automationEstimateStatusText,
});
} finally {
automationEstimateRunning = false;
automationEstimateRunningMode = "";
setAutomationEstimateControlStatus({
running: false,
message: automationEstimateStatusText,
});
}
}
async function runAutomationEstimateCalculation() {
if (automationEstimateRunning) {
return;
}
automationEstimateRunningMode = "";
const state = getGameState();
const panelInstance = getLabyrinthPanelInstance();
const table = getAutomationEstimateTable();
if (!state?.characterLabyrinth || !table) {
automationEstimateStatusText = t("automationListNotFound");
setAutomationEstimateControlStatus({
running: false,
message: automationEstimateStatusText,
});
return;
}
const entries = getAutomationRoomTypeEntries(panelInstance);
if (!entries.length) {
automationEstimateStatusText = t("noCalculableRooms");
setAutomationEstimateControlStatus({
running: false,
message: automationEstimateStatusText,
});
return;
}
automationRecommendColumnEnabled = false;
automationEstimateColumnEnabled = true;
automationRecommendByRoomTypeKey.clear();
automationRecommendSignatureByRoomTypeKey.clear();
renderAutomationEstimateTable(entries);
const initClientData = getInitClientData();
if (!initClientData) {
automationEstimateStatusText = t("missingClientData");
setAutomationEstimateControlStatus({
running: false,
message: automationEstimateStatusText,
});
return;
}
automationEstimateRunning = true;
automationEstimateRunningMode = "estimate";
setAutomationEstimateControlStatus({
running: true,
message: t("preparing"),
});
try {
const maxEnhancementByItem = buildMaxEnhancementByItem(state);
const combatTrials = saveAutomationCombatSimTrialsSetting(getSelectedAutomationCombatTrials());
const skipThresholdOverrides = buildAutomationSkipThresholdOverrideMap(entries);
automationEstimateByRoomTypeKey.clear();
let computedCount = 0;
let skippedCount = 0;
for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i];
automationEstimateStatusText = t("calculatingProgressFmt", { current: i + 1, total: entries.length });
setAutomationEstimateControlStatus({
running: true,
message: automationEstimateStatusText,
});
const roomLabel = getAutomationRoomLabel(entry, initClientData);
const skipThreshold = resolveAutomationSkipThreshold(panelInstance, entry.key, state, skipThresholdOverrides);
const effectiveLevel = resolveAutomationEffectiveLevel(panelInstance, entry, state, initClientData, {
includePersonalBuffs: false,
});
const roomLevel = computeAutomationTargetRoomLevel(effectiveLevel, skipThreshold);
if (roomLevel < 1) {
automationEstimateByRoomTypeKey.set(entry.key, {
status: "skipped",
roomLabel,
roomLevel,
isCombat: entry.isCombat,
message: t("skipRoom"),
});
skippedCount += 1;
renderAutomationEstimateTable(entries);
if ((i + 1) % 2 === 0) {
await nextFrame();
}
continue;
}
const room = createAutomationRoomFromEntry(entry, roomLevel);
try {
const result = entry.isCombat
? await computeCombatRoomClearChance(
state,
initClientData,
room,
maxEnhancementByItem,
null,
combatTrials,
`auto:${entry.key}:${roomLevel}`,
{ disableCache: true, includePersonalBuffs: false }
)
: computeRoomClearChance(state, initClientData, room, maxEnhancementByItem, {
includePersonalBuffs: false,
});
if (!result) {
automationEstimateByRoomTypeKey.set(entry.key, {
status: "error",
roomLabel,
roomLevel,
isCombat: entry.isCombat,
message: t("calcFailed"),
});
} else {
const clearChance = clamp01(finiteNumber(result.clearChance, 0));
const shownPercent = Math.round(clearChance * 100);
const expectedSeconds = finiteNumber(result.expectedSecondsPerClear, Infinity);
const etaText = formatEtaText(expectedSeconds, shownPercent);
const detailPreview = entry.isCombat
? buildCombatPreview(state, room, initClientData, result)
: result.skillingPreview || null;
automationEstimateByRoomTypeKey.set(entry.key, {
status: "ready",
roomLabel,
roomLevel,
isCombat: entry.isCombat,
clearChance,
expectedSeconds,
etaText,
failureReason: String(result?.combatMeta?.failureReason || ""),
combatTrials: entry.isCombat ? combatTrials : 0,
detailPreview,
});
computedCount += 1;
}
} catch (error) {
console.error("[Lab Clear Rate] automation estimate failed:", error);
automationEstimateByRoomTypeKey.set(entry.key, {
status: "error",
roomLabel,
roomLevel,
isCombat: entry.isCombat,
message: t("calcFailed"),
});
}
renderAutomationEstimateTable(entries);
if ((i + 1) % 2 === 0) {
await nextFrame();
}
}
if (computedCount > 0) {
automationEstimateStatusText = t("calcDone");
} else if (skippedCount > 0) {
automationEstimateStatusText = t("skippedRooms");
} else {
automationEstimateStatusText = t("calcDone");
}
setAutomationEstimateControlStatus({
running: false,
message: automationEstimateStatusText,
});
} finally {
automationEstimateRunning = false;
automationEstimateRunningMode = "";
setAutomationEstimateControlStatus({
running: false,
message: automationEstimateStatusText,
});
}
}
function refreshAutomationEstimatePanel(state) {
const table = getAutomationEstimateTable();
if (!table) {
clearAutomationWideLayout();
return;
}
const panelInstance = getLabyrinthPanelInstance();
const entries = getAutomationRoomTypeEntries(panelInstance);
if (entries.length > 0) {
const sharedSignature = buildAutomationEstimateSharedSignature(state);
const skipThresholdOverrides = buildAutomationSkipThresholdOverrideMap(entries);
const nextSignatures = new Map();
let invalidated = false;
const recommendSharedSignature = buildAutomationRecommendSharedSignature(state, getSelectedAutomationTargetWinRate());
const nextRecommendSignatures = new Map();
let recommendInvalidated = false;
for (const entry of entries) {
const key = String(entry?.key || "");
if (!key) {
continue;
}
const nextSignature = buildAutomationEstimateEntrySignature(
panelInstance,
entry,
state,
sharedSignature,
skipThresholdOverrides
);
nextSignatures.set(key, nextSignature);
const prevSignature = automationEstimateSignatureByRoomTypeKey.get(key);
if (!automationEstimateRunning && prevSignature && prevSignature !== nextSignature) {
if (automationEstimateByRoomTypeKey.has(key)) {
automationEstimateByRoomTypeKey.delete(key);
}
invalidated = true;
}
const nextRecommendSignature = buildAutomationRecommendEntrySignature(
panelInstance,
entry,
state,
recommendSharedSignature
);
nextRecommendSignatures.set(key, nextRecommendSignature);
const prevRecommendSignature = automationRecommendSignatureByRoomTypeKey.get(key);
if (!automationEstimateRunning && prevRecommendSignature && prevRecommendSignature !== nextRecommendSignature) {
if (automationRecommendByRoomTypeKey.has(key)) {
automationRecommendByRoomTypeKey.delete(key);
}
recommendInvalidated = true;
}
}
for (const key of Array.from(automationEstimateSignatureByRoomTypeKey.keys())) {
if (nextSignatures.has(key)) {
continue;
}
automationEstimateSignatureByRoomTypeKey.delete(key);
if (automationEstimateByRoomTypeKey.has(key)) {
automationEstimateByRoomTypeKey.delete(key);
invalidated = true;
}
}
for (const key of Array.from(automationRecommendSignatureByRoomTypeKey.keys())) {
if (nextRecommendSignatures.has(key)) {
continue;
}
automationRecommendSignatureByRoomTypeKey.delete(key);
if (automationRecommendByRoomTypeKey.has(key)) {
automationRecommendByRoomTypeKey.delete(key);
recommendInvalidated = true;
}
}
automationEstimateSignatureByRoomTypeKey = nextSignatures;
automationRecommendSignatureByRoomTypeKey = nextRecommendSignatures;
if (!automationEstimateRunning && (invalidated || recommendInvalidated)) {
automationEstimateStatusText = t("pending");
}
} else {
automationEstimateSignatureByRoomTypeKey.clear();
automationRecommendSignatureByRoomTypeKey.clear();
}
const section = getAutomationEstimateSection(table);
applyAutomationWideLayout(section, table);
ensureAutomationEstimateControl(section);
setAutomationEstimateControlStatus({
running: automationEstimateRunning,
message: automationEstimateStatusText,
});
renderAutomationEstimateTable(entries);
}
function getControlPanel() {
return document.getElementById(CONTROL_ID);
}
function getControlCombatTrialsInput() {
const root = getControlPanel();
if (!root) {
return null;
}
return root.querySelector(`.${CONTROL_CLASS}__settings-input`);
}
function getSelectedCombatSimTrials() {
const input = getControlCombatTrialsInput();
if (!input) {
return loadCombatSimTrialsSetting();
}
const normalized = normalizeCombatSimTrials(Number(input.value));
if (input.value !== String(normalized)) {
input.value = String(normalized);
}
return normalized;
}
function getSelectedLoanSealItemHrids() {
const selected = [];
for (const [itemHrid, enabled] of loanSealSelectionByItemHrid.entries()) {
if (enabled) {
selected.push(String(itemHrid || ""));
}
}
return selected;
}
function updateLoanCalcButtonState(panelRoot = null) {
const root = panelRoot || getControlPanel();
if (!root) {
return;
}
const loanCalcButton = root.querySelector(`.${CONTROL_LOAN_CALC_CLASS}`);
if (!loanCalcButton) {
return;
}
const checkedCount = Array.from(
root.querySelectorAll(`.${CONTROL_LOAN_LIST_CLASS} input[type="checkbox"]:not(:disabled):checked`)
).length;
loanCalcButton.disabled = checkedCount <= 0;
}
function resolveLoanPanelGridRect() {
const state = getGameState();
const roomRows = Array.isArray(state?.characterLabyrinth?.roomData) ? state.characterLabyrinth.roomData : [];
const totalCells = roomRows.flat().length;
let gridParent = totalCells > 0 ? findRoomGridParent(totalCells) : null;
if (!gridParent) {
const anyCell = document.querySelector('div[class*="LabyrinthPanel_roomCell"]');
gridParent = anyCell ? anyCell.parentElement : null;
}
if (!gridParent) {
return null;
}
return gridParent.getBoundingClientRect();
}
function positionLoanSealPanel(panelRoot = null) {
const root = panelRoot || getControlPanel();
if (!root) {
return;
}
const panel = root.querySelector(`.${CONTROL_LOAN_PANEL_CLASS}`);
const list = root.querySelector(`.${CONTROL_LOAN_LIST_CLASS}`);
if (!panel || panel.hasAttribute("hidden")) {
return;
}
const gridRect = resolveLoanPanelGridRect();
if (!gridRect) {
panel.style.position = "absolute";
panel.style.top = "0";
panel.style.left = "calc(100% + 8px)";
panel.style.right = "auto";
panel.style.maxHeight = "320px";
if (list) {
list.style.maxHeight = "230px";
}
return;
}
const viewportWidth = Math.max(0, window.innerWidth || document.documentElement.clientWidth || 0);
const viewportHeight = Math.max(0, window.innerHeight || document.documentElement.clientHeight || 0);
const margin = 8;
const gutter = 10;
const panelWidth = Math.max(240, Math.ceil(panel.getBoundingClientRect().width || 250));
let left = Math.round(gridRect.right + gutter);
if (left + panelWidth > viewportWidth - margin) {
left = Math.round(Math.max(margin, gridRect.left - panelWidth - gutter));
}
let top = Math.round(Math.max(margin, gridRect.top));
if (viewportHeight > 0) {
top = Math.min(top, Math.max(margin, viewportHeight - 140));
}
const panelMaxHeight = Math.max(180, Math.min(420, Math.floor(Math.max(240, viewportHeight - top - margin))));
panel.style.position = "fixed";
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
panel.style.right = "auto";
panel.style.maxHeight = `${panelMaxHeight}px`;
if (list) {
const listMaxHeight = Math.max(110, panelMaxHeight - 90);
list.style.maxHeight = `${listMaxHeight}px`;
}
}
function isLoanSealPanelOpen(root = null) {
const panelRoot = root || getControlPanel();
if (!panelRoot) {
return false;
}
const panel = panelRoot.querySelector(`.${CONTROL_LOAN_PANEL_CLASS}`);
return Boolean(panel) && !panel.hasAttribute("hidden");
}
function renderLoanSealPanel(root = null) {
const panelRoot = root || getControlPanel();
if (!panelRoot) {
return;
}
const panel = panelRoot.querySelector(`.${CONTROL_LOAN_PANEL_CLASS}`);
const list = panelRoot.querySelector(`.${CONTROL_LOAN_LIST_CLASS}`);
const loanCalcButton = panelRoot.querySelector(`.${CONTROL_LOAN_CALC_CLASS}`);
if (!panel || !list || !loanCalcButton) {
return;
}
const state = getGameState();
const initClientData = getInitClientData();
const catalog = buildLoanSealEffectCatalog(state, initClientData);
const catalogMap = new Map(catalog.map((effect) => [String(effect.itemHrid || ""), effect]));
if (catalog.length > 0) {
for (const itemHrid of Array.from(loanSealSelectionByItemHrid.keys())) {
if (!catalogMap.has(itemHrid)) {
loanSealSelectionByItemHrid.delete(itemHrid);
}
}
}
list.textContent = "";
if (!catalog.length) {
const empty = document.createElement("div");
empty.className = CONTROL_LOAN_ITEM_STATUS_CLASS;
empty.textContent = t("loanNoOptions");
list.appendChild(empty);
loanCalcButton.disabled = true;
positionLoanSealPanel(panelRoot);
return;
}
for (const effect of catalog) {
const itemHrid = String(effect.itemHrid || "");
const canSelect = !effect.isActive && effect.canApply;
if (!canSelect && loanSealSelectionByItemHrid.get(itemHrid)) {
loanSealSelectionByItemHrid.set(itemHrid, false);
}
const row = document.createElement("div");
row.className = CONTROL_LOAN_ITEM_CLASS;
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.disabled = !canSelect;
checkbox.checked = canSelect && loanSealSelectionByItemHrid.get(itemHrid) === true;
checkbox.addEventListener("change", (event) => {
const checked = Boolean(event?.target?.checked);
loanSealSelectionByItemHrid.set(itemHrid, checked);
updateLoanCalcButtonState(panelRoot);
});
row.addEventListener("click", (event) => {
const target = event?.target;
if (target && typeof target.closest === "function" && target.closest('input[type="checkbox"]')) {
return;
}
if (!canSelect || checkbox.disabled) {
return;
}
checkbox.checked = !checkbox.checked;
loanSealSelectionByItemHrid.set(itemHrid, checkbox.checked);
updateLoanCalcButtonState(panelRoot);
});
const nameNode = document.createElement("span");
nameNode.className = `${CONTROL_LOAN_ITEM_CLASS}__name`;
nameNode.textContent = effect.labelText;
const statusNode = document.createElement("span");
statusNode.className = CONTROL_LOAN_ITEM_STATUS_CLASS;
if (effect.isActive) {
statusNode.classList.add(`${CONTROL_LOAN_ITEM_STATUS_CLASS}--warn`);
statusNode.textContent = `${t("loanAlreadyActive")} x${effect.quantity}`;
if (effect.activeBuff?.expiresAt) {
statusNode.title = String(effect.activeBuff.expiresAt);
}
} else if (!effect.canApply) {
statusNode.classList.add(`${CONTROL_LOAN_ITEM_STATUS_CLASS}--warn`);
statusNode.textContent = `${t("loanCannotApply")} x${effect.quantity}`;
} else {
statusNode.textContent = `x${effect.quantity}`;
}
row.appendChild(checkbox);
row.appendChild(nameNode);
row.appendChild(statusNode);
list.appendChild(row);
}
updateLoanCalcButtonState(panelRoot);
positionLoanSealPanel(panelRoot);
}
function refreshLoanSealPanelOnOpen(root = null) {
const panelRoot = root || getControlPanel();
if (!panelRoot || !isLoanSealPanelOpen(panelRoot)) {
return;
}
renderLoanSealPanel(panelRoot);
window.setTimeout(() => {
if (!panelRoot.isConnected || !isLoanSealPanelOpen(panelRoot)) {
return;
}
renderLoanSealPanel(panelRoot);
}, 180);
}
function loadRoomLogPanelPosition() {
const fallback = {
left: Math.max(10, (window.innerWidth || 1280) - 360),
top: 92,
};
try {
const raw = localStorage.getItem(ROOM_LOG_POSITION_STORAGE_KEY);
if (!raw) {
return fallback;
}
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") {
return fallback;
}
const left = Math.floor(finiteNumber(parsed.left, fallback.left));
const top = Math.floor(finiteNumber(parsed.top, fallback.top));
return { left, top };
} catch (_error) {
return fallback;
}
}
function saveRoomLogPanelPosition(position) {
if (!position || typeof position !== "object") {
return;
}
const payload = {
left: Math.floor(finiteNumber(position.left, 0)),
top: Math.floor(finiteNumber(position.top, 0)),
};
try {
localStorage.setItem(ROOM_LOG_POSITION_STORAGE_KEY, JSON.stringify(payload));
} catch (_error) {
// Ignore storage errors.
}
}
function clampRoomLogPanelPosition(panel, position) {
const panelRect = panel ? panel.getBoundingClientRect() : null;
const panelWidth = Math.max(280, Math.ceil(panelRect?.width || 340));
const panelHeight = Math.max(180, Math.ceil(panelRect?.height || 320));
const viewportWidth = Math.max(320, window.innerWidth || document.documentElement.clientWidth || 1280);
const viewportHeight = Math.max(240, window.innerHeight || document.documentElement.clientHeight || 720);
const margin = 8;
const maxLeft = Math.max(margin, viewportWidth - panelWidth - margin);
const maxTop = Math.max(margin, viewportHeight - panelHeight - margin);
return {
left: Math.min(maxLeft, Math.max(margin, Math.floor(finiteNumber(position?.left, margin)))),
top: Math.min(maxTop, Math.max(margin, Math.floor(finiteNumber(position?.top, margin)))),
};
}
function applyRoomLogPanelPosition(panel, position, persist = false) {
if (!panel) {
return;
}
const clamped = clampRoomLogPanelPosition(panel, position);
panel.style.left = `${clamped.left}px`;
panel.style.top = `${clamped.top}px`;
panel.style.right = "auto";
if (persist) {
saveRoomLogPanelPosition(clamped);
}
}
function ensureRoomLogFloatingPanel() {
let root = getRoomLogFloatingPanel();
if (root) {
return root;
}
root = document.createElement("div");
root.id = ROOM_LOG_FLOAT_ID;
root.className = ROOM_LOG_FLOAT_CLASS;
root.setAttribute("hidden", "hidden");
root.innerHTML = `
<div class="${ROOM_LOG_FLOAT_HEADER_CLASS}">
<div class="${ROOM_LOG_FLOAT_TITLE_CLASS}">${t("roomLogTitleFmt", { count: ROOM_LOG_MAX_SESSIONS })}</div>
<div class="${ROOM_LOG_FLOAT_ACTIONS_CLASS}">
<button type="button" class="${ROOM_LOG_FLOAT_CLEAR_CLASS}">${t("roomLogClear")}</button>
<button type="button" class="${ROOM_LOG_FLOAT_CLOSE_CLASS}" title="${t("roomLogClose")}">×</button>
</div>
</div>
<div class="${CONTROL_LOG_PANEL_CLASS}">
<div class="${CONTROL_LOG_LIST_CLASS}"></div>
</div>
`;
document.body.appendChild(root);
applyRoomLogPanelPosition(root, loadRoomLogPanelPosition(), false);
const closeButton = root.querySelector(`.${ROOM_LOG_FLOAT_CLOSE_CLASS}`);
if (closeButton) {
closeButton.addEventListener("click", (event) => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
root.setAttribute("hidden", "hidden");
});
}
const clearButton = root.querySelector(`.${ROOM_LOG_FLOAT_CLEAR_CLASS}`);
if (clearButton) {
clearButton.addEventListener("click", (event) => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
clearRoomLogData();
});
}
const header = root.querySelector(`.${ROOM_LOG_FLOAT_HEADER_CLASS}`);
if (header) {
header.addEventListener("pointerdown", (event) => {
const target = event?.target;
if (target && typeof target.closest === "function" && target.closest(`.${ROOM_LOG_FLOAT_CLOSE_CLASS}`)) {
return;
}
if (!(event instanceof PointerEvent) || event.button !== 0) {
return;
}
event.preventDefault();
const initialRect = root.getBoundingClientRect();
const startX = event.clientX;
const startY = event.clientY;
const startLeft = initialRect.left;
const startTop = initialRect.top;
const onMove = (moveEvent) => {
const nextLeft = startLeft + (moveEvent.clientX - startX);
const nextTop = startTop + (moveEvent.clientY - startY);
applyRoomLogPanelPosition(root, { left: nextLeft, top: nextTop }, false);
};
const onUp = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
const finalRect = root.getBoundingClientRect();
applyRoomLogPanelPosition(root, { left: finalRect.left, top: finalRect.top }, true);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
});
}
if (!ensureRoomLogFloatingPanel.__resizeBound) {
ensureRoomLogFloatingPanel.__resizeBound = true;
window.addEventListener("resize", () => {
const panel = getRoomLogFloatingPanel();
if (!panel || panel.hasAttribute("hidden")) {
return;
}
const rect = panel.getBoundingClientRect();
applyRoomLogPanelPosition(panel, { left: rect.left, top: rect.top }, true);
});
}
return root;
}
function isRoomLogPanelOpen() {
const panel = getRoomLogFloatingPanel();
return Boolean(panel) && !panel.hasAttribute("hidden");
}
function setRoomLogPanelOpen(opening) {
let panel = getRoomLogFloatingPanel();
if (!panel && !opening) {
return;
}
if (!panel) {
panel = ensureRoomLogFloatingPanel();
}
if (!panel) {
return;
}
if (opening) {
panel.removeAttribute("hidden");
applyRoomLogPanelPosition(panel, loadRoomLogPanelPosition(), false);
renderRoomLogPanel();
return;
}
panel.setAttribute("hidden", "hidden");
}
function clearRoomLogData() {
roomLogSessions = [];
activeRoomLogSession = null;
persistRoomLogStorage();
refreshRoomLogPanelIfVisible();
}
function formatRoomLogDurationSeconds(session) {
if (!session || typeof session !== "object") {
return "--";
}
const startedAt = Math.max(0, Math.floor(finiteNumber(session.startedAt, 0)));
if (!startedAt) {
return "--";
}
const endedAtRaw = Math.max(0, Math.floor(finiteNumber(session.endedAt, 0)));
const endedAt = endedAtRaw > 0 ? endedAtRaw : Date.now();
const durationMs = Math.max(0, endedAt - startedAt);
const seconds = Math.max(0, Math.round(durationMs / 1000));
return t("roomLogDurationFmt", { seconds });
}
function normalizeRoomLogDisplaySkillName(rawName) {
const input = String(rawName || "").trim();
if (!input) {
return "--";
}
const cleaned = input.replace(/^(技能|skill|skilling|强化|enhancing)\s*[·\.::\-]?\s*/i, "").trim();
return cleaned || input;
}
function getRoomLogActionOutcomeClass(action) {
const outcome = String(action?.outcome || ROOM_LOG_ACTION_OUTCOME_UNKNOWN);
if (outcome === ROOM_LOG_ACTION_OUTCOME_DOUBLE) {
return `${CONTROL_LOG_ACTION_CLASS} ${CONTROL_LOG_ACTION_CLASS}--double`;
}
if (outcome === ROOM_LOG_ACTION_OUTCOME_SUCCESS) {
return `${CONTROL_LOG_ACTION_CLASS} ${CONTROL_LOG_ACTION_CLASS}--success`;
}
if (outcome === ROOM_LOG_ACTION_OUTCOME_FAIL) {
return `${CONTROL_LOG_ACTION_CLASS} ${CONTROL_LOG_ACTION_CLASS}--fail`;
}
return `${CONTROL_LOG_ACTION_CLASS} ${CONTROL_LOG_ACTION_CLASS}--unknown`;
}
function getRoomLogMetaText(session) {
if (session?.mode === "combat") {
return t("roomLogComingSoon");
}
const successText = formatRoomLogPercent(clamp01(finiteNumber(session?.successRate, 0)) * 100);
const doubleText = formatRoomLogPercent(clamp01(finiteNumber(session?.doubleChance, 0)) * 100);
const parts = [t("roomLogRateFmt", { success: successText, double: doubleText })];
if (session?.mode === "enhancing") {
parts.push(
t("roomLogEnhFmt", {
current: Math.max(0, Math.floor(finiteNumber(session?.currentEnhLevel, 0))),
target: Math.max(0, Math.floor(finiteNumber(session?.targetLevel, 0))),
})
);
} else {
parts.push(t("roomLogWorkFmt", { value: Math.round(Math.max(0, finiteNumber(session?.progressPerAction, 0))) }));
parts.push(
t("roomLogProgressFmt", {
current: formatRoomLogPercent(Math.max(0, finiteNumber(session?.currentProgressPct, 0))),
target: 100,
})
);
}
return parts.join(" | ");
}
function getRoomLogIncompleteText(session) {
const reasons = Array.isArray(session?.incompleteReasons) ? session.incompleteReasons : [];
if (reasons.includes("action_gap")) {
return `${t("roomLogIncomplete")} · ${t("roomLogActionGap")}`;
}
return t("roomLogIncomplete");
}
function appendRoomLogActionNodes(container, actions) {
if (!container) {
return;
}
const actionList = Array.isArray(actions) ? actions : [];
if (!actionList.length) {
const empty = document.createElement("span");
empty.className = `${CONTROL_LOG_ACTION_CLASS} ${CONTROL_LOG_ACTION_CLASS}--unknown`;
empty.textContent = "--";
container.appendChild(empty);
return;
}
for (let i = 0; i < actionList.length; i += 1) {
if (i > 0) {
const separator = document.createElement("span");
separator.className = `${CONTROL_LOG_ITEM_CLASS}__sep`;
separator.textContent = " - ";
container.appendChild(separator);
}
const action = actionList[i];
const node = document.createElement("span");
node.className = getRoomLogActionOutcomeClass(action);
node.textContent = String(action?.text || "?");
container.appendChild(node);
}
}
function renderRoomLogPanel() {
const panelRoot = ensureRoomLogFloatingPanel();
if (!panelRoot) {
return;
}
const title = panelRoot.querySelector(`.${ROOM_LOG_FLOAT_TITLE_CLASS}`);
const clearButton = panelRoot.querySelector(`.${ROOM_LOG_FLOAT_CLEAR_CLASS}`);
const closeButton = panelRoot.querySelector(`.${ROOM_LOG_FLOAT_CLOSE_CLASS}`);
const list = panelRoot.querySelector(`.${CONTROL_LOG_LIST_CLASS}`);
if (!title || !list) {
return;
}
title.textContent = t("roomLogTitleFmt", { count: ROOM_LOG_MAX_SESSIONS });
if (clearButton) {
clearButton.textContent = t("roomLogClear");
clearButton.title = t("roomLogClear");
}
if (closeButton) {
closeButton.title = t("roomLogClose");
}
list.textContent = "";
if (!Array.isArray(roomLogSessions) || roomLogSessions.length === 0) {
const empty = document.createElement("div");
empty.className = CONTROL_LOG_META_CLASS;
empty.textContent = t("roomLogEmpty");
list.appendChild(empty);
return;
}
const displaySessions = roomLogSessions.slice(0, ROOM_LOG_MAX_SESSIONS);
for (const session of displaySessions) {
const card = document.createElement("div");
card.className = CONTROL_LOG_ITEM_CLASS;
const header = document.createElement("div");
header.className = `${CONTROL_LOG_ITEM_CLASS}__header`;
const level = Math.max(0, Math.floor(finiteNumber(session?.recommendedLevel, 0)));
const displaySkillName = normalizeRoomLogDisplaySkillName(session?.skillName);
const nameText = level > 0 ? `${displaySkillName} Lv.${level}` : displaySkillName;
const titleText = document.createElement("span");
titleText.textContent = nameText;
const timeText = document.createElement("span");
timeText.className = `${CONTROL_LOG_ITEM_CLASS}__time`;
const durationText = formatRoomLogDurationSeconds(session);
if (session?.mode === "combat") {
timeText.textContent = durationText;
} else {
const expText = t("roomLogExpFmt", { value: formatRoomLogExperience(session?.totalExperience) });
timeText.textContent = `${durationText} | ${expText}`;
}
header.appendChild(titleText);
header.appendChild(timeText);
if (session?.incomplete || !session?.completed) {
const incomplete = document.createElement("span");
incomplete.className = CONTROL_LOG_INCOMPLETE_CLASS;
incomplete.textContent = getRoomLogIncompleteText(session);
header.appendChild(incomplete);
}
card.appendChild(header);
const meta = document.createElement("div");
meta.className = CONTROL_LOG_META_CLASS;
meta.textContent = getRoomLogMetaText(session);
card.appendChild(meta);
const actionRow = document.createElement("div");
actionRow.className = `${CONTROL_LOG_ITEM_CLASS}__actions`;
appendRoomLogActionNodes(actionRow, session?.actions);
card.appendChild(actionRow);
list.appendChild(card);
}
}
function createControlPanel() {
let root = getControlPanel();
if (root && root.getAttribute(CONTROL_SCHEMA_ATTR) !== CONTROL_SCHEMA_VERSION) {
root.remove();
root = null;
}
if (root) {
const settingsLabel = root.querySelector(`.${CONTROL_CLASS}__settings-label`);
if (settingsLabel) {
settingsLabel.textContent = t("combatTrials");
}
const logToggle = root.querySelector(`.${CONTROL_LOG_TOGGLE_CLASS}`);
if (logToggle) {
logToggle.textContent = t("roomLog");
}
const text = root.querySelector(`.${CONTROL_CLASS}__text`);
if (text && !String(text.textContent || "").trim()) {
text.textContent = t("pending");
}
bindControlPanelInteractions(root);
refreshRoomLogPanelIfVisible();
return root;
}
root = document.createElement("div");
root.id = CONTROL_ID;
root.className = CONTROL_CLASS;
root.setAttribute(CONTROL_SCHEMA_ATTR, CONTROL_SCHEMA_VERSION);
root.innerHTML = `
<button type="button" class="${CONTROL_CLASS}__button">${t("calcMaze")}</button>
<div class="${CONTROL_CLASS}__settings">
<span class="${CONTROL_CLASS}__settings-label">${t("combatTrials")}</span>
<input type="number" class="${CONTROL_CLASS}__settings-input" min="${MIN_COMBAT_SIM_TRIALS}" max="${MAX_COMBAT_SIM_TRIALS}" step="1" value="${DEFAULT_COMBAT_SIM_TRIALS}" inputmode="numeric" />
</div>
<button type="button" class="${CONTROL_LOG_TOGGLE_CLASS}">${t("roomLog")}</button>
<div class="${CONTROL_CLASS}__progress">
<div class="${CONTROL_CLASS}__track"><div class="${CONTROL_CLASS}__bar"></div></div>
<div class="${CONTROL_CLASS}__text">${t("pending")}</div>
</div>
`;
bindControlPanelInteractions(root);
refreshRoomLogPanelIfVisible();
return root;
}
function bindControlPanelInteractions(root) {
if (!root || root[CONTROL_BOUND_FLAG]) {
return;
}
const button = root.querySelector(`.${CONTROL_CLASS}__button`);
if (button) {
button.addEventListener(
"click",
(event) => {
if (event) {
event.preventDefault();
if (typeof event.stopImmediatePropagation === "function") {
event.stopImmediatePropagation();
}
event.stopPropagation();
}
runManualUpdate();
},
true
);
}
const loanToggle = root.querySelector(`.${CONTROL_LOAN_TOGGLE_CLASS}`);
if (loanToggle) {
loanToggle.addEventListener(
"click",
(event) => {
if (event) {
event.preventDefault();
if (typeof event.stopImmediatePropagation === "function") {
event.stopImmediatePropagation();
}
event.stopPropagation();
}
const panel = root.querySelector(`.${CONTROL_LOAN_PANEL_CLASS}`);
if (!panel) {
return;
}
const opening = panel.hasAttribute("hidden");
if (opening) {
setRoomLogPanelOpen(false);
panel.removeAttribute("hidden");
refreshLoanSealPanelOnOpen(root);
} else {
panel.setAttribute("hidden", "hidden");
}
},
true
);
}
const logToggle = root.querySelector(`.${CONTROL_LOG_TOGGLE_CLASS}`);
if (logToggle) {
logToggle.addEventListener(
"click",
(event) => {
if (event) {
event.preventDefault();
if (typeof event.stopImmediatePropagation === "function") {
event.stopImmediatePropagation();
}
event.stopPropagation();
}
const loanPanel = root.querySelector(`.${CONTROL_LOAN_PANEL_CLASS}`);
if (loanPanel) {
loanPanel.setAttribute("hidden", "hidden");
}
setRoomLogPanelOpen(!isRoomLogPanelOpen());
},
true
);
}
const loanCalcButton = root.querySelector(`.${CONTROL_LOAN_CALC_CLASS}`);
if (loanCalcButton) {
loanCalcButton.addEventListener(
"click",
(event) => {
if (event) {
event.preventDefault();
if (typeof event.stopImmediatePropagation === "function") {
event.stopImmediatePropagation();
}
event.stopPropagation();
}
const state = getGameState();
const initClientData = getInitClientData();
const catalog = buildLoanSealEffectCatalog(state, initClientData);
const selectedItemHrids = getSelectedLoanSealItemHrids();
const loanOptions = buildLoanSimulationOptions(state, catalog, selectedItemHrids);
if (!loanOptions) {
return;
}
runManualUpdate({
loanPersonalActionTypeBuffsDict: loanOptions.loanPersonalActionTypeBuffsDict,
selectedSealItemHrids: loanOptions.selectedSealItemHrids,
});
},
true
);
}
const trialsInput = root.querySelector(`.${CONTROL_CLASS}__settings-input`);
if (trialsInput) {
const initialTrials = loadCombatSimTrialsSetting();
trialsInput.value = String(initialTrials);
const syncTrials = (finalize = false) => {
const raw = String(trialsInput.value ?? "").trim();
if (!raw) {
if (finalize) {
const fallback = saveCombatSimTrialsSetting(DEFAULT_COMBAT_SIM_TRIALS);
trialsInput.value = String(fallback);
}
return;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed)) {
if (finalize) {
const fallback = saveCombatSimTrialsSetting(DEFAULT_COMBAT_SIM_TRIALS);
trialsInput.value = String(fallback);
}
return;
}
const normalized = saveCombatSimTrialsSetting(parsed);
if (finalize || /^\d+$/.test(raw)) {
trialsInput.value = String(normalized);
}
};
trialsInput.addEventListener(
"input",
(event) => {
if (event && typeof event.stopImmediatePropagation === "function") {
event.stopImmediatePropagation();
}
syncTrials(false);
},
true
);
trialsInput.addEventListener(
"change",
(event) => {
if (event && typeof event.stopImmediatePropagation === "function") {
event.stopImmediatePropagation();
}
syncTrials(true);
},
true
);
trialsInput.addEventListener(
"blur",
(event) => {
if (event && typeof event.stopImmediatePropagation === "function") {
event.stopImmediatePropagation();
}
syncTrials(true);
},
true
);
}
root[CONTROL_BOUND_FLAG] = true;
}
function findLongestPathControlHost(gridParent) {
if (!gridParent) {
return null;
}
const searchRoot = gridParent.closest('div[class*="LabyrinthPanel_activeRun"]') || gridParent.parentElement || gridParent;
let marker = null;
const xpathQueries = [
".//*[contains(normalize-space(text()), '最长路径')]",
".//*[contains(translate(normalize-space(text()), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'longest path')]",
];
for (const xpath of xpathQueries) {
const snapshot = document.evaluate(xpath, searchRoot, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
for (let i = 0; i < snapshot.snapshotLength; i += 1) {
const node = snapshot.snapshotItem(i);
if (!node) {
continue;
}
const text = String(node.textContent || "").trim();
if (!text || text.length > 96) {
continue;
}
marker = node;
break;
}
if (marker) {
break;
}
}
if (!marker) {
const nodes = Array.from(searchRoot.querySelectorAll("div, span, p, strong"));
marker = nodes.find((node) => {
if (node.childElementCount > 0) {
return false;
}
const text = String(node.textContent || "").replace(/\s+/g, "").toLowerCase();
return text.includes("最长路径") || text.includes("longestpath");
});
}
if (!marker) {
return null;
}
let host = marker.parentElement;
for (let depth = 0; depth < 4 && host; depth += 1) {
const display = getComputedStyle(host).display;
if (display.includes("flex")) {
return host;
}
host = host.parentElement;
}
return marker.parentElement;
}
function ensureControlPanel(gridParent) {
if (!gridParent) {
return getControlPanel();
}
const root = createControlPanel();
const inlineHost = findLongestPathControlHost(gridParent);
if (inlineHost) {
root.classList.add(`${CONTROL_CLASS}--inline`);
root.classList.remove(`${CONTROL_CLASS}--block`);
if (root.parentElement !== inlineHost) {
inlineHost.appendChild(root);
}
if (isLoanSealPanelOpen(root)) {
positionLoanSealPanel(root);
}
return root;
}
const gridContainer = gridParent.parentElement || gridParent;
const host = gridContainer.parentElement || gridContainer;
const anchor = gridContainer;
if (!host) {
return getControlPanel();
}
root.classList.add(`${CONTROL_CLASS}--block`);
root.classList.remove(`${CONTROL_CLASS}--inline`);
if (root.parentElement !== host || root.nextElementSibling !== anchor) {
host.insertBefore(root, anchor);
}
if (isLoanSealPanelOpen(root)) {
positionLoanSealPanel(root);
}
return root;
}
function setControlStatus(status) {
const root = getControlPanel();
if (!root) {
return;
}
const button = root.querySelector(`.${CONTROL_CLASS}__button`);
const loanToggle = root.querySelector(`.${CONTROL_LOAN_TOGGLE_CLASS}`);
const logToggle = root.querySelector(`.${CONTROL_LOG_TOGGLE_CLASS}`);
const loanCalcButton = root.querySelector(`.${CONTROL_LOAN_CALC_CLASS}`);
const trialsInput = root.querySelector(`.${CONTROL_CLASS}__settings-input`);
const bar = root.querySelector(`.${CONTROL_CLASS}__bar`);
const text = root.querySelector(`.${CONTROL_CLASS}__text`);
if (button && Object.prototype.hasOwnProperty.call(status, "running")) {
const running = Boolean(status.running);
button.disabled = running;
button.textContent = running ? t("calculating") : t("calcMaze");
if (loanToggle) {
loanToggle.disabled = running;
}
if (logToggle) {
logToggle.disabled = running;
}
if (loanCalcButton) {
if (running) {
loanCalcButton.disabled = true;
} else {
updateLoanCalcButtonState(root);
}
}
if (trialsInput) {
trialsInput.disabled = running;
}
}
if (bar && Object.prototype.hasOwnProperty.call(status, "ratio")) {
const ratio = clamp01(finiteNumber(status.ratio, 0));
bar.style.width = `${(ratio * 100).toFixed(1)}%`;
}
if (text && typeof status.message === "string") {
text.textContent = status.message;
}
}
function nextFrame() {
return new Promise((resolve) => {
requestAnimationFrame(() => resolve());
});
}
function createProgressTracker(totalUnits) {
const safeTotal = Math.max(1, Math.floor(finiteNumber(totalUnits, 1)));
let completed = 0;
function update(message) {
const ratio = clamp01(completed / safeTotal);
const percent = Math.round(ratio * 100);
const msg = message || t("progressFmt", { percent });
setControlStatus({
running: true,
ratio,
message: msg,
});
}
update(t("preparing"));
return {
add(units = 1, message) {
const value = Math.max(0, Math.floor(finiteNumber(units, 0)));
completed = Math.min(safeTotal, completed + value);
update(message);
},
finish(message) {
completed = safeTotal;
update(message || t("calcDone"));
},
};
}
async function fetchCombatWorkerChunkText(url) {
const response = await fetch(url, { cache: "force-cache", credentials: "omit" });
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}`);
}
return response.text();
}
function buildCombatSimulatorWorkerSource(vendorChunkSource, workerChunkSource) {
const runtimePrelude = `"use strict";
var __webpack_modules__ = {};
var __webpack_module_cache__ = {};
function __webpack_require__(moduleId) {
var cached = __webpack_module_cache__[moduleId];
if (cached !== undefined) {
return cached.exports;
}
var module = (__webpack_module_cache__[moduleId] = { exports: {} });
var factory = __webpack_modules__[moduleId];
if (!factory) {
throw new Error("Missing webpack module: " + moduleId);
}
factory(module, module.exports, __webpack_require__);
return module.exports;
}
__webpack_require__.o = function (obj, prop) {
return Object.prototype.hasOwnProperty.call(obj, prop);
};
__webpack_require__.d = function (exports, definition) {
for (var key in definition) {
if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
}
}
};
__webpack_require__.r = function (exports) {
if (typeof Symbol !== "undefined" && Symbol.toStringTag) {
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
}
Object.defineProperty(exports, "__esModule", { value: true });
};
var __webpack_chunk_array__ = [];
__webpack_chunk_array__.push = function (data) {
var moreModules = data[1] || {};
var runtime = data[2];
for (var moduleId in moreModules) {
if (__webpack_require__.o(moreModules, moduleId)) {
__webpack_modules__[moduleId] = moreModules[moduleId];
}
}
if (runtime) {
runtime(__webpack_require__);
}
};
self["webpackChunkmwicombatsimulator"] = __webpack_chunk_array__;`;
const runtimeSuffix = `
(function () {
var Player = __webpack_require__("./src/combatsimulator/player.js").default;
var CombatSimulator = __webpack_require__("./src/combatsimulator/combatSimulator.js").default;
var Labyrinth = __webpack_require__("./src/combatsimulator/labyrinth.js").default;
var ONE_SECOND_NS = 1e9;
var ONE_HOUR_NS = 3600 * ONE_SECOND_NS;
function clone(value) {
if (value === null || value === undefined) {
return value;
}
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value));
}
function finite(value, fallback) {
var n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function normalizeCrates(raw) {
if (!Array.isArray(raw)) {
return [];
}
var seen = Object.create(null);
var result = [];
for (var i = 0; i < raw.length; i += 1) {
var hrid = String(raw[i] || "");
if (!hrid || seen[hrid]) {
continue;
}
seen[hrid] = true;
result.push(hrid);
}
return result;
}
function normalizeBuffs(raw) {
if (!Array.isArray(raw)) {
return [];
}
var result = [];
var seen = Object.create(null);
for (var i = 0; i < raw.length; i += 1) {
var buff = raw[i];
var typeHrid = String((buff && buff.typeHrid) || "");
if (!typeHrid) {
continue;
}
var normalized = {
uniqueHrid: String((buff && buff.uniqueHrid) || ""),
typeHrid: typeHrid,
ratioBoost: finite(buff && buff.ratioBoost, 0),
ratioBoostLevelBonus: finite(buff && buff.ratioBoostLevelBonus, 0),
flatBoost: finite(buff && buff.flatBoost, 0),
flatBoostLevelBonus: finite(buff && buff.flatBoostLevelBonus, 0),
startTime: String((buff && buff.startTime) || "0001-01-01T00:00:00Z"),
duration: Math.max(0, finite(buff && buff.duration, 0)),
};
var dedupeKey = [
normalized.typeHrid,
normalized.ratioBoost,
normalized.ratioBoostLevelBonus,
normalized.flatBoost,
normalized.flatBoostLevelBonus,
normalized.duration,
].join("|");
if (seen[dedupeKey]) {
continue;
}
seen[dedupeKey] = true;
result.push(normalized);
}
return result;
}
function normalizePersonalBuffItemHrids(raw) {
if (!Array.isArray(raw)) {
return [];
}
var result = [];
var seen = Object.create(null);
for (var i = 0; i < raw.length; i += 1) {
var itemHrid = String(raw[i] || "");
if (!itemHrid || seen[itemHrid]) {
continue;
}
seen[itemHrid] = true;
result.push(itemHrid);
}
return result;
}
function buildPersonalBuffsFromItemHrids(raw) {
var itemHrids = normalizePersonalBuffItemHrids(raw);
var detailByItem = {
"/items/seal_of_combat_drop": { typeHrid: "/buff_types/combat_drop_quantity", flatBoost: 0.15, ratioBoost: 0 },
"/items/seal_of_attack_speed": { typeHrid: "/buff_types/attack_speed", flatBoost: 0, ratioBoost: 0.15 },
"/items/seal_of_cast_speed": { typeHrid: "/buff_types/cast_speed", flatBoost: 0.15, ratioBoost: 0 },
"/items/seal_of_damage": { typeHrid: "/buff_types/damage", flatBoost: 0, ratioBoost: 0.08 },
"/items/seal_of_critical_rate": { typeHrid: "/buff_types/critical_rate", flatBoost: 0.1, ratioBoost: 0 },
"/items/seal_of_wisdom": { typeHrid: "/buff_types/wisdom", flatBoost: 0.2, ratioBoost: 0 },
"/items/seal_of_rare_find": { typeHrid: "/buff_types/rare_find", flatBoost: 0.6, ratioBoost: 0 },
};
var buffs = [];
for (var i = 0; i < itemHrids.length; i += 1) {
var itemHrid = itemHrids[i];
var detail = detailByItem[itemHrid];
if (!detail || !detail.typeHrid) {
continue;
}
buffs.push({
uniqueHrid: "/buff_uniques/personal_" + itemHrid.split("/").pop(),
typeHrid: detail.typeHrid,
ratioBoost: finite(detail.ratioBoost, 0),
ratioBoostLevelBonus: 0,
flatBoost: finite(detail.flatBoost, 0),
flatBoostLevelBonus: 0,
startTime: "0001-01-01T00:00:00Z",
duration: 0,
});
}
return normalizeBuffs(buffs);
}
function ensureEncounterRecorderPatched() {
if (!CombatSimulator || !CombatSimulator.prototype) {
return;
}
if (CombatSimulator.prototype.__mwiLabEncounterRecorderPatched) {
return;
}
var originalStartNewEncounter = CombatSimulator.prototype.startNewEncounter;
var originalCheckEncounterEnd = CombatSimulator.prototype.checkEncounterEnd;
if (typeof originalStartNewEncounter !== "function" || typeof originalCheckEncounterEnd !== "function") {
return;
}
CombatSimulator.prototype.startNewEncounter = function () {
var result = originalStartNewEncounter.apply(this, arguments);
if (this && this.labyrinth) {
if (!this.__mwiLabEncounterStats || typeof this.__mwiLabEncounterStats !== "object") {
this.__mwiLabEncounterStats = { completed: [], currentStartNs: null };
}
if (!Array.isArray(this.__mwiLabEncounterStats.completed)) {
this.__mwiLabEncounterStats.completed = [];
}
this.__mwiLabEncounterStats.currentStartNs = Math.max(0, finite(this.simulationTime, 0));
}
return result;
};
CombatSimulator.prototype.checkEncounterEnd = function () {
var beforeEncounters = Math.max(0, Math.floor(finite(this && this.simResult ? this.simResult.encounters : 0, 0)));
var stats = this && this.__mwiLabEncounterStats && typeof this.__mwiLabEncounterStats === "object"
? this.__mwiLabEncounterStats
: null;
var startNs = stats ? finite(stats.currentStartNs, NaN) : NaN;
var ended = originalCheckEncounterEnd.apply(this, arguments);
if (ended && this && this.labyrinth) {
if (!stats || typeof stats !== "object") {
stats = { completed: [], currentStartNs: null };
this.__mwiLabEncounterStats = stats;
}
if (!Array.isArray(stats.completed)) {
stats.completed = [];
}
var afterEncounters = Math.max(0, Math.floor(finite(this && this.simResult ? this.simResult.encounters : 0, 0)));
var reason = "timeout";
if (afterEncounters > beforeEncounters) {
reason = "success";
} else if (this.allPlayersDead === true) {
reason = "death";
}
var endNs = Math.max(0, finite(this.simulationTime, 0));
var beginNs = Number.isFinite(startNs) ? Math.max(0, startNs) : endNs;
var durationNs = Math.max(0, endNs - beginNs);
stats.completed.push({
reason: reason,
startNs: beginNs,
endNs: endNs,
durationNs: durationNs,
});
stats.currentStartNs = null;
}
return ended;
};
CombatSimulator.prototype.__mwiLabEncounterRecorderPatched = true;
}
function getDeathCount(simResult, playerHrid) {
if (!simResult || typeof simResult !== "object") {
return 0;
}
var deaths = simResult.deaths;
if (!deaths || typeof deaths !== "object") {
return 0;
}
var directCount = Math.floor(finite(deaths[playerHrid], 0));
if (directCount > 0) {
return directCount;
}
var total = 0;
for (var key in deaths) {
if (!Object.prototype.hasOwnProperty.call(deaths, key)) {
continue;
}
if (String(key || "").indexOf("player") !== 0) {
continue;
}
total += Math.max(0, Math.floor(finite(deaths[key], 0)));
}
return total;
}
async function simulateSingleRoomBatch(params) {
var roomDurationSeconds = Math.max(1, Number(params.roomDurationSeconds) || 120);
var trials = Math.max(1, Math.floor(Number(params.trials) || 1));
var roomLevel = Math.max(1, Math.floor(Number(params.mazeDifficulty) || 100));
var monsterHrid = String(params.monsterHrid || "");
var playerDto = clone(params.playerDto);
if (!playerDto || !monsterHrid) {
throw new Error("Missing playerDto or monsterHrid");
}
var player = Player.createFromDTO(playerDto);
// Lab clear-rate calculation always assumes no active food/coffee consumables.
player.food = [null, null, null];
player.drinks = [null, null, null];
player.extraBuffs = normalizeBuffs(
buildPersonalBuffsFromItemHrids(params.playerPersonalBuffItemHrids).concat(params.labyrinthCombatBuffs || [])
);
var mazeCrates = normalizeCrates(params.mazeCrateItemHrids);
var labyrinth = new Labyrinth(monsterHrid, roomLevel, mazeCrates);
player.zoneBuffs = labyrinth && Array.isArray(labyrinth.buffs) ? labyrinth.buffs : [];
ensureEncounterRecorderPatched();
var simulator = new CombatSimulator([player], null, labyrinth, { enableHpMpVisualization: false });
var simulationLimitNs = Math.max(1, Math.floor(roomDurationSeconds * trials * ONE_SECOND_NS));
simulator.__mwiLabEncounterStats = { completed: [], currentStartNs: null };
var simResult = await simulator.simulate(simulationLimitNs);
var encounters = Math.max(0, Math.floor(finite(simResult && simResult.encounters, 0)));
var deaths = clone((simResult && simResult.deaths) || {});
var completedEncounters =
simulator &&
simulator.__mwiLabEncounterStats &&
Array.isArray(simulator.__mwiLabEncounterStats.completed)
? simulator.__mwiLabEncounterStats.completed.slice()
: [];
var completedTrialsRecorded = Math.max(0, completedEncounters.length);
var successes = encounters;
var playerHrid = String(player.hrid || "player1");
var failedByDeath = Math.max(0, getDeathCount(simResult, playerHrid));
var failedByTimeout = Math.max(0, completedTrialsRecorded - successes - failedByDeath);
var completedTrials = Math.max(0, successes + failedByDeath + failedByTimeout);
var simulatedNs = Math.max(0, finite(simResult && simResult.simulatedTime, simulationLimitNs));
var completedSpentNs = completedEncounters.reduce(function (sum, entry) {
return sum + Math.max(0, finite(entry && entry.durationNs, 0));
}, 0);
var lastEncounterFinishNs = Math.max(0, finite(simResult && simResult.lastEncounterFinishTime, 0));
var completedTimeNs = lastEncounterFinishNs > 0 ? lastEncounterFinishNs : completedSpentNs;
var totalSpentSeconds = Math.max(0, completedTimeNs / ONE_SECOND_NS);
var minElapsedSeconds = 0;
var maxElapsedSeconds = 0;
if (completedEncounters.length > 0) {
minElapsedSeconds = completedEncounters.reduce(function (minValue, entry) {
var seconds = Math.max(0, finite(entry && entry.durationNs, 0)) / ONE_SECOND_NS;
return Math.min(minValue, seconds);
}, Infinity);
maxElapsedSeconds = completedEncounters.reduce(function (maxValue, entry) {
var seconds = Math.max(0, finite(entry && entry.durationNs, 0)) / ONE_SECOND_NS;
return Math.max(maxValue, seconds);
}, 0);
if (!Number.isFinite(minElapsedSeconds)) {
minElapsedSeconds = 0;
}
}
var deathCount = failedByDeath;
var monsterKillCount = 0;
for (var deathKey in deaths) {
if (!Object.prototype.hasOwnProperty.call(deaths, deathKey)) {
continue;
}
if (String(deathKey || "").indexOf("player") === 0) {
continue;
}
monsterKillCount += Math.max(0, Math.floor(finite(deaths[deathKey], 0)));
}
var completedHours = completedTimeNs > 0 ? completedTimeNs / ONE_HOUR_NS : (simulatedNs > 0 ? simulatedNs / ONE_HOUR_NS : 0);
var uiHours = simulatedNs > 0 ? simulatedNs / ONE_HOUR_NS : completedHours;
var encountersPerHour = completedHours > 0 ? successes / completedHours : 0;
var monsterKillsPerHour = completedHours > 0 ? monsterKillCount / completedHours : 0;
var playerDeathsPerHour = completedHours > 0 ? failedByDeath / completedHours : 0;
var uiMonsterKillsPerHour = uiHours > 0 ? monsterKillCount / uiHours : 0;
var uiPlayerDeathsPerHour = uiHours > 0 ? failedByDeath / uiHours : 0;
var hadIncompleteFinalEncounter =
simulator &&
simulator.__mwiLabEncounterStats &&
Number.isFinite(finite(simulator.__mwiLabEncounterStats.currentStartNs, NaN));
return {
successes: successes,
trials: completedTrials,
totalSpentSeconds: totalSpentSeconds,
minElapsedSeconds: minElapsedSeconds,
maxElapsedSeconds: maxElapsedSeconds,
failedByTimeout: failedByTimeout,
failedByDeath: failedByDeath,
debug: {
requestedTrials: trials,
simulatedSecondsLimit: simulationLimitNs / ONE_SECOND_NS,
completedSeconds: completedTimeNs / ONE_SECOND_NS,
completedTrials: completedTrials,
completedTrialsRecorded: completedTrialsRecorded,
encounters: encounters,
monsterKillCount: monsterKillCount,
simulatedTime: simulatedNs,
lastEncounterFinishTime: lastEncounterFinishNs,
simulationLimitNs: simulationLimitNs,
encountersPerHour: encountersPerHour,
monsterKillsPerHour: monsterKillsPerHour,
playerDeathsPerHour: playerDeathsPerHour,
uiMonsterKillsPerHour: uiMonsterKillsPerHour,
uiPlayerDeathsPerHour: uiPlayerDeathsPerHour,
hadIncompleteFinalEncounter: hadIncompleteFinalEncounter,
deaths: deaths,
deathCount: deathCount,
},
};
}
self.onmessage = async function (event) {
var data = event && event.data ? event.data : {};
if (data.type !== "simulate_room") {
return;
}
var requestId = data.requestId;
try {
var trials = Math.max(1, Math.floor(Number(data.trials) || 1));
var run = await simulateSingleRoomBatch({
playerDto: data.playerDto,
playerPersonalBuffItemHrids: data.playerPersonalBuffItemHrids,
labyrinthCombatBuffs: data.labyrinthCombatBuffs,
monsterHrid: data.monsterHrid,
mazeDifficulty: data.mazeDifficulty,
roomDurationSeconds: data.roomDurationSeconds,
mazeCrateItemHrids: data.mazeCrateItemHrids,
trials: trials,
});
self.postMessage({
type: "room_progress",
requestId: requestId,
completed: trials,
trials: trials,
});
self.postMessage({
type: "room_result",
requestId: requestId,
successes: Math.max(0, Math.floor(finite(run && run.successes, 0))),
trials: Math.max(1, Math.floor(finite(run && run.trials, trials))),
totalSpentSeconds: Math.max(0, finite(run && run.totalSpentSeconds, 0)),
minElapsedSeconds: Math.max(0, finite(run && run.minElapsedSeconds, 0)),
maxElapsedSeconds: Math.max(0, finite(run && run.maxElapsedSeconds, 0)),
failedByTimeout: Math.max(0, Math.floor(finite(run && run.failedByTimeout, 0))),
failedByDeath: Math.max(0, Math.floor(finite(run && run.failedByDeath, 0))),
firstRunDebug: run && run.debug ? run.debug : null,
});
} catch (error) {
self.postMessage({
type: "room_error",
requestId: requestId,
error: error && error.message ? error.message : String(error),
});
}
};
})();`;
return [runtimePrelude, vendorChunkSource, workerChunkSource, runtimeSuffix].join("\n\n");
}
function resetCombatSimulatorWorker(error) {
if (combatSimulatorWorker) {
try {
combatSimulatorWorker.terminate();
} catch (_e) {}
}
combatSimulatorWorker = null;
if (combatSimulatorWorkerUrl) {
URL.revokeObjectURL(combatSimulatorWorkerUrl);
combatSimulatorWorkerUrl = "";
}
if (combatWorkerPendingRequests.size > 0) {
for (const pending of combatWorkerPendingRequests.values()) {
pending.reject(error || new Error("Combat worker reset"));
}
combatWorkerPendingRequests.clear();
}
}
async function ensureCombatSimulatorWorker() {
if (combatSimulatorWorker) {
return combatSimulatorWorker;
}
if (!combatWorkerScriptPromise) {
combatWorkerScriptPromise = Promise.all([
fetchCombatWorkerChunkText(COMBAT_SIM_VENDOR_CHUNK_URL),
fetchCombatWorkerChunkText(COMBAT_SIM_WORKER_CHUNK_URL),
]).then(([vendorChunkSource, workerChunkSource]) =>
buildCombatSimulatorWorkerSource(vendorChunkSource, workerChunkSource)
);
}
const workerSource = await combatWorkerScriptPromise;
combatSimulatorWorkerUrl = URL.createObjectURL(new Blob([workerSource], { type: "text/javascript" }));
const worker = new Worker(combatSimulatorWorkerUrl);
worker.addEventListener("message", (event) => {
const message = event?.data || {};
const pending = combatWorkerPendingRequests.get(message.requestId);
if (!pending) {
return;
}
if (message.type === "room_progress") {
const completed = Math.max(0, Math.floor(finiteNumber(message.completed, 0)));
const targetCompleted = Math.min(pending.trials, completed);
const delta = Math.max(0, targetCompleted - pending.completed);
pending.completed = targetCompleted;
if (delta > 0 && pending.progressTracker) {
pending.progressTracker.add(delta);
}
return;
}
if (message.type === "room_result") {
combatWorkerPendingRequests.delete(message.requestId);
if (pending.progressTracker && pending.completed < pending.trials) {
pending.progressTracker.add(pending.trials - pending.completed);
}
pending.resolve(message);
return;
}
combatWorkerPendingRequests.delete(message.requestId);
pending.reject(new Error(String(message.error || "Unknown combat worker error")));
});
worker.addEventListener("error", (event) => {
const reason = new Error(event?.message || "Combat simulator worker crashed");
resetCombatSimulatorWorker(reason);
combatWorkerScriptPromise = null;
});
combatSimulatorWorker = worker;
return combatSimulatorWorker;
}
async function simulateCombatRoomWithWorker(params, progressTracker) {
const worker = await ensureCombatSimulatorWorker();
const requestId = ++combatWorkerRequestId;
const trials = Math.max(1, Math.floor(finiteNumber(params?.trials, 1)));
return new Promise((resolve, reject) => {
combatWorkerPendingRequests.set(requestId, {
resolve,
reject,
completed: 0,
trials,
progressTracker,
});
try {
worker.postMessage({
type: "simulate_room",
requestId,
playerDto: deepCloneJson(params.playerDto),
playerPersonalBuffItemHrids: deepCloneJson(params.playerPersonalBuffItemHrids),
labyrinthCombatBuffs: deepCloneJson(params.labyrinthCombatBuffs),
monsterHrid: params.monsterHrid,
mazeDifficulty: params.mazeDifficulty,
roomDurationSeconds: params.roomDurationSeconds,
mazeCrateItemHrids: deepCloneJson(params.mazeCrateItemHrids),
trials,
});
} catch (error) {
combatWorkerPendingRequests.delete(requestId);
reject(error);
}
});
}
function findRoomGridParent(totalCells) {
const allCells = Array.from(document.querySelectorAll('div[class*="LabyrinthPanel_roomCell"]'));
if (!allCells.length) {
return null;
}
const parentCount = new Map();
for (const cell of allCells) {
const parent = cell.parentElement;
if (!parent) {
continue;
}
parentCount.set(parent, (parentCount.get(parent) || 0) + 1);
}
const candidates = [];
for (const [parent, count] of parentCount.entries()) {
if (count === totalCells) {
candidates.push(parent);
}
}
if (!candidates.length) {
return null;
}
return (
candidates.find((parent) => parent.querySelector('svg[aria-label="Exit"], use[href$="#flag"], use[xlink\\:href$="#flag"]')) ||
candidates[0]
);
}
function findRoomGridCells(totalCells) {
const preferredParent = findRoomGridParent(totalCells);
if (!preferredParent) {
return [];
}
return Array.from(preferredParent.children).filter((el) => String(el.className || "").includes("LabyrinthPanel_roomCell"));
}
function toRoomKey(x, y) {
return `${x},${y}`;
}
function parseRoomKey(roomKey) {
if (!roomKey) {
return null;
}
const [xRaw, yRaw] = String(roomKey).split(",");
const x = Number(xRaw);
const y = Number(yRaw);
if (!Number.isInteger(x) || !Number.isInteger(y)) {
return null;
}
return { x, y };
}
function getRoomKeyFromCell(cell) {
if (!cell) {
return "";
}
const x = Number(cell.getAttribute("data-room-x"));
const y = Number(cell.getAttribute("data-room-y"));
if (!Number.isInteger(x) || !Number.isInteger(y)) {
return "";
}
return toRoomKey(x, y);
}
function clearDisplayedRoomData() {
hidePreviewTooltip();
skillingPreviewByCell = new WeakMap();
combatPreviewByCell = new WeakMap();
latestRoomEstimateByRoomKey.clear();
const roomCells = Array.from(document.querySelectorAll('div[class*="LabyrinthPanel_roomCell"]'));
for (const cell of roomCells) {
removeBadge(cell);
}
}
function parseLabyrinthPathData(pathData) {
if (Array.isArray(pathData)) {
return pathData;
}
if (typeof pathData === "string" && pathData) {
try {
const parsed = JSON.parse(pathData);
return Array.isArray(parsed) ? parsed : [];
} catch (_error) {
return [];
}
}
return [];
}
function getCurrentPathRoomKey(state) {
const labyrinth = state?.characterLabyrinth;
if (!labyrinth) {
return "";
}
const path = parseLabyrinthPathData(labyrinth.pathData);
const point = path[0];
if (!point) {
return "";
}
const x = Number(point.x);
const y = Number(point.y);
if (!Number.isInteger(x) || !Number.isInteger(y)) {
return "";
}
return `${x},${y}`;
}
function getLiveActionRateHost() {
const actionName = document.querySelector("div[class*='Header_actionName']");
if (!actionName) {
return null;
}
return actionName.querySelector("div[class*='Header_displayName']") || actionName;
}
function clearLiveActionRateDisplay() {
const existing = document.getElementById(LIVE_ACTION_RATE_ID);
if (existing) {
existing.remove();
}
lastLiveActionRateToken = "";
}
function upsertLiveActionRateDisplay(text, title) {
const host = getLiveActionRateHost();
if (!host) {
return false;
}
let node = document.getElementById(LIVE_ACTION_RATE_ID);
if (!node || node.parentElement !== host) {
if (node) {
node.remove();
}
node = document.createElement("span");
node.id = LIVE_ACTION_RATE_ID;
node.className = LIVE_ACTION_RATE_CLASS;
host.appendChild(node);
}
node.textContent = text;
node.style.color = LIVE_ACTION_RATE_MWITOOLS_COLOR;
node.style.fontSize = LIVE_ACTION_RATE_MWITOOLS_FONT_SIZE;
node.title = title || "";
return true;
}
function buildLabyrinthLiveProgressToken(roomProgress) {
if (!roomProgress || typeof roomProgress !== "object") {
return "";
}
const isEnhancing = roomProgress.targetLevel !== null && roomProgress.targetLevel !== undefined;
if (isEnhancing) {
return [
"enh",
Math.floor(finiteNumber(roomProgress.actionCounter, 0)),
Math.floor(finiteNumber(roomProgress.currentEnhLevel, 0)),
Math.floor(finiteNumber(roomProgress.targetLevel, 0)),
Math.round(normalizeChance(roomProgress.successRate) * 10000),
Math.round(normalizeChance(roomProgress.doubleProgressChance) * 10000),
].join("|");
}
return [
"skill",
Math.floor(finiteNumber(roomProgress.actionCounter, 0)),
Math.round(clamp01(finiteNumber(roomProgress.currentProgress, 0)) * 10000),
Math.round(finiteNumber(roomProgress.currentWorkValue, 0) * 1000),
Math.round(finiteNumber(roomProgress.targetWorkValue, 0) * 1000),
Math.round(finiteNumber(roomProgress.progressPerAction, 0) * 1000),
Math.round(normalizeChance(roomProgress.successRate) * 10000),
Math.round(normalizeChance(roomProgress.doubleProgressChance) * 10000),
].join("|");
}
function computeLabyrinthLiveSkillingEstimate(roomProgress) {
if (!roomProgress || typeof roomProgress !== "object") {
return null;
}
const isEnhancing = roomProgress.targetLevel !== null && roomProgress.targetLevel !== undefined;
const successChance = normalizeChance(roomProgress.successRate);
const doubleChance = normalizeChance(roomProgress.doubleProgressChance);
const fallbackActionMs = (isEnhancing ? BASE_ENHANCING_ACTION_SECONDS : BASE_ACTION_SECONDS) * 1000;
const actionTimeMs = Math.max(1, finiteNumber(roomProgress.actionTimeMs, fallbackActionMs));
const totalAttempts = Math.max(0, Math.floor((ROOM_DURATION_SECONDS * 1000) / actionTimeMs));
const actionCounter = Math.max(0, Math.floor(finiteNumber(roomProgress.actionCounter, 0)));
const attemptsLeft = Math.max(0, totalAttempts - actionCounter);
if (isEnhancing) {
const targetLevel = Math.max(0, Math.floor(finiteNumber(roomProgress.targetLevel, 0)));
if (targetLevel <= 0) {
return null;
}
const currentLevel = Math.max(0, Math.floor(finiteNumber(roomProgress.currentEnhLevel, 0)));
const clearStats = computeEnhancingClearStats({
attempts: attemptsLeft,
successChance,
doubleChance,
targetLevel,
startLevel: currentLevel,
});
return {
isEnhancing: true,
successChance,
doubleChance,
actionCounter,
totalAttempts,
attemptsLeft,
targetLevel,
currentLevel,
clearChance: clamp01(finiteNumber(clearStats?.clearChance, 0)),
};
}
const progressPerSuccess = Math.max(0, finiteNumber(roomProgress.progressPerAction, 0));
const targetWorkValue = Math.max(0, finiteNumber(roomProgress.targetWorkValue, 0));
if (targetWorkValue <= 0) {
return null;
}
let currentWorkValue = Math.max(0, finiteNumber(roomProgress.currentWorkValue, 0));
if (targetWorkValue > 0 && currentWorkValue <= 0) {
const progressRatio = clamp01(finiteNumber(roomProgress.currentProgress, 0));
if (progressRatio > 0) {
currentWorkValue = targetWorkValue * progressRatio;
}
}
const remainingWorkValue = Math.max(0, targetWorkValue - currentWorkValue);
const clearStats = computeNonEnhancingClearStats({
attempts: attemptsLeft,
successChance,
doubleChance,
progressPerSuccess,
targetProgress: remainingWorkValue,
});
return {
isEnhancing: false,
successChance,
doubleChance,
actionCounter,
totalAttempts,
attemptsLeft,
targetWorkValue,
currentWorkValue,
progressPerSuccess,
clearChance: clamp01(finiteNumber(clearStats?.clearChance, 0)),
};
}
function formatLabyrinthLiveEstimateText(estimate) {
const chanceText = (clamp01(estimate?.clearChance) * 100).toFixed(1);
if (estimate?.isEnhancing) {
return t("liveEnhFmt", {
chance: chanceText,
current: estimate.currentLevel,
target: estimate.targetLevel,
left: estimate.attemptsLeft,
});
}
return t("liveBasicFmt", {
chance: chanceText,
left: estimate?.attemptsLeft || 0,
});
}
function formatLabyrinthLiveEstimateTitle(estimate) {
if (!estimate) {
return "";
}
const parts = [
t("liveSuccessFmt", { chance: (clamp01(estimate.successChance) * 100).toFixed(1) }),
t("liveDoubleFmt", { chance: (clamp01(estimate.doubleChance) * 100).toFixed(1) }),
t("liveActionsFmt", { current: estimate.actionCounter, total: estimate.totalAttempts }),
];
if (estimate.isEnhancing) {
parts.push(t("liveEnhTitleFmt", { current: estimate.currentLevel, target: estimate.targetLevel }));
} else {
parts.push(t("liveProgressFmt", {
current: Math.round(finiteNumber(estimate.currentWorkValue, 0)),
target: Math.round(finiteNumber(estimate.targetWorkValue, 0)),
}));
}
return parts.join(" | ");
}
function refreshLiveActionRateDisplay(state, roomProgressOverride = null) {
const roomProgress =
roomProgressOverride ||
(state && typeof state === "object" ? state.labyrinthRoomProgress || null : null);
const progressToken = buildLabyrinthLiveProgressToken(roomProgress);
if (!progressToken) {
clearLiveActionRateDisplay();
return;
}
const existing = document.getElementById(LIVE_ACTION_RATE_ID);
if (progressToken === lastLiveActionRateToken && existing) {
return;
}
const estimate = computeLabyrinthLiveSkillingEstimate(roomProgress);
if (!estimate) {
clearLiveActionRateDisplay();
return;
}
const text = formatLabyrinthLiveEstimateText(estimate);
const title = formatLabyrinthLiveEstimateTitle(estimate);
const rendered = upsertLiveActionRateDisplay(text, title);
if (rendered) {
lastLiveActionRateToken = progressToken;
}
}
function handleLiveActionRateWsMessage(rawData) {
if (typeof rawData !== "string") {
return;
}
if (!rawData.includes("\"labyrinth_room_progress\"")) {
return;
}
try {
const msg = JSON.parse(rawData);
if (!msg || msg.type !== "labyrinth_room_progress") {
return;
}
refreshLiveActionRateDisplay(null, msg);
handleRoomLogProgressMessage(msg);
} catch (_error) {
// Ignore malformed message payloads.
}
}
function installLiveActionRateWsHook() {
if (liveActionRateWsHookInstalled) {
return;
}
const descriptor = Object.getOwnPropertyDescriptor(MessageEvent.prototype, "data");
if (!descriptor || typeof descriptor.get !== "function") {
return;
}
const originalGetter = descriptor.get;
try {
Object.defineProperty(MessageEvent.prototype, "data", {
...descriptor,
get: function () {
const data = originalGetter.call(this);
if (this.currentTarget instanceof WebSocket && typeof data === "string") {
handleLiveActionRateWsMessage(data);
}
return data;
},
});
liveActionRateWsHookInstalled = true;
} catch (error) {
console.warn("[Lab Clear Rate] Failed to install live action WS hook:", error);
}
}
function isRoomChallengeRunning(state) {
if (!state?.characterLabyrinth) {
return false;
}
if (state.labyrinthRoomProgress) {
return true;
}
return Array.isArray(state.labyrinthBattleMonsters) && state.labyrinthBattleMonsters.length > 0;
}
function clearSingleRoomDisplay(state, roomKey) {
if (!roomKey) {
return;
}
const labyrinth = state?.characterLabyrinth;
if (!labyrinth || !Array.isArray(labyrinth.roomData) || labyrinth.roomData.length === 0) {
return;
}
const [xRaw, yRaw] = String(roomKey).split(",");
const x = Number(xRaw);
const y = Number(yRaw);
if (!Number.isInteger(x) || !Number.isInteger(y)) {
return;
}
const rowCount = labyrinth.roomData.length;
const colCount = Array.isArray(labyrinth.roomData[0]) ? labyrinth.roomData[0].length : 0;
if (!colCount || x < 0 || y < 0 || x >= colCount || y >= rowCount) {
return;
}
const roomCells = findRoomGridCells(rowCount * colCount);
if (!roomCells.length) {
return;
}
const cell = roomCells[y * colCount + x];
if (!cell) {
return;
}
removeBadge(cell);
clearCellSkillingPreview(cell);
clearCellCombatPreview(cell);
clearRoomEstimate(roomKey);
}
function getRoomAtKey(state, roomKey) {
if (!state?.characterLabyrinth || !Array.isArray(state.characterLabyrinth.roomData)) {
return null;
}
const point = parseRoomKey(roomKey);
if (!point) {
return null;
}
const row = state.characterLabyrinth.roomData[point.y];
if (!Array.isArray(row)) {
return null;
}
return row[point.x] || null;
}
function isRoomKeyStillCalculable(state, roomKey) {
return isCalculableRoom(getRoomAtKey(state, roomKey));
}
function pruneInvalidRoomDisplays(state) {
if (!state?.characterLabyrinth) {
return;
}
if (!(latestRoomEstimateByRoomKey instanceof Map) || latestRoomEstimateByRoomKey.size === 0) {
return;
}
for (const roomKey of Array.from(latestRoomEstimateByRoomKey.keys())) {
if (isRoomKeyStillCalculable(state, roomKey)) {
continue;
}
clearSingleRoomDisplay(state, roomKey);
}
}
function syncCompletedRoomCleanup(state) {
if (!state?.characterLabyrinth) {
lastProgressRoomKey = "";
wasRoomChallengeRunning = false;
lastObservedPathRoomKey = "";
return;
}
const currentKey = getCurrentPathRoomKey(state);
const hasProgress = isRoomChallengeRunning(state);
if (hasProgress) {
if (!wasRoomChallengeRunning) {
lastProgressRoomKey = currentKey || lastObservedPathRoomKey || lastProgressRoomKey;
} else if (!lastProgressRoomKey && currentKey) {
lastProgressRoomKey = currentKey;
}
wasRoomChallengeRunning = true;
if (currentKey) {
lastObservedPathRoomKey = currentKey;
}
return;
}
if (wasRoomChallengeRunning && lastProgressRoomKey) {
if (!isRoomKeyStillCalculable(state, lastProgressRoomKey)) {
clearSingleRoomDisplay(state, lastProgressRoomKey);
}
} else if (!wasRoomChallengeRunning && lastObservedPathRoomKey && currentKey && currentKey !== lastObservedPathRoomKey) {
// Fallback: if a very short room run did not get sampled while active, clear the previous head cell on path shift.
if (!isRoomKeyStillCalculable(state, lastObservedPathRoomKey)) {
clearSingleRoomDisplay(state, lastObservedPathRoomKey);
}
}
lastProgressRoomKey = "";
wasRoomChallengeRunning = false;
lastObservedPathRoomKey = currentKey || "";
}
function getLabyrinthDisplaySignature(state) {
const labyrinth = state?.characterLabyrinth;
if (!labyrinth) {
return "";
}
return hashString(
stableStringify({
startedAt: String(labyrinth.startedAt || ""),
currentFloor: Math.max(0, Math.floor(finiteNumber(labyrinth.currentFloor, 0))),
})
);
}
function getCalculableRoomSnapshot(state) {
const roomData = state?.characterLabyrinth?.roomData;
if (!Array.isArray(roomData) || roomData.length === 0) {
return {
count: 0,
signature: "",
};
}
const parts = [];
for (let y = 0; y < roomData.length; y += 1) {
const row = roomData[y];
if (!Array.isArray(row)) {
continue;
}
for (let x = 0; x < row.length; x += 1) {
const room = row[x];
const roomType = String(room?.roomType || "");
if (roomType !== LABYRINTH_COMBAT_ROOM_TYPE && roomType !== LABYRINTH_SKILLING_ROOM_TYPE) {
continue;
}
parts.push(
`${x},${y}|${roomType}|${String(room?.monsterHrid || "")}|${String(room?.skillHrid || "")}|${Math.max(
0,
Math.floor(finiteNumber(room?.recommendedLevel, 0))
)}`
);
}
}
parts.sort();
return {
count: parts.length,
signature: parts.length > 0 ? hashString(parts.join("||")) : "",
entries: parts,
};
}
function resetAutoRecalcState() {
autoRecalcArmed = false;
autoRecalcLabyrinthSignature = "";
lastCalculatedCalculableRoomSignature = "";
lastCalculatedCalculableRoomCount = 0;
lastCalculatedCalculableRoomEntries = new Set();
pendingAutoRecalcRoomKeys = new Set();
if (autoRecalcTimerId) {
window.clearTimeout(autoRecalcTimerId);
autoRecalcTimerId = 0;
}
}
function updateAutoRecalcBaseline(state) {
const snapshot = getCalculableRoomSnapshot(state);
autoRecalcArmed = true;
autoRecalcLabyrinthSignature = getLabyrinthDisplaySignature(state);
lastCalculatedCalculableRoomSignature = snapshot.signature;
lastCalculatedCalculableRoomCount = snapshot.count;
lastCalculatedCalculableRoomEntries = new Set(snapshot.entries || []);
}
function getNewCalculableRoomKeys(state) {
if (!autoRecalcArmed || manualUpdateRunning) {
return [];
}
if (!state?.characterLabyrinth || !Array.isArray(state.characterLabyrinth.roomData)) {
return [];
}
const signature = getLabyrinthDisplaySignature(state);
if (!signature) {
return [];
}
if (autoRecalcLabyrinthSignature && signature !== autoRecalcLabyrinthSignature) {
resetAutoRecalcState();
return [];
}
const snapshot = getCalculableRoomSnapshot(state);
if (!Array.isArray(snapshot.entries) || snapshot.entries.length === 0) {
return [];
}
const addedRoomKeys = [];
const seenRoomKeys = new Set();
for (const entry of snapshot.entries) {
if (lastCalculatedCalculableRoomEntries.has(entry)) {
continue;
}
const roomKey = String(entry).split("|")[0] || "";
if (!roomKey || seenRoomKeys.has(roomKey)) {
continue;
}
seenRoomKeys.add(roomKey);
addedRoomKeys.push(roomKey);
}
return addedRoomKeys;
}
function scheduleAutoRecalcIfNeeded(state) {
const roomKeys = getNewCalculableRoomKeys(state);
if (!roomKeys.length) {
return;
}
for (const roomKey of roomKeys) {
pendingAutoRecalcRoomKeys.add(roomKey);
}
if (autoRecalcTimerId) {
return;
}
autoRecalcTimerId = window.setTimeout(() => {
autoRecalcTimerId = 0;
const latestState = getGameState();
const latestRoomKeys = getNewCalculableRoomKeys(latestState);
for (const roomKey of latestRoomKeys) {
pendingAutoRecalcRoomKeys.add(roomKey);
}
const keysToRecalc = Array.from(pendingAutoRecalcRoomKeys);
pendingAutoRecalcRoomKeys.clear();
if (!keysToRecalc.length) {
return;
}
runManualUpdate({ trigger: "auto-new-tiles", roomKeys: keysToRecalc });
}, AUTO_RECALC_DEBOUNCE_MS);
}
function markLabyrinthTransition(nextSignature = "") {
const normalizedSignature = String(nextSignature || "");
clearDisplayedRoomData();
lastProgressRoomKey = "";
wasRoomChallengeRunning = false;
lastObservedPathRoomKey = "";
lastLabyrinthDisplaySignature = normalizedSignature;
activeLoanSimulationOptions = null;
resetAutoRecalcState();
}
function markLabyrinthTransitionPreserveTooltip(nextSignature = "") {
const normalizedSignature = String(nextSignature || "");
skillingPreviewByCell = new WeakMap();
combatPreviewByCell = new WeakMap();
latestRoomEstimateByRoomKey.clear();
const roomCells = Array.from(document.querySelectorAll('div[class*="LabyrinthPanel_roomCell"]'));
for (const cell of roomCells) {
removeBadge(cell);
}
lastProgressRoomKey = "";
wasRoomChallengeRunning = false;
lastObservedPathRoomKey = "";
lastLabyrinthDisplaySignature = normalizedSignature;
activeLoanSimulationOptions = null;
resetAutoRecalcState();
}
function removeBadge(cell) {
const badge = cell.querySelector(`.${BADGE_CLASS}`);
if (badge) {
badge.remove();
}
}
function formatEtaText(expectedSeconds, shownPercent) {
if (shownPercent === 0 || expectedSeconds === Infinity) {
return ETA_INFINITE_TEXT;
}
if (!Number.isFinite(expectedSeconds)) {
return "--";
}
const roundedSeconds = Math.max(0, Math.ceil(expectedSeconds));
if (roundedSeconds > 999) {
return "999+";
}
return `${roundedSeconds}s`;
}
function upsertBadge(cell, probability, expectedSeconds, details) {
let badge = cell.querySelector(`.${BADGE_CLASS}`);
if (!badge) {
badge = document.createElement("div");
badge.className = BADGE_CLASS;
cell.style.position = "relative";
cell.appendChild(badge);
}
let chanceNode = badge.querySelector(`.${BADGE_CLASS}__chance`);
if (!chanceNode) {
chanceNode = document.createElement("div");
chanceNode.className = `${BADGE_CLASS}__chance`;
badge.appendChild(chanceNode);
}
let etaNode = badge.querySelector(`.${BADGE_CLASS}__eta`);
if (!etaNode) {
etaNode = document.createElement("div");
etaNode.className = `${BADGE_CLASS}__eta`;
badge.appendChild(etaNode);
}
const shownPercent = Math.round(probability * 100);
chanceNode.textContent = `${shownPercent}%`;
etaNode.textContent = formatEtaText(expectedSeconds, shownPercent);
badge.style.backgroundColor = getBadgeColor(probability);
badge.title = details;
}
function clearRoomEstimate(roomKey) {
if (!roomKey) {
return;
}
latestRoomEstimateByRoomKey.delete(roomKey);
}
function updateRoomEstimate(roomKey, result) {
if (!roomKey || !result) {
return;
}
const clearChance = clamp01(finiteNumber(result.clearChance, 0));
const expectedSeconds = finiteNumber(result.expectedSecondsPerClear, Infinity);
const shownPercent = Math.round(clearChance * 100);
const etaText = formatEtaText(expectedSeconds, shownPercent);
latestRoomEstimateByRoomKey.set(roomKey, {
clearChance,
expectedSeconds,
shownPercent,
etaText,
isImpassable: etaText === ETA_INFINITE_TEXT,
});
}
function computeRoomClearChance(state, initClientData, room, maxEnhancementByItem, options = {}) {
if (!room || room.roomType !== LABYRINTH_SKILLING_ROOM_TYPE || !room.skillHrid) {
return null;
}
const skillId = skillHridToSkillId(room.skillHrid);
if (!skillId) {
return null;
}
const actionTypeHrid = skillIdToActionTypeHrid(skillId);
const isEnhancingRoom = skillId === "enhancing";
const crateSelection = getLabyrinthCrateSelection(state);
const labyrinthUpgradeLevels = resolveLabyrinthUpgradeLevels(options);
const teaCrateItemHrid = String(crateSelection?.teaCrateItemHrid || "");
const crateBuffs = getCrateBuffs(initClientData, teaCrateItemHrid);
const labyrinthUpgradeMetrics = buildLabyrinthUpgradeSkillingMetrics(labyrinthUpgradeLevels);
const labyrinthExperienceBonus = getLabyrinthUpgradeExperienceBonus(labyrinthUpgradeLevels);
const includePersonalBuffs = false;
const { equipmentMetrics, globalMetrics } = getSkillingGlobalMetrics(state, skillId, actionTypeHrid, {
includePersonalBuffs,
});
const fallbackMetrics = createEmptySkillingMetrics();
addSkillingMetrics(fallbackMetrics, equipmentMetrics);
const roomLoadout = getSkillingRoomLoadoutMetrics(
state,
initClientData,
room,
skillId,
actionTypeHrid,
maxEnhancementByItem,
fallbackMetrics
);
const crateMetrics = getSkillingBuffMetrics(skillId, crateBuffs);
const loanMetrics = createEmptySkillingMetrics();
const combinedMetrics = createEmptySkillingMetrics();
addSkillingMetrics(combinedMetrics, globalMetrics);
addSkillingMetrics(combinedMetrics, roomLoadout.metrics);
addSkillingMetrics(combinedMetrics, crateMetrics);
addSkillingMetrics(combinedMetrics, labyrinthUpgradeMetrics);
addSkillingMetrics(combinedMetrics, loanMetrics);
const baseSkillLevel = getSkillLevel(state.characterSkillMap, room.skillHrid);
const skillLevelBonus = combinedMetrics.skillLevelBonus;
const efficiencyBonus = combinedMetrics.efficiencyBonus;
const actionSpeedBonus = combinedMetrics.actionSpeedBonus;
const successBonus = combinedMetrics.successBonus;
const experienceBonusDetail = computeSkillingExperienceBonusForRoom(
state,
skillId,
actionTypeHrid,
roomLoadout.metrics,
crateMetrics,
loanMetrics,
includePersonalBuffs,
labyrinthExperienceBonus
);
const experienceBonus = finiteNumber(experienceBonusDetail?.totalBonus, 0);
const crateDoubleProgressBonus = combinedMetrics.crateDoubleProgressBonus;
const gatheringBonus = combinedMetrics.gatheringBonus;
const effectiveSkillLevel = baseSkillLevel + skillLevelBonus;
const roomLevel = Number(room.recommendedLevel || 0);
const levelDelta = effectiveSkillLevel - roomLevel;
const levelBonus = levelDelta >= 0 ? levelDelta * 0.005 : levelDelta * 0.01;
const successChance = clamp01(0.8 * (1 + levelBonus + successBonus));
const doubleChance = clamp01(crateDoubleProgressBonus + gatheringBonus);
const rewardPreview = createRoomRewardPreview(state, room);
const baseActionSeconds = isEnhancingRoom ? BASE_ENHANCING_ACTION_SECONDS : BASE_ACTION_SECONDS;
const actionSeconds = baseActionSeconds / Math.max(0.05, 1 + actionSpeedBonus);
const attempts = Math.max(1, Math.floor(ROOM_DURATION_SECONDS / actionSeconds));
const baseExperiencePerRoom = Math.max(0, roomLevel * 50);
const experienceMultiplier = Math.max(0, finiteNumber(experienceBonusDetail?.multiplier, 1));
const experiencePerRoom = Math.max(0, baseExperiencePerRoom * experienceMultiplier);
const experiencePerAction = attempts > 0 ? Math.max(0, experiencePerRoom / attempts) : experiencePerRoom;
const speedDeltaForOneMoreAttempt = computeActionSpeedDeltaForOneMoreAttempt(baseActionSeconds, actionSpeedBonus, attempts);
let progressPerSuccess = null;
let targetProgress = null;
let targetEnhLevel = null;
let clearStats = null;
let preview = null;
if (isEnhancingRoom) {
targetEnhLevel = getEnhancingTargetLevelByRoomLevel(roomLevel);
clearStats = computeEnhancingClearStats({
attempts,
successChance,
doubleChance,
targetLevel: targetEnhLevel,
startLevel: 0,
});
preview = {
type: "enhancing",
targetLevel: targetEnhLevel,
successChance,
doubleChance,
attempts,
actionSeconds,
speedDeltaForOneMoreAttempt,
experiencePerAction,
experiencePerRoom,
experiencePerHour: 0,
rewardPreview,
};
} else {
progressPerSuccess = Math.max(0, effectiveSkillLevel * (1 + efficiencyBonus));
targetProgress = roomLevel * 10;
const neededUnits = progressPerSuccess > 0 ? Math.ceil(targetProgress / progressPerSuccess - 1e-9) : 0;
const efficiencyDeltaForOneLessProgressUnit = computeEfficiencyDeltaForOneLessProgressUnit(
targetProgress,
effectiveSkillLevel,
efficiencyBonus,
neededUnits
);
clearStats = computeNonEnhancingClearStats({
attempts,
successChance,
doubleChance,
progressPerSuccess,
targetProgress,
});
preview = {
type: "skilling",
workPower: progressPerSuccess,
successChance,
doubleChance,
attempts,
actionSeconds,
efficiencyDeltaForOneLessProgressUnit,
speedDeltaForOneMoreAttempt,
experiencePerAction,
experiencePerRoom,
experiencePerHour: 0,
rewardPreview,
};
}
const clearChance = clearStats.clearChance;
const expectedSecondsOnSuccessfulRun = Number.isFinite(clearStats.expectedAttemptsOnClear)
? clearStats.expectedAttemptsOnClear * actionSeconds
: null;
const expectedSecondsPerClear =
clearChance > 0 && Number.isFinite(expectedSecondsOnSuccessfulRun)
? (clearChance * expectedSecondsOnSuccessfulRun + (1 - clearChance) * ROOM_DURATION_SECONDS) / clearChance
: Infinity;
const expectedSecondsPerClearForExpPerHour =
clearChance > 0 && Number.isFinite(expectedSecondsOnSuccessfulRun)
? (clearChance * (expectedSecondsOnSuccessfulRun + ROOM_ENTRY_SECONDS) +
(1 - clearChance) * (ROOM_DURATION_SECONDS + ROOM_ENTRY_SECONDS)) /
clearChance
: Infinity;
const experiencePerHour =
Number.isFinite(expectedSecondsPerClearForExpPerHour) && expectedSecondsPerClearForExpPerHour > 0
? Math.max(0, experiencePerRoom * (3600 / expectedSecondsPerClearForExpPerHour))
: 0;
if (preview && typeof preview === "object") {
preview.experiencePerHour = experiencePerHour;
}
return {
clearChance,
expectedSecondsPerClear,
skillingPreview: preview,
debug: [
`skill=${skillId}`,
`loadout=${roomLoadout.loadoutInfo.loadout?.name || roomLoadout.loadoutInfo.loadoutId || "current"}`,
`mode=${roomLoadout.mode}`,
`base=${baseSkillLevel.toFixed(2)}`,
`skill+${skillLevelBonus.toFixed(2)}`,
`globalSkill+${globalMetrics.skillLevelBonus.toFixed(2)}`,
`lvlBonus=${(levelBonus * 100).toFixed(1)}%`,
`eff+${(efficiencyBonus * 100).toFixed(1)}%`,
`globalEff+${(globalMetrics.efficiencyBonus * 100).toFixed(1)}%`,
labyrinthUpgradeMetrics.actionSpeedBonus > 0 ? `labAct+${(labyrinthUpgradeMetrics.actionSpeedBonus * 100).toFixed(1)}%` : "",
labyrinthUpgradeMetrics.efficiencyBonus > 0 ? `labEff+${(labyrinthUpgradeMetrics.efficiencyBonus * 100).toFixed(1)}%` : "",
labyrinthUpgradeMetrics.successBonus > 0 ? `labSuccess+${(labyrinthUpgradeMetrics.successBonus * 100).toFixed(1)}%` : "",
labyrinthUpgradeMetrics.crateDoubleProgressBonus > 0
? `labDouble+${(labyrinthUpgradeMetrics.crateDoubleProgressBonus * 100).toFixed(1)}%`
: "",
`exp+${(experienceBonus * 100).toFixed(1)}%`,
`exp(wisdom)=${(finiteNumber(experienceBonusDetail?.genericBonus, 0) * 100).toFixed(1)}%`,
`exp(skilling)=${(finiteNumber(experienceBonusDetail?.skillingBonus, 0) * 100).toFixed(1)}%`,
`exp(skill)=${(finiteNumber(experienceBonusDetail?.skillBonus, 0) * 100).toFixed(1)}%`,
labyrinthExperienceBonus > 0 ? `labExp+${(labyrinthExperienceBonus * 100).toFixed(1)}%` : "",
`success=${(successChance * 100).toFixed(1)}%`,
`globalSuccess+${(globalMetrics.successBonus * 100).toFixed(1)}%`,
`double=${(doubleChance * 100).toFixed(1)}%`,
hasMeaningfulSkillingMetrics(loanMetrics) ? "loan=1" : "",
isEnhancingRoom ? `target=+${targetEnhLevel}` : `target=${Math.round(targetProgress)}`,
isEnhancingRoom ? "" : `work=${progressPerSuccess.toFixed(2)}`,
`attempts=${attempts}`,
`eta=${formatEtaText(expectedSecondsPerClear, Math.round(clearChance * 100))}`,
]
.filter(Boolean)
.join(" | "),
};
}
function toPascalCase(text) {
return String(text || "")
.split("_")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
}
function getLabyrinthRoomLoadoutSettingKey(room) {
if (!room) {
return "";
}
if (room.roomType === LABYRINTH_SKILLING_ROOM_TYPE && room.skillHrid) {
const suffix = toPascalCase(skillHridToSkillId(room.skillHrid));
return suffix ? `labyrinthLoadout${suffix}` : "";
}
if (room.roomType === LABYRINTH_COMBAT_ROOM_TYPE && room.monsterHrid) {
const monsterTail = String(room.monsterHrid).split("/").pop() || "";
const suffix = toPascalCase(monsterTail);
return suffix ? `labyrinthLoadout${suffix}` : "";
}
return "";
}
function getLoadoutById(loadoutDict, loadoutId) {
if (!loadoutDict || !Number.isFinite(Number(loadoutId))) {
return null;
}
const numericId = Number(loadoutId);
if (loadoutDict[numericId]) {
return loadoutDict[numericId];
}
if (loadoutDict[String(numericId)]) {
return loadoutDict[String(numericId)];
}
for (const value of Object.values(loadoutDict)) {
if (Number(value?.id) === numericId) {
return value;
}
}
return null;
}
function getLabyrinthRoomLoadout(state, room) {
const settingKey = getLabyrinthRoomLoadoutSettingKey(room);
const loadoutId = Number(state?.characterSetting?.[settingKey] || 0);
const loadout = getLoadoutById(state?.characterLoadoutDict, loadoutId);
return {
settingKey,
loadoutId,
loadout,
};
}
function isCombatLoadout(loadout) {
return Boolean(loadout && loadout.actionTypeHrid === "/action_types/combat");
}
function listCombatLoadouts(state) {
const loadouts = [];
for (const value of Object.values(state?.characterLoadoutDict || {})) {
if (isCombatLoadout(value)) {
loadouts.push(value);
}
}
loadouts.sort((a, b) => Number(a?.id || 0) - Number(b?.id || 0));
return loadouts;
}
function getMostUsedCombatLoadoutIdOnCurrentLabyrinth(state) {
const roomData = state?.characterLabyrinth?.roomData;
if (!Array.isArray(roomData) || roomData.length === 0) {
return 0;
}
const countByLoadoutId = new Map();
for (const row of roomData) {
if (!Array.isArray(row)) {
continue;
}
for (const room of row) {
if (!room || room.roomType !== LABYRINTH_COMBAT_ROOM_TYPE) {
continue;
}
const key = getLabyrinthRoomLoadoutSettingKey(room);
const id = Number(state?.characterSetting?.[key] || 0);
if (!Number.isFinite(id) || id <= 0) {
continue;
}
const loadout = getLoadoutById(state?.characterLoadoutDict, id);
if (!isCombatLoadout(loadout)) {
continue;
}
countByLoadoutId.set(id, (countByLoadoutId.get(id) || 0) + 1);
}
}
let bestId = 0;
let bestCount = 0;
for (const [id, count] of countByLoadoutId.entries()) {
if (count > bestCount || (count === bestCount && id < bestId)) {
bestId = id;
bestCount = count;
}
}
return bestId;
}
function resolveCombatRoomLoadout(state, room) {
const direct = getLabyrinthRoomLoadout(state, room);
const selectedLoadoutId = Number(direct.loadoutId || 0);
if (isCombatLoadout(direct.loadout) && selectedLoadoutId > 0) {
return {
...direct,
source: "room",
selectedLoadoutId,
};
}
const fallbackIdOnFloor = getMostUsedCombatLoadoutIdOnCurrentLabyrinth(state);
if (fallbackIdOnFloor > 0) {
const fallbackLoadout = getLoadoutById(state?.characterLoadoutDict, fallbackIdOnFloor);
if (isCombatLoadout(fallbackLoadout)) {
return {
...direct,
loadoutId: fallbackIdOnFloor,
loadout: fallbackLoadout,
source: "fallback-floor",
selectedLoadoutId,
};
}
}
const combatLoadouts = listCombatLoadouts(state);
if (combatLoadouts.length > 0) {
const fallbackLoadout = combatLoadouts[0];
return {
...direct,
loadoutId: Number(fallbackLoadout.id || 0),
loadout: fallbackLoadout,
source: "fallback-any",
selectedLoadoutId,
};
}
return {
...direct,
loadout: null,
source: "missing",
selectedLoadoutId,
};
}
function getItemDetailByHrid(state, initClientData, itemHrid) {
if (!itemHrid) {
return null;
}
const fromState = state?.itemDetailDict?.[itemHrid];
if (fromState) {
return fromState;
}
return initClientData?.itemDetailMap?.[itemHrid] || null;
}
function getAbilityDetailByHrid(state, initClientData, abilityHrid) {
if (!abilityHrid) {
return null;
}
const fromState = getContainerValue(state?.abilityDetailDict, abilityHrid);
if (fromState) {
return fromState;
}
return getContainerValue(initClientData?.abilityDetailMap, abilityHrid) || null;
}
function getAbilityLevelFromState(state, abilityHrid) {
if (!abilityHrid) {
return 1;
}
const ability = getContainerValue(state?.characterAbilityMap, abilityHrid);
return positiveNumber(ability?.level, 1);
}
function getCombatTriggerList(triggerMap, triggerKey) {
const value = getContainerValue(triggerMap, triggerKey);
if (!Array.isArray(value)) {
return null;
}
return normalizeTriggerList(value);
}
function hasContainerKey(container, key) {
const normalizedKey = String(key || "");
if (!normalizedKey) {
return false;
}
if (container instanceof Map) {
return container.has(normalizedKey);
}
if (container && typeof container === "object") {
return Object.prototype.hasOwnProperty.call(container, normalizedKey);
}
return false;
}
function hasAnyContainerEntries(container) {
if (container instanceof Map) {
return container.size > 0;
}
if (container && typeof container === "object") {
return Object.keys(container).length > 0;
}
return false;
}
function getDefaultAbilityTriggers(state, initClientData, abilityHrid) {
const detail = getAbilityDetailByHrid(state, initClientData, abilityHrid);
return normalizeTriggerList(detail?.defaultCombatTriggers);
}
function buildHouseRoomLevelMap(state) {
const result = {};
for (const [roomHrid, room] of getContainerEntries(state?.characterHouseRoomDict)) {
if (!roomHrid) {
continue;
}
result[roomHrid] = Math.max(0, Math.floor(finiteNumber(room?.level, 0)));
}
return result;
}
function buildAchievementCompletionMap(state) {
const result = {};
for (const [achievementHrid, info] of getContainerEntries(state?.characterAchievementMap)) {
if (!achievementHrid) {
continue;
}
const completed = info === true || info?.isCompleted === true;
if (completed) {
result[achievementHrid] = true;
}
}
return result;
}
function isTaskBadgeItemHrid(itemHrid) {
const tail = String(itemHrid || "").split("/").pop() || "";
return tail.includes("task_badge");
}
function isSupportedCombatEquipmentType(equipmentType) {
const typeHrid = String(equipmentType || "");
return typeHrid ? SIMULATOR_SUPPORTED_EQUIPMENT_TYPES.has(typeHrid) : false;
}
function shouldIncludeCombatEquipment(itemHrid, equipmentType) {
if (!isSupportedCombatEquipmentType(equipmentType)) {
return false;
}
if (isTaskBadgeItemHrid(itemHrid)) {
return false;
}
return true;
}
function buildCombatLoadoutEquipmentDto(state, initClientData, loadout, maxEnhancementByItem) {
const equipment = {};
for (const rawRef of Object.values(loadout?.wearableMap || {})) {
const entry = parseWearableReference(rawRef);
if (!entry?.itemHrid) {
continue;
}
const itemDetail = getItemDetailByHrid(state, initClientData, entry.itemHrid);
const equipmentType = itemDetail?.equipmentDetail?.type;
if (!shouldIncludeCombatEquipment(entry.itemHrid, equipmentType)) {
continue;
}
const enhancementLevel = Math.max(0, Math.floor(resolveWearableEnhancement(entry, loadout, maxEnhancementByItem)));
equipment[equipmentType] = {
hrid: entry.itemHrid,
enhancementLevel,
};
}
return equipment;
}
function buildCombatLoadoutAbilityDtos(state, initClientData, loadout, intelligenceLevel) {
const abilityDtos = [];
const requirementList = Array.isArray(state?.abilitySlotsLevelRequirementList)
? state.abilitySlotsLevelRequirementList
: [0, 1, 1, 20, 50, 90];
const loadoutAbilityMap = loadout?.abilityMap || {};
const loadoutTriggerMap = loadout?.abilityCombatTriggersMap;
const globalTriggerMap = state?.abilityCombatTriggersDict;
const loadoutHasTriggerConfig = hasAnyContainerEntries(loadoutTriggerMap);
for (let slot = 1; slot <= COMBAT_SLOT_COUNT; slot += 1) {
const abilityHrid = String(loadoutAbilityMap[slot] || loadoutAbilityMap[String(slot)] || "");
if (!abilityHrid) {
abilityDtos.push(null);
continue;
}
const minIntelligence = Math.max(0, Math.floor(finiteNumber(requirementList[slot], 0)));
if (intelligenceLevel < minIntelligence) {
abilityDtos.push(null);
continue;
}
let abilityTriggers = null;
if (loadoutHasTriggerConfig) {
if (hasContainerKey(loadoutTriggerMap, abilityHrid)) {
const explicitLoadoutTriggers = getCombatTriggerList(loadoutTriggerMap, abilityHrid);
abilityTriggers = Array.isArray(explicitLoadoutTriggers) ? explicitLoadoutTriggers : [];
} else {
// When a loadout has its own trigger config, missing ability key means intentionally empty.
abilityTriggers = [];
}
} else {
abilityTriggers =
getCombatTriggerList(globalTriggerMap, abilityHrid) ||
getDefaultAbilityTriggers(state, initClientData, abilityHrid);
}
abilityDtos.push({
hrid: abilityHrid,
level: getAbilityLevelFromState(state, abilityHrid),
triggers: abilityTriggers,
});
}
return abilityDtos;
}
function buildCombatPlayerDtoForRoom(state, initClientData, room, maxEnhancementByItem) {
const loadoutInfo = resolveCombatRoomLoadout(state, room);
const loadout = loadoutInfo.loadout;
if (!isCombatLoadout(loadout)) {
return {
playerDto: null,
loadoutInfo,
};
}
const levels = getCombatSkillLevelsFromState(state);
const intelligenceLevel = positiveNumber(levels.intelligence, 1);
const equipment = buildCombatLoadoutEquipmentDto(state, initClientData, loadout, maxEnhancementByItem);
const abilities = buildCombatLoadoutAbilityDtos(state, initClientData, loadout, intelligenceLevel);
const houseRooms = buildHouseRoomLevelMap(state);
const achievements = buildAchievementCompletionMap(state);
const playerDto = {
hrid: "player1",
staminaLevel: positiveNumber(levels.stamina, 1),
intelligenceLevel,
attackLevel: positiveNumber(levels.attack, 1),
meleeLevel: positiveNumber(levels.melee, 1),
defenseLevel: positiveNumber(levels.defense, 1),
rangedLevel: positiveNumber(levels.ranged, 1),
magicLevel: positiveNumber(levels.magic, 1),
equipment,
food: [],
drinks: [],
abilities,
houseRooms,
achievements,
debuffOnLevelGap: 0,
};
return {
playerDto,
loadoutInfo,
};
}
function buildCombatInputSnapshot(playerData, room) {
const playerDto = playerData?.playerDto;
const loadoutInfo = playerData?.loadoutInfo || {};
const loadout = loadoutInfo.loadout || null;
if (!playerDto) {
return null;
}
const equipment = [];
for (const [slot, detail] of Object.entries(playerDto.equipment || {})) {
if (!detail?.hrid) {
continue;
}
equipment.push({
slot: String(slot),
hrid: String(detail.hrid),
enhancementLevel: Math.max(0, Math.floor(finiteNumber(detail.enhancementLevel, 0))),
});
}
equipment.sort((a, b) => a.slot.localeCompare(b.slot));
const abilitySlots = [];
const abilityList = Array.isArray(playerDto.abilities) ? playerDto.abilities : [];
for (let i = 0; i < abilityList.length; i += 1) {
const ability = abilityList[i];
if (!ability?.hrid) {
continue;
}
abilitySlots.push({
slot: i + 1,
hrid: String(ability.hrid),
level: Math.max(1, Math.floor(finiteNumber(ability.level, 1))),
});
}
return {
roomKey: "",
roomMonsterHrid: String(room?.monsterHrid || ""),
roomRecommendedLevel: Math.max(0, Math.floor(finiteNumber(room?.recommendedLevel, 0))),
loadoutId: Number(loadoutInfo.loadoutId || 0),
loadoutName: String(loadout?.name || ""),
loadoutMode: loadout?.useExactEnhancement ? "exact" : "highest",
loadoutSource: String(loadoutInfo.source || "room"),
selectedLoadoutId: Number(loadoutInfo.selectedLoadoutId || loadoutInfo.loadoutId || 0),
loadoutSettingKey: String(loadoutInfo.settingKey || ""),
levels: {
stamina: positiveNumber(playerDto.staminaLevel, 1),
intelligence: positiveNumber(playerDto.intelligenceLevel, 1),
attack: positiveNumber(playerDto.attackLevel, 1),
melee: positiveNumber(playerDto.meleeLevel, 1),
defense: positiveNumber(playerDto.defenseLevel, 1),
ranged: positiveNumber(playerDto.rangedLevel, 1),
magic: positiveNumber(playerDto.magicLevel, 1),
},
equipment,
abilitySlots,
houseRoomCount: Object.keys(playerDto.houseRooms || {}).length,
achievementCount: Object.keys(playerDto.achievements || {}).length,
};
}
function equipmentTypeToItemLocationHrid(equipmentTypeHrid) {
const raw = String(equipmentTypeHrid || "");
if (!raw.startsWith("/equipment_types/")) {
return "";
}
return raw.replace("/equipment_types/", "/item_locations/");
}
function normalizeSimulatorImportConsumableSlots(entries, slotCount = 3) {
const result = [];
const source = Array.isArray(entries) ? entries : [];
for (const entry of source) {
const itemHrid = String(entry?.hrid || entry?.itemHrid || "");
result.push({ itemHrid });
if (result.length >= slotCount) {
break;
}
}
while (result.length < slotCount) {
result.push({ itemHrid: "" });
}
return result;
}
function normalizeSimulatorImportAbilitySlots(entries, slotCount = 5) {
const result = [];
const source = Array.isArray(entries) ? entries : [];
for (let i = 0; i < slotCount; i += 1) {
const ability = source[i];
const abilityHrid = String(ability?.hrid || ability?.abilityHrid || "");
const level = Math.max(1, Math.floor(finiteNumber(ability?.level, 1)));
result.push({
abilityHrid,
level: String(level),
});
}
return result;
}
function buildSimulatorImportTriggerMap(playerDto, options = {}) {
const includeConsumables = options?.includeConsumables !== false;
const triggerMap = {};
function include(entry) {
const hrid = String(entry?.hrid || "");
if (!hrid) {
return;
}
const triggers = normalizeTriggerList(entry?.triggers || []);
if (!triggers.length && options?.keepEmptyAbilityTriggers !== true) {
return;
}
triggerMap[hrid] = deepCloneJson(triggers);
}
const foods = Array.isArray(playerDto?.food) ? playerDto.food : [];
const drinks = Array.isArray(playerDto?.drinks) ? playerDto.drinks : [];
const abilities = Array.isArray(playerDto?.abilities) ? playerDto.abilities : [];
if (includeConsumables) {
for (const entry of foods) {
include(entry);
}
for (const entry of drinks) {
include(entry);
}
}
for (const entry of abilities) {
include(entry);
}
return triggerMap;
}
function buildCombatZoneCandidatesByMonster(initClientData, monsterHrid) {
const targetMonsterHrid = String(monsterHrid || "");
if (!targetMonsterHrid) {
return [];
}
const actionDetailMap = initClientData?.actionDetailMap;
if (!actionDetailMap) {
return [];
}
const candidateMap = new Map();
function pushCandidate(actionHrid, actionDetail, difficultyTier, sourceTag, waveHint = 0) {
const normalizedActionHrid = String(actionHrid || "");
if (!normalizedActionHrid) {
return;
}
const normalizedDifficultyTier = Math.max(0, Math.floor(finiteNumber(difficultyTier, 0)));
const normalizedWaveHint = Math.max(0, Math.floor(finiteNumber(waveHint, 0)));
const key = `${normalizedActionHrid}|${normalizedDifficultyTier}`;
const current = candidateMap.get(key);
if (!current) {
candidateMap.set(key, {
actionHrid: normalizedActionHrid,
difficultyTier: normalizedDifficultyTier,
source: sourceTag,
sortIndex: finiteNumber(actionDetail?.sortIndex, Number.MAX_SAFE_INTEGER),
isDungeon: actionDetail?.combatZoneInfo?.isDungeon === true,
startWave:
normalizedWaveHint > 0 &&
(sourceTag === "dungeon-fixed" || sourceTag === "dungeon-random")
? normalizedWaveHint
: 0,
});
return;
}
if (!current.startWave && normalizedWaveHint > 0) {
current.startWave = normalizedWaveHint;
} else if (current.startWave > 0 && normalizedWaveHint > 0) {
current.startWave = Math.min(current.startWave, normalizedWaveHint);
}
}
function collectFromSpawnList(actionHrid, actionDetail, spawnList, sourceTag, waveHint = 0) {
for (const spawn of Array.isArray(spawnList) ? spawnList : []) {
if (!spawn || String(spawn.combatMonsterHrid || "") !== targetMonsterHrid) {
continue;
}
pushCandidate(actionHrid, actionDetail, spawn.difficultyTier, sourceTag, waveHint);
}
}
for (const [actionHrid, actionDetail] of getContainerEntries(actionDetailMap)) {
if (!actionDetail || actionDetail.type !== "/action_types/combat") {
continue;
}
const zoneInfo = actionDetail.combatZoneInfo;
if (!zoneInfo) {
continue;
}
const fightInfo = zoneInfo.fightInfo || {};
collectFromSpawnList(actionHrid, actionDetail, fightInfo.bossSpawns, "boss");
collectFromSpawnList(actionHrid, actionDetail, fightInfo?.randomSpawnInfo?.spawns, "random");
const dungeonInfo = zoneInfo.dungeonInfo || {};
for (const [waveKey, fixedSpawns] of Object.entries(dungeonInfo.fixedSpawnsMap || {})) {
collectFromSpawnList(actionHrid, actionDetail, fixedSpawns, "dungeon-fixed", Number(waveKey));
}
for (const [waveKey, randomInfo] of Object.entries(dungeonInfo.randomSpawnInfoMap || {})) {
collectFromSpawnList(
actionHrid,
actionDetail,
randomInfo?.spawns,
"dungeon-random",
Number(waveKey)
);
}
}
const candidates = Array.from(candidateMap.values());
if (!candidates.length) {
return [];
}
candidates.sort((a, b) => {
if (a.isDungeon !== b.isDungeon) {
return a.isDungeon ? -1 : 1;
}
if (a.sortIndex !== b.sortIndex) {
return a.sortIndex - b.sortIndex;
}
if (a.difficultyTier !== b.difficultyTier) {
return a.difficultyTier - b.difficultyTier;
}
return a.actionHrid.localeCompare(b.actionHrid);
});
return candidates;
}
function findSuggestedCombatZoneByMonster(initClientData, monsterHrid) {
const candidates = buildCombatZoneCandidatesByMonster(initClientData, monsterHrid);
return candidates.length ? candidates[0] : null;
}
function buildSimulatorImportSetFromPlayerDto(state, playerDto, defaultZoneHrid, simulationTime = "24") {
const levels = {
attackLevel: positiveNumber(playerDto?.attackLevel, 1),
magicLevel: positiveNumber(playerDto?.magicLevel, 1),
meleeLevel: positiveNumber(playerDto?.meleeLevel, 1),
rangedLevel: positiveNumber(playerDto?.rangedLevel, 1),
defenseLevel: positiveNumber(playerDto?.defenseLevel, 1),
staminaLevel: positiveNumber(playerDto?.staminaLevel, 1),
intelligenceLevel: positiveNumber(playerDto?.intelligenceLevel, 1),
};
const equipment = [];
for (const [equipmentTypeHrid, entry] of Object.entries(playerDto?.equipment || {})) {
const itemHrid = String(entry?.hrid || "");
if (!itemHrid) {
continue;
}
const itemLocationHrid = equipmentTypeToItemLocationHrid(equipmentTypeHrid);
if (!itemLocationHrid) {
continue;
}
equipment.push({
itemLocationHrid,
itemHrid,
enhancementLevel: Math.max(0, Math.floor(finiteNumber(entry?.enhancementLevel, 0))),
});
}
equipment.sort((a, b) => a.itemLocationHrid.localeCompare(b.itemLocationHrid));
const food = normalizeSimulatorImportConsumableSlots(playerDto?.food, 3);
const drinks = normalizeSimulatorImportConsumableSlots(playerDto?.drinks, 3);
const abilities = normalizeSimulatorImportAbilitySlots(playerDto?.abilities, 5);
const triggerMap = buildSimulatorImportTriggerMap(playerDto, {
includeConsumables: false,
keepEmptyAbilityTriggers: true,
});
return {
player: {
...levels,
equipment,
},
food: {
"/action_types/combat": food,
},
drinks: {
"/action_types/combat": drinks,
},
abilities,
triggerMap,
zone: String(defaultZoneHrid || "/actions/combat/fly"),
simulationTime: String(simulationTime || "24"),
houseRooms: buildHouseRoomLevelMap(state),
achievements: buildAchievementCompletionMap(state),
};
}
function buildEmptySimulatorImportConsumableSlots(slotCount = 3) {
const result = [];
for (let i = 0; i < slotCount; i += 1) {
result.push({ itemHrid: "" });
}
return result;
}
function stripConsumablesFromSimulatorImportSet(importSet) {
if (!importSet || typeof importSet !== "object") {
return importSet;
}
const sanitizedTriggerMap = {};
for (const [key, value] of Object.entries(importSet.triggerMap || {})) {
const hrid = String(key || "");
if (!hrid.startsWith("/abilities/")) {
continue;
}
sanitizedTriggerMap[hrid] = deepCloneJson(value);
}
return {
...importSet,
food: {
"/action_types/combat": buildEmptySimulatorImportConsumableSlots(3),
},
drinks: {
"/action_types/combat": buildEmptySimulatorImportConsumableSlots(3),
},
triggerMap: sanitizedTriggerMap,
};
}
function buildFallbackSimulatorImportSetFromState(state, defaultZoneHrid, simulationTime = "24") {
const levels = getCombatSkillLevelsFromState(state);
const fallbackDto = {
attackLevel: positiveNumber(levels.attack, 1),
magicLevel: positiveNumber(levels.magic, 1),
meleeLevel: positiveNumber(levels.melee, 1),
rangedLevel: positiveNumber(levels.ranged, 1),
defenseLevel: positiveNumber(levels.defense, 1),
staminaLevel: positiveNumber(levels.stamina, 1),
intelligenceLevel: positiveNumber(levels.intelligence, 1),
equipment: {},
food: [],
drinks: [],
abilities: [],
};
return buildSimulatorImportSetFromPlayerDto(state, fallbackDto, defaultZoneHrid, simulationTime);
}
function buildCombatRoomSimulatorBridgePayload(state, initClientData, room, maxEnhancementByItem) {
if (!room || room.roomType !== LABYRINTH_COMBAT_ROOM_TYPE || !room.monsterHrid) {
return null;
}
const roomLevel = Math.max(1, Math.floor(positiveNumber(room?.recommendedLevel, 1)));
const zoneCandidates = buildCombatZoneCandidatesByMonster(initClientData, room.monsterHrid);
const zoneSuggestion = zoneCandidates.length ? zoneCandidates[0] : null;
const suggestedZoneHrid = String(zoneSuggestion?.actionHrid || "/actions/combat/fly");
const suggestedDifficultyTier = Math.max(0, Math.floor(finiteNumber(zoneSuggestion?.difficultyTier, 0)));
const suggestedStartWave = Math.max(0, Math.floor(finiteNumber(zoneSuggestion?.startWave, 0)));
const loadoutInfo = resolveCombatRoomLoadout(state, room);
const combatCrate = getCombatCrateBuffs(state, initClientData);
const combatCrateItemHrids = Array.isArray(combatCrate?.combatCrateItemHrids) ? combatCrate.combatCrateItemHrids : [];
const coffeeCrateItemHrid = String(
combatCrate?.coffeeCrateItemHrid ||
combatCrateItemHrids.find((itemHrid) => String(itemHrid || "").includes("coffee_crate")) ||
""
);
const foodCrateItemHrid = String(
combatCrate?.foodCrateItemHrid ||
combatCrateItemHrids.find((itemHrid) => String(itemHrid || "").includes("food_crate")) ||
""
);
const teaCrateItemHrid = String(combatCrate?.teaCrateItemHrid || "");
const playerData = buildCombatPlayerDtoForRoom(state, initClientData, room, maxEnhancementByItem);
const importSetRaw = playerData?.playerDto
? buildSimulatorImportSetFromPlayerDto(state, playerData.playerDto, suggestedZoneHrid, "24")
: buildFallbackSimulatorImportSetFromState(state, suggestedZoneHrid, "24");
const importSet = stripConsumablesFromSimulatorImportSet(importSetRaw);
const simulatorPersonalBuffItemHrids = getSimulatorPersonalBuffItemHrids(state);
return {
source: SIMULATOR_BRIDGE_SOURCE,
version: SIMULATOR_BRIDGE_VERSION,
generatedAt: new Date().toISOString(),
uiLanguage: getUiLanguage(),
monsterHrid: String(room.monsterHrid),
roomLevel,
mazeDifficulty: roomLevel,
suggestedZoneHrid,
suggestedDifficultyTier,
suggestedIsDungeon: zoneSuggestion?.isDungeon === true,
suggestedStartWave,
zoneCandidates: zoneCandidates.slice(0, 32).map((candidate) => ({
zoneHrid: String(candidate?.actionHrid || ""),
difficultyTier: Math.max(0, Math.floor(finiteNumber(candidate?.difficultyTier, 0))),
isDungeon: candidate?.isDungeon === true,
startWave: Math.max(0, Math.floor(finiteNumber(candidate?.startWave, 0))),
})),
loadoutSource: String(loadoutInfo?.source || "unknown"),
loadoutId: Number(loadoutInfo?.loadoutId || 0),
selectedLoadoutId: Number(loadoutInfo?.selectedLoadoutId || loadoutInfo?.loadoutId || 0),
teaCrateItemHrid,
coffeeCrateItemHrid,
foodCrateItemHrid,
importSet,
simulatorPersonalBuffItemHrids,
};
}
function getRoomFromCell(state, cell) {
const roomKey = getRoomKeyFromCell(cell);
const point = parseRoomKey(roomKey);
const roomRows = state?.characterLabyrinth?.roomData;
if (!point || !Array.isArray(roomRows) || !Array.isArray(roomRows[point.y])) {
return null;
}
return roomRows[point.y][point.x] || null;
}
function openCombatRoomSimulatorFromCell(cell) {
const state = getGameState();
const initClientData = getInitClientData();
if (!state || !initClientData) {
window.alert(t("readGameDataFailed"));
return;
}
const room = getRoomFromCell(state, cell);
if (!room || room.roomType !== LABYRINTH_COMBAT_ROOM_TYPE || !room.monsterHrid) {
window.alert(t("exportableCombatRoomNotFound"));
return;
}
const maxEnhancementByItem = buildMaxEnhancementByItem(state);
const payload = buildCombatRoomSimulatorBridgePayload(state, initClientData, room, maxEnhancementByItem);
if (!payload?.importSet) {
window.alert(t("simulatorExportNoLoadout"));
return;
}
const launchUrl = buildSimulatorBridgeLaunchUrl(payload);
window.open(launchUrl, "_blank", "noopener,noreferrer");
}
function getAutomationCombatRoomFromCell(cell, state, initClientData) {
if (!cell || !state || !initClientData) {
return null;
}
const roomTypeKey = String(cell.getAttribute("data-mwi-auto-room-key") || "");
if (!roomTypeKey) {
return null;
}
const panelInstance = getLabyrinthPanelInstance();
const entry = getAutomationRoomTypeEntryByKey(roomTypeKey, panelInstance);
if (!entry || !entry.isCombat || !entry.monsterHrid) {
return null;
}
let roomLevel = NaN;
const recommend = getAutomationRecommendFromCell(cell);
if (recommend?.status === "ready" && recommend?.isCombat) {
roomLevel = Math.floor(finiteNumber(recommend.roomLevel, NaN));
}
const estimate = getAutomationEstimateFromCell(cell);
if (!Number.isFinite(roomLevel) && estimate?.status === "ready" && estimate?.isCombat) {
roomLevel = Math.floor(finiteNumber(estimate.roomLevel, NaN));
}
if (!Number.isFinite(roomLevel)) {
const entries = getAutomationRoomTypeEntries(panelInstance);
const skipThresholdOverrides = buildAutomationSkipThresholdOverrideMap(entries);
const skipThreshold = resolveAutomationSkipThreshold(panelInstance, entry.key, state, skipThresholdOverrides);
const effectiveLevel = resolveAutomationEffectiveLevel(panelInstance, entry, state, initClientData, {
includePersonalBuffs: false,
});
roomLevel = computeAutomationTargetRoomLevel(effectiveLevel, skipThreshold);
}
if (!Number.isFinite(roomLevel) || roomLevel < 1) {
return {
skipped: true,
};
}
return {
room: createAutomationRoomFromEntry(entry, roomLevel),
};
}
function openCombatRoomSimulatorFromAutomationCell(cell) {
const state = getGameState();
const initClientData = getInitClientData();
if (!state || !initClientData) {
window.alert(t("readGameDataFailed"));
return;
}
const resolved = getAutomationCombatRoomFromCell(cell, state, initClientData);
if (!resolved) {
window.alert(t("exportableCombatRoomNotFound"));
return;
}
if (resolved.skipped) {
window.alert(t("skippedCannotExport"));
return;
}
const room = resolved.room;
if (!room || room.roomType !== LABYRINTH_COMBAT_ROOM_TYPE || !room.monsterHrid) {
window.alert(t("exportableCombatRoomNotFound"));
return;
}
const maxEnhancementByItem = buildMaxEnhancementByItem(state);
const payload = buildCombatRoomSimulatorBridgePayload(state, initClientData, room, maxEnhancementByItem);
if (!payload?.importSet) {
window.alert(t("simulatorExportNoLoadout"));
return;
}
const launchUrl = buildSimulatorBridgeLaunchUrl(payload);
window.open(launchUrl, "_blank", "noopener,noreferrer");
}
function parseWearableReference(rawValue) {
if (!rawValue) {
return null;
}
const parts = String(rawValue).split("::");
if (parts.length < 4) {
return null;
}
return {
itemHrid: parts[2] || "",
enhancementLevel: finiteNumber(Number(parts[3]), 0),
};
}
function buildMaxEnhancementByItem(state) {
const maxByItem = new Map();
const itemMap = state?.characterItemMap;
function consume(item) {
if (!item || !item.itemHrid) {
return;
}
if (!Number.isFinite(Number(item.count)) || Number(item.count) <= 0) {
return;
}
const itemHrid = item.itemHrid;
const enhancement = Math.max(0, finiteNumber(item.enhancementLevel, 0));
const existing = maxByItem.get(itemHrid);
if (!Number.isFinite(existing) || enhancement > existing) {
maxByItem.set(itemHrid, enhancement);
}
}
if (itemMap instanceof Map) {
for (const item of itemMap.values()) {
consume(item);
}
} else if (itemMap && typeof itemMap === "object") {
for (const item of Object.values(itemMap)) {
consume(item);
}
}
return maxByItem;
}
function resolveWearableEnhancement(entry, loadout, maxEnhancementByItem) {
if (!entry) {
return 0;
}
if (loadout?.useExactEnhancement) {
return Math.max(0, finiteNumber(entry.enhancementLevel, 0));
}
const highest = maxEnhancementByItem.get(entry.itemHrid);
if (Number.isFinite(highest)) {
return Math.max(0, highest);
}
return Math.max(0, finiteNumber(entry.enhancementLevel, 0));
}
function getEnhancementBonusMultiplier(initClientData, enhancementLevel) {
const level = Math.max(0, Math.floor(finiteNumber(enhancementLevel, 0)));
const table = initClientData?.enhancementLevelTotalBonusMultiplierTable;
const fromTable =
table && Object.prototype.hasOwnProperty.call(table, level) ? Number(table[level]) : Number.NaN;
if (Number.isFinite(fromTable)) {
return fromTable;
}
return level;
}
function addFlatBuff(buffs, typeHrid, amount) {
const value = finiteNumber(amount, 0);
if (!typeHrid || !Number.isFinite(value) || value === 0) {
return;
}
buffs.push({
typeHrid,
flatBoost: value,
flatBoostLevelBonus: 0,
ratioBoost: 0,
ratioBoostLevelBonus: 0,
});
}
function addRatioBuff(buffs, typeHrid, amount) {
const value = finiteNumber(amount, 0);
if (!typeHrid || !Number.isFinite(value) || value === 0) {
return;
}
buffs.push({
typeHrid,
flatBoost: 0,
flatBoostLevelBonus: 0,
ratioBoost: value,
ratioBoostLevelBonus: 0,
});
}
function getToolSlotForActionType(actionTypeHrid) {
if (!actionTypeHrid || typeof actionTypeHrid !== "string") {
return "";
}
if (!actionTypeHrid.startsWith("/action_types/")) {
return "";
}
const skillId = actionTypeHrid.split("/").pop() || "";
if (!skillId || skillId === "combat" || skillId === "special" || skillId === "labyrinth") {
return "";
}
return `/item_locations/${skillId}_tool`;
}
function shouldUseWearableSlotForSkillingAction(slotKey, actionTypeHrid) {
if (!slotKey) {
return false;
}
if (!slotKey.endsWith("_tool")) {
return true;
}
const requiredToolSlot = getToolSlotForActionType(actionTypeHrid);
return requiredToolSlot ? slotKey === requiredToolSlot : false;
}
function buildLoadoutNoncombatStatTotals(state, initClientData, loadout, maxEnhancementByItem, actionTypeHrid) {
const totals = {};
if (!loadout) {
return totals;
}
for (const [slotKey, rawRef] of Object.entries(loadout.wearableMap || {})) {
if (!shouldUseWearableSlotForSkillingAction(slotKey, actionTypeHrid)) {
continue;
}
const entry = parseWearableReference(rawRef);
if (!entry || !entry.itemHrid) {
continue;
}
const itemDetail = getItemDetailByHrid(state, initClientData, entry.itemHrid);
const equipmentDetail = itemDetail?.equipmentDetail;
if (!equipmentDetail) {
continue;
}
const enhancementLevel = resolveWearableEnhancement(entry, loadout, maxEnhancementByItem);
const enhancementMultiplier = getEnhancementBonusMultiplier(initClientData, enhancementLevel);
const baseStats = equipmentDetail.noncombatStats || {};
const enhancementStats = equipmentDetail.noncombatEnhancementBonuses || {};
for (const [key, value] of Object.entries(baseStats)) {
if (!Number.isFinite(Number(value))) {
continue;
}
totals[key] = finiteNumber(totals[key], 0) + Number(value);
}
for (const [key, value] of Object.entries(enhancementStats)) {
if (!Number.isFinite(Number(value))) {
continue;
}
totals[key] = finiteNumber(totals[key], 0) + Number(value) * enhancementMultiplier;
}
}
return totals;
}
function buildSkillingEquipmentBuffsFromTotals(skillId, totals) {
const buffs = [];
if (!skillId || !totals) {
return buffs;
}
const actionSpeed = finiteNumber(totals[`${skillId}Speed`], 0) + finiteNumber(totals.skillingSpeed, 0);
const efficiency = finiteNumber(totals[`${skillId}Efficiency`], 0) + finiteNumber(totals.skillingEfficiency, 0);
const gathering = finiteNumber(totals.gatheringQuantity, 0);
const success = finiteNumber(totals[`${skillId}Success`], 0);
const skillingExperience = finiteNumber(totals.skillingExperience, 0);
const skillExperience = finiteNumber(totals[`${skillId}Experience`], 0);
const wisdom = finiteNumber(totals.wisdom, 0);
// Runtime state folds equipment skilling/skill EXP stats into generic EXP (wisdom/experience).
// Keep reconstruction aligned with actual in-game actionType buff buckets.
const genericExperience = wisdom + skillingExperience + skillExperience;
addFlatBuff(buffs, "/buff_types/action_speed", actionSpeed);
addFlatBuff(buffs, "/buff_types/efficiency", efficiency);
addFlatBuff(buffs, "/buff_types/gathering", gathering);
addRatioBuff(buffs, `/buff_types/${skillId}_success`, success);
addRatioBuff(buffs, "/buff_types/wisdom", genericExperience);
return buffs;
}
function getSkillingRoomLoadoutMetrics(state, initClientData, room, skillId, actionTypeHrid, maxEnhancementByItem, fallbackMetrics) {
const loadoutInfo = getLabyrinthRoomLoadout(state, room);
const fallback = cloneSkillingMetrics(fallbackMetrics);
const loadout = loadoutInfo.loadout;
if (!loadout) {
return {
metrics: fallback,
loadoutInfo,
mode: "current",
};
}
const noncombatTotals = buildLoadoutNoncombatStatTotals(state, initClientData, loadout, maxEnhancementByItem, actionTypeHrid);
const equipmentBuffs = buildSkillingEquipmentBuffsFromTotals(skillId, noncombatTotals);
const metrics = createEmptySkillingMetrics();
addSkillingMetrics(metrics, getSkillingBuffMetrics(skillId, equipmentBuffs));
const mode = loadout.actionTypeHrid === actionTypeHrid ? (loadout.useExactEnhancement ? "exact" : "highest") : "configured";
return {
metrics,
loadoutInfo,
mode,
};
}
function computeSkillingExperienceBonusForRoom(
state,
skillId,
actionTypeHrid,
roomLoadoutMetrics,
crateMetrics,
loanMetrics,
includePersonalBuffs,
labyrinthExperienceBonus = 0
) {
const { globalMetrics: globalWithoutPersonal } = getSkillingGlobalMetrics(state, skillId, actionTypeHrid, {
includePersonalBuffs: false,
});
let genericBonus = 0;
let skillingBonus = 0;
let skillBonus = 0;
const appendExperienceBuckets = (metrics) => {
genericBonus += finiteNumber(metrics?.genericExperienceBonus, 0);
skillingBonus += finiteNumber(metrics?.skillingExperienceBonus, 0);
skillBonus += finiteNumber(metrics?.skillExperienceBonus, 0);
};
appendExperienceBuckets(globalWithoutPersonal);
appendExperienceBuckets(roomLoadoutMetrics);
appendExperienceBuckets(crateMetrics);
appendExperienceBuckets(loanMetrics);
genericBonus += finiteNumber(labyrinthExperienceBonus, 0);
if (includePersonalBuffs) {
const personalMetrics = getSkillingActionMetricsFromState(
state,
skillId,
actionTypeHrid,
"personalActionTypeBuffsDict"
);
appendExperienceBuckets(personalMetrics);
}
const genericMultiplier = Math.max(0, 1 + genericBonus);
const skillingMultiplier = Math.max(0, 1 + skillingBonus);
const skillMultiplier = Math.max(0, 1 + skillBonus);
const multiplier = genericMultiplier * skillingMultiplier * skillMultiplier;
return {
genericBonus,
skillingBonus,
skillBonus,
multiplier,
totalBonus: multiplier - 1,
};
}
function getCombatSkillLevelsFromState(state) {
const levels = {};
for (const [key, hrid] of Object.entries(COMBAT_SKILL_HRID_BY_KEY)) {
levels[key] = positiveNumber(getSkillLevel(state?.characterSkillMap, hrid), 1);
}
if (!state?.characterSkillMap && state?.combatUnit?.combatDetails) {
levels.attack = positiveNumber(state.combatUnit.combatDetails.attackLevel, levels.attack);
levels.melee = positiveNumber(state.combatUnit.combatDetails.meleeLevel, levels.melee);
levels.defense = positiveNumber(state.combatUnit.combatDetails.defenseLevel, levels.defense);
levels.ranged = positiveNumber(state.combatUnit.combatDetails.rangedLevel, levels.ranged);
levels.magic = positiveNumber(state.combatUnit.combatDetails.magicLevel, levels.magic);
levels.stamina = positiveNumber(state.combatUnit.combatDetails.staminaLevel, levels.stamina);
levels.intelligence = positiveNumber(state.combatUnit.combatDetails.intelligenceLevel, levels.intelligence);
}
return levels;
}
function createEmptyCombatStats() {
return {
combatStyleHrid: "/combat_styles/smash",
damageType: "/damage_types/physical",
attackInterval: 3000000000,
autoAttackDamage: 0,
castSpeed: 0,
criticalRate: 0,
criticalDamage: 0,
stabAccuracy: 0,
slashAccuracy: 0,
smashAccuracy: 0,
rangedAccuracy: 0,
magicAccuracy: 0,
stabDamage: 0,
slashDamage: 0,
smashDamage: 0,
rangedDamage: 0,
magicDamage: 0,
defensiveDamage: 0,
taskDamage: 0,
armorPenetration: 0,
waterPenetration: 0,
naturePenetration: 0,
firePenetration: 0,
maxHitpoints: 0,
maxManapoints: 0,
stabEvasion: 0,
slashEvasion: 0,
smashEvasion: 0,
rangedEvasion: 0,
magicEvasion: 0,
armor: 0,
waterResistance: 0,
natureResistance: 0,
fireResistance: 0,
tenacity: 0,
hpRegenPer10: 0,
mpRegenPer10: 0,
drinkConcentration: 0,
combatRareFind: 0,
combatDropRate: 0,
combatDropQuantity: 0,
combatExperience: 0,
foodSlots: 1,
drinkSlots: 1,
physicalAmplify: 0,
waterAmplify: 0,
natureAmplify: 0,
fireAmplify: 0,
healingAmplify: 0,
physicalThorns: 0,
elementalThorns: 0,
lifeSteal: 0,
abilityHaste: 0,
manaLeech: 0,
threat: 100,
parry: 0,
mayhem: 0,
pierce: 0,
curse: 0,
fury: 0,
weaken: 0,
ripple: 0,
bloom: 0,
blaze: 0,
attackSpeed: 0,
foodHaste: 0,
damageTaken: 0,
retaliation: 0,
primaryTraining: "/skills/melee",
focusTraining: "",
};
}
function addNumericStat(stats, key, value) {
const n = Number(value);
if (!Number.isFinite(n)) {
return;
}
stats[key] = finiteNumber(stats[key], 0) + n;
}
function getPositiveMultiplier(value) {
return Math.max(0.05, 1 + finiteNumber(value, 0));
}
function buildPlayerDetailsFromCombatProfile(profile) {
const levels = profile.levels;
const combatStats = profile.combatStats;
const attack = positiveNumber(levels.attack, 1);
const melee = positiveNumber(levels.melee, 1);
const defense = positiveNumber(levels.defense, 1);
const ranged = positiveNumber(levels.ranged, 1);
const magic = positiveNumber(levels.magic, 1);
const stamina = positiveNumber(levels.stamina, 1);
const intelligence = positiveNumber(levels.intelligence, 1);
const details = {
attackLevel: attack,
meleeLevel: melee,
defenseLevel: defense,
rangedLevel: ranged,
magicLevel: magic,
staminaLevel: stamina,
intelligenceLevel: intelligence,
maxHitpoints: Math.floor(10 * (10 + stamina) + finiteNumber(combatStats.maxHitpoints, 0)),
maxManapoints: Math.floor(10 * (10 + intelligence) + finiteNumber(combatStats.maxManapoints, 0)),
stabAccuracyRating: (10 + attack) * getPositiveMultiplier(combatStats.stabAccuracy),
slashAccuracyRating: (10 + attack) * getPositiveMultiplier(combatStats.slashAccuracy),
smashAccuracyRating: (10 + attack) * getPositiveMultiplier(combatStats.smashAccuracy),
rangedAccuracyRating: (10 + attack) * getPositiveMultiplier(combatStats.rangedAccuracy),
magicAccuracyRating: (10 + attack) * getPositiveMultiplier(combatStats.magicAccuracy),
stabMaxDamage: (10 + melee) * getPositiveMultiplier(combatStats.stabDamage),
slashMaxDamage: (10 + melee) * getPositiveMultiplier(combatStats.slashDamage),
smashMaxDamage: (10 + melee) * getPositiveMultiplier(combatStats.smashDamage),
rangedMaxDamage: (10 + ranged) * getPositiveMultiplier(combatStats.rangedDamage),
magicMaxDamage: (10 + magic) * getPositiveMultiplier(combatStats.magicDamage),
defensiveMaxDamage: (10 + defense) * getPositiveMultiplier(combatStats.defensiveDamage),
stabEvasionRating: (10 + defense) * getPositiveMultiplier(combatStats.stabEvasion),
slashEvasionRating: (10 + defense) * getPositiveMultiplier(combatStats.slashEvasion),
smashEvasionRating: (10 + defense) * getPositiveMultiplier(combatStats.smashEvasion),
rangedEvasionRating: (10 + defense) * getPositiveMultiplier(combatStats.rangedEvasion),
magicEvasionRating: (10 + defense) * getPositiveMultiplier(combatStats.magicEvasion),
totalArmor: 0.2 * defense + finiteNumber(combatStats.armor, 0),
totalWaterResistance: 0.2 * defense + finiteNumber(combatStats.waterResistance, 0),
totalNatureResistance: 0.2 * defense + finiteNumber(combatStats.natureResistance, 0),
totalFireResistance: 0.2 * defense + finiteNumber(combatStats.fireResistance, 0),
};
details.currentHitpoints = details.maxHitpoints;
details.currentManapoints = details.maxManapoints;
const baseInterval = positiveNumber(combatStats.attackInterval, COMBAT_ONE_SECOND_NS);
let attackInterval = baseInterval / (1 + attack / 2000);
attackInterval /= Math.max(0.05, 1 + finiteNumber(combatStats.attackSpeed, 0));
attackInterval /= Math.max(0.05, 1 + finiteNumber(profile.extraAttackSpeedRatio, 0));
combatStats.attackInterval = attackInterval;
combatStats.hpRegenPer10 = 0.01 + finiteNumber(combatStats.hpRegenPer10, 0);
combatStats.mpRegenPer10 = 0.01 + finiteNumber(combatStats.mpRegenPer10, 0);
combatStats.castSpeed = finiteNumber(combatStats.castSpeed, 0) + attack / 2000;
details.combatStats = combatStats;
return details;
}
function cloneCombatStatsForSimulation(rawCombatStats, rawAttackIntervalNs) {
const source = rawCombatStats || {};
const combatStats = createEmptyCombatStats();
for (const [key, value] of Object.entries(source)) {
const numericValue = Number(value);
if (Number.isFinite(numericValue)) {
combatStats[key] = numericValue;
}
}
combatStats.combatStyleHrid = getCombatStyleHrid(source);
combatStats.damageType = getDamageTypeHrid(source);
combatStats.attackInterval = positiveNumber(rawAttackIntervalNs || source.attackInterval, COMBAT_ONE_SECOND_NS);
return combatStats;
}
function buildMazeMonsterDetailsForRoom(monsterDetails, roomLevel) {
if (!monsterDetails || !monsterDetails.combatStats) {
return null;
}
const resolvedRoomLevel = positiveNumber(roomLevel, positiveNumber(monsterDetails.combatLevel, 100));
const mazeScale = resolvedRoomLevel / 100;
const levels = {
attack: positiveNumber(monsterDetails.attackLevel, 1) * mazeScale,
melee: positiveNumber(monsterDetails.meleeLevel, 1) * mazeScale,
defense: positiveNumber(monsterDetails.defenseLevel, 1) * mazeScale,
ranged: positiveNumber(monsterDetails.rangedLevel, 1) * mazeScale,
magic: positiveNumber(monsterDetails.magicLevel, 1) * mazeScale,
stamina: positiveNumber(monsterDetails.staminaLevel, 1) * mazeScale,
intelligence: positiveNumber(monsterDetails.intelligenceLevel, 1) * mazeScale,
};
const combatStats = cloneCombatStatsForSimulation(monsterDetails.combatStats, monsterDetails.attackInterval);
const details = buildPlayerDetailsFromCombatProfile({
levels,
combatStats,
extraAttackSpeedRatio: 0,
});
if (!details) {
return null;
}
// Monster template max HP/MP already includes monster-specific modifiers (e.g. aura-like effects).
// Prefer scaling template values directly to avoid underestimating preview stats.
const scaledTemplateMaxHp = finiteNumber(monsterDetails.maxHitpoints, NaN) * mazeScale;
const scaledTemplateMaxMp = finiteNumber(monsterDetails.maxManapoints, NaN) * mazeScale;
if (Number.isFinite(scaledTemplateMaxHp) && scaledTemplateMaxHp > 0) {
details.maxHitpoints = Math.max(1, Math.floor(scaledTemplateMaxHp));
}
if (Number.isFinite(scaledTemplateMaxMp) && scaledTemplateMaxMp > 0) {
details.maxManapoints = Math.max(1, Math.floor(scaledTemplateMaxMp));
}
// Use game's labyrinth rule: armor/resistance scales by room level from base values.
details.totalArmor = finiteNumber(monsterDetails.totalArmor, details.totalArmor) * mazeScale;
details.totalWaterResistance = finiteNumber(monsterDetails.totalWaterResistance, details.totalWaterResistance) * mazeScale;
details.totalNatureResistance = finiteNumber(monsterDetails.totalNatureResistance, details.totalNatureResistance) * mazeScale;
details.totalFireResistance = finiteNumber(monsterDetails.totalFireResistance, details.totalFireResistance) * mazeScale;
details.currentHitpoints = details.maxHitpoints;
details.currentManapoints = details.maxManapoints;
return details;
}
function buildCombatProfileFromLoadout(state, initClientData, loadout, maxEnhancementByItem) {
if (!loadout || loadout.actionTypeHrid !== "/action_types/combat") {
return null;
}
const levels = getCombatSkillLevelsFromState(state);
const combatStats = createEmptyCombatStats();
let weaponStyle = combatStats.combatStyleHrid;
let weaponDamageType = combatStats.damageType;
let weaponAttackInterval = combatStats.attackInterval;
let weaponPrimaryTraining = combatStats.primaryTraining;
let focusTraining = combatStats.focusTraining;
for (const [slotKey, rawRef] of Object.entries(loadout.wearableMap || {})) {
const entry = parseWearableReference(rawRef);
if (!entry || !entry.itemHrid) {
continue;
}
const itemDetail = getItemDetailByHrid(state, initClientData, entry.itemHrid);
const equipmentDetail = itemDetail?.equipmentDetail;
if (!equipmentDetail) {
continue;
}
if (!shouldIncludeCombatEquipment(entry.itemHrid, equipmentDetail.type)) {
continue;
}
const enhancementLevel = resolveWearableEnhancement(entry, loadout, maxEnhancementByItem);
const enhancementMultiplier = getEnhancementBonusMultiplier(initClientData, enhancementLevel);
const baseStats = equipmentDetail.combatStats || {};
const enhancementStats = equipmentDetail.combatEnhancementBonuses || {};
for (const [key, value] of Object.entries(baseStats)) {
addNumericStat(combatStats, key, value);
}
for (const [key, value] of Object.entries(enhancementStats)) {
addNumericStat(combatStats, key, finiteNumber(value, 0) * enhancementMultiplier);
}
if (slotKey === "/item_locations/main_hand" || slotKey === "/item_locations/two_hand") {
if (Array.isArray(baseStats.combatStyleHrids) && baseStats.combatStyleHrids.length > 0) {
weaponStyle = baseStats.combatStyleHrids[0] || weaponStyle;
} else if (typeof baseStats.combatStyleHrid === "string" && baseStats.combatStyleHrid) {
weaponStyle = baseStats.combatStyleHrid;
}
if (typeof baseStats.damageType === "string" && baseStats.damageType) {
weaponDamageType = baseStats.damageType;
}
if (Number.isFinite(Number(baseStats.attackInterval))) {
weaponAttackInterval = Number(baseStats.attackInterval);
}
if (typeof baseStats.primaryTraining === "string" && baseStats.primaryTraining) {
weaponPrimaryTraining = baseStats.primaryTraining;
}
}
if (slotKey === "/item_locations/charm" && typeof baseStats.focusTraining === "string" && baseStats.focusTraining) {
focusTraining = baseStats.focusTraining;
}
}
combatStats.combatStyleHrid = weaponStyle;
combatStats.damageType = weaponDamageType;
combatStats.attackInterval = weaponAttackInterval;
combatStats.primaryTraining = weaponPrimaryTraining;
combatStats.focusTraining = focusTraining;
const profile = {
levels,
combatStats,
extraAttackSpeedRatio: 0,
};
return profile;
}
function createPlayerCombatTemplateForRoom(state, initClientData, room, maxEnhancementByItem) {
const loadoutInfo = resolveCombatRoomLoadout(state, room);
if (!isCombatLoadout(loadoutInfo.loadout)) {
return {
template: createPlayerCombatTemplate(state),
loadoutInfo,
};
}
const profile = buildCombatProfileFromLoadout(state, initClientData, loadoutInfo.loadout, maxEnhancementByItem);
if (!profile) {
return {
template: createPlayerCombatTemplate(state),
loadoutInfo,
};
}
const details = buildPlayerDetailsFromCombatProfile(profile);
if (!details) {
return {
template: createPlayerCombatTemplate(state),
loadoutInfo,
};
}
const template = {
isPlayer: true,
combatDetails: details,
};
applyMazePlayerBonuses(template);
return {
template,
loadoutInfo,
};
}
function getCombatStyleHrid(combatStats) {
if (!combatStats) {
return "/combat_styles/smash";
}
if (Array.isArray(combatStats.combatStyleHrids) && combatStats.combatStyleHrids.length > 0) {
return combatStats.combatStyleHrids[0] || "/combat_styles/smash";
}
return combatStats.combatStyleHrid || "/combat_styles/smash";
}
function getDamageTypeHrid(combatStats) {
if (!combatStats) {
return "/damage_types/physical";
}
return combatStats.damageType || "/damage_types/physical";
}
function getAccuracyRating(details, combatStyle) {
switch (combatStyle) {
case "/combat_styles/stab":
return positiveNumber(details.stabAccuracyRating, 1);
case "/combat_styles/slash":
return positiveNumber(details.slashAccuracyRating, 1);
case "/combat_styles/smash":
return positiveNumber(details.smashAccuracyRating, 1);
case "/combat_styles/ranged":
return positiveNumber(details.rangedAccuracyRating, 1);
case "/combat_styles/magic":
return positiveNumber(details.magicAccuracyRating, 1);
default:
return positiveNumber(details.smashAccuracyRating, 1);
}
}
function getMaxDamage(details, combatStyle) {
switch (combatStyle) {
case "/combat_styles/stab":
return positiveNumber(details.stabMaxDamage, 1);
case "/combat_styles/slash":
return positiveNumber(details.slashMaxDamage, 1);
case "/combat_styles/smash":
return positiveNumber(details.smashMaxDamage, 1);
case "/combat_styles/ranged":
return positiveNumber(details.rangedMaxDamage, 1);
case "/combat_styles/magic":
return positiveNumber(details.magicMaxDamage, 1);
default:
return positiveNumber(details.smashMaxDamage, 1);
}
}
function getEvasionRating(details, combatStyle) {
switch (combatStyle) {
case "/combat_styles/stab":
return positiveNumber(details.stabEvasionRating, 1);
case "/combat_styles/slash":
return positiveNumber(details.slashEvasionRating, 1);
case "/combat_styles/smash":
return positiveNumber(details.smashEvasionRating, 1);
case "/combat_styles/ranged":
return positiveNumber(details.rangedEvasionRating, 1);
case "/combat_styles/magic":
return positiveNumber(details.magicEvasionRating, 1);
default:
return positiveNumber(details.smashEvasionRating, 1);
}
}
function getDamageAmplify(combatStats, damageType) {
switch (damageType) {
case "/damage_types/water":
return finiteNumber(combatStats.waterAmplify, 0);
case "/damage_types/nature":
return finiteNumber(combatStats.natureAmplify, 0);
case "/damage_types/fire":
return finiteNumber(combatStats.fireAmplify, 0);
case "/damage_types/physical":
default:
return finiteNumber(combatStats.physicalAmplify, 0);
}
}
function getResistance(details, damageType) {
switch (damageType) {
case "/damage_types/water":
return finiteNumber(details.totalWaterResistance, 0);
case "/damage_types/nature":
return finiteNumber(details.totalNatureResistance, 0);
case "/damage_types/fire":
return finiteNumber(details.totalFireResistance, 0);
case "/damage_types/physical":
default:
return finiteNumber(details.totalArmor, 0);
}
}
function getPenetration(combatStats, damageType) {
switch (damageType) {
case "/damage_types/water":
return finiteNumber(combatStats.waterPenetration, 0);
case "/damage_types/nature":
return finiteNumber(combatStats.naturePenetration, 0);
case "/damage_types/fire":
return finiteNumber(combatStats.firePenetration, 0);
case "/damage_types/physical":
default:
return finiteNumber(combatStats.armorPenetration, 0);
}
}
function getThorns(combatStats, damageType) {
if (damageType === "/damage_types/physical") {
return finiteNumber(combatStats.physicalThorns, 0);
}
return finiteNumber(combatStats.elementalThorns, 0);
}
function randomBetween(min, max) {
const lo = finiteNumber(min, 0);
const hi = finiteNumber(max, lo);
if (hi <= lo) {
return lo;
}
return lo + Math.random() * (hi - lo);
}
function addHitpoints(unit, amount) {
if (!unit || amount <= 0) {
return 0;
}
const details = unit.combatDetails;
const oldHp = details.currentHitpoints;
details.currentHitpoints = Math.min(details.maxHitpoints, details.currentHitpoints + amount);
return details.currentHitpoints - oldHp;
}
function addManapoints(unit, amount) {
if (!unit || amount <= 0) {
return 0;
}
const details = unit.combatDetails;
const oldMp = details.currentManapoints;
details.currentManapoints = Math.min(details.maxManapoints, details.currentManapoints + amount);
return details.currentManapoints - oldMp;
}
function cloneCombatant(template) {
return {
isPlayer: template.isPlayer,
combatDetails: {
...template.combatDetails,
combatStats: { ...template.combatDetails.combatStats },
currentHitpoints: template.combatDetails.maxHitpoints,
currentManapoints: template.combatDetails.maxManapoints,
},
};
}
function createCombatTemplateFromDetails(rawDetails, isPlayer) {
if (!rawDetails || !rawDetails.combatStats) {
return null;
}
const stats = rawDetails.combatStats;
const style = getCombatStyleHrid(stats);
const damageType = getDamageTypeHrid(stats);
return {
isPlayer,
combatDetails: {
maxHitpoints: positiveNumber(rawDetails.maxHitpoints, 1),
currentHitpoints: positiveNumber(rawDetails.maxHitpoints, 1),
maxManapoints: positiveNumber(rawDetails.maxManapoints, 1),
currentManapoints: positiveNumber(rawDetails.maxManapoints, 1),
attackLevel: positiveNumber(rawDetails.attackLevel, 1),
meleeLevel: positiveNumber(rawDetails.meleeLevel, 1),
defenseLevel: positiveNumber(rawDetails.defenseLevel, 1),
rangedLevel: positiveNumber(rawDetails.rangedLevel, 1),
magicLevel: positiveNumber(rawDetails.magicLevel, 1),
staminaLevel: positiveNumber(rawDetails.staminaLevel, 1),
intelligenceLevel: positiveNumber(rawDetails.intelligenceLevel, 1),
stabAccuracyRating: positiveNumber(rawDetails.stabAccuracyRating, 1),
slashAccuracyRating: positiveNumber(rawDetails.slashAccuracyRating, 1),
smashAccuracyRating: positiveNumber(rawDetails.smashAccuracyRating, 1),
rangedAccuracyRating: positiveNumber(rawDetails.rangedAccuracyRating, 1),
magicAccuracyRating: positiveNumber(rawDetails.magicAccuracyRating, 1),
stabMaxDamage: positiveNumber(rawDetails.stabMaxDamage, 1),
slashMaxDamage: positiveNumber(rawDetails.slashMaxDamage, 1),
smashMaxDamage: positiveNumber(rawDetails.smashMaxDamage, 1),
rangedMaxDamage: positiveNumber(rawDetails.rangedMaxDamage, 1),
magicMaxDamage: positiveNumber(rawDetails.magicMaxDamage, 1),
stabEvasionRating: positiveNumber(rawDetails.stabEvasionRating, 1),
slashEvasionRating: positiveNumber(rawDetails.slashEvasionRating, 1),
smashEvasionRating: positiveNumber(rawDetails.smashEvasionRating, 1),
rangedEvasionRating: positiveNumber(rawDetails.rangedEvasionRating, 1),
magicEvasionRating: positiveNumber(rawDetails.magicEvasionRating, 1),
defensiveMaxDamage: positiveNumber(rawDetails.defensiveMaxDamage, 1),
totalArmor: finiteNumber(rawDetails.totalArmor, 0),
totalWaterResistance: finiteNumber(rawDetails.totalWaterResistance, 0),
totalNatureResistance: finiteNumber(rawDetails.totalNatureResistance, 0),
totalFireResistance: finiteNumber(rawDetails.totalFireResistance, 0),
combatStats: {
combatStyleHrid: style,
damageType,
attackInterval: positiveNumber(rawDetails.attackInterval || stats.attackInterval, COMBAT_ONE_SECOND_NS),
autoAttackDamage: finiteNumber(stats.autoAttackDamage, 0),
criticalRate: finiteNumber(stats.criticalRate, 0),
criticalDamage: finiteNumber(stats.criticalDamage, 0),
taskDamage: finiteNumber(stats.taskDamage, 0),
damageTaken: finiteNumber(stats.damageTaken, 0),
attackSpeed: finiteNumber(stats.attackSpeed, 0),
armorPenetration: finiteNumber(stats.armorPenetration, 0),
waterPenetration: finiteNumber(stats.waterPenetration, 0),
naturePenetration: finiteNumber(stats.naturePenetration, 0),
firePenetration: finiteNumber(stats.firePenetration, 0),
physicalAmplify: finiteNumber(stats.physicalAmplify, 0),
waterAmplify: finiteNumber(stats.waterAmplify, 0),
natureAmplify: finiteNumber(stats.natureAmplify, 0),
fireAmplify: finiteNumber(stats.fireAmplify, 0),
physicalThorns: finiteNumber(stats.physicalThorns, 0),
elementalThorns: finiteNumber(stats.elementalThorns, 0),
retaliation: finiteNumber(stats.retaliation, 0),
lifeSteal: finiteNumber(stats.lifeSteal, 0),
manaLeech: finiteNumber(stats.manaLeech, 0),
},
},
};
}
function applyMazePlayerBonuses(playerTemplate) {
if (!playerTemplate) {
return;
}
const details = playerTemplate.combatDetails;
const stats = details.combatStats;
const oldAttackLevel = positiveNumber(details.attackLevel, 1);
const oldMeleeLevel = positiveNumber(details.meleeLevel, 1);
const oldDefenseLevel = positiveNumber(details.defenseLevel, 1);
const oldRangedLevel = positiveNumber(details.rangedLevel, 1);
const oldMagicLevel = positiveNumber(details.magicLevel, 1);
details.attackLevel += COMBAT_MAZE_PLAYER_LEVEL_BONUS;
details.meleeLevel += COMBAT_MAZE_PLAYER_LEVEL_BONUS;
details.defenseLevel += COMBAT_MAZE_PLAYER_LEVEL_BONUS;
details.rangedLevel += COMBAT_MAZE_PLAYER_LEVEL_BONUS;
details.magicLevel += COMBAT_MAZE_PLAYER_LEVEL_BONUS;
details.staminaLevel += COMBAT_MAZE_PLAYER_LEVEL_BONUS;
details.intelligenceLevel += COMBAT_MAZE_PLAYER_LEVEL_BONUS;
const attackRatio = (10 + details.attackLevel) / (10 + oldAttackLevel);
const meleeRatio = (10 + details.meleeLevel) / (10 + oldMeleeLevel);
const defenseRatio = (10 + details.defenseLevel) / (10 + oldDefenseLevel);
const rangedRatio = (10 + details.rangedLevel) / (10 + oldRangedLevel);
const magicRatio = (10 + details.magicLevel) / (10 + oldMagicLevel);
details.stabAccuracyRating *= attackRatio;
details.slashAccuracyRating *= attackRatio;
details.smashAccuracyRating *= attackRatio;
details.rangedAccuracyRating *= attackRatio;
details.magicAccuracyRating *= attackRatio;
details.stabMaxDamage *= meleeRatio;
details.slashMaxDamage *= meleeRatio;
details.smashMaxDamage *= meleeRatio;
details.rangedMaxDamage *= rangedRatio;
details.magicMaxDamage *= magicRatio;
details.defensiveMaxDamage *= defenseRatio;
details.stabEvasionRating *= defenseRatio;
details.slashEvasionRating *= defenseRatio;
details.smashEvasionRating *= defenseRatio;
details.rangedEvasionRating *= defenseRatio;
details.magicEvasionRating *= defenseRatio;
details.totalArmor += COMBAT_MAZE_PLAYER_LEVEL_BONUS * 0.2;
details.totalWaterResistance += COMBAT_MAZE_PLAYER_LEVEL_BONUS * 0.2;
details.totalNatureResistance += COMBAT_MAZE_PLAYER_LEVEL_BONUS * 0.2;
details.totalFireResistance += COMBAT_MAZE_PLAYER_LEVEL_BONUS * 0.2;
details.maxHitpoints += COMBAT_MAZE_PLAYER_LEVEL_BONUS * 10;
details.maxManapoints += COMBAT_MAZE_PLAYER_LEVEL_BONUS * 10;
details.currentHitpoints = details.maxHitpoints;
details.currentManapoints = details.maxManapoints;
const attackInterval = positiveNumber(stats.attackInterval, COMBAT_ONE_SECOND_NS);
const levelAdjusted = attackInterval * ((1 + oldAttackLevel / 2000) / (1 + details.attackLevel / 2000));
stats.attackInterval = levelAdjusted / (1 + COMBAT_MAZE_PLAYER_ATTACK_SPEED_BONUS);
stats.hpRegenPer10 = Math.max(0, finiteNumber(stats.hpRegenPer10, 0) + COMBAT_MAZE_PLAYER_REGEN_BONUS);
stats.mpRegenPer10 = Math.max(0, finiteNumber(stats.mpRegenPer10, 0) + COMBAT_MAZE_PLAYER_REGEN_BONUS);
stats.criticalRate += COMBAT_MAZE_PLAYER_CRIT_RATE_BONUS;
stats.criticalDamage += COMBAT_MAZE_PLAYER_CRIT_DAMAGE_BONUS;
}
function createPlayerCombatTemplate(state) {
const rawDetails = state?.combatUnit?.combatDetails;
const template = createCombatTemplateFromDetails(rawDetails, true);
if (!template) {
return null;
}
applyMazePlayerBonuses(template);
return template;
}
function createMonsterCombatTemplate(initClientData, room) {
const monsterDetails = initClientData?.combatMonsterDetailMap?.[room?.monsterHrid]?.combatDetails;
if (!monsterDetails) {
return null;
}
const details = buildMazeMonsterDetailsForRoom(monsterDetails, room?.recommendedLevel);
if (!details) {
const fallback = createCombatTemplateFromDetails(monsterDetails, false);
return fallback || null;
}
return {
isPlayer: false,
combatDetails: details,
};
}
function simulateAutoAttack(source, target, options = {}) {
if (!source || !target) {
return;
}
const sourceStats = source.combatDetails.combatStats;
const targetStats = target.combatDetails.combatStats;
const combatStyle = sourceStats.combatStyleHrid || "/combat_styles/smash";
const damageType = sourceStats.damageType || "/damage_types/physical";
const sourceAccuracyRating = getAccuracyRating(source.combatDetails, combatStyle);
const sourceMaxDamage = getMaxDamage(source.combatDetails, combatStyle);
const targetEvasionRating = getEvasionRating(target.combatDetails, combatStyle);
const sourceDamageMultiplier = 1 + getDamageAmplify(sourceStats, damageType);
const sourceResistance = getResistance(source.combatDetails, damageType);
const sourcePenetration = getPenetration(sourceStats, damageType);
const targetResistance = getResistance(target.combatDetails, damageType);
const targetThorns = getThorns(targetStats, damageType);
const targetPenetration = getPenetration(targetStats, damageType);
const hitChance = clamp01(
Math.pow(Math.max(1, sourceAccuracyRating), 1.4) /
(Math.pow(Math.max(1, sourceAccuracyRating), 1.4) + Math.pow(Math.max(1, targetEvasionRating), 1.4))
);
let critChance = combatStyle === "/combat_styles/ranged" ? 0.3 * hitChance : 0;
critChance = clamp01(critChance + finiteNumber(sourceStats.criticalRate, 0));
let minDamage = sourceDamageMultiplier;
let maxDamage = sourceDamageMultiplier * sourceMaxDamage;
if (Math.random() < critChance) {
maxDamage *= 1 + finiteNumber(sourceStats.criticalDamage, 0);
minDamage = maxDamage;
}
let damageRoll = randomBetween(minDamage, maxDamage);
damageRoll *= 1 + finiteNumber(sourceStats.taskDamage, 0);
damageRoll *= 1 + finiteNumber(targetStats.damageTaken, 0);
damageRoll += damageRoll * finiteNumber(sourceStats.autoAttackDamage, 0);
damageRoll = Math.max(0, damageRoll);
let damageDone = 0;
let didHit = false;
const forceKill = Boolean(options.mazeInstantKill && !source.isPlayer && target.isPlayer);
if (forceKill) {
didHit = true;
damageDone = target.combatDetails.currentHitpoints;
target.combatDetails.currentHitpoints = 0;
} else if (Math.random() < hitChance) {
didHit = true;
let penetratedTargetResistance = targetResistance;
if (sourcePenetration > 0 && targetResistance > 0) {
penetratedTargetResistance = targetResistance / (1 + sourcePenetration);
}
let targetDamageTakenRatio = 100 / (100 + penetratedTargetResistance);
if (penetratedTargetResistance < 0) {
targetDamageTakenRatio = (100 - penetratedTargetResistance) / 100;
}
const mitigatedDamage = Math.ceil(targetDamageTakenRatio * damageRoll);
damageDone = Math.min(mitigatedDamage, target.combatDetails.currentHitpoints);
target.combatDetails.currentHitpoints -= damageDone;
}
if (targetThorns > 0 && targetResistance > -99) {
let penetratedSourceResistance = sourceResistance;
if (sourceResistance > 0) {
penetratedSourceResistance = sourceResistance / (1 + targetPenetration);
}
let sourceDamageTakenRatio = 100 / (100 + penetratedSourceResistance);
if (penetratedSourceResistance < 0) {
sourceDamageTakenRatio = (100 - penetratedSourceResistance) / 100;
}
const targetDamageMultiplier = (1 + finiteNumber(targetStats.taskDamage, 0)) * (1 + finiteNumber(sourceStats.damageTaken, 0));
const thornsMax = targetDamageMultiplier * target.combatDetails.defensiveMaxDamage * (1 + targetResistance / 100) * targetThorns;
const thornsRoll = randomBetween(1, Math.max(1, thornsMax));
const mitigatedThornsDamage = Math.ceil(sourceDamageTakenRatio * thornsRoll);
const thornDamageDone = Math.min(mitigatedThornsDamage, source.combatDetails.currentHitpoints);
source.combatDetails.currentHitpoints -= thornDamageDone;
}
if (finiteNumber(targetStats.retaliation, 0) > 0) {
const retaliationHitChance = clamp01(
Math.pow(Math.max(1, target.combatDetails.smashAccuracyRating), 1.4) /
(Math.pow(Math.max(1, target.combatDetails.smashAccuracyRating), 1.4) + Math.pow(Math.max(1, source.combatDetails.smashEvasionRating), 1.4))
);
if (Math.random() < retaliationHitChance) {
let sourceArmor = source.combatDetails.totalArmor;
if (sourceArmor > 0) {
sourceArmor = sourceArmor / (1 + finiteNumber(targetStats.armorPenetration, 0));
}
let sourceDamageTakenRatio = 100 / (100 + sourceArmor);
if (sourceArmor < 0) {
sourceDamageTakenRatio = (100 - sourceArmor) / 100;
}
const retaliationMultiplier = (1 + finiteNumber(targetStats.taskDamage, 0)) * (1 + finiteNumber(sourceStats.damageTaken, 0));
const premitigatedDamage = Math.min(damageRoll, target.combatDetails.defensiveMaxDamage * 5);
const retaliationMinDamage = retaliationMultiplier * targetStats.retaliation * premitigatedDamage;
const retaliationMaxDamage = retaliationMultiplier * targetStats.retaliation * (target.combatDetails.defensiveMaxDamage + premitigatedDamage);
const retaliationRoll = randomBetween(retaliationMinDamage, retaliationMaxDamage);
const mitigatedRetaliationDamage = Math.ceil(sourceDamageTakenRatio * retaliationRoll);
const retaliationDone = Math.min(mitigatedRetaliationDamage, source.combatDetails.currentHitpoints);
source.combatDetails.currentHitpoints -= retaliationDone;
}
}
if (didHit && finiteNumber(sourceStats.lifeSteal, 0) > 0) {
addHitpoints(source, Math.floor(sourceStats.lifeSteal * damageDone));
}
if (didHit && finiteNumber(sourceStats.manaLeech, 0) > 0) {
addManapoints(source, Math.floor(sourceStats.manaLeech * damageDone));
}
}
function runCombatSimulation(playerTemplate, monsterTemplate) {
const player = cloneCombatant(playerTemplate);
const monster = cloneCombatant(monsterTemplate);
const timeLimitNs = ROOM_DURATION_SECONDS * COMBAT_ONE_SECOND_NS;
let simulationTimeNs = 0;
const playerAttackInterval = positiveNumber(player.combatDetails.combatStats.attackInterval, COMBAT_ONE_SECOND_NS);
const monsterAttackInterval = positiveNumber(monster.combatDetails.combatStats.attackInterval, COMBAT_ONE_SECOND_NS);
let nextPlayerAttackTime = playerAttackInterval;
let nextMonsterAttackTime = monsterAttackInterval;
while (player.combatDetails.currentHitpoints > 0 && monster.combatDetails.currentHitpoints > 0) {
if (nextPlayerAttackTime <= nextMonsterAttackTime) {
simulationTimeNs = nextPlayerAttackTime;
if (simulationTimeNs > timeLimitNs) {
break;
}
nextPlayerAttackTime += playerAttackInterval;
simulateAutoAttack(player, monster, { mazeInstantKill: false });
} else {
simulationTimeNs = nextMonsterAttackTime;
if (simulationTimeNs > timeLimitNs) {
break;
}
nextMonsterAttackTime += monsterAttackInterval;
simulateAutoAttack(monster, player, { mazeInstantKill: simulationTimeNs >= timeLimitNs });
}
}
if (monster.combatDetails.currentHitpoints <= 0 && player.combatDetails.currentHitpoints > 0 && simulationTimeNs <= timeLimitNs) {
return {
cleared: true,
elapsedSeconds: Math.max(0, simulationTimeNs / COMBAT_ONE_SECOND_NS),
};
}
if (simulationTimeNs >= timeLimitNs) {
return { cleared: false, elapsedSeconds: ROOM_DURATION_SECONDS };
}
const failSeconds = Math.max(0, simulationTimeNs / COMBAT_ONE_SECOND_NS);
return { cleared: false, elapsedSeconds: failSeconds > 0 ? Math.min(ROOM_DURATION_SECONDS, failSeconds) : ROOM_DURATION_SECONDS };
}
function getCombatPlayerSignature(state) {
const details = state?.combatUnit?.combatDetails;
if (!details || !details.combatStats) {
return "";
}
const stats = details.combatStats;
const signatureParts = [
getCombatStyleHrid(stats),
getDamageTypeHrid(stats),
roundForSignature(details.maxHitpoints),
roundForSignature(details.maxManapoints),
roundForSignature(details.attackLevel),
roundForSignature(details.meleeLevel),
roundForSignature(details.defenseLevel),
roundForSignature(details.rangedLevel),
roundForSignature(details.magicLevel),
roundForSignature(details.stabAccuracyRating),
roundForSignature(details.slashAccuracyRating),
roundForSignature(details.smashAccuracyRating),
roundForSignature(details.rangedAccuracyRating),
roundForSignature(details.magicAccuracyRating),
roundForSignature(details.stabMaxDamage),
roundForSignature(details.slashMaxDamage),
roundForSignature(details.smashMaxDamage),
roundForSignature(details.rangedMaxDamage),
roundForSignature(details.magicMaxDamage),
roundForSignature(details.stabEvasionRating),
roundForSignature(details.slashEvasionRating),
roundForSignature(details.smashEvasionRating),
roundForSignature(details.rangedEvasionRating),
roundForSignature(details.magicEvasionRating),
roundForSignature(details.defensiveMaxDamage),
roundForSignature(details.totalArmor),
roundForSignature(details.totalWaterResistance),
roundForSignature(details.totalNatureResistance),
roundForSignature(details.totalFireResistance),
roundForSignature(stats.attackInterval || details.attackInterval),
roundForSignature(stats.autoAttackDamage),
roundForSignature(stats.criticalRate),
roundForSignature(stats.criticalDamage),
roundForSignature(stats.taskDamage),
roundForSignature(stats.damageTaken),
roundForSignature(stats.armorPenetration),
roundForSignature(stats.waterPenetration),
roundForSignature(stats.naturePenetration),
roundForSignature(stats.firePenetration),
roundForSignature(stats.physicalAmplify),
roundForSignature(stats.waterAmplify),
roundForSignature(stats.natureAmplify),
roundForSignature(stats.fireAmplify),
roundForSignature(stats.physicalThorns),
roundForSignature(stats.elementalThorns),
roundForSignature(stats.retaliation),
roundForSignature(stats.lifeSteal),
roundForSignature(stats.manaLeech),
];
return signatureParts.join("|");
}
function getCombatRoomSignature(state, initClientData, room, maxEnhancementByItem, options = {}) {
const includePersonalBuffs = !(options && options.includePersonalBuffs === false);
const baseSignature = getCombatPlayerSignature(state);
const combatCrate = getCombatCrateBuffs(state, initClientData);
const labyrinthUpgradeLevels = resolveLabyrinthUpgradeLevels(options);
const labyrinthCombatBuffs = buildLabyrinthCombatBuffs(labyrinthUpgradeLevels);
const personalSealItemHrids = getCombatSimulatorPersonalSealItemHrids(state, {
includePersonalBuffs,
selectedSealItemHrids: Array.isArray(options?.selectedSealItemHrids) ? options.selectedSealItemHrids : [],
});
const combatCrateSignature = String(combatCrate.combatCrateSignature || combatCrate.teaCrateItemHrid || "");
const crateBuffHash = hashString(stableStringify(combatCrate.combatBuffs || []));
const labyrinthBuffHash = hashString(stableStringify(labyrinthCombatBuffs || []));
const personalBuffHash = hashString(stableStringify(personalSealItemHrids || []));
const monsterDetailHash = hashString(stableStringify(initClientData?.combatMonsterDetailMap?.[room?.monsterHrid] || null));
const loadoutInfo = resolveCombatRoomLoadout(state, room);
const loadout = loadoutInfo.loadout;
if (!isCombatLoadout(loadout)) {
return [
"fallback",
`model=${COMBAT_MODEL_SIGNATURE}`,
`monster=${room?.monsterHrid || ""}`,
`monsterData=${monsterDetailHash}`,
`crate=${combatCrateSignature}`,
`crateBuff=${crateBuffHash}`,
`labBuff=${labyrinthBuffHash}`,
`pMode=${includePersonalBuffs ? 1 : 0}`,
`pBuff=${personalBuffHash}`,
baseSignature,
].join("|");
}
const levelParts = [];
const levels = getCombatSkillLevelsFromState(state);
for (const [key, value] of Object.entries(levels)) {
levelParts.push(`${key}:${roundForSignature(value)}`);
}
levelParts.sort();
const wearableParts = [];
for (const [slotKey, rawRef] of Object.entries(loadout.wearableMap || {})) {
const entry = parseWearableReference(rawRef);
if (!entry || !entry.itemHrid) {
continue;
}
const itemDetail = getItemDetailByHrid(state, initClientData, entry.itemHrid);
const equipmentType = itemDetail?.equipmentDetail?.type;
if (!shouldIncludeCombatEquipment(entry.itemHrid, equipmentType)) {
continue;
}
const enh = resolveWearableEnhancement(entry, loadout, maxEnhancementByItem);
wearableParts.push(`${slotKey}:${entry.itemHrid}:${enh}`);
}
wearableParts.sort();
const abilityParts = [];
const abilityMap = loadout.abilityMap || {};
for (let slot = 1; slot <= COMBAT_SLOT_COUNT; slot += 1) {
const abilityHrid = String(abilityMap[slot] || abilityMap[String(slot)] || "");
if (!abilityHrid) {
continue;
}
const level = getAbilityLevelFromState(state, abilityHrid);
abilityParts.push(`${slot}:${abilityHrid}:${level}`);
}
abilityParts.sort();
const abilityTriggerHash = hashString(stableStringify(loadout.abilityCombatTriggersMap || {}));
const houseHash = hashString(stableStringify(buildHouseRoomLevelMap(state)));
const achievementHash = hashString(stableStringify(buildAchievementCompletionMap(state)));
return [
`model=${COMBAT_MODEL_SIGNATURE}`,
`monster=${room?.monsterHrid || ""}`,
`monsterData=${monsterDetailHash}`,
`loadout=${loadoutInfo.loadoutId}`,
`source=${loadoutInfo.source || "room"}`,
`selected=${loadoutInfo.selectedLoadoutId || loadoutInfo.loadoutId || 0}`,
`exact=${loadout.useExactEnhancement ? 1 : 0}`,
`setting=${loadoutInfo.settingKey}`,
`crate=${combatCrateSignature}`,
`crateBuff=${crateBuffHash}`,
`labBuff=${labyrinthBuffHash}`,
`pMode=${includePersonalBuffs ? 1 : 0}`,
`pBuff=${personalBuffHash}`,
`levels=${levelParts.join(",")}`,
`ability=${abilityParts.join(",")}`,
`aTrig=${abilityTriggerHash}`,
`house=${houseHash}`,
`ach=${achievementHash}`,
`wear=${wearableParts.join(",")}`,
`fallback=${baseSignature}`,
].join("|");
}
async function computeCombatRoomClearChanceFullFlow(
state,
initClientData,
room,
maxEnhancementByItem,
progressTracker,
combatTrials,
roomKey = "",
options = {}
) {
const roomLevel = positiveNumber(room?.recommendedLevel, 1);
const trials = normalizeCombatSimTrials(combatTrials);
const combatCrate = getCombatCrateBuffs(state, initClientData);
const labyrinthUpgradeLevels = resolveLabyrinthUpgradeLevels(options);
const labyrinthCombatBuffs = buildLabyrinthCombatBuffs(labyrinthUpgradeLevels);
const includePersonalBuffs = !(options && options.includePersonalBuffs === false);
const playerPersonalBuffItemHrids = getCombatSimulatorPersonalSealItemHrids(state, {
includePersonalBuffs,
selectedSealItemHrids: Array.isArray(options?.selectedSealItemHrids) ? options.selectedSealItemHrids : [],
});
const combatCrateSignature = String(combatCrate.combatCrateSignature || combatCrate.teaCrateItemHrid || "");
const playerData = buildCombatPlayerDtoForRoom(state, initClientData, room, maxEnhancementByItem);
if (!playerData.playerDto) {
return null;
}
const inputSnapshot = buildCombatInputSnapshot(playerData, room);
if (inputSnapshot) {
inputSnapshot.roomKey = String(roomKey || "");
}
const workerResult = await simulateCombatRoomWithWorker(
{
playerDto: playerData.playerDto,
playerPersonalBuffItemHrids,
labyrinthCombatBuffs,
monsterHrid: room.monsterHrid,
mazeDifficulty: roomLevel,
roomDurationSeconds: ROOM_DURATION_SECONDS,
mazeCrateItemHrids: Array.isArray(combatCrate.combatCrateItemHrids)
? combatCrate.combatCrateItemHrids.slice()
: [],
trials,
},
progressTracker
);
const executedTrials = Math.max(0, Math.floor(finiteNumber(workerResult?.trials, trials)));
const successes = Math.max(0, Math.floor(finiteNumber(workerResult?.successes, 0)));
const totalSpentSeconds = Math.max(0, finiteNumber(workerResult?.totalSpentSeconds, 0));
const minElapsedSeconds = Math.max(0, finiteNumber(workerResult?.minElapsedSeconds, 0));
const maxElapsedSeconds = Math.max(minElapsedSeconds, finiteNumber(workerResult?.maxElapsedSeconds, minElapsedSeconds));
const failedByTimeoutRaw = Math.max(0, Math.floor(finiteNumber(workerResult?.failedByTimeout, 0)));
const failedByDeathRaw = Math.max(0, Math.floor(finiteNumber(workerResult?.failedByDeath, 0)));
const firstRunDebug = workerResult?.firstRunDebug || null;
const clearChance = executedTrials > 0 ? clamp01(successes / executedTrials) : 0;
const expectedSecondsPerClear = successes > 0 ? totalSpentSeconds / successes : Infinity;
const totalFailures = Math.max(0, executedTrials - successes);
const knownFailures = Math.max(0, failedByTimeoutRaw + failedByDeathRaw);
const failedByTimeout = knownFailures >= totalFailures ? failedByTimeoutRaw : failedByTimeoutRaw + (totalFailures - knownFailures);
const failedByDeath = knownFailures >= totalFailures ? failedByDeathRaw : failedByDeathRaw;
const failureReason = clearChance < 1 ? deriveCombatFailureReasonFromCounts(totalFailures, failedByTimeout, failedByDeath) : "";
return {
clearChance,
expectedSecondsPerClear,
combatMeta: {
source: "full",
roomKey: String(roomKey || ""),
monsterHrid: String(room?.monsterHrid || ""),
roomLevel,
trials: executedTrials,
successes,
failures: totalFailures,
failedByTimeout,
failedByDeath,
failureReason,
totalSpentSeconds,
minElapsedSeconds,
maxElapsedSeconds,
expectedSecondsPerClearRaw: expectedSecondsPerClear,
loadoutId: playerData.loadoutInfo.loadoutId || 0,
loadoutName: playerData.loadoutInfo.loadout?.name || "",
loadoutMode: playerData.loadoutInfo.loadout?.useExactEnhancement ? "exact" : "highest",
loadoutSource: playerData.loadoutInfo.source || "room",
selectedLoadoutId: playerData.loadoutInfo.selectedLoadoutId || playerData.loadoutInfo.loadoutId || 0,
mazeCrateItemHrid: combatCrateSignature,
mazeCrateItemHrids: Array.isArray(combatCrate.combatCrateItemHrids) ? combatCrate.combatCrateItemHrids.slice() : [],
mazeCrateBuffCount: Array.isArray(combatCrate.combatBuffs) ? combatCrate.combatBuffs.length : 0,
labyrinthBuffCount: Array.isArray(labyrinthCombatBuffs) ? labyrinthCombatBuffs.length : 0,
personalBuffCount: Array.isArray(playerPersonalBuffItemHrids) ? playerPersonalBuffItemHrids.length : 0,
combatInputSnapshot: inputSnapshot,
firstRunDebug: firstRunDebug || null,
},
debug: [
"combat-full",
`monster=${room.monsterHrid.split("/").pop() || room.monsterHrid}`,
`loadout=${playerData.loadoutInfo.loadout?.name || playerData.loadoutInfo.loadoutId || "current"}`,
`source=${playerData.loadoutInfo.source || "room"}`,
playerData.loadoutInfo.selectedLoadoutId && playerData.loadoutInfo.selectedLoadoutId !== playerData.loadoutInfo.loadoutId
? `selected=${playerData.loadoutInfo.selectedLoadoutId}`
: "",
`mode=${playerData.loadoutInfo.loadout?.useExactEnhancement ? "exact" : "highest"}`,
`crate=${combatCrateSignature || "none"}`,
getLabyrinthUpgradeLevel(labyrinthUpgradeLevels, LABYRINTH_UPGRADE_KEY_COMBAT_DAMAGE) > 0
? `labDmg+${(
getLabyrinthUpgradeLevel(labyrinthUpgradeLevels, LABYRINTH_UPGRADE_KEY_COMBAT_DAMAGE) *
LABYRINTH_UPGRADE_STEP_RATIO *
100
).toFixed(1)}%`
: "",
getLabyrinthUpgradeLevel(labyrinthUpgradeLevels, LABYRINTH_UPGRADE_KEY_ATTACK_SPEED) > 0
? `labAtkSpd+${(
getLabyrinthUpgradeLevel(labyrinthUpgradeLevels, LABYRINTH_UPGRADE_KEY_ATTACK_SPEED) *
LABYRINTH_UPGRADE_STEP_RATIO *
100
).toFixed(1)}%`
: "",
getLabyrinthUpgradeLevel(labyrinthUpgradeLevels, LABYRINTH_UPGRADE_KEY_CAST_SPEED) > 0
? `labCastSpd+${(
getLabyrinthUpgradeLevel(labyrinthUpgradeLevels, LABYRINTH_UPGRADE_KEY_CAST_SPEED) *
LABYRINTH_UPGRADE_STEP_RATIO *
100
).toFixed(1)}%`
: "",
getLabyrinthUpgradeLevel(labyrinthUpgradeLevels, LABYRINTH_UPGRADE_KEY_CRITICAL_RATE) > 0
? `labCrit+${(
getLabyrinthUpgradeLevel(labyrinthUpgradeLevels, LABYRINTH_UPGRADE_KEY_CRITICAL_RATE) *
LABYRINTH_UPGRADE_STEP_RATIO *
100
).toFixed(1)}%`
: "",
Array.isArray(playerPersonalBuffItemHrids) && playerPersonalBuffItemHrids.length > 0
? `pBuff=${playerPersonalBuffItemHrids.length}`
: "",
`roomLv=${roomLevel}`,
`trials=${executedTrials}`,
`wins=${successes}`,
`t=${minElapsedSeconds.toFixed(2)}-${maxElapsedSeconds.toFixed(2)}s`,
firstRunDebug && Number.isFinite(Number(firstRunDebug.encounters))
? `enc=${Math.max(0, Math.floor(finiteNumber(firstRunDebug.encounters, 0)))}`
: "",
firstRunDebug && Number.isFinite(Number(firstRunDebug.simulatedTime))
? `simT=${(finiteNumber(firstRunDebug.simulatedTime, 0) / COMBAT_ONE_SECOND_NS).toFixed(2)}s`
: "",
firstRunDebug && firstRunDebug.deaths && typeof firstRunDebug.deaths === "object"
? `deaths=${Object.values(firstRunDebug.deaths).reduce(
(sum, value) => sum + Math.max(0, Math.floor(finiteNumber(value, 0))),
0
)}`
: "",
totalFailures > 0 ? `failT=${failedByTimeout}` : "",
totalFailures > 0 ? `failD=${failedByDeath}` : "",
failureReason ? `why=${failureReason}` : "",
`eta=${formatEtaText(expectedSecondsPerClear, Math.round(clearChance * 100))}`,
].join(" | "),
};
}
async function computeCombatRoomClearChance(
state,
initClientData,
room,
maxEnhancementByItem,
progressTracker,
combatTrials,
roomKey = "",
options = {}
) {
if (!room || room.roomType !== LABYRINTH_COMBAT_ROOM_TYPE || !room.monsterHrid) {
return null;
}
const includePersonalBuffs = !(options && options.includePersonalBuffs === false);
const labyrinthUpgradeLevels = resolveLabyrinthUpgradeLevels(options);
const playerSignature = getCombatRoomSignature(state, initClientData, room, maxEnhancementByItem, {
includePersonalBuffs,
selectedSealItemHrids: Array.isArray(options?.selectedSealItemHrids) ? options.selectedSealItemHrids : [],
labyrinthUpgradeLevels,
});
if (!playerSignature) {
return null;
}
const roomLevel = positiveNumber(room.recommendedLevel, 1);
const trials = normalizeCombatSimTrials(combatTrials);
const disableCache = Boolean(options && options.disableCache);
const cacheKey = `${room.monsterHrid}|${roomLevel}|trials=${trials}|${playerSignature}`;
if (!disableCache) {
const cached = combatEstimateCache.get(cacheKey);
if (cached) {
if (progressTracker) {
progressTracker.add(trials);
}
return {
...cached,
combatMeta: {
...(cached.combatMeta || {}),
source: "cache",
roomKey: String(roomKey || cached.combatMeta?.roomKey || ""),
cacheKey,
},
};
}
}
let result = null;
try {
result = await computeCombatRoomClearChanceFullFlow(
state,
initClientData,
room,
maxEnhancementByItem,
progressTracker,
trials,
roomKey,
{
includePersonalBuffs,
selectedSealItemHrids: Array.isArray(options?.selectedSealItemHrids) ? options.selectedSealItemHrids : [],
labyrinthUpgradeLevels,
}
);
} catch (error) {
const fatalError = error instanceof Error ? error : new Error(String(error));
console.error("[Lab Clear Rate] full combat simulation failed:", fatalError);
resetCombatSimulatorWorker(error instanceof Error ? error : new Error(String(error)));
combatWorkerScriptPromise = null;
const alertMessage = t("combatFlowFailedFmt", { message: fatalError.message || String(fatalError) });
if (typeof window !== "undefined" && typeof window.alert === "function") {
window.alert(alertMessage);
}
throw fatalError;
}
if (!result) {
return null;
}
const cacheableResult = {
...result,
combatMeta: {
...(result.combatMeta || {}),
source: "full",
roomKey: String(roomKey || result.combatMeta?.roomKey || ""),
cacheKey,
},
};
if (!disableCache) {
combatEstimateCache.set(cacheKey, cacheableResult);
if (combatEstimateCache.size > COMBAT_SIM_CACHE_LIMIT) {
const oldestKey = combatEstimateCache.keys().next().value;
if (oldestKey) {
combatEstimateCache.delete(oldestKey);
}
}
}
return cacheableResult;
}
function isCalculableRoom(room) {
return room?.roomType === LABYRINTH_COMBAT_ROOM_TYPE || room?.roomType === LABYRINTH_SKILLING_ROOM_TYPE;
}
function getTotalProgressUnits(rooms, combatTrials) {
const trials = normalizeCombatSimTrials(combatTrials);
let total = 0;
for (const room of rooms) {
if (room?.roomType === LABYRINTH_COMBAT_ROOM_TYPE) {
total += trials;
} else if (room?.roomType === LABYRINTH_SKILLING_ROOM_TYPE) {
total += 1;
}
}
return total;
}
function getTotalProgressUnitsForIndexes(rooms, indexes, combatTrials) {
const trials = normalizeCombatSimTrials(combatTrials);
let total = 0;
for (const index of indexes) {
const room = rooms[index];
if (room?.roomType === LABYRINTH_COMBAT_ROOM_TYPE) {
total += trials;
} else if (room?.roomType === LABYRINTH_SKILLING_ROOM_TYPE) {
total += 1;
}
}
return total;
}
function parseRoomKeyToIndex(roomKey, colCount, totalCount) {
if (!roomKey || !Number.isInteger(colCount) || colCount <= 0) {
return -1;
}
const [xRaw, yRaw] = String(roomKey).split(",");
const x = Number(xRaw);
const y = Number(yRaw);
if (!Number.isInteger(x) || !Number.isInteger(y)) {
return -1;
}
if (x < 0 || y < 0 || x >= colCount) {
return -1;
}
const index = y * colCount + x;
if (!Number.isInteger(index) || index < 0 || index >= totalCount) {
return -1;
}
return index;
}
function getTargetRoomIndexes(flatRooms, colCount, roomKeys) {
const totalCount = Array.isArray(flatRooms) ? flatRooms.length : 0;
if (!totalCount) {
return [];
}
if (!Array.isArray(roomKeys) || roomKeys.length === 0) {
return Array.from({ length: totalCount }, (_unused, index) => index);
}
const indexes = new Set();
for (const roomKey of roomKeys) {
const index = parseRoomKeyToIndex(roomKey, colCount, totalCount);
if (index < 0) {
continue;
}
if (!isCalculableRoom(flatRooms[index])) {
continue;
}
indexes.add(index);
}
return Array.from(indexes).sort((a, b) => a - b);
}
function refreshControlPanelPlacement() {
syncVisibleLabyrinthUpgradeLevelsCache();
const state = getGameState();
refreshLiveActionRateDisplay(state);
syncRoomLogSessionState(state);
if (!state || !state.characterLabyrinth) {
markLabyrinthTransition("");
hidePreviewTooltip();
clearAutomationWideLayout();
automationEstimateByRoomTypeKey.clear();
automationEstimateSignatureByRoomTypeKey.clear();
automationRecommendByRoomTypeKey.clear();
automationRecommendSignatureByRoomTypeKey.clear();
automationEstimateStatusText = t("pending");
automationEstimateRunning = false;
automationEstimateRunningMode = "";
automationEstimateColumnEnabled = false;
automationRecommendColumnEnabled = false;
removeAutomationEstimateColumn(getAutomationEstimateTable());
removeAutomationRecommendColumn(getAutomationEstimateTable());
const existing = getControlPanel();
if (existing && !manualUpdateRunning) {
existing.remove();
}
return null;
}
// Automation page should render independently from active labyrinth room grid data.
refreshAutomationEstimatePanel(state);
const roomRows = Array.isArray(state.characterLabyrinth.roomData) ? state.characterLabyrinth.roomData : [];
const flatRooms = roomRows.flat();
if (!flatRooms.length) {
if (isAutomationEstimatePanelVisible()) {
markLabyrinthTransitionPreserveTooltip("");
} else {
markLabyrinthTransition("");
hidePreviewTooltip();
}
return null;
}
const signature = getLabyrinthDisplaySignature(state);
if (signature && signature !== lastLabyrinthDisplaySignature) {
markLabyrinthTransition(signature);
setControlStatus({
running: false,
ratio: 0,
message: t("pending"),
});
}
syncCompletedRoomCleanup(state);
pruneInvalidRoomDisplays(state);
const gridParent = findRoomGridParent(flatRooms.length);
if (!gridParent) {
if (!isAutomationEstimatePanelVisible()) {
hidePreviewTooltip();
}
return null;
}
const panel = ensureControlPanel(gridParent);
scheduleAutoRecalcIfNeeded(state);
return panel;
}
async function updateRoomBadges(options = {}) {
ensureStyle();
hidePreviewTooltip();
lastLabyrinthCalcDoneMessage = t("calcDone");
const state = getGameState();
if (!state || !state.characterLabyrinth || !Array.isArray(state.characterLabyrinth.roomData)) {
markLabyrinthTransition("");
setControlStatus({
running: false,
ratio: 0,
message: t("notInLabyrinth"),
});
return false;
}
const roomRows = state.characterLabyrinth.roomData;
const flatRooms = roomRows.flat();
const colCount = Array.isArray(roomRows[0]) ? roomRows[0].length : 0;
const targetRoomKeys = Array.isArray(options?.roomKeys) ? options.roomKeys : [];
const hasTargetRoomFilter = targetRoomKeys.length > 0;
if (!flatRooms.length) {
markLabyrinthTransition("");
setControlStatus({
running: false,
ratio: 0,
message: t("noLabyrinthData"),
});
return false;
}
const runSignature = getLabyrinthDisplaySignature(state);
lastLabyrinthDisplaySignature = runSignature;
function isRunStale() {
const latestSignature = getLabyrinthDisplaySignature(getGameState());
return Boolean(runSignature && latestSignature && latestSignature !== runSignature);
}
const roomCells = findRoomGridCells(flatRooms.length);
if (roomCells.length !== flatRooms.length) {
setControlStatus({
running: false,
ratio: 0,
message: t("cellsNotFound"),
});
return false;
}
ensureControlPanel(roomCells[0]?.parentElement || null);
const combatTrials = saveCombatSimTrialsSetting(getSelectedCombatSimTrials());
const targetIndexes = getTargetRoomIndexes(flatRooms, colCount, targetRoomKeys);
if (hasTargetRoomFilter && targetIndexes.length === 0) {
setControlStatus({
running: false,
ratio: 1,
message: t("noNewTiles"),
});
return true;
}
const progressTracker = createProgressTracker(
hasTargetRoomFilter
? getTotalProgressUnitsForIndexes(flatRooms, targetIndexes, combatTrials)
: getTotalProgressUnits(flatRooms, combatTrials)
);
const initClientData = getInitClientData();
const maxEnhancementByItem = buildMaxEnhancementByItem(state);
const labyrinthUpgradeLevels = getLabyrinthUpgradeLevels(true);
const targetCount = targetIndexes.length;
const loanPersonalActionTypeBuffsDict =
options && options.loanPersonalActionTypeBuffsDict && typeof options.loanPersonalActionTypeBuffsDict === "object"
? options.loanPersonalActionTypeBuffsDict
: null;
const selectedSealItemHrids = Array.isArray(options?.selectedSealItemHrids) ? options.selectedSealItemHrids : [];
for (let targetPos = 0; targetPos < targetCount; targetPos += 1) {
if (isRunStale()) {
markLabyrinthTransition(getLabyrinthDisplaySignature(getGameState()));
setControlStatus({
running: false,
ratio: 0,
message: t("pending"),
});
return false;
}
const i = targetIndexes[targetPos];
const room = flatRooms[i];
const cell = roomCells[i];
const roomX = colCount > 0 ? i % colCount : -1;
const roomY = colCount > 0 ? Math.floor(i / colCount) : -1;
const roomKey = roomX >= 0 && roomY >= 0 ? `${roomX},${roomY}` : "";
if (!cell) {
continue;
}
const roomProgressMessage = hasTargetRoomFilter
? t("roomFmt", { current: targetPos + 1, total: targetCount })
: t("roomFmt", { current: i + 1, total: flatRooms.length });
const combatProgressMessage = hasTargetRoomFilter
? t("combatFmt", { current: targetPos + 1, total: targetCount })
: t("combatFmt", { current: i + 1, total: flatRooms.length });
let result = null;
if (room?.roomType === LABYRINTH_SKILLING_ROOM_TYPE) {
result = computeRoomClearChance(state, initClientData, room, maxEnhancementByItem, {
loanPersonalActionTypeBuffsDict,
labyrinthUpgradeLevels,
});
progressTracker.add(1, roomProgressMessage);
} else if (room?.roomType === LABYRINTH_COMBAT_ROOM_TYPE) {
setControlStatus({
running: true,
message: combatProgressMessage,
});
result = await computeCombatRoomClearChance(
state,
initClientData,
room,
maxEnhancementByItem,
progressTracker,
combatTrials,
roomKey,
{
selectedSealItemHrids,
labyrinthUpgradeLevels,
}
);
}
if (isRunStale()) {
markLabyrinthTransition(getLabyrinthDisplaySignature(getGameState()));
setControlStatus({
running: false,
ratio: 0,
message: t("pending"),
});
return false;
}
if (!result) {
removeBadge(cell);
clearCellSkillingPreview(cell);
clearCellCombatPreview(cell);
if (isCalculableRoom(room)) {
clearRoomEstimate(roomKey);
} else {
const rewardOnlyPreview = buildRewardOnlyPreview(state, room);
if (rewardOnlyPreview) {
setCellSkillingPreview(cell, rewardOnlyPreview);
}
}
} else {
upsertBadge(cell, result.clearChance, result.expectedSecondsPerClear, result.debug);
if (isCalculableRoom(room)) {
updateRoomEstimate(roomKey, result);
}
if (result.skillingPreview) {
setCellSkillingPreview(cell, result.skillingPreview);
} else {
clearCellSkillingPreview(cell);
}
const combatPreview = buildCombatPreview(state, room, initClientData, result);
if (combatPreview) {
setCellCombatPreview(cell, combatPreview);
} else {
clearCellCombatPreview(cell);
}
}
if ((targetPos + 1) % 2 === 0) {
await nextFrame();
}
}
const doneMessage = buildLabyrinthCalcDoneMessage(state, flatRooms, targetIndexes);
lastLabyrinthCalcDoneMessage = doneMessage;
progressTracker.finish(doneMessage);
return true;
}
async function runManualUpdate(options = {}) {
if (manualUpdateRunning) {
return;
}
if (autoRecalcTimerId) {
window.clearTimeout(autoRecalcTimerId);
autoRecalcTimerId = 0;
}
pendingAutoRecalcRoomKeys.clear();
const trigger = String(options?.trigger || "manual");
const explicitLoanOptions = normalizeLoanSimulationOptions(options);
let effectiveLoanOptions = explicitLoanOptions;
if (!effectiveLoanOptions && trigger === "auto-new-tiles") {
effectiveLoanOptions = normalizeLoanSimulationOptions(activeLoanSimulationOptions);
}
if (trigger !== "auto-new-tiles") {
activeLoanSimulationOptions = effectiveLoanOptions ? deepCloneJson(effectiveLoanOptions) : null;
}
const runOptions = {
...(options && typeof options === "object" ? options : {}),
};
if (effectiveLoanOptions) {
runOptions.loanPersonalActionTypeBuffsDict = deepCloneJson(effectiveLoanOptions.loanPersonalActionTypeBuffsDict);
runOptions.selectedSealItemHrids = Array.isArray(effectiveLoanOptions.selectedSealItemHrids)
? Array.from(effectiveLoanOptions.selectedSealItemHrids)
: [];
} else {
delete runOptions.loanPersonalActionTypeBuffsDict;
delete runOptions.selectedSealItemHrids;
}
manualUpdateRunning = true;
setControlStatus({
running: true,
ratio: 0,
message: trigger === "auto-new-tiles" ? t("autoNewTiles") : t("preparing"),
});
try {
const ok = await updateRoomBadges(runOptions);
if (ok) {
const latestState = getGameState();
if (latestState?.characterLabyrinth) {
updateAutoRecalcBaseline(latestState);
}
setControlStatus({
running: false,
ratio: 1,
message: lastLabyrinthCalcDoneMessage || t("calcDone"),
});
}
} catch (error) {
console.error("[Lab Clear Rate] manual update failed:", error);
if (trigger === "auto-new-tiles") {
const latestState = getGameState();
if (latestState?.characterLabyrinth) {
updateAutoRecalcBaseline(latestState);
}
}
setControlStatus({
running: false,
message: t("calcFailed"),
});
} finally {
manualUpdateRunning = false;
}
}
function setInputControlValue(element, value) {
if (!element) {
return;
}
element.value = String(value ?? "");
element.dispatchEvent(new Event("input", { bubbles: true }));
element.dispatchEvent(new Event("change", { bubbles: true }));
}
function setSelectControlValue(select, value) {
if (!select || !select.options) {
return false;
}
const target = String(value || "");
if (!target) {
return false;
}
for (const option of Array.from(select.options)) {
if (String(option.value || "") === target) {
select.value = option.value;
select.dispatchEvent(new Event("input", { bubbles: true }));
select.dispatchEvent(new Event("change", { bubbles: true }));
return true;
}
}
const targetTail = target.split("/").pop() || target;
for (const option of Array.from(select.options)) {
const optionValue = String(option.value || "");
const optionTail = optionValue.split("/").pop() || optionValue;
if (optionTail === targetTail) {
select.value = option.value;
select.dispatchEvent(new Event("input", { bubbles: true }));
select.dispatchEvent(new Event("change", { bubbles: true }));
return true;
}
}
return false;
}
function setCheckboxControlValue(checkbox, checked) {
if (!checkbox) {
return false;
}
const target = checked === true;
if (checkbox.checked !== target) {
checkbox.checked = target;
checkbox.dispatchEvent(new Event("change", { bubbles: true }));
}
return true;
}
function resolveSimulatorLabyrinthSelection(payload) {
const monsterHrid = String(payload?.monsterHrid || "");
if (!monsterHrid) {
return null;
}
const roomLevelRaw = Number(payload?.mazeDifficulty);
const roomLevel = Number.isFinite(roomLevelRaw) ? Math.max(20, Math.floor(roomLevelRaw)) : null;
return {
labyrinthHrid: monsterHrid,
roomLevel,
};
}
function applyMonsterSelectionOnSimulatorPage(payload) {
let applied = false;
setCheckboxControlValue(document.querySelector("input#simAllZoneToggle"), false);
setCheckboxControlValue(document.querySelector("input#simAllSoloToggle"), false);
setCheckboxControlValue(document.querySelector("input#simAllLabyrinthsToggle"), false);
const labyrinthSelection = resolveSimulatorLabyrinthSelection(payload);
if (!labyrinthSelection) {
return false;
}
if (setCheckboxControlValue(document.querySelector("input#simLabyrinthToggle"), true)) {
applied = true;
}
if (setCheckboxControlValue(document.querySelector("input#simDungeonToggle"), false)) {
applied = true;
}
if (setSelectControlValue(document.querySelector("select#selectLabyrinth"), labyrinthSelection.labyrinthHrid)) {
applied = true;
}
if (Number.isFinite(Number(labyrinthSelection.roomLevel))) {
setInputControlValue(
document.querySelector("input#inputRoomLevel"),
String(Math.max(20, Math.floor(Number(labyrinthSelection.roomLevel))))
);
applied = true;
}
return applied;
}
function applySimulatorPlayerSelectionOnPage() {
let applied = false;
for (let i = 1; i <= 5; i += 1) {
const checkbox = document.querySelector(`input#player${i}.form-check-input.player-checkbox`) || document.querySelector(`input#player${i}`);
if (!checkbox) {
continue;
}
const shouldCheck = i === 1;
if (setCheckboxControlValue(checkbox, shouldCheck)) {
applied = true;
}
}
return applied;
}
function applySimulatorCrateSelectionOnPage(payload) {
let applied = false;
if (setSelectControlValue(document.querySelector("select#selectCoffeeCrates"), payload?.coffeeCrateItemHrid)) {
applied = true;
}
if (setSelectControlValue(document.querySelector("select#selectFoodCrates"), payload?.foodCrateItemHrid)) {
applied = true;
}
if (setSelectControlValue(document.querySelector("select#selectTeaCrates"), payload?.teaCrateItemHrid)) {
applied = true;
}
return applied;
}
function applySimulatorLanguageOnPage(payload) {
const nextLanguage = normalizeUiLanguage(payload?.uiLanguage) || UI_LANGUAGE_EN;
try {
localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, nextLanguage);
} catch (_error) {
// Ignore storage write errors.
}
try {
if (window.i18next && typeof window.i18next.changeLanguage === "function") {
window.i18next.changeLanguage(nextLanguage);
}
} catch (_error) {
// Ignore language switching errors.
}
}
function applySimulatorExtraBuffsOnPage(payload) {
const configuredBuffs = Array.isArray(payload?.simulatorPersonalBuffItemHrids)
? payload.simulatorPersonalBuffItemHrids
: [];
const expected = new Set(
configuredBuffs
.map((value) => String(value || ""))
.filter((value) => SIMULATOR_PERSONAL_BUFF_ITEM_HRIDS.has(value))
);
const toggle = document.querySelector("input#personalBuffsToggle");
const buffInputs = Array.from(document.querySelectorAll("#personalBuffsBox input[type='checkbox']"));
if (!toggle && buffInputs.length === 0) {
return false;
}
let applied = false;
if (toggle && setCheckboxControlValue(toggle, expected.size > 0)) {
applied = true;
}
for (const input of buffInputs) {
const value = String(input?.value || "");
const shouldCheck = expected.has(value);
if (setCheckboxControlValue(input, shouldCheck)) {
applied = true;
}
}
return applied;
}
function applySimulatorBridgeFieldsOnPage(payload, sanitizedImportSet) {
applySimulatorLanguageOnPage(payload);
applyMonsterSelectionOnSimulatorPage(payload);
applySimulatorPlayerSelectionOnPage();
applySimulatorCrateSelectionOnPage(payload);
applySimulatorExtraBuffsOnPage(payload);
if (Number.isFinite(Number(payload?.mazeDifficulty))) {
const roomLevel = Math.max(20, Math.floor(Number(payload.mazeDifficulty)));
setInputControlValue(document.querySelector("input#inputRoomLevel"), String(roomLevel));
}
if (sanitizedImportSet?.simulationTime !== undefined) {
setInputControlValue(
document.querySelector("input#inputSimulationTime"),
String(sanitizedImportSet.simulationTime)
);
}
}
function collectSimulatorSupportedHrids(attributeName) {
const supported = new Set();
if (!attributeName) {
return supported;
}
const selector = `[${attributeName}]`;
for (const element of Array.from(document.querySelectorAll(selector))) {
const hrid = String(element?.getAttribute(attributeName) || "").trim();
if (hrid) {
supported.add(hrid);
}
}
return supported;
}
function sanitizeImportMapBySupportedHrids(rawMap, supportedHrids, transformValue) {
const result = {};
if (!rawMap || typeof rawMap !== "object" || Array.isArray(rawMap)) {
return result;
}
if (!(supportedHrids instanceof Set) || supportedHrids.size === 0) {
return result;
}
for (const [rawKey, rawValue] of Object.entries(rawMap)) {
const hrid = String(rawKey || "");
if (!hrid || !supportedHrids.has(hrid)) {
continue;
}
result[hrid] = typeof transformValue === "function" ? transformValue(rawValue) : rawValue;
}
return result;
}
function sanitizeSimulatorImportSetForPage(importSet) {
if (!importSet || typeof importSet !== "object") {
return importSet;
}
const sanitized = {
...importSet,
};
const supportedHouseRoomHrids = collectSimulatorSupportedHrids("data-house-hrid");
sanitized.houseRooms = sanitizeImportMapBySupportedHrids(importSet.houseRooms, supportedHouseRoomHrids, (value) => {
return Math.max(0, Math.floor(finiteNumber(value, 0)));
});
const supportedAchievementHrids = collectSimulatorSupportedHrids("data-achievement-hrid");
sanitized.achievements = sanitizeImportMapBySupportedHrids(importSet.achievements, supportedAchievementHrids, (value) => {
return value === true;
});
return sanitized;
}
function buildSimulatorGroupImportSetForPage(importSet) {
if (!importSet || typeof importSet !== "object") {
return null;
}
const serialized = JSON.stringify(importSet);
return {
1: serialized,
2: serialized,
3: serialized,
4: serialized,
5: serialized,
};
}
function applySimulatorBridgePayloadOnSimulatorPage(payload) {
const importSet = payload?.importSet;
if (!importSet || typeof importSet !== "object") {
return false;
}
const sanitizedImportSet = sanitizeSimulatorImportSetForPage(importSet);
const importButton = document.querySelector("button#buttonImportSet");
if (!importButton) {
return false;
}
let imported = false;
const groupTab = document.querySelector("a#group-combat-tab");
if (groupTab) {
groupTab.click();
}
const inputSetGroupAll = document.querySelector("input#inputSetGroupCombatAll, textarea#inputSetGroupCombatAll");
if (inputSetGroupAll) {
const groupImportSet = buildSimulatorGroupImportSetForPage(sanitizedImportSet);
if (groupImportSet) {
setInputControlValue(inputSetGroupAll, JSON.stringify(groupImportSet));
importButton.click();
imported = true;
}
}
if (!imported) {
const soloTab = document.querySelector("a#solo-tab");
if (soloTab) {
soloTab.click();
}
const inputSetSolo = document.querySelector("input#inputSetSolo, textarea#inputSetSolo");
if (!inputSetSolo) {
return false;
}
setInputControlValue(inputSetSolo, JSON.stringify(sanitizedImportSet));
importButton.click();
imported = true;
}
if (!imported) {
return false;
}
applySimulatorBridgeFieldsOnPage(payload, sanitizedImportSet);
// Some controls are initialized a moment after import completes.
// Perform one delayed sync, but avoid long-running rewrites that block user edits.
window.setTimeout(() => {
applySimulatorBridgeFieldsOnPage(payload, sanitizedImportSet);
}, 180);
return true;
}
function bootstrapSimulatorBridgePage() {
const payload = extractSimulatorBridgePayloadFromLocation();
if (!payload || typeof payload !== "object") {
return;
}
clearSimulatorBridgePayloadFromLocation();
let attempts = 0;
const maxAttempts = 120;
const timerId = window.setInterval(() => {
attempts += 1;
try {
if (applySimulatorBridgePayloadOnSimulatorPage(payload)) {
window.clearInterval(timerId);
return;
}
} catch (error) {
console.error("[Lab Clear Rate] Simulator bridge apply failed:", error);
window.clearInterval(timerId);
return;
}
if (attempts >= maxAttempts) {
window.clearInterval(timerId);
console.error("[Lab Clear Rate] Simulator bridge timeout: import controls not found.");
}
}, 150);
}
migrateLegacySimulatorBridgeUrl();
if (isSimulatorBridgePage()) {
bootstrapSimulatorBridgePage();
return;
}
initializeRoomLogState();
installLiveActionRateWsHook();
function schedulePanelRefresh() {
if (panelRefreshTimerId) {
return;
}
panelRefreshTimerId = window.setTimeout(() => {
panelRefreshTimerId = 0;
refreshControlPanelPlacement();
}, PANEL_REFRESH_DEBOUNCE_MS);
}
const observer = new MutationObserver(() => {
schedulePanelRefresh();
});
observer.observe(document.body, { childList: true, subtree: true });
// Fallback for state transitions that do not trigger childList mutations.
window.setInterval(() => {
schedulePanelRefresh();
}, PANEL_REFRESH_POLL_MS);
ensureStyle();
if (refreshControlPanelPlacement()) {
setControlStatus({
running: false,
ratio: 0,
message: t("pending"),
});
}
})();