diff --git a/README.md b/README.md index a5c44c98..506b0410 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ feature request or issue reports join the [discord](https://discord.gg/YyzaPhAN6 - Automatically marks all common, magic, and optionally rare gear as junk - Quickly move items from your stash or inventory - Supported resolutions are all aspect ratios between 16:10 and 21:9 +- Paragon Overlay with optional import from supported build planners (Mobalytics, Maxroll, D4Builds) ## How to Setup @@ -47,18 +48,28 @@ feature request or issue reports join the [discord](https://discord.gg/YyzaPhAN6 - Use the hotkeys listed in d4lf.exe to run filtering. By default, F11 will run the loot filter and filter your items. - For most common issues, if something is wrong, you will see an error or warning when you start d4lf.exe. Join our [discord](https://discord.gg/YyzaPhAN6T) for more help. -### Updating an existing installation +#### Paragon overlay -All configurations are stored in a separate location so all you need to do is download the newest version and delete your old version. This can be done manually by downloading from the [releases page](https://github.com/d4lfteam/d4lf/releases) or by running autoupdater.bat. +D4LF can import Paragon boards from supported build planners and show them in-game using the Paragon overlay. -Your profiles and configuration should continue to work. The only exception to this is if the major version of the release changes. In that case, a change was made that will make previous profiles no longer work. +**How to use** -Example 1: You're on version 5.1.14 and updating to 5.2.0. Your profiles will continue to work fine. +1. Import your build from a supported planner (Mobalytics / Maxroll / D4Builds). +1. Enable **Import Paragon** in the importer (optional). Paragon data will be stored in your profile YAMLs in the profiles folder (default: `~/.d4lf/profiles`). +1. Toggle the Paragon overlay using the hotkey (default **F10**, configurable in *Advanced options*). -Example 2: You're on version 5.1.14 and updating to 6.0.0. Your profiles will no longer work and you'll need to update or re-import them on the newest version. +**Tips** + +- Overlays may not work in exclusive fullscreen; use **borderless windowed** if the overlay does not appear. +- Planner websites can change over time. If an import/export stops working, it may need an importer update. ### Common problems +- Paragon overlay does not appear / does nothing + - Ensure Diablo IV is running in **borderless windowed** (exclusive fullscreen may block overlays). + - Ensure your profiles folder contains `*.yaml`/`*.yml` profile files with a top-level `Paragon:` section (default: `C:/Users//.d4lf/profiles`). + - Check/adjust `advanced_options.toggle_paragon_overlay` (default `f10`) and ensure it is not conflicting with other hotkeys. + - If the overlay is off-screen, delete `d4_overlay_config.json` next to `d4lf.exe` to reset its position. - The GUI crashes immediately upon opening, with no error message given - This almost always means there is an issue in your params.ini. Delete the file and then open the GUI and configure your params.ini through the config tab. Using the GUI for configuration will ensure the file is always accurate. @@ -90,6 +101,7 @@ The config folder in `C:/Users//.d4lf` contains: automatically. - **params.ini**: Different hotkey settings and number of chest stashes that should be looked at. Management of this file should be done through the GUI in the config window. +- **profiles/\*.yaml**: Profiles including embedded Paragon data for the integrated overlay (top-level `Paragon:`). Generated/updated by the importer when "Import Paragon" is enabled. Default location: `C:/Users//.d4lf/profiles` ### params.ini @@ -117,19 +129,21 @@ The config folder in `C:/Users//.d4lf` contains: | --------- | --------------------------------- | | inventory | Your hotkey for opening inventory | -| [advanced_options] | Description | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| move_to_inv | Hotkey for moving items from stash to inventory | -| move_to_chest | Hotkey for moving items from inventory to stash | -| run_filter | Hotkey to start/stop filtering items | -| run_filter_force_refresh | Hotkey to start/stop filtering items with a force refresh. All item statuses will be reset | -| run_vision_mode | Hotkey to start/stop vision mode | -| force_refresh_only | Hotkey to reset all item statuses without running a filter after | -| exit_key | Hotkey to exit d4lf.exe | -| log_lvl | Logging level. Can be any of [debug, info, warning, error, critical] | -| process_name | Process name of the D4 app. Defaults to "Diablo IV.exe". In case of using some remote play this might need to be adapted | -| vision_mode_only | If set to true, only the vision mode will be available. All functionality that clicks the screen is disabled. | -| fast_vision_mode_coordinates | If you are using fast vision mode, provide the location on screen where you want the overlay to appear. For example, you could provide (500, 800) | +| [advanced_options] | Description | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| move_to_inv | Hotkey for moving items from stash to inventory | +| move_to_chest | Hotkey for moving items from inventory to stash | +| run_filter | Hotkey to start/stop filtering items | +| run_filter_force_refresh | Hotkey to start/stop filtering items with a force refresh. All item statuses will be reset | +| run_vision_mode | Hotkey to start/stop vision mode | +| force_refresh_only | Hotkey to reset all item statuses without running a filter after | +| exit_key | Hotkey to exit d4lf.exe | +| toggle_paragon_overlay | Hotkey to open/close the Paragon overlay (default: f10) | +| paragon_overlay_source_dir | Folder containing profile YAML files with embedded Paragon data (top-level `Paragon:`) for the overlay. Leave blank to use the default: `~/.d4lf/profiles` | +| log_lvl | Logging level. Can be any of [debug, info, warning, error, critical] | +| process_name | Process name of the D4 app. Defaults to "Diablo IV.exe". In case of using some remote play this might need to be adapted | +| vision_mode_only | If set to true, only the vision mode will be available. All functionality that clicks the screen is disabled. | +| fast_vision_mode_coordinates | If you are using fast vision mode, provide the location on screen where you want the overlay to appear. For example, you could provide (500, 800) | ### GUI @@ -143,7 +157,8 @@ automatically picked up and no restart is necessary. Current functionality: -- Import builds from maxroll/d4builds/mobalytics +- Import builds from maxroll/d4builds/mobalytics (optionally import Paragon data) +- Toggle the integrated Paragon overlay (default hotkey: F10) and configure its JSON folder via "Paragon Folder" - Complete management of your settings through the config tab - A beta version of a manual profile editor/creator diff --git a/assets/lang/enUS/paragon_maxroll_ids.json b/assets/lang/enUS/paragon_maxroll_ids.json new file mode 100644 index 00000000..26190e29 --- /dev/null +++ b/assets/lang/enUS/paragon_maxroll_ids.json @@ -0,0 +1,212 @@ +{ + "boards": { + "Paragon_Barb_00": "Start", + "Paragon_Barb_01": "Hemorrhage", + "Paragon_Barb_02": "Blood Rage", + "Paragon_Barb_03": "Carnage", + "Paragon_Barb_04": "Decimator", + "Paragon_Barb_05": "Bone Breaker", + "Paragon_Barb_06": "Flawless Technique", + "Paragon_Barb_07": "Warbringer", + "Paragon_Barb_08": "Weapons Master", + "Paragon_Barb_10": "Force of Nature", + "Paragon_Druid_00": "Start", + "Paragon_Druid_01": "Thunderstruck", + "Paragon_Druid_02": "Earthen Devastation", + "Paragon_Druid_03": "Survival Instincts", + "Paragon_Druid_04": "Lust for Carnage", + "Paragon_Druid_05": "Heightened Malice", + "Paragon_Druid_06": "Inner Beast", + "Paragon_Druid_07": "Constricting Tendrils", + "Paragon_Druid_08": "Ancestral Guidance", + "Paragon_Druid_10": "Untamed", + "Paragon_Necro_00": "Start", + "Paragon_Necro_01": "Cult Leader", + "Paragon_Necro_02": "Hulking Monstrosity", + "Paragon_Necro_03": "Flesh-eater", + "Paragon_Necro_04": "Scent of Death", + "Paragon_Necro_05": "Bone Graft", + "Paragon_Necro_06": "Blood Begets Blood", + "Paragon_Necro_07": "Bloodbath", + "Paragon_Necro_08": "Wither", + "Paragon_Necro_10": "Frailty", + "Paragon_Paladin_00": "Start", + "Paragon_Paladin_01": "Castle", + "Paragon_Paladin_02": "Shield Bearer", + "Paragon_Paladin_03": "Fervent", + "Paragon_Paladin_04": "Preacher", + "Paragon_Paladin_05": "Divinity", + "Paragon_Paladin_06": "Relentless", + "Paragon_Paladin_07": "Sentencing", + "Paragon_Paladin_08": "Endure", + "Paragon_Paladin_09": "Beacon", + "Paragon_Rogue_00": "Start", + "Paragon_Rogue_01": "Eldritch Bounty", + "Paragon_Rogue_02": "Tricks of the Trade", + "Paragon_Rogue_03": "Cheap Shot", + "Paragon_Rogue_04": "Deadly Ambush", + "Paragon_Rogue_05": "Leyrana's Instinct", + "Paragon_Rogue_06": "No Witnesses", + "Paragon_Rogue_07": "Exploit Weakness", + "Paragon_Rogue_08": "Cunning Stratagem", + "Paragon_Rogue_10": "Danse Macabre", + "Paragon_Sorc_00": "Start", + "Paragon_Sorc_01": "Searing Heat", + "Paragon_Sorc_02": "Frigid Fate", + "Paragon_Sorc_03": "Static Surge", + "Paragon_Sorc_04": "Elemental Summoner", + "Paragon_Sorc_05": "Burning Instinct", + "Paragon_Sorc_06": "Icefall", + "Paragon_Sorc_07": "Ceaseless Conduit", + "Paragon_Sorc_08": "Enchantment Master", + "Paragon_Sorc_10": "Fundamental Release", + "Paragon_Spirit_0": "Start", + "Paragon_Spirit_01": "In-Fighter", + "Paragon_Spirit_02": "Spiney Skin", + "Paragon_Spirit_03": "Viscous Shield", + "Paragon_Spirit_04": "Bitter Medicine", + "Paragon_Spirit_05": "Revealing", + "Paragon_Spirit_06": "Drive", + "Paragon_Spirit_07": "Convergence", + "Paragon_Spirit_08": "Sapping" + }, + "glyphs": { + "Rare_001_Intelligence_Main": "Enchanter", + "Rare_002_Intelligence_Main": "Unleash", + "Rare_003_Intelligence_Main": "Elementalist", + "Rare_004_Intelligence_Main": "Adept", + "Rare_005_Intelligence_Main": "Conjurer", + "Rare_006_Intelligence_Main": "Charged", + "Rare_007_Willpower_Side": "Torch", + "Rare_008_Willpower_Side": "Pyromaniac", + "Rare_009_Willpower_Side": "Cryopathy", + "Rare_010_Dexterity_Main": "Tactician", + "Rare_011_Intelligence_Side": "Guzzler", + "Rare_011_Willpower_Side": "Imbiber", + "Rare_012_Intelligence_Side": "Protector", + "Rare_012_Willpower_Side": "Reinforced", + "Rare_013_Dexterity_Side": "Poise", + "Rare_014_Dexterity_Side": "Territorial", + "Rare_014_Strength_Main": "Turf", + "Rare_014_Strength_Side": "Turf", + "Rare_015_Dexterity_Side": "Flamefeeder", + "Rare_016_Dexterity_Side": "Exploit", + "Rare_016_Intelligence_Side": "Exploit", + "Rare_016_Strength_Side": "Exploit", + "Rare_017_Dexterity_Side": "Winter", + "Rare_018_Dexterity_Side": "Electrocute", + "Rare_019_Dexterity_Side": "Destruction", + "Rare_020_Dexterity_Side": "Control", + "Rare_020_Intelligence_Main": "Control", + "Rare_020_Intelligence_Side": "Control", + "Rare_021_Strength_Main": "Ambidextrous", + "Rare_022_Strength_Main": "Might", + "Rare_023_Strength_Main": "Cleaver", + "Rare_024_Strength_Main": "Seething", + "Rare_025_Strength_Main": "Crusher", + "Rare_026_Strength_Main": "Executioner", + "Rare_027_Strength_Main": "Ire", + "Rare_028_Strength_Main": "Marshal", + "Rare_029_Dexterity_Side": "Bloodfeeder", + "Rare_030_Dexterity_Side": "Wrath", + "Rare_031_Dexterity_Side": "Weapon Master", + "Rare_032_Dexterity_Side": "Mortal Draw", + "Rare_033_Intelligence_Side": "Revenge", + "Rare_033_Willpower_Side": "Revenge", + "Rare_033_Willpower_Side_Necro": "Revenge", + "Rare_034_Intelligence_Side": "Undaunted", + "Rare_034_Willpower_Side": "Undaunted", + "Rare_035_Intelligence_Side": "Dominate", + "Rare_035_Willpower_Side": "Dominate", + "Rare_035_Willpower_Side_Necro": "Dominate", + "Rare_036_Willpower_Side": "Disembowel", + "Rare_037_Willpower_Side": "Brawl", + "Rare_038_Intelligence_Main": "Corporeal", + "Rare_039_Willpower_Main": "Fang and Claw", + "Rare_040_Willpower_Main": "Earth and Sky", + "Rare_041_Intelligence_Side": "Wilds", + "Rare_042_Willpower_Main": "Werebear", + "Rare_043_Willpower_Main": "Werewolf", + "Rare_044_Willpower_Main": "Human", + "Rare_045_Intelligence_Side": "Bane", + "Rare_045_Strength_Side": "Bane", + "Rare_046_Dexterity_Side": "Abyssal", + "Rare_046_Intelligence_Side": "Keeper", + "Rare_047_Dexterity_Side": "Fulminate", + "Rare_047_Intelligence_Side": "Fulminate", + "Rare_048_Dexterity_Side": "Tracker", + "Rare_048_Intelligence_Side": "Tracker", + "Rare_049_Dexterity_Side": "Outmatch", + "Rare_049_Strength_Main": "Outmatch", + "Rare_049_Strength_Side": "Outmatch", + "Rare_050_Dexterity_Main": "Spirit", + "Rare_050_Dexterity_Side": "Spirit", + "Rare_050_Willpower_Side": "Spirit", + "Rare_051_Dexterity_Side": "Shapeshifter", + "Rare_052_Dexterity_Main": "Versatility", + "Rare_053_Dexterity_Main": "Closer", + "Rare_054_Dexterity_Main": "Ranger", + "Rare_055_Dexterity_Main": "Chip", + "Rare_055_Dexterity_Side": "Chip", + "Rare_055_Willpower_Side": "Chip", + "Rare_056_Dexterity_Main": "Frostfeeder", + "Rare_057_Dexterity_Main": "Fluidity", + "Rare_058_Intelligence_Side": "Infusion", + "Rare_059_Dexterity_Main": "Devious", + "Rare_060_Dexterity_Side": "Warrior", + "Rare_061_Intelligence_Side": "Combat", + "Rare_062_Dexterity_Side": "Gravekeeper", + "Rare_063_Intelligence_Side": "Canny", + "Rare_064_Intelligence_Side": "Efficacy", + "Rare_065_Intelligence_Side": "Snare", + "Rare_066_Dexterity_Side": "Essence", + "Rare_067_Strength_Side": "Pride", + "Rare_068_Strength_Side": "Ambush", + "Rare_069_Intelligence_Main": "Sacrificial", + "Rare_070_Intelligence_Main": "Blood-drinker", + "Rare_071_Intelligence_Main": "Deadraiser", + "Rare_072_Intelligence_Main": "Mage", + "Rare_073_Intelligence_Main": "Amplify", + "Rare_074_Willpower_Side": "Golem", + "Rare_075_Willpower_Side": "Scourge", + "Rare_076_Strength_Main": "Diminish", + "Rare_076_Strength_Side": "Diminish", + "Rare_077_Willpower_Side": "Warding", + "Rare_078_Willpower_Side": "Darkness", + "Rare_079_Dexterity_Side": "Exploit", + "Rare_080_Strength_Main": "Twister", + "Rare_081_Strength_Main": "Rumble", + "Rare_082_Dexterity_Main": "Explosive", + "Rare_083_Intelligence_Side": "Nightstalker", + "Rare_084_Intelligence_Main": "Stalagmite", + "Rare_085_Dexterity_Side": "Invocation", + "Rare_086_Dexterity_Side": "Tectonic", + "Rare_087_Willpower_Main": "Electrocution", + "Rare_088_Intelligence_Main": "Exhumation", + "Rare_089_Willpower_Side": "Desecration", + "Rare_090_Dexterity_Main": "Menagerist", + "Rare_091_Strength_Side": "Hone", + "Rare_092_Intelligence_Side": "Consumption", + "Rare_093_Dexterity_Main": "Fitness", + "Rare_094_Intelligence_Side": "Ritual", + "Rare_095_Dexterity_Main": "Jagged Plume", + "Rare_096_Strength_Side": "Innate", + "Rare_097_Dexterity_Main": "Wildfire", + "Rare_098_Strength_Side": "Colossal", + "Rare_100_Dexterity_Main": "Talon", + "Rare_101_Strength_Side": "Hubris", + "Rare_102_Dexterity_Main": "Fester", + "Rare_103_Strength_Main": "Sentinel", + "Rare_104_Dexterity_Side": "Honed", + "Rare_105_Strength_Main": "Law", + "Rare_106_Willpower_Side": "Arbiter ", + "Rare_107_Strength_Main": "Resplendence", + "Rare_108_Intelligence_Side": "Judicator", + "Rare_109_Dexterity_Side": "Feverous", + "Rare_110_Strength_Main": "Apostle", + "Rare_Dex_Generic": "Headhunter", + "Rare_Int_Generic": "Eliminator", + "Rare_Str_Generic": "Challenger", + "Rare_Will_Generic": "Headhunter" + } +} diff --git a/src/config/models.py b/src/config/models.py index f41e25f8..02203dbf 100644 --- a/src/config/models.py +++ b/src/config/models.py @@ -200,6 +200,11 @@ class AdvancedOptionsModel(_IniBaseModel): description="Hotkey to move configured items from stash to inventory", json_schema_extra={IS_HOTKEY_KEY: "True"}, ) + paragon_overlay_source_dir: str = Field( + default="", + description="Folder containing Paragon JSON files for the Paragon overlay. Leave blank to use the default: ~/.d4lf/paragon", + json_schema_extra={HIDE_FROM_GUI_KEY: "True"}, + ) process_name: str = Field( default="Diablo IV.exe", description="The process that is running Diablo 4. Could help usage when playing through a streaming service like GeForce Now", @@ -215,6 +220,9 @@ class AdvancedOptionsModel(_IniBaseModel): run_vision_mode: str = Field( default="f9", description="Hotkey to enable/disable the vision mode", json_schema_extra={IS_HOTKEY_KEY: "True"} ) + toggle_paragon_overlay: str = Field( + default="f10", description="Hotkey to open/close the Paragon overlay", json_schema_extra={IS_HOTKEY_KEY: "True"} + ) vision_mode_only: bool = Field( default=False, description="Only allow vision mode to run. All hotkeys and actions that click will be disabled." ) @@ -223,6 +231,7 @@ class AdvancedOptionsModel(_IniBaseModel): def key_must_be_unique(self) -> AdvancedOptionsModel: keys = [ self.exit_key, + self.toggle_paragon_overlay, self.force_refresh_only, self.move_to_chest, self.move_to_inv, @@ -237,6 +246,7 @@ def key_must_be_unique(self) -> AdvancedOptionsModel: @field_validator( "exit_key", + "toggle_paragon_overlay", "force_refresh_only", "move_to_chest", "move_to_inv", diff --git a/src/gui/activity_log_widget.py b/src/gui/activity_log_widget.py index dd653462..5c2970a9 100644 --- a/src/gui/activity_log_widget.py +++ b/src/gui/activity_log_widget.py @@ -1,4 +1,5 @@ -from PyQt6.QtCore import Qt +from PyQt6.QtCore import Qt, QUrl +from PyQt6.QtGui import QDesktopServices from PyQt6.QtWidgets import QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget from src.config.loader import IniConfigLoader @@ -33,7 +34,7 @@ def __init__(self, parent=None): config = IniConfigLoader() hotkey_text = QLabel() - hotkey_text.setMaximumHeight(65) + hotkey_text.setMaximumHeight(85) hotkey_text.setWordWrap(True) hotkey_text.setTextFormat(Qt.TextFormat.RichText) hotkey_text.setStyleSheet("margin-left: 5px;") @@ -55,6 +56,7 @@ def __init__(self, parent=None): hotkeys_html += f"{config.advanced_options.run_vision_mode.upper()}: Run/Stop Vision Mode
" hotkeys_html += "Vision Mode Only - clicking functionality disabled   " + hotkeys_html += f"{config.advanced_options.toggle_paragon_overlay.upper()}: Toggle Paragon Overlay   " hotkeys_html += f"{config.advanced_options.exit_key.upper()}: Exit D4LF" hotkeys_html += "" @@ -77,9 +79,19 @@ def __init__(self, parent=None): self.editor_btn.setMinimumHeight(40) button_layout.addWidget(self.editor_btn) + self.user_dir_btn = QPushButton("Open User Config") + self.user_dir_btn.setMinimumHeight(40) + self.user_dir_btn.setToolTip("Open the D4LF user config directory") + button_layout.addWidget(self.user_dir_btn) + # === CONNECT BUTTONS TO UnifiedMainWindow === self.import_btn.clicked.connect(self.parent().open_import_dialog) self.settings_btn.clicked.connect(self.parent().open_settings_dialog) self.editor_btn.clicked.connect(self.parent().open_profile_editor) + self.user_dir_btn.clicked.connect(self._open_user_dir) self.main_layout.addLayout(button_layout) + + def _open_user_dir(self) -> None: + user_dir = IniConfigLoader().user_dir + QDesktopServices.openUrl(QUrl.fromLocalFile(str(user_dir))) diff --git a/src/gui/importer/d4builds.py b/src/gui/importer/d4builds.py index d786cd4c..ccdce980 100644 --- a/src/gui/importer/d4builds.py +++ b/src/gui/importer/d4builds.py @@ -30,6 +30,11 @@ update_mingreateraffixcount, ) from src.gui.importer.importer_config import ImportConfig +from src.gui.importer.paragon_export import ( + build_paragon_profile_payload, + extract_d4builds_paragon_steps, + write_paragon_into_profile_yaml, +) from src.item.data.affix import Affix, AffixType from src.item.data.item_type import WEAPON_TYPES, ItemType from src.item.descr.text import clean_str, closest_match @@ -38,7 +43,6 @@ if TYPE_CHECKING: from selenium.webdriver.chromium.webdriver import ChromiumDriver - LOGGER = logging.getLogger(__name__) BASE_URL = "https://d4builds.gg/builds" @@ -212,6 +216,15 @@ def import_d4builds(config: ImportConfig, driver: ChromiumDriver = None): corrected_file_name = save_as_profile(file_name=file_name, profile=profile, url=url) if config.add_to_profiles: add_to_profiles(corrected_file_name) + + if config.export_paragon: + steps = extract_d4builds_paragon_steps(driver, class_name=class_name) + if steps: + payload = build_paragon_profile_payload(build_name=file_name, source_url=url, paragon_boards_list=steps) + write_paragon_into_profile_yaml(profile_ref=corrected_file_name, payload=payload) + else: + LOGGER.warning("Paragon export enabled, but no paragon data was found on this D4Builds page.") + LOGGER.info("Finished") @@ -276,6 +289,7 @@ def _get_legendary_aspects(data: lxml.html.HtmlElement) -> list[str]: add_to_profiles=False, import_greater_affixes=True, require_greater_affixes=True, + export_paragon=False, custom_file_name=None, ) import_d4builds(config) diff --git a/src/gui/importer/importer_config.py b/src/gui/importer/importer_config.py index c10d8581..3684bfcb 100644 --- a/src/gui/importer/importer_config.py +++ b/src/gui/importer/importer_config.py @@ -9,4 +9,5 @@ class ImportConfig: add_to_profiles: bool import_greater_affixes: bool require_greater_affixes: bool - custom_file_name: str | None + export_paragon: bool = False + custom_file_name: str | None = None diff --git a/src/gui/importer/maxroll.py b/src/gui/importer/maxroll.py index e7ed9de5..c0fbf5c2 100644 --- a/src/gui/importer/maxroll.py +++ b/src/gui/importer/maxroll.py @@ -23,6 +23,11 @@ update_mingreateraffixcount, ) from src.gui.importer.importer_config import ImportConfig +from src.gui.importer.paragon_export import ( + build_paragon_profile_payload, + extract_maxroll_paragon_steps, + write_paragon_into_profile_yaml, +) from src.item.data.affix import Affix, AffixType from src.item.data.item_type import ItemType from src.item.descr.text import clean_str, closest_match @@ -184,6 +189,14 @@ def import_maxroll(config: ImportConfig): if config.add_to_profiles: add_to_profiles(corrected_file_name) + if config.export_paragon: + steps = extract_maxroll_paragon_steps(active_profile) + if steps: + payload = build_paragon_profile_payload(build_name=build_name, source_url=url, paragon_boards_list=steps) + write_paragon_into_profile_yaml(profile_ref=corrected_file_name, payload=payload) + else: + LOGGER.warning("Paragon export enabled, but no paragon steps were found in this Maxroll profile.") + LOGGER.info("Finished") @@ -396,6 +409,7 @@ def _extract_planner_url_and_id_from_guide(url: str) -> tuple[str, int]: add_to_profiles=False, import_greater_affixes=True, require_greater_affixes=True, + export_paragon=False, custom_file_name=None, ) import_maxroll(config) diff --git a/src/gui/importer/mobalytics.py b/src/gui/importer/mobalytics.py index 8bc73ee0..daf3f9c7 100644 --- a/src/gui/importer/mobalytics.py +++ b/src/gui/importer/mobalytics.py @@ -27,6 +27,11 @@ update_mingreateraffixcount, ) from src.gui.importer.importer_config import ImportConfig +from src.gui.importer.paragon_export import ( + build_paragon_profile_payload, + extract_mobalytics_paragon_steps, + write_paragon_into_profile_yaml, +) from src.item.data.affix import Affix, AffixType from src.item.data.item_type import WEAPON_TYPES, ItemType from src.item.descr.text import clean_str, closest_match @@ -98,6 +103,19 @@ def import_mobalytics(config: ImportConfig): f"$..['{root_document_name}'].data.buildVariants.values[0].id", full_script_data_json )[0] + # Keep the full build variant object around (needed for optional paragon export) + try: + if variant_id: + variant = jsonpath.findall( + f"$..['{root_document_name}'].data.buildVariants.values[?@.id=='{variant_id}']", full_script_data_json + )[0] + else: + variant = jsonpath.findall( + f"$..['{root_document_name}'].data.buildVariants.values[0]", full_script_data_json + )[0] + except Exception: + variant = {} + variant_name = jsonpath.findall( f"..['NgfDocumentCmWidgetContentVariantsV1DataChildVariant:{variant_id}'].title", full_script_data_json ) @@ -224,6 +242,14 @@ def import_mobalytics(config: ImportConfig): if config.add_to_profiles: add_to_profiles(corrected_file_name) + if config.export_paragon: + steps = extract_mobalytics_paragon_steps(variant if isinstance(variant, dict) else {}) + if steps: + payload = build_paragon_profile_payload(build_name=build_name, source_url=url, paragon_boards_list=steps) + write_paragon_into_profile_yaml(profile_ref=corrected_file_name, payload=payload) + else: + LOGGER.warning("Paragon export enabled, but no paragon data was found for this Mobalytics variant.") + LOGGER.info("Finished") @@ -288,6 +314,7 @@ def _convert_raw_to_affixes(raw_stats: list[dict], import_greater_affixes=False) add_to_profiles=False, import_greater_affixes=True, require_greater_affixes=True, + export_paragon=False, custom_file_name=None, ) import_mobalytics(config) diff --git a/src/gui/importer/paragon_export.py b/src/gui/importer/paragon_export.py new file mode 100644 index 00000000..894113f6 --- /dev/null +++ b/src/gui/importer/paragon_export.py @@ -0,0 +1,760 @@ +from __future__ import annotations + +import datetime +import json +import logging +import re +import time +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from src import __version__ +from src.config.loader import IniConfigLoader + +try: + from ruamel.yaml import YAML as RUAMEL_YAML +except ImportError: # pragma: no cover + RUAMEL_YAML = None + +try: + import yaml as PyYAML +except ImportError: # pragma: no cover + PyYAML = None + + +try: + from selenium.webdriver.common.by import By + from selenium.webdriver.support.ui import WebDriverWait +except ImportError: # pragma: no cover + By = None # type: ignore[assignment] + WebDriverWait = None # type: ignore[assignment] + +if TYPE_CHECKING: + from selenium.webdriver.remote.webdriver import WebDriver + + +def _class_slug_from_name(class_name: str) -> str: + class_name = (class_name or "").strip().lower() + if not class_name or class_name == "unknown": + return "" + # normalize spaces/underscores + return re.sub(r"[^a-z0-9\-]", "", re.sub(r"[\s_]+", "-", class_name)) + + +def _prefix_with_class_slug(slug: str, class_slug: str) -> str: + if not slug: + return slug + if not class_slug: + return slug + if slug.startswith(class_slug + "-"): + return slug + return f"{class_slug}-{slug}" + + +LOGGER = logging.getLogger(__name__) + +GRID = 21 +NODES_LEN = GRID * GRID + + +# --------------------------------------------------------------------------- +# Maxroll ID -> human friendly names (ported from Diablo4Companion data files). +# Used to export Paragon JSON with readable identifiers (similar to Mobalytics). +# --------------------------------------------------------------------------- + +_MAXROLL_BOARD_ID_TO_NAME = { + "Paragon_Barb_00": "Start", + "Paragon_Barb_01": "Hemorrhage", + "Paragon_Barb_02": "Blood Rage", + "Paragon_Barb_03": "Carnage", + "Paragon_Barb_04": "Decimator", + "Paragon_Barb_05": "Bone Breaker", + "Paragon_Barb_06": "Flawless Technique", + "Paragon_Barb_07": "Warbringer", + "Paragon_Barb_08": "Weapons Master", + "Paragon_Barb_10": "Force of Nature", + "Paragon_Druid_00": "Start", + "Paragon_Druid_01": "Thunderstruck", + "Paragon_Druid_02": "Earthen Devastation", + "Paragon_Druid_03": "Survival Instincts", + "Paragon_Druid_04": "Lust for Carnage", + "Paragon_Druid_05": "Heightened Malice", + "Paragon_Druid_06": "Inner Beast", + "Paragon_Druid_07": "Constricting Tendrils", + "Paragon_Druid_08": "Ancestral Guidance", + "Paragon_Druid_10": "Untamed", + "Paragon_Necro_00": "Start", + "Paragon_Necro_01": "Cult Leader", + "Paragon_Necro_02": "Hulking Monstrosity", + "Paragon_Necro_03": "Flesh-eater", + "Paragon_Necro_04": "Scent of Death", + "Paragon_Necro_05": "Bone Graft", + "Paragon_Necro_06": "Blood Begets Blood", + "Paragon_Necro_07": "Bloodbath", + "Paragon_Necro_08": "Wither", + "Paragon_Necro_10": "Frailty", + "Paragon_Paladin_00": "Start", + "Paragon_Paladin_01": "Castle", + "Paragon_Paladin_02": "Shield Bearer", + "Paragon_Paladin_03": "Fervent", + "Paragon_Paladin_04": "Preacher", + "Paragon_Paladin_05": "Divinity", + "Paragon_Paladin_06": "Relentless", + "Paragon_Paladin_07": "Sentencing", + "Paragon_Paladin_08": "Endure", + "Paragon_Paladin_09": "Beacon", + "Paragon_Rogue_00": "Start", + "Paragon_Rogue_01": "Eldritch Bounty", + "Paragon_Rogue_02": "Tricks of the Trade", + "Paragon_Rogue_03": "Cheap Shot", + "Paragon_Rogue_04": "Deadly Ambush", + "Paragon_Rogue_05": "Leyrana's Instinct", + "Paragon_Rogue_06": "No Witnesses", + "Paragon_Rogue_07": "Exploit Weakness", + "Paragon_Rogue_08": "Cunning Stratagem", + "Paragon_Rogue_10": "Danse Macabre", + "Paragon_Sorc_00": "Start", + "Paragon_Sorc_01": "Searing Heat", + "Paragon_Sorc_02": "Frigid Fate", + "Paragon_Sorc_03": "Static Surge", + "Paragon_Sorc_04": "Elemental Summoner", + "Paragon_Sorc_05": "Burning Instinct", + "Paragon_Sorc_06": "Icefall", + "Paragon_Sorc_07": "Ceaseless Conduit", + "Paragon_Sorc_08": "Enchantment Master", + "Paragon_Sorc_10": "Fundamental Release", + "Paragon_Spirit_0": "Start", + "Paragon_Spirit_01": "In-Fighter", + "Paragon_Spirit_02": "Spiney Skin", + "Paragon_Spirit_03": "Viscous Shield", + "Paragon_Spirit_04": "Bitter Medicine", + "Paragon_Spirit_05": "Revealing", + "Paragon_Spirit_06": "Drive", + "Paragon_Spirit_07": "Convergence", + "Paragon_Spirit_08": "Sapping", +} + +_MAXROLL_GLYPH_ID_TO_NAME = { + "Rare_001_Intelligence_Main": "Enchanter", + "Rare_002_Intelligence_Main": "Unleash", + "Rare_003_Intelligence_Main": "Elementalist", + "Rare_004_Intelligence_Main": "Adept", + "Rare_005_Intelligence_Main": "Conjurer", + "Rare_006_Intelligence_Main": "Charged", + "Rare_007_Willpower_Side": "Torch", + "Rare_008_Willpower_Side": "Pyromaniac", + "Rare_009_Willpower_Side": "Cryopathy", + "Rare_010_Dexterity_Main": "Tactician", + "Rare_011_Intelligence_Side": "Guzzler", + "Rare_011_Willpower_Side": "Imbiber", + "Rare_012_Intelligence_Side": "Protector", + "Rare_012_Willpower_Side": "Reinforced", + "Rare_013_Dexterity_Side": "Poise", + "Rare_014_Dexterity_Side": "Territorial", + "Rare_014_Strength_Main": "Turf", + "Rare_014_Strength_Side": "Turf", + "Rare_015_Dexterity_Side": "Flamefeeder", + "Rare_016_Dexterity_Side": "Exploit", + "Rare_016_Intelligence_Side": "Exploit", + "Rare_016_Strength_Side": "Exploit", + "Rare_017_Dexterity_Side": "Winter", + "Rare_018_Dexterity_Side": "Electrocute", + "Rare_019_Dexterity_Side": "Destruction", + "Rare_020_Dexterity_Side": "Control", + "Rare_020_Intelligence_Main": "Control", + "Rare_020_Intelligence_Side": "Control", + "Rare_021_Strength_Main": "Ambidextrous", + "Rare_022_Strength_Main": "Might", + "Rare_023_Strength_Main": "Cleaver", + "Rare_024_Strength_Main": "Seething", + "Rare_025_Strength_Main": "Crusher", + "Rare_026_Strength_Main": "Executioner", + "Rare_027_Strength_Main": "Ire", + "Rare_028_Strength_Main": "Marshal", + "Rare_029_Dexterity_Side": "Bloodfeeder", + "Rare_030_Dexterity_Side": "Wrath", + "Rare_031_Dexterity_Side": "Weapon Master", + "Rare_032_Dexterity_Side": "Mortal Draw", + "Rare_033_Intelligence_Side": "Revenge", + "Rare_033_Willpower_Side": "Revenge", + "Rare_033_Willpower_Side_Necro": "Revenge", + "Rare_034_Intelligence_Side": "Undaunted", + "Rare_034_Willpower_Side": "Undaunted", + "Rare_035_Intelligence_Side": "Dominate", + "Rare_035_Willpower_Side": "Dominate", + "Rare_035_Willpower_Side_Necro": "Dominate", + "Rare_036_Willpower_Side": "Disembowel", + "Rare_037_Willpower_Side": "Brawl", + "Rare_038_Intelligence_Main": "Corporeal", + "Rare_039_Willpower_Main": "Fang and Claw", + "Rare_040_Willpower_Main": "Earth and Sky", + "Rare_041_Intelligence_Side": "Wilds", + "Rare_042_Willpower_Main": "Werebear", + "Rare_043_Willpower_Main": "Werewolf", + "Rare_044_Willpower_Main": "Human", + "Rare_045_Intelligence_Side": "Bane", + "Rare_045_Strength_Side": "Bane", + "Rare_046_Dexterity_Side": "Abyssal", + "Rare_046_Intelligence_Side": "Keeper", + "Rare_047_Dexterity_Side": "Fulminate", + "Rare_047_Intelligence_Side": "Fulminate", + "Rare_048_Dexterity_Side": "Tracker", + "Rare_048_Intelligence_Side": "Tracker", + "Rare_049_Dexterity_Side": "Outmatch", + "Rare_049_Strength_Main": "Outmatch", + "Rare_049_Strength_Side": "Outmatch", + "Rare_050_Dexterity_Main": "Spirit", + "Rare_050_Dexterity_Side": "Spirit", + "Rare_050_Willpower_Side": "Spirit", + "Rare_051_Dexterity_Side": "Shapeshifter", + "Rare_052_Dexterity_Main": "Versatility", + "Rare_053_Dexterity_Main": "Closer", + "Rare_054_Dexterity_Main": "Ranger", + "Rare_055_Dexterity_Main": "Chip", + "Rare_055_Dexterity_Side": "Chip", + "Rare_055_Willpower_Side": "Chip", + "Rare_056_Dexterity_Main": "Frostfeeder", + "Rare_057_Dexterity_Main": "Fluidity", + "Rare_058_Intelligence_Side": "Infusion", + "Rare_059_Dexterity_Main": "Devious", + "Rare_060_Dexterity_Side": "Warrior", + "Rare_061_Intelligence_Side": "Combat", + "Rare_062_Dexterity_Side": "Gravekeeper", + "Rare_063_Intelligence_Side": "Canny", + "Rare_064_Intelligence_Side": "Efficacy", + "Rare_065_Intelligence_Side": "Snare", + "Rare_066_Dexterity_Side": "Essence", + "Rare_067_Strength_Side": "Pride", + "Rare_068_Strength_Side": "Ambush", + "Rare_069_Intelligence_Main": "Sacrificial", + "Rare_070_Intelligence_Main": "Blood-drinker", + "Rare_071_Intelligence_Main": "Deadraiser", + "Rare_072_Intelligence_Main": "Mage", + "Rare_073_Intelligence_Main": "Amplify", + "Rare_074_Willpower_Side": "Golem", + "Rare_075_Willpower_Side": "Scourge", + "Rare_076_Strength_Main": "Diminish", + "Rare_076_Strength_Side": "Diminish", + "Rare_077_Willpower_Side": "Warding", + "Rare_078_Willpower_Side": "Darkness", + "Rare_079_Dexterity_Side": "Exploit", + "Rare_080_Strength_Main": "Twister", + "Rare_081_Strength_Main": "Rumble", + "Rare_082_Dexterity_Main": "Explosive", + "Rare_083_Intelligence_Side": "Nightstalker", + "Rare_084_Intelligence_Main": "Stalagmite", + "Rare_085_Dexterity_Side": "Invocation", + "Rare_086_Dexterity_Side": "Tectonic", + "Rare_087_Willpower_Main": "Electrocution", + "Rare_088_Intelligence_Main": "Exhumation", + "Rare_089_Willpower_Side": "Desecration", + "Rare_090_Dexterity_Main": "Menagerist", + "Rare_091_Strength_Side": "Hone", + "Rare_092_Intelligence_Side": "Consumption", + "Rare_093_Dexterity_Main": "Fitness", + "Rare_094_Intelligence_Side": "Ritual", + "Rare_095_Dexterity_Main": "Jagged Plume", + "Rare_096_Strength_Side": "Innate", + "Rare_097_Dexterity_Main": "Wildfire", + "Rare_098_Strength_Side": "Colossal", + "Rare_100_Dexterity_Main": "Talon", + "Rare_101_Strength_Side": "Hubris", + "Rare_102_Dexterity_Main": "Fester", + "Rare_103_Strength_Main": "Sentinel", + "Rare_104_Dexterity_Side": "Honed", + "Rare_105_Strength_Main": "Law", + "Rare_106_Willpower_Side": "Arbiter ", + "Rare_107_Strength_Main": "Resplendence", + "Rare_108_Intelligence_Side": "Judicator", + "Rare_109_Dexterity_Side": "Feverous", + "Rare_110_Strength_Main": "Apostle", + "Rare_Dex_Generic": "Headhunter", + "Rare_Int_Generic": "Eliminator", + "Rare_Str_Generic": "Challenger", + "Rare_Will_Generic": "Headhunter", +} + + +def _slugify(s: str) -> str: + s = (s or "").strip().lower() + s = re.sub(r"[^a-z0-9]+", "-", s) + return s.strip("-") + + +def _maxroll_class_slug(board_id: str) -> str: + # Example: "Paragon_Paladin_02" -> "paladin" + m = re.match(r"^Paragon_([A-Za-z]+)_\d+$", board_id or "") + return _slugify(m.group(1)) if m else "" + + +def _maxroll_board_slug(board_id: str) -> str: + cls = _maxroll_class_slug(board_id) + name = _MAXROLL_BOARD_ID_TO_NAME.get(board_id, board_id) + name_slug = _slugify(name) + return f"{cls}-{name_slug}" if cls and name_slug else _slugify(board_id) + + +def _maxroll_glyph_slug(glyph_id: str, board_id: str) -> str: + # We prefix with class for consistency with Mobalytics output. + cls = _maxroll_class_slug(board_id) + name = _MAXROLL_GLYPH_ID_TO_NAME.get(glyph_id, glyph_id) + name_slug = _slugify(name) + return f"{cls}-{name_slug}" if cls and name_slug else _slugify(glyph_id) + + +def export_paragon_build_json( + file_stem: str, build_name: str, source_url: str, paragon_boards_list: list[list[dict[str, Any]]] +) -> Path: + """Write a D4Companion-compatible JSON containing Name + ParagonBoardsList. + + Output format is a JSON list with a single entry, so it can be consumed by tools that expect a list. + """ + out_dir = IniConfigLoader().user_dir / "paragon" + out_dir.mkdir(parents=True, exist_ok=True) + + out_path = out_dir / f"{file_stem}.json" + + payload = [ + { + "Name": build_name, + "Source": source_url, + "GeneratedAt": datetime.datetime.now(tz=datetime.UTC).strftime("%Y-%m-%d %H:%M:%S UTC"), + "Generator": f"d4lf v{__version__}", + "ParagonBoardsList": paragon_boards_list, + } + ] + + with out_path.open("w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + + LOGGER.info(f"Exported paragon JSON: {out_path}") + return out_path + + +def build_paragon_profile_payload( + build_name: str, source_url: str, paragon_boards_list: list[list[dict[str, Any]]] +) -> dict[str, Any]: + """Build the Paragon payload intended to be embedded into a profile YAML. + + The structure matches the existing JSON export payload (without the outer list wrapper). + """ + return { + "Name": build_name, + "Source": source_url, + "GeneratedAt": datetime.datetime.now(tz=datetime.UTC).strftime("%Y-%m-%d %H:%M:%S UTC"), + "Generator": f"d4lf v{__version__}", + "ParagonBoardsList": paragon_boards_list, + } + + +def write_paragon_into_profile_yaml(profile_ref: str, payload: dict[str, Any]) -> Path | None: + """Embed paragon payload into a profile YAML under a top-level ``Paragon`` key. + + Returns the updated profile path if successful; otherwise returns ``None``. + """ + profiles_dir = IniConfigLoader().user_dir / "profiles" + p = Path(profile_ref) + + # Accept absolute or relative path + if p.is_file(): + profile_path = p + else: + stem = p.stem + candidates = [ + profiles_dir / f"{stem}.yaml", + profiles_dir / f"{stem}.yml", + profiles_dir / stem, # in case save_as_profile returns full filename + ] + profile_path = next((c for c in candidates if c.exists()), None) + + if profile_path is None or not profile_path.exists(): + LOGGER.warning("Unable to locate profile YAML for Paragon embed: %s", profile_ref) + return None + + data: dict[str, Any] = {} + try: + if RUAMEL_YAML is not None: + y = RUAMEL_YAML(typ="rt") + with profile_path.open("r", encoding="utf-8") as f: + loaded = y.load(f) or {} + if isinstance(loaded, dict): + data = loaded + data["Paragon"] = payload + with profile_path.open("w", encoding="utf-8") as f: + y.dump(data, f) + elif PyYAML is not None: + with profile_path.open("r", encoding="utf-8") as f: + loaded = PyYAML.safe_load(f) or {} + if isinstance(loaded, dict): + data = loaded + data["Paragon"] = payload + with profile_path.open("w", encoding="utf-8") as f: + PyYAML.safe_dump(data, f, sort_keys=False, allow_unicode=True) + else: # pragma: no cover + LOGGER.error("No YAML library available (ruamel.yaml or PyYAML); cannot embed Paragon.") + return None + except Exception: + LOGGER.exception("Failed embedding Paragon into profile YAML: %s", profile_path) + return None + + LOGGER.info("Embedded Paragon into profile: %s", profile_path) + return profile_path + + +def extract_maxroll_paragon_steps(active_profile: dict[str, Any]) -> list[list[dict[str, Any]]]: + """Extract paragon steps from Maxroll planner data. + + Matches the rotation + node-index transformation used in Diablo4Companion. + """ + steps_out: list[list[dict[str, Any]]] = [] + paragon = (active_profile or {}).get("paragon") or {} + steps = paragon.get("steps") or [] + + for step in steps: + boards_out: list[dict[str, Any]] = [] + for bd in (step or {}).get("data") or []: + board_id = (bd or {}).get("id", "") + glyph_id = (bd or {}).get("glyph", "") + rotation = int((bd or {}).get("rotation", 0)) + nodes_bool = [False] * NODES_LEN + + nodes_dict = (bd or {}).get("nodes") or {} + for loc_key in nodes_dict: + try: + loc = int(loc_key) + except TypeError, ValueError: + loc = None + if loc is None: + continue + idx = _transform_maxroll_location(loc=loc, rotation=rotation) + if 0 <= idx < NODES_LEN: + nodes_bool[idx] = True + + boards_out.append({ + "Name": _maxroll_board_slug(board_id), + "Glyph": _maxroll_glyph_slug(glyph_id, board_id) if glyph_id else "", + "Rotation": _rotation_info_maxroll(rotation), + "Nodes": nodes_bool, + "BoardId": board_id, + "GlyphId": glyph_id, + }) + + if boards_out: + steps_out.append(boards_out) + + return steps_out + + +def extract_mobalytics_paragon_steps(variant: dict[str, Any]) -> list[list[dict[str, Any]]]: + """Extract paragon boards from Mobalytics preloaded-state build variant. + + Matches the rotation + node-index transformation used in Diablo4Companion. + """ + paragon = (variant or {}).get("paragon") or {} + boards_data = paragon.get("boards") or [] + nodes_data = paragon.get("nodes") or [] + + boards_out: list[dict[str, Any]] = [] + + for board in boards_data: + board_slug = ((board or {}).get("board") or {}).get("slug", "") + board_slug = _fix_mobalytics_starting_board_slug(board_slug) + + glyph_slug = ((board or {}).get("glyph") or {}).get("slug", "") + rotation = int((board or {}).get("rotation", 0)) + + nodes_bool = [False] * NODES_LEN + board_nodes = [ + n + for n in nodes_data + if isinstance(n, dict) and isinstance(n.get("slug"), str) and n["slug"].startswith(board_slug) + ] + + for n in board_nodes: + slug = n.get("slug", "") + node_position = slug.replace(board_slug + "-", "") + try: + x_part, y_part = node_position.split("-", 1) + x = int(x_part.lstrip("x")) + y = int(y_part.lstrip("y")) + except ValueError, IndexError: + x = None + y = None + if x is None or y is None: + continue + + idx = _transform_xy_common(x=x, y=y, rotation_deg=rotation, base="mobalytics") + if 0 <= idx < NODES_LEN: + nodes_bool[idx] = True + + boards_out.append({ + "Name": board_slug, + "Glyph": glyph_slug, + "Rotation": _rotation_info_degrees(rotation), + "Nodes": nodes_bool, + }) + + return [boards_out] if boards_out else [] + + +def _parse_d4builds_paragon_boards(driver: WebDriver, class_slug: str) -> list[list[dict[str, Any]]]: + """Parse D4Builds paragon boards from the currently loaded page.""" + boards_out: list[dict[str, Any]] = [] + try: + board_elements = driver.find_elements(By.CLASS_NAME, "paragon__board") + except Exception: + board_elements = [] + + for board_elem in board_elements: + name_raw = "" + lines = [] + name_display = "" + try: + name_raw = board_elem.find_element(By.CLASS_NAME, "paragon__board__name").get_attribute("innerText") or "" + lines = [ln.strip() for ln in (name_raw or "").splitlines() if ln.strip()] + # Prefer first line that contains letters (D4Builds sometimes shows just a numeric index on line 1) + name_display = next((ln for ln in lines if any(ch.isalpha() for ch in ln)), (lines[0] if lines else "")) + except Exception: + name_display = "" + + # Try to detect a stable board id/slug from element attributes (best effort) + board_id = "" + try: + attrs = driver.execute_script( + "var a=arguments[0].attributes; var o={}; for (var i=0;i list[list[dict[str, Any]]]: + """Extract paragon boards from D4Builds using Selenium. + + This reuses the existing Selenium session/page state created by the importer. We only + click/wait for the Paragon tab if boards are not already present in the DOM. + """ + class_slug = _class_slug_from_name(class_name) + + if By is None or WebDriverWait is None: # pragma: no cover + msg = "Selenium not available, cannot export D4Builds paragon" + raise RuntimeError(msg) + + if wait is None: + wait = WebDriverWait(driver, 10) + + # Fast path: if boards are already present, don't click/wait again. + try: + if driver.find_elements(By.CLASS_NAME, "paragon__board"): + return _parse_d4builds_paragon_boards(driver, class_slug) + except Exception: + LOGGER.debug("Could not query for existing D4Builds paragon boards (continuing).", exc_info=True) + + # Best effort: ensure the navigation is present before attempting to click Paragon. + try: + wait.until(lambda d: len(d.find_elements(By.CLASS_NAME, "builder__navigation__link")) > 0) + except Exception: + LOGGER.debug("Timed out waiting for D4Builds navigation links (continuing).", exc_info=True) + + # Switch to Paragon tab (D4Builds uses left navigation links) + try: + nav_links = driver.find_elements(By.CLASS_NAME, "builder__navigation__link") + if len(nav_links) >= 3: + driver.execute_script("arguments[0].click();", nav_links[2]) + else: + # Fallback: click any element containing 'Paragon' + el = driver.find_element(By.XPATH, "//*[contains(normalize-space(.), 'Paragon')]") + driver.execute_script("arguments[0].click();", el) + time.sleep(0.25) + except Exception: + # Not fatal: sometimes paragon is already visible or site changed + LOGGER.debug("Could not click Paragon tab (continuing).", exc_info=True) + + # Wait for paragon boards to appear (best effort) + try: + wait.until(lambda d: len(d.find_elements(By.CLASS_NAME, "paragon__board")) > 0) + except Exception: + LOGGER.debug("Timed out waiting for D4Builds paragon boards (continuing).", exc_info=True) + + return _parse_d4builds_paragon_boards(driver, class_slug) + + +# --- Helper functions (ported from Diablo4Companion) --- + + +def _rotation_info_maxroll(rot: int) -> str: + return {0: "0°", 1: "90°", 2: "180°", 3: "270°"}.get(rot, "?°") + + +def _rotation_info_degrees(rot: int) -> str: + rot = rot % 360 + return {0: "0°", 90: "90°", 180: "180°", 270: "270°"}.get(rot, "?°") + + +def _transform_maxroll_location(loc: int, rotation: int) -> int: + """Transform a 0-based location index from Maxroll into the Nodes[] index. + + This follows the exact switch used in Diablo4Companion BuildsManagerMaxroll. + """ + x = loc % GRID + y = loc // GRID + xt = x + yt = y + + match rotation: + case 0: + return loc + case 1: + xt = GRID - y + yt = x + xt -= 1 + return yt * GRID + xt + case 2: + xt = GRID - x + yt = GRID - y + xt -= 1 + yt -= 1 + return yt * GRID + xt + case 3: + xt = y + yt = GRID - x + yt -= 1 + return yt * GRID + xt + case _: + return loc + + +def _transform_xy_common(x: int, y: int, rotation_deg: int, base: str) -> int: + """Shared x/y to Nodes[] transform. + + base: + - 'd4builds' uses 1-based r/c coordinates. + - 'mobalytics' uses 1-based x/y coordinates. + + The formulas mirror Diablo4Companion's implementations for each source. + """ + rotation_deg = rotation_deg % 360 + + xt = x + yt = y + + if base in {"d4builds", "mobalytics"}: + # both sources provide 1-based coords in the '0°' case and need (x-1, y-1) + if rotation_deg in {0, 360}: + xt -= 1 + yt -= 1 + elif rotation_deg == 90: + xt = GRID - y + yt = x + yt -= 1 + elif rotation_deg == 180: + xt = GRID - x + yt = GRID - y + elif rotation_deg == 270: + xt = y + yt = GRID - x + xt -= 1 + + return yt * GRID + xt + + +def _fix_mobalytics_starting_board_slug(board_slug: str) -> str: + # Fix naming inconsistency (ported from Diablo4Companion) + return ( + board_slug + .replace("barbarian-starter-board", "barbarian-starting-board") + .replace("druid-starter-board", "druid-starting-board") + .replace("necromancer-starter-board", "necromancer-starting-board") + .replace("paladin-starter-board", "paladin-starting-board") + .replace("rogue-starter-board", "rogue-starting-board") + .replace("sorcerer-starter-board", "sorcerer-starting-board") + .replace("spiritborn-starter-board", "spiritborn-starting-board") + ) diff --git a/src/gui/importer_window.py b/src/gui/importer_window.py index 996160f4..563c1768 100644 --- a/src/gui/importer_window.py +++ b/src/gui/importer_window.py @@ -107,6 +107,13 @@ def __init__(self, parent=None): "false", ) + self.export_paragon_checkbox = self._generate_checkbox( + "Import Paragon", + "export_paragon", + "Import Paragon boards into your profile for the integrated Paragon overlay.", + "false", + ) + # GA dependency logic def disable_require_if_import_disabled(): if not self.import_gas_checkbox.isChecked(): @@ -135,6 +142,7 @@ def disable_require_if_import_disabled(): checkbox_hbox = QHBoxLayout() checkbox_hbox.addWidget(self.import_gas_checkbox) checkbox_hbox.addWidget(self.require_all_gas_checkbox) + checkbox_hbox.addWidget(self.export_paragon_checkbox) layout.addLayout(checkbox_hbox) # Generate button @@ -223,6 +231,7 @@ def _generate_button_click(self): self.add_to_profiles_checkbox.isChecked(), self.import_gas_checkbox.isChecked(), self.require_all_gas_checkbox.isChecked(), + self.export_paragon_checkbox.isChecked(), custom_filename, ) diff --git a/src/gui/unified_window.py b/src/gui/unified_window.py index ab17af37..e9c4b76a 100644 --- a/src/gui/unified_window.py +++ b/src/gui/unified_window.py @@ -179,14 +179,19 @@ def __init__(self): theme_name = getattr(config.general, "theme", None) or "dark" stylesheet = DARK_THEME if theme_name == "dark" else LIGHT_THEME QApplication.instance().setStyleSheet(stylesheet) - # --- Logging setup --- running_from_source = not getattr(sys, "frozen", False) - setup_logging(enable_stdout=running_from_source) root_logger = logging.getLogger() - # Remove existing handlers, but keep stdout handler if running from source + # Ensure file logging stays enabled. unified_window previously removed all handlers (including the file handler), + # which stopped live log writing to d4lf/logs. + if not any(getattr(h, "name", "") == "D4LF_FILE" for h in root_logger.handlers): + setup_logging(log_level=config.advanced_options.log_lvl.value, enable_stdout=running_from_source) + + # Remove existing handlers, but keep file handler and (optionally) stdout when running from source for h in list(root_logger.handlers): + if getattr(h, "name", "") == "D4LF_FILE": + continue # Keep file logging if running_from_source and isinstance(h, logging.StreamHandler) and h.stream == sys.stdout: continue # Keep stdout handler for IDE terminal root_logger.removeHandler(h) @@ -327,6 +332,7 @@ def open_profile_editor(self): LOGGER.error(f"Failed to open profile editor: {e}") def restore_geometry(self): + settings = QSettings("d4lf", "mainwindow") size = settings.value("size", QSize(1000, 800)) diff --git a/src/main.py b/src/main.py index 7df347a2..7aaa7720 100644 --- a/src/main.py +++ b/src/main.py @@ -54,6 +54,7 @@ def main(): table = BeautifulTable() table.set_style(BeautifulTable.STYLE_BOX_ROUNDED) table.rows.append([IniConfigLoader().advanced_options.run_vision_mode, "Run/Stop Vision Mode"]) + table.rows.append([IniConfigLoader().advanced_options.toggle_paragon_overlay, "Toggle Paragon Overlay"]) if not IniConfigLoader().advanced_options.vision_mode_only: table.rows.append([IniConfigLoader().advanced_options.run_filter, "Run/Stop Auto Filter"]) diff --git a/src/paragon_overlay.py b/src/paragon_overlay.py new file mode 100644 index 00000000..d671ed61 --- /dev/null +++ b/src/paragon_overlay.py @@ -0,0 +1,540 @@ +"""Paragon overlay (tkinter).""" + +from __future__ import annotations + +import json +import logging +import re +import sys +import tkinter as tk +from contextlib import suppress +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from src.cam import Cam +from src.config.ui import ResManager + +try: + from ruamel.yaml import YAML as RUAMEL_YAML +except ImportError: # pragma: no cover + RUAMEL_YAML = None + +try: + import yaml as PyYAML +except ImportError: # pragma: no cover + PyYAML = None + + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + +LOGGER = logging.getLogger(__name__) + +GRID = 21 # 21x21 nodes +NODES_LEN = GRID * GRID + + +# ---------------------------- +# Data loading / normalization +# ---------------------------- +def _iter_entries(data: Any) -> Iterable[dict[str, Any]]: + if isinstance(data, dict): + yield data + return + if isinstance(data, list): + for it in data: + if isinstance(it, dict): + yield it + + +def _normalize_steps(raw_list: Any) -> list[list[dict[str, Any]]]: + if not isinstance(raw_list, list) or not raw_list: + return [] + if isinstance(raw_list[0], list): + return [step for step in raw_list if isinstance(step, list) and step] + return [raw_list] + + +def _load_yaml_file(path: Path) -> dict[str, Any] | None: + try: + if RUAMEL_YAML is not None: + y = RUAMEL_YAML(typ="rt") + with path.open("r", encoding="utf-8") as f: + loaded = y.load(f) or {} + return loaded if isinstance(loaded, dict) else None + if PyYAML is not None: + with path.open("r", encoding="utf-8") as f: + loaded = PyYAML.safe_load(f) or {} + return loaded if isinstance(loaded, dict) else None + except Exception: + LOGGER.debug("Failed reading YAML: %s", path, exc_info=True) + return None + + LOGGER.error("No YAML library available (ruamel.yaml or PyYAML)") + return None + + +def _extract_paragon_payloads_from_profile_yaml(profile_yaml: dict[str, Any]) -> Iterable[dict[str, Any]]: + paragon = profile_yaml.get("Paragon") + if isinstance(paragon, dict): + yield paragon + return + if isinstance(paragon, list): + for it in paragon: + if isinstance(it, dict): + yield it + + +def load_builds_from_path(preset_path: str) -> list[dict[str, Any]]: + p = Path(preset_path) + + msg_not_found = "Preset file/folder not found" + if not p.exists(): + raise ValueError(msg_not_found) + + builds: list[dict[str, Any]] = [] + + if p.is_file(): + suffix = p.suffix.lower() + if suffix == ".json": + builds.extend(_load_builds_from_json_file(p, profile=p.stem)) + elif suffix in (".yaml", ".yml"): + builds.extend(_load_builds_from_profile_yaml_file(p, profile=p.stem)) + else: + msg = "Unsupported preset file type" + raise ValueError(msg) + if not builds: + msg = "No valid builds found" + raise ValueError(msg) + return builds + + # Directory mode: + # - legacy exports: *.json in the selected folder + # - current format: profiles/*.yaml (or sibling ../profiles/*.yaml) + json_files = sorted(p.glob("*.json"), key=lambda fp: fp.stat().st_mtime, reverse=True) + if json_files: + for fp in json_files: + try: + builds.extend(_load_builds_from_json_file(fp, name_tag=fp.stem, profile=fp.stem)) + except Exception: + LOGGER.debug("Skipping invalid paragon preset JSON: %s", fp, exc_info=True) + + yaml_dirs: list[Path] = [] + if (p / "profiles").is_dir(): + yaml_dirs.append(p / "profiles") + if (p.parent / "profiles").is_dir(): + yaml_dirs.append(p.parent / "profiles") + yaml_dirs.append(p) + + seen: set[Path] = set() + for yd in yaml_dirs: + for fp in sorted(yd.glob("*.ya*"), key=lambda x: x.stat().st_mtime, reverse=True): + if fp in seen: + continue + seen.add(fp) + try: + builds.extend(_load_builds_from_profile_yaml_file(fp, profile=fp.stem)) + except Exception: + LOGGER.debug("Skipping invalid profile YAML: %s", fp, exc_info=True) + + if not builds: + msg = "No valid builds found in folder" + raise ValueError(msg) + return builds + + +def _load_builds_from_json_file( + preset_file: Path, name_tag: str | None = None, profile: str | None = None +) -> list[dict[str, Any]]: + with preset_file.open(encoding="utf-8") as f: + data = json.load(f) + + builds: list[dict[str, Any]] = [] + for entry in _iter_entries(data): + builds.extend(_builds_from_paragon_entry(entry, name_tag=name_tag, profile=profile)) + if not builds: + msg = "No valid builds in JSON" + raise ValueError(msg) + return builds + + +def _load_builds_from_profile_yaml_file(profile_file: Path, profile: str | None = None) -> list[dict[str, Any]]: + loaded = _load_yaml_file(profile_file) + if not loaded: + msg = "Invalid or empty profile YAML" + raise ValueError(msg) + + builds: list[dict[str, Any]] = [] + for payload in _extract_paragon_payloads_from_profile_yaml(loaded): + builds.extend(_builds_from_paragon_entry(payload, name_tag=profile_file.stem, profile=profile)) + if not builds: + msg = "No Paragon payload found in profile YAML" + raise ValueError(msg) + return builds + + +def _builds_from_paragon_entry( + entry: dict[str, Any], *, name_tag: str | None, profile: str | None +) -> list[dict[str, Any]]: + base_name = entry.get("Name") or entry.get("name") or "Unknown Build" + steps = _normalize_steps(entry.get("ParagonBoardsList", [])) + if not steps: + return [] + + builds: list[dict[str, Any]] = [] + for idx in range(len(steps) - 1, -1, -1): + boards = steps[idx] + step_name = base_name + if len(steps) > 1: + step_name = f"{base_name} - Step {idx + 1}" + if name_tag: + step_name = f"{step_name} [{name_tag}]" + builds.append({"name": step_name, "boards": boards, "profile": profile}) + return builds + + +def parse_rotation(rot_str: str) -> int: + m = re.search(r"(\d+)", rot_str or "") + deg = int(m.group(1)) if m else 0 + deg = deg % 360 + return deg if deg in (0, 90, 180, 270) else 0 + + +def nodes_to_grid(nodes_441: list[int] | list[bool]) -> list[list[bool]]: + return [[bool(nodes_441[y * GRID + x]) for x in range(GRID)] for y in range(GRID)] + + +def rotate_grid(grid: list[list[bool]], deg: int) -> list[list[bool]]: + if deg == 90: + return [list(reversed(col)) for col in zip(*grid, strict=True)] + if deg == 180: + return [list(reversed(r)) for r in reversed(grid)] + if deg == 270: + return [list(col) for col in reversed(list(zip(*grid, strict=True)))] + return grid + + +# ---------------------------- +# Overlay UI +# ---------------------------- +@dataclass(slots=True) +class OverlayConfig: + cell_size: int = 24 + panel_w: int = 420 + poll_ms: int = 500 + + +class ParagonOverlay(tk.Toplevel): + """Tkinter paragon overlay window.""" + + def __init__( + self, + parent: tk.Misc, + builds: list[dict[str, Any]], + *, + cfg: OverlayConfig | None = None, + on_close: Callable[[], None] | None = None, + ) -> None: + super().__init__(parent) + self._cfg = cfg or OverlayConfig() + self._on_close = on_close + + self._cam = Cam() + self._res = ResManager() + + self.builds = builds + self.current_build_idx = 0 + self.boards = self.builds[0]["boards"] if self.builds else [] + self.selected_board_idx = 0 + + self._last_roi: tuple[int, int, int, int] | None = None + self._last_res: tuple[int, int] | None = None + + self.title("D4LF Paragon Overlay") + self.attributes("-topmost", True) + + # Overlay-like appearance, best effort. + self.configure(bg="#ff00ff") + with suppress(tk.TclError): + self.overrideredirect(True) + with suppress(tk.TclError): + self.wm_attributes("-transparentcolor", "#ff00ff") + + self.protocol("WM_DELETE_WINDOW", self.close) + + self._build_ui() + self._bind_events() + + self._apply_geometry() + self._refresh_lists() + self.redraw() + self.after(self._cfg.poll_ms, self._poll_window_state) + + # -------- UI layout -------- + def _build_ui(self) -> None: + outer = tk.Frame(self, bg="#ff00ff") + outer.pack(fill="both", expand=True) + + self.left = tk.Frame(outer, width=self._cfg.panel_w, bg="#1b1b1b") + self.left.pack(side="left", fill="y") + + header = tk.Frame(self.left, bg="#222") + header.pack(fill="x") + + self.btn_close = tk.Button(header, text="EXIT", command=self.close) + self.btn_close.pack(side="right", padx=6, pady=6) + + self.lbl_title = tk.Label(header, text="Paragon Overlay", fg="#eee", bg="#222") + self.lbl_title.pack(side="left", padx=8) + + self.build_list = tk.Listbox(self.left, activestyle="none") + self.build_list.pack(fill="x", padx=8, pady=(6, 8)) + + self.board_list = tk.Listbox(self.left, activestyle="none") + self.board_list.pack(fill="both", expand=True, padx=8, pady=(0, 8)) + + footer = tk.Frame(self.left, bg="#222") + footer.pack(fill="x") + self.lbl_hint = tk.Label(footer, text="Wheel: zoom | Drag grid: move window", fg="#cfa15b", bg="#222") + self.lbl_hint.pack(side="left", padx=8, pady=6) + + self.right = tk.Frame(outer, bg="#000") + self.right.pack(side="right", fill="both", expand=True) + + self.canvas = tk.Canvas(self.right, highlightthickness=0, bg="#000") + self.canvas.pack(fill="both", expand=True) + + def _bind_events(self) -> None: + self.build_list.bind("<>", self._on_select_build) + self.board_list.bind("<>", self._on_select_board) + + self.canvas.bind("", self._on_mousewheel) + self.canvas.bind("", self._on_mousewheel) + self.canvas.bind("", self._on_mousewheel) + + self.canvas.bind("", self._on_drag_start) + self.canvas.bind("", self._on_drag_move) + + # -------- polling for ROI/resolution changes -------- + def _poll_window_state(self) -> None: + try: + roi = self._get_cam_roi() + res = self._get_resolution() + + if roi != self._last_roi or res != self._last_res: + self._last_roi = roi + self._last_res = res + self._apply_geometry() + self.redraw() + finally: + self.after(self._cfg.poll_ms, self._poll_window_state) + + # -------- handlers -------- + def _on_select_build(self, _: Any) -> None: + sel = self._get_listbox_index(self.build_list) + if sel is None: + return + self.current_build_idx = sel + self.boards = self.builds[sel]["boards"] + self.selected_board_idx = 0 + self._refresh_lists() + self.redraw() + + def _on_select_board(self, _: Any) -> None: + sel = self._get_listbox_index(self.board_list) + if sel is None: + return + self.selected_board_idx = sel + self.redraw() + + def _on_mousewheel(self, e: tk.Event) -> None: + delta = 0 + if getattr(e, "delta", 0): + delta = 1 if e.delta > 0 else -1 + elif getattr(e, "num", 0) in (4, 5): + delta = 1 if e.num == 4 else -1 + + if not delta: + return + + new_size = max(10, min(80, self._cfg.cell_size + (2 * delta))) + if new_size != self._cfg.cell_size: + self._cfg.cell_size = new_size + self.redraw() + + def _on_drag_start(self, e: tk.Event) -> None: + self._drag_start_xy = (e.x_root, e.y_root) + self._drag_start_geo = (self.winfo_x(), self.winfo_y()) + + def _on_drag_move(self, e: tk.Event) -> None: + sx, sy = self._drag_start_xy + gx, gy = self._drag_start_geo + dx = e.x_root - sx + dy = e.y_root - sy + self.geometry(f"+{gx + dx}+{gy + dy}") + + # -------- helpers -------- + @staticmethod + def _get_listbox_index(lb: tk.Listbox) -> int | None: + cur = lb.curselection() + return int(cur[0]) if cur else None + + def _get_resolution(self) -> tuple[int, int]: + with suppress(Exception): + w, h = tuple(self._res.resolution)[:2] + return (int(w), int(h)) + return (self.winfo_screenwidth(), self.winfo_screenheight()) + + def _get_cam_roi(self) -> tuple[int, int, int, int] | None: + roi = getattr(self._cam, "window_roi", None) + if not roi: + return None + try: + x, y, w, h = roi + return (int(x), int(y), int(w), int(h)) + except Exception: + return None + + def _apply_geometry(self) -> None: + w, h = self._get_resolution() + roi = self._get_cam_roi() + + grid_w = min(760, max(480, w - self._cfg.panel_w - 80)) + grid_h = min(760, max(480, h - 120)) + total_w = self._cfg.panel_w + grid_w + total_h = grid_h + + if roi is not None: + x, y, _, _ = roi + self.geometry(f"{total_w}x{total_h}+{x + 20}+{y + 20}") + else: + self.geometry(f"{total_w}x{total_h}+20+20") + + self.canvas.config(width=grid_w, height=grid_h) + + def _refresh_lists(self) -> None: + self.build_list.delete(0, tk.END) + for b in self.builds: + self.build_list.insert(tk.END, b.get("name", "Unknown Build")) + if self.builds: + self.build_list.selection_set(self.current_build_idx) + + self.board_list.delete(0, tk.END) + for bd in self.boards or []: + name = bd.get("Name", "?") + rot = bd.get("Rotation", "0") + glyph = bd.get("Glyph") + label = f"{name} ({rot})" + if glyph: + label += f" ({glyph})" + self.board_list.insert(tk.END, label) + if self.boards: + self.board_list.selection_set(self.selected_board_idx) + + cur_name = self.builds[self.current_build_idx]["name"] if self.builds else "Paragon Overlay" + self.lbl_title.config(text=cur_name) + + # -------- drawing -------- + def redraw(self) -> None: + self.canvas.delete("all") + if not self.boards: + self.canvas.create_text(20, 20, anchor="nw", fill="#fff", text="No boards loaded") + return + + board = self.boards[self.selected_board_idx] + nodes = board.get("Nodes") or [] + if len(nodes) != NODES_LEN: + self.canvas.create_text(20, 20, anchor="nw", fill="#fff", text="Invalid board node data") + return + + rot = parse_rotation(board.get("Rotation", "0°")) + grid = rotate_grid(nodes_to_grid(nodes), rot) + + cs = self._cfg.cell_size + pad = 16 + gx0, gy0 = pad, pad + grid_px = GRID * cs + + self.canvas.create_rectangle(gx0 - 6, gy0 - 6, gx0 + grid_px + 6, gy0 + grid_px + 6, outline="#cfa15b", width=3) + + for i in range(GRID + 1): + p = i * cs + self.canvas.create_line(gx0, gy0 + p, gx0 + grid_px, gy0 + p, fill="#444", width=1) + self.canvas.create_line(gx0 + p, gy0, gx0 + p, gy0 + grid_px, fill="#444", width=1) + + path_w = max(2, cs // 6) + half = cs // 2 + for y in range(GRID): + for x in range(GRID): + if not grid[y][x]: + continue + cx = gx0 + x * cs + half + cy = gy0 + y * cs + half + if x + 1 < GRID and grid[y][x + 1]: + nx = gx0 + (x + 1) * cs + half + self.canvas.create_line(cx, cy, nx, cy, fill="#22aa44", width=path_w) + if y + 1 < GRID and grid[y + 1][x]: + ny = gy0 + (y + 1) * cs + half + self.canvas.create_line(cx, cy, cx, ny, fill="#22aa44", width=path_w) + + inset = max(3, cs // 4) + for y in range(GRID): + for x in range(GRID): + if not grid[y][x]: + continue + x1 = gx0 + x * cs + inset + y1 = gy0 + y * cs + inset + x2 = gx0 + (x + 1) * cs - inset + y2 = gy0 + (y + 1) * cs - inset + self.canvas.create_rectangle(x1, y1, x2, y2, fill="#18dd44", outline="#bbffbb", width=1) + + # -------- lifecycle -------- + def close(self) -> None: + try: + self.destroy() + finally: + if self._on_close: + self._on_close() + + +# ---------------------------- +# Public API (used by app) +# ---------------------------- +def run_paragon_overlay(preset_path: str | None = None, *, parent: tk.Misc | None = None) -> ParagonOverlay | None: + """Start overlay in-process.""" + preset = preset_path or (sys.argv[1] if len(sys.argv) > 1 else "") + if not preset: + LOGGER.error("No preset path provided") + return None + + try: + builds = load_builds_from_path(preset) + except Exception: + LOGGER.exception("Failed to load Paragon preset(s): %s", preset) + return None + + owns_root = False + if parent is None: + root = tk.Tk() + root.withdraw() + parent = root + owns_root = True + + overlay = ParagonOverlay(parent, builds, on_close=(parent.quit if owns_root else None)) + + if owns_root: + parent.mainloop() + return overlay + + +def request_close(overlay: ParagonOverlay | None) -> None: + """Best-effort close from elsewhere.""" + if overlay is None: + return + with suppress(Exception): + overlay.after(0, overlay.close) + + +if __name__ == "__main__": + run_paragon_overlay() diff --git a/src/scripts/handler.py b/src/scripts/handler.py index 22a4df3d..fe27c608 100644 --- a/src/scripts/handler.py +++ b/src/scripts/handler.py @@ -1,11 +1,12 @@ import logging -import sys import threading import time import typing +from contextlib import suppress +from pathlib import Path + +import keyboard -if sys.platform != "darwin": - import keyboard import src.scripts.loot_filter_tts import src.scripts.vision_mode_fast import src.scripts.vision_mode_with_highlighting @@ -14,6 +15,7 @@ from src.config.loader import IniConfigLoader from src.config.models import ItemRefreshType, VisionModeType from src.loot_mover import move_items_to_inventory, move_items_to_stash +from src.paragon_overlay import request_close, run_paragon_overlay from src.scripts.common import SETUP_INSTRUCTIONS_URL from src.ui.char_inventory import CharInventory from src.ui.stash import Stash @@ -29,6 +31,8 @@ class ScriptHandler: def __init__(self): self.loot_interaction_thread = None + self.paragon_overlay_thread: threading.Thread | None = None + self._vision_mode_was_running_before_overlay = False if IniConfigLoader().general.vision_mode_type == VisionModeType.fast: self.vision_mode = src.scripts.vision_mode_fast.VisionModeFast() else: @@ -41,9 +45,67 @@ def __init__(self): def _graceful_exit(self): safe_exit() + def toggle_paragon_overlay(self): + """Toggle the Paragon overlay thread (start if not running, request close if running).""" + try: + if self.paragon_overlay_thread is not None and self.paragon_overlay_thread.is_alive(): + LOGGER.info("Closing Paragon overlay") + with suppress(Exception): + request_close() + self.paragon_overlay_thread.join(timeout=2) + # Vision mode is restored by the overlay thread cleanup. + return + + config = IniConfigLoader() + overlay_dir_str = getattr(config.advanced_options, "paragon_overlay_source_dir", "") or "" + overlay_dir = ( + Path(overlay_dir_str).expanduser() if str(overlay_dir_str).strip() else (config.user_dir / "profiles") + ) + overlay_dir.mkdir(parents=True, exist_ok=True) + + yaml_files = list(Path(overlay_dir).glob("*.yaml")) + list(Path(overlay_dir).glob("*.yml")) + json_files = list(Path(overlay_dir).glob("*.json")) + if not (yaml_files or json_files): + LOGGER.warning( + "No Paragon profiles found in %s. Import a build first or place *.yaml/*.yml profiles there (legacy *.json also supported).", + overlay_dir, + ) + + # Disable vision mode while the overlay is active; restore it when the overlay closes. + self._vision_mode_was_running_before_overlay = self.vision_mode.running() + if self._vision_mode_was_running_before_overlay: + self.vision_mode.stop() + + LOGGER.info("Opening Paragon overlay (source: %s)", overlay_dir) + self.paragon_overlay_thread = threading.Thread( + target=self._run_paragon_overlay, args=(str(overlay_dir),), daemon=True + ) + self.paragon_overlay_thread.start() + + except Exception: + LOGGER.exception("Failed to toggle Paragon overlay") + + def _run_paragon_overlay(self, preset_path: str) -> None: + try: + run_paragon_overlay(preset_path) + except Exception: + LOGGER.exception("Paragon overlay crashed") + finally: + # Overlay has stopped (or failed to start). Restore vision mode if we stopped it. + try: + if self._vision_mode_was_running_before_overlay and not self.vision_mode.running(): + self.vision_mode.start() + except Exception: + LOGGER.exception("Failed to restore vision mode after Paragon overlay") + finally: + self.paragon_overlay_thread = None + def setup_key_binds(self): keyboard.add_hotkey(IniConfigLoader().advanced_options.run_vision_mode, lambda: self.run_vision_mode()) keyboard.add_hotkey(IniConfigLoader().advanced_options.exit_key, lambda: self._graceful_exit()) + keyboard.add_hotkey( + IniConfigLoader().advanced_options.toggle_paragon_overlay, lambda: self.toggle_paragon_overlay() + ) if not IniConfigLoader().advanced_options.vision_mode_only: keyboard.add_hotkey(IniConfigLoader().advanced_options.run_filter, lambda: self.filter_items()) keyboard.add_hotkey(