From 88baa6526a3dff096e0a9e837524262cbedab8e9 Mon Sep 17 00:00:00 2001 From: chrisoro Date: Wed, 23 Oct 2024 16:30:03 +0200 Subject: [PATCH 1/2] some internal cleanup --- .pre-commit-config.yaml | 2 +- README.md | 1 - requirements.txt | 4 +- src/__init__.py | 2 +- src/config/models.py | 10 +- src/main.py | 23 +--- src/overlay.py | 187 ++------------------------------- src/scripts/handler.py | 151 ++++++++++++++++++++++++++ src/scripts/loot_filter.py | 8 +- src/scripts/loot_filter_tts.py | 8 +- src/ui/char_inventory.py | 4 - src/ui/chest.py | 7 +- src/ui/menu.py | 70 +----------- src/utils/mouse_selector.py | 26 ----- 14 files changed, 175 insertions(+), 328 deletions(-) create mode 100644 src/scripts/handler.py delete mode 100644 src/utils/mouse_selector.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ccc536de..5c2658a6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,7 +23,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.9 + rev: v0.7.0 hooks: - id: ruff args: [--fix] diff --git a/README.md b/README.md index 89a8555c..6f324498 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,6 @@ The config folder in `C:/Users//.d4lf` contains: | full_dump | When using the import build feature, whether to use the full dump (e.g. contains all filter items) or not | | handle_rares | - `filter`: Filter them based on your profiles
- `ignore`: Ignores all rares, vision mode shows them as blue and auto mode never junks or favorites them
- `junk`: Vision mode shows them always as red, auto mode always junks rares | | handle_uniques | How to handle uniques that do not match any filter. This property does not apply to filtered uniques. All mythics are favorited regardless of filter.
- `favorite`: Mark the unique as favorite and vision mode will show it as green (default)
- `ignore`: Do nothing with the unique and vision mode will show it as green
- `junk`: Mark any uniques that don't match any filters as junk and show as red in vision mode | -| hidden_transparency | The overlay will become transparent after not hovering it for a while. This can be changed by specifying any value between [0, 1] with 0 being completely invisible and 1 completely visible | | keep_aspects | - `all`: Keep all legendary items
- `upgrade`: Keep all legendary items that upgrade your codex of power
- `none`: Keep no legendary items based on aspect (they are still filtered!) | | mark_as_favorite | Whether to favorite matched items or not. Defaults to true | | minimum_overlay_font_size | The minimum font size for the vision overlay, specifically the green text that shows which filter(s) are matching. Note: For small profile names, the font may actually be larger than this size but will never go below this size. | diff --git a/requirements.txt b/requirements.txt index 863e925f..9ed1d2f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,6 @@ ./dependencies/tesserocr-2.7.0-cp312-cp312-win_amd64.whl beautifultable colorama -coverage -cryptography httpx keyboard lxml @@ -10,7 +8,7 @@ mouse mss natsort numpy<2 -opencv-python==4.10.0.82 +opencv-python==4.10.0.84 Pillow pre-commit psutil diff --git a/src/__init__.py b/src/__init__.py index 6a2448e3..05ee1577 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -2,4 +2,4 @@ TP = concurrent.futures.ThreadPoolExecutor() -__version__ = "5.9.0alpha3" +__version__ = "5.9.0alpha4" diff --git a/src/config/models.py b/src/config/models.py index 26f99814..f62a081a 100644 --- a/src/config/models.py +++ b/src/config/models.py @@ -17,6 +17,7 @@ IS_HOTKEY_KEY = "is_hotkey" DEPRECATED_INI_KEYS = [ + "hidden_transparency", "import_build", "local_prefs_path", "move_item_type", @@ -253,9 +254,6 @@ class GeneralModel(_IniBaseModel): default=UnfilteredUniquesType.favorite, description="What should be done with uniques that do not match any profile. Mythics are always favorited. If mark_as_favorite is unchecked then uniques that match a profile will not be favorited.", ) - hidden_transparency: float = Field( - default=0.35, description="Transparency of the overlay when not hovering it (has a 3 second delay after hovering)" - ) keep_aspects: AspectFilterType = Field( default=AspectFilterType.upgrade, description="Whether to keep aspects that didn't match a filter" ) @@ -309,12 +307,6 @@ def language_must_exist(cls, v: str) -> str: raise ValueError("language not supported") return v - @field_validator("hidden_transparency") - def transparency_in_range(cls, v: float) -> float: - if not 0 <= v <= 1: - raise ValueError("must be in [0, 1]") - return v - @field_validator("minimum_overlay_font_size") def font_size_in_range(cls, v: int) -> int: if not 10 <= v <= 20: diff --git a/src/main.py b/src/main.py index 24d0e342..52c0f3a8 100644 --- a/src/main.py +++ b/src/main.py @@ -4,7 +4,6 @@ import time import traceback -import keyboard from beautifultable import BeautifulTable from PIL import Image # noqa # Note: Somehow needed, otherwise the binary has an issue with tesserocr @@ -12,12 +11,12 @@ from src import __version__, tts from src.cam import Cam from src.config.loader import IniConfigLoader -from src.config.models import ItemRefreshType, UseTTSType +from src.config.models import UseTTSType from src.gui.qt_gui import start_gui from src.item.filter import Filter from src.logger import LOG_DIR from src.overlay import Overlay -from src.utils.process_handler import safe_exit +from src.scripts.handler import ScriptHandler from src.utils.window import WindowSpec, start_detecting_window LOGGER = logging.getLogger(__name__) @@ -55,24 +54,12 @@ def main(): while not Cam().is_offset_set(): time.sleep(0.2) - overlay = None + ScriptHandler() - keyboard.add_hotkey(IniConfigLoader().advanced_options.run_scripts, lambda: overlay.run_scripts() if overlay is not None else None) - keyboard.add_hotkey(IniConfigLoader().advanced_options.exit_key, lambda: safe_exit()) - if not IniConfigLoader().advanced_options.vision_mode_only: - keyboard.add_hotkey(IniConfigLoader().advanced_options.run_filter, lambda: overlay.filter_items() if overlay is not None else None) - keyboard.add_hotkey( - IniConfigLoader().advanced_options.run_filter_force_refresh, - lambda: overlay.filter_items(ItemRefreshType.force_with_filter) if overlay is not None else None, - ) - keyboard.add_hotkey( - IniConfigLoader().advanced_options.force_refresh_only, - lambda: overlay.filter_items(ItemRefreshType.force_without_filter) if overlay is not None else None, - ) - keyboard.add_hotkey(IniConfigLoader().advanced_options.move_to_inv, lambda: overlay.move_items_to_inventory()) - keyboard.add_hotkey(IniConfigLoader().advanced_options.move_to_chest, lambda: overlay.move_items_to_stash()) if IniConfigLoader().general.use_tts in [UseTTSType.full, UseTTSType.mixed]: + LOGGER.debug(f"tts: {IniConfigLoader().general.use_tts.value}") tts.start_connection() + overlay = Overlay() overlay.run() diff --git a/src/overlay.py b/src/overlay.py index 2853e83f..c5f7749a 100644 --- a/src/overlay.py +++ b/src/overlay.py @@ -1,26 +1,6 @@ -import ctypes import logging import threading -import time import tkinter as tk -import typing - -import src.item.descr.read_descr_tts -import src.logger -import src.scripts.loot_filter -import src.scripts.loot_filter_tts -import src.scripts.vision_mode -import src.scripts.vision_mode_tts -import src.tts -from src.cam import Cam -from src.config.loader import IniConfigLoader -from src.config.models import ItemRefreshType, UseTTSType -from src.loot_mover import move_items_to_inventory, move_items_to_stash -from src.ui.char_inventory import CharInventory -from src.ui.chest import Chest -from src.utils.custom_mouse import mouse -from src.utils.process_handler import kill_thread -from src.utils.window import screenshot LOGGER = logging.getLogger(__name__) @@ -29,169 +9,14 @@ class Overlay: def __init__(self): - self.loot_interaction_thread = None - self.script_threads = [] - self.is_minimized = True self.root = tk.Tk() - self.root.title("LootFilter Overlay") - self.root.attributes("-alpha", 0.94) - self.hide_id = self.root.after(8000, lambda: self.root.attributes("-alpha", IniConfigLoader().general.hidden_transparency)) self.root.overrideredirect(True) - self.root.wm_attributes("-topmost", True) - - self.screen_width = Cam().window_roi["width"] - self.screen_height = Cam().window_roi["height"] - self.initial_height = int(Cam().window_roi["height"] * 0.03) - self.initial_width = int(self.screen_width * 0.047) - self.maximized_height = int(self.initial_height * 3.4) - self.maximized_width = int(self.initial_width * 5) - - self.screen_off_x = Cam().window_roi["left"] - self.screen_off_y = Cam().window_roi["top"] - self.canvas = tk.Canvas(self.root, bg="black", height=self.initial_height, width=self.initial_width, highlightthickness=0) - self.root.geometry( - f"{self.initial_width}x{self.initial_height}+{self.screen_width // 2 - self.initial_width // 2 + self.screen_off_x}+{self.screen_height - self.initial_height + self.screen_off_y}" - ) - self.canvas.pack() - self.root.bind("", self.show_canvas) - self.root.bind("", self.hide_canvas) - - self.start_scripts_button = tk.Button(self.root, text="vision", bg="#222222", fg="#555555", borderwidth=0, command=self.run_scripts) - - if not IniConfigLoader().advanced_options.vision_mode_only: - self.filter_button = tk.Button(self.root, text="filter", bg="#222222", fg="#555555", borderwidth=0, command=self.filter_items) - self.canvas.create_window(int(self.initial_width * 0.24), self.initial_height // 2, window=self.filter_button) - - self.start_scripts_button = tk.Button(self.root, text="vision", bg="#222222", fg="#555555", borderwidth=0, command=self.run_scripts) - self.canvas.create_window(int(self.initial_width * 0.73), self.initial_height // 2, window=self.start_scripts_button) - - if IniConfigLoader().general.hidden_transparency == 0: - self.root.update() - hwnd = self.root.winfo_id() - style = ctypes.windll.user32.GetWindowLongW(hwnd, -20) - ctypes.windll.user32.SetWindowLongW(hwnd, -20, style | 0x80000 | 0x20) - - if IniConfigLoader().general.run_vision_mode_on_startup: - self.run_scripts() - - def show_canvas(self, _): - # Cancel the pending hide if it exists - if self.hide_id: - self.root.after_cancel(self.hide_id) - self.hide_id = None - # Make the window visible - self.root.attributes("-alpha", 0.94) - - def hide_canvas(self, _): - # Reset the hide timer - if self.is_minimized: - if self.hide_id is not None: - self.root.after_cancel(self.hide_id) - self.hide_id = self.root.after(3000, lambda: self.root.attributes("-alpha", IniConfigLoader().general.hidden_transparency)) - - def filter_items(self, force_refresh=ItemRefreshType.no_refresh): - if IniConfigLoader().general.use_tts in [UseTTSType.full, UseTTSType.mixed]: - self._start_or_stop_loot_interaction_thread(run_loot_filter, (force_refresh, True)) - else: - self._start_or_stop_loot_interaction_thread(run_loot_filter, (force_refresh, False)) - - def move_items_to_inventory(self): - self._start_or_stop_loot_interaction_thread(move_items_to_inventory) - - def move_items_to_stash(self): - self._start_or_stop_loot_interaction_thread(move_items_to_stash) - - def _start_or_stop_loot_interaction_thread(self, loot_interaction_method: typing.Callable, method_args=()): - if LOCK.acquire(blocking=False): - try: - if self.loot_interaction_thread is not None: - LOGGER.info("Stopping filter or move process") - kill_thread(self.loot_interaction_thread) - self.loot_interaction_thread = None - self.filter_button.config(fg="#555555") - else: - self.loot_interaction_thread = threading.Thread( - target=self._wrapper_run_loot_interaction_method, args=(loot_interaction_method, method_args), daemon=True - ) - self.loot_interaction_thread.start() - self.filter_button.config(fg="#006600") - finally: - LOCK.release() - else: - return - - def _wrapper_run_loot_interaction_method(self, loot_interaction_method: typing.Callable, method_args=()): - try: - # We will stop all scripts if they are currently running and restart them afterwards if needed - did_stop_scripts = False - if len(self.script_threads) > 0: - LOGGER.info("Stopping Scripts") - self.start_scripts_button.config(fg="#555555") - for script_thread in self.script_threads: - kill_thread(script_thread) - self.script_threads = [] - did_stop_scripts = True - - loot_interaction_method(*method_args) - - if did_stop_scripts: - self.run_scripts() - finally: - self.loot_interaction_thread = None - self.filter_button.config(fg="#555555") - - def run_scripts(self): - if LOCK.acquire(blocking=False): - try: - if len(self.script_threads) > 0: - LOGGER.info("Stopping Vision Mode") - self.start_scripts_button.config(fg="#555555") - for script_thread in self.script_threads: - kill_thread(script_thread) - self.script_threads = [] - else: - if not IniConfigLoader().advanced_options.scripts: - LOGGER.info("No scripts configured") - return - for name in IniConfigLoader().advanced_options.scripts: - if name == "vision_mode": - if IniConfigLoader().general.use_tts == UseTTSType.full: - vision_mode_thread = threading.Thread(target=src.scripts.vision_mode_tts.VisionMode().start, daemon=True) - else: - vision_mode_thread = threading.Thread(target=src.scripts.vision_mode.vision_mode, daemon=True) - vision_mode_thread.start() - self.script_threads.append(vision_mode_thread) - self.start_scripts_button.config(fg="#006600") - finally: - LOCK.release() - else: - return + self.root.attributes("-topmost", True) + self.root.attributes("-transparentcolor", "white") + self.root.attributes("-alpha", 1.0) + self.canvas = tk.Canvas(self.root, bg="white", highlightthickness=0) + self.canvas.pack(fill=tk.BOTH, expand=True) + self.canvas.config(height=self.root.winfo_screenheight(), width=self.root.winfo_screenwidth()) def run(self): self.root.mainloop() - - -def run_loot_filter(force_refresh: ItemRefreshType = ItemRefreshType.no_refresh, tts: bool = False): - LOGGER.info("Run Loot filter") - mouse.move(*Cam().abs_window_to_monitor((0, 0))) - check_items = src.scripts.loot_filter_tts.check_items if tts else src.scripts.loot_filter.check_items - - inv = CharInventory() - chest = Chest() - - if chest.is_open(): - for i in IniConfigLoader().general.check_chest_tabs: - chest.switch_to_tab(i) - time.sleep(0.3) - check_items(chest, force_refresh) - mouse.move(*Cam().abs_window_to_monitor((0, 0))) - time.sleep(0.3) - check_items(inv, force_refresh) - else: - if not inv.open(): - screenshot("inventory_not_open", img=Cam().grab()) - LOGGER.error("Inventory did not open up") - return - check_items(inv, force_refresh) - mouse.move(*Cam().abs_window_to_monitor((0, 0))) - LOGGER.info("Loot Filter done") diff --git a/src/scripts/handler.py b/src/scripts/handler.py new file mode 100644 index 00000000..3660d0c7 --- /dev/null +++ b/src/scripts/handler.py @@ -0,0 +1,151 @@ +import logging +import threading +import time +import typing + +import keyboard + +import src.item.descr.read_descr_tts +import src.logger +import src.scripts.loot_filter +import src.scripts.loot_filter_tts +import src.scripts.vision_mode +import src.scripts.vision_mode_tts +import src.tts +from src.cam import Cam +from src.config.loader import IniConfigLoader +from src.config.models import ItemRefreshType, UseTTSType +from src.loot_mover import move_items_to_inventory, move_items_to_stash +from src.ui.char_inventory import CharInventory +from src.ui.chest import Chest +from src.utils.custom_mouse import mouse +from src.utils.process_handler import kill_thread, safe_exit +from src.utils.window import screenshot + +LOGGER = logging.getLogger(__name__) + +LOCK = threading.Lock() + + +class ScriptHandler: + def __init__(self): + self.loot_interaction_thread = None + self.script_threads = [] + + self.setup_key_binds() + if IniConfigLoader().general.run_vision_mode_on_startup: + self.run_scripts() + + def setup_key_binds(self): + keyboard.add_hotkey(IniConfigLoader().advanced_options.run_scripts, lambda: self.run_scripts()) + keyboard.add_hotkey(IniConfigLoader().advanced_options.exit_key, lambda: safe_exit()) + if not IniConfigLoader().advanced_options.vision_mode_only: + keyboard.add_hotkey(IniConfigLoader().advanced_options.run_filter, lambda: self.filter_items()) + keyboard.add_hotkey( + IniConfigLoader().advanced_options.run_filter_force_refresh, + lambda: self.filter_items(ItemRefreshType.force_with_filter), + ) + keyboard.add_hotkey( + IniConfigLoader().advanced_options.force_refresh_only, + lambda: self.filter_items(ItemRefreshType.force_without_filter), + ) + keyboard.add_hotkey(IniConfigLoader().advanced_options.move_to_inv, lambda: self.move_items_to_inventory()) + keyboard.add_hotkey(IniConfigLoader().advanced_options.move_to_chest, lambda: self.move_items_to_stash()) + + def filter_items(self, force_refresh=ItemRefreshType.no_refresh): + if IniConfigLoader().general.use_tts in [UseTTSType.full, UseTTSType.mixed]: + self._start_or_stop_loot_interaction_thread(run_loot_filter, (force_refresh, True)) + else: + self._start_or_stop_loot_interaction_thread(run_loot_filter, (force_refresh, False)) + + def move_items_to_inventory(self): + self._start_or_stop_loot_interaction_thread(move_items_to_inventory) + + def move_items_to_stash(self): + self._start_or_stop_loot_interaction_thread(move_items_to_stash) + + def _start_or_stop_loot_interaction_thread(self, loot_interaction_method: typing.Callable, method_args=()): + if LOCK.acquire(blocking=False): + try: + if self.loot_interaction_thread is not None: + LOGGER.info("Stopping filter or move process") + kill_thread(self.loot_interaction_thread) + self.loot_interaction_thread = None + else: + self.loot_interaction_thread = threading.Thread( + target=self._wrapper_run_loot_interaction_method, args=(loot_interaction_method, method_args), daemon=True + ) + self.loot_interaction_thread.start() + finally: + LOCK.release() + else: + return + + def _wrapper_run_loot_interaction_method(self, loot_interaction_method: typing.Callable, method_args=()): + try: + # We will stop all scripts if they are currently running and restart them afterward if needed + did_stop_scripts = False + if len(self.script_threads) > 0: + LOGGER.info("Stopping Scripts") + for script_thread in self.script_threads: + kill_thread(script_thread) + self.script_threads = [] + did_stop_scripts = True + + loot_interaction_method(*method_args) + + if did_stop_scripts: + self.run_scripts() + finally: + self.loot_interaction_thread = None + + def run_scripts(self): + if LOCK.acquire(blocking=False): + try: + if len(self.script_threads) > 0: + LOGGER.info("Stopping Vision Mode") + for script_thread in self.script_threads: + kill_thread(script_thread) + self.script_threads = [] + else: + if not IniConfigLoader().advanced_options.scripts: + LOGGER.info("No scripts configured") + return + for name in IniConfigLoader().advanced_options.scripts: + if name == "vision_mode": + if IniConfigLoader().general.use_tts == UseTTSType.full: + vision_mode_thread = threading.Thread(target=src.scripts.vision_mode_tts.VisionMode().start, daemon=True) + else: + vision_mode_thread = threading.Thread(target=src.scripts.vision_mode.vision_mode, daemon=True) + vision_mode_thread.start() + self.script_threads.append(vision_mode_thread) + finally: + LOCK.release() + else: + return + + +def run_loot_filter(force_refresh: ItemRefreshType = ItemRefreshType.no_refresh, tts: bool = False): + LOGGER.info("Run Loot filter") + mouse.move(*Cam().abs_window_to_monitor((0, 0))) + check_items = src.scripts.loot_filter_tts.check_items if tts else src.scripts.loot_filter.check_items + + inv = CharInventory() + chest = Chest() + + if chest.is_open(): + for i in IniConfigLoader().general.check_chest_tabs: + chest.switch_to_tab(i) + time.sleep(0.3) + check_items(chest, force_refresh) + mouse.move(*Cam().abs_window_to_monitor((0, 0))) + time.sleep(0.3) + check_items(inv, force_refresh) + else: + if not inv.open(): + screenshot("inventory_not_open", img=Cam().grab()) + LOGGER.error("Inventory did not open up") + return + check_items(inv, force_refresh) + mouse.move(*Cam().abs_window_to_monitor((0, 0))) + LOGGER.info("Loot Filter done") diff --git a/src/scripts/loot_filter.py b/src/scripts/loot_filter.py index 6ec27c14..29a5f595 100644 --- a/src/scripts/loot_filter.py +++ b/src/scripts/loot_filter.py @@ -1,8 +1,6 @@ import logging import time -import keyboard - from src.cam import Cam from src.config.loader import IniConfigLoader from src.config.models import HandleRaresType, ItemRefreshType, UnfilteredUniquesType @@ -96,15 +94,13 @@ def check_items(inv: InventoryBase, force_refresh: ItemRefreshType): LOGGER.info("Matched: Temper Manual") continue if rarity in [ItemRarity.Magic, ItemRarity.Common] and item_descr.item_type != ItemType.Sigil: - keyboard.send("space") - time.sleep(0.13) + mark_as_junk() continue if rarity == ItemRarity.Rare and IniConfigLoader().general.handle_rares == HandleRaresType.ignore: LOGGER.info("Matched: Rare, ignore Item") continue if rarity == ItemRarity.Rare and IniConfigLoader().general.handle_rares == HandleRaresType.junk: - keyboard.send("space") - time.sleep(0.13) + mark_as_junk() continue # Check if we want to keep the item diff --git a/src/scripts/loot_filter_tts.py b/src/scripts/loot_filter_tts.py index b1bc26ef..74f3638d 100644 --- a/src/scripts/loot_filter_tts.py +++ b/src/scripts/loot_filter_tts.py @@ -1,8 +1,6 @@ import logging import time -import keyboard - import src.item.descr.read_descr_tts from src.cam import Cam from src.config.loader import IniConfigLoader @@ -72,15 +70,13 @@ def check_items(inv: InventoryBase, force_refresh: ItemRefreshType): LOGGER.info("Matched: Temper Manual") continue if item_descr.rarity in [ItemRarity.Magic, ItemRarity.Common] and item_descr.item_type != ItemType.Sigil: - keyboard.send("space") - time.sleep(0.13) + mark_as_junk() continue if item_descr.rarity == ItemRarity.Rare and IniConfigLoader().general.handle_rares == HandleRaresType.ignore: LOGGER.info("Matched: Rare, ignore Item") continue if item_descr.rarity == ItemRarity.Rare and IniConfigLoader().general.handle_rares == HandleRaresType.junk: - keyboard.send("space") - time.sleep(0.13) + mark_as_junk() continue # Check if we want to keep the item diff --git a/src/ui/char_inventory.py b/src/ui/char_inventory.py index b8019b4d..79de0067 100644 --- a/src/ui/char_inventory.py +++ b/src/ui/char_inventory.py @@ -2,7 +2,6 @@ from src.config.ui import ResManager from src.template_finder import SearchArgs from src.ui.inventory_base import InventoryBase -from src.ui.menu import ToggleMethod class CharInventory(InventoryBase): @@ -13,7 +12,4 @@ def __init__(self): ref=["sort_icon", "sort_icon_hover"], threshold=0.8, roi=ResManager().roi.sort_icon, use_grayscale=False ) self.open_hotkey = IniConfigLoader().char.inventory - self.open_method = ToggleMethod.HOTKEY - self.close_hotkey = "esc" - self.close_method = ToggleMethod.HOTKEY self.delay = 1 # Needed as they added a "fad-in" for the items diff --git a/src/ui/chest.py b/src/ui/chest.py index 9e431215..0aa9eecb 100644 --- a/src/ui/chest.py +++ b/src/ui/chest.py @@ -5,7 +5,6 @@ from src.config.ui import ResManager from src.template_finder import SearchArgs from src.ui.inventory_base import InventoryBase -from src.ui.menu import ToggleMethod from src.utils.custom_mouse import mouse LOGGER = logging.getLogger(__name__) @@ -18,8 +17,6 @@ def __init__(self): self.is_open_search_args = SearchArgs( ref=["stash_menu_icon", "stash_menu_icon_medium"], threshold=0.8, roi="stash_menu_icon", use_grayscale=True ) - self.close_hotkey = "esc" - self.close_method = ToggleMethod.HOTKEY self.curr_tab = 0 @staticmethod @@ -32,7 +29,7 @@ def switch_to_tab(tab_idx) -> bool: section_length = w // NUMBER_TABS centers = [(x + (i + 0.5) * section_length, y + h // 2) for i in range(NUMBER_TABS)] mouse.move(*Cam().window_to_monitor(centers[tab_idx]), randomize=2) - time.sleep(0.5) + time.sleep(0.1) mouse.click("left") - time.sleep(0.5) + time.sleep(0.2) return True diff --git a/src/ui/menu.py b/src/ui/menu.py index 0515a528..8b25ee25 100644 --- a/src/ui/menu.py +++ b/src/ui/menu.py @@ -5,9 +5,8 @@ import keyboard import numpy as np -from src.template_finder import SearchArgs, SearchResult, TemplateMatch +from src.template_finder import SearchArgs, SearchResult from src.utils.misc import run_until_condition -from src.utils.mouse_selector import select_search_result LOGGER = logging.getLogger(__name__) @@ -22,35 +21,9 @@ def __init__(self): self.menu_name: str = "" self.parent_menu: Menu | None = None self.is_open_search_args: SearchArgs | None = None - self.open_button_search_args: SearchArgs | None = None - self.close_button_search_args = self.open_button_search_args self.open_hotkey: str = "" - self.close_hotkey: str = "esc" - self.open_method: ToggleMethod = ToggleMethod.BUTTON - self.close_method: ToggleMethod = ToggleMethod.HOTKEY self.delay = 0 - @staticmethod - def select_button(search: SearchArgs | TemplateMatch) -> bool: - """ - Selects a button based on a given search object. - :param search: A SearchArgs or TemplateMatch object to identify the button - :return: True if the button is successfully selected, False otherwise. - """ - if isinstance(search, SearchArgs): - result = search.detect() - if not result.success: - LOGGER.error(f"Could not find {search.ref} button") - return False - match = result.matches[0] - elif isinstance(search, TemplateMatch): - match = search - else: - LOGGER.error(f"Invalid type {type(search)} for search") - return False - select_search_result(match) - return True - def open(self) -> bool: """ Opens the menu by clicking the open button. @@ -61,35 +34,12 @@ def open(self) -> bool: LOGGER.error(f"Could not open parent menu {self.parent_menu.menu_name}") return False if not (is_open := self.is_open()): - debug_string = f"Opening {self.menu_name} with" - if self.open_method == ToggleMethod.BUTTON: - debug_string += f" button {self.open_button_search_args.ref}" - self.select_button(self.open_button_search_args) - elif self.open_method == ToggleMethod.HOTKEY: - debug_string += f" hotkey {self.open_hotkey}" - keyboard.send(self.open_hotkey) - LOGGER.debug(debug_string) + LOGGER.debug(f"Opening {self.menu_name} using hotkey {self.open_hotkey}") + keyboard.send(self.open_hotkey) else: LOGGER.debug(f"{self.menu_name} already open") return is_open or self.wait_until_open() - def close(self) -> bool: - """ - Closes the menu by pressing the escape key. - :return: True if the menu is successfully closed, False otherwise. - """ - if self.is_open(): - debug_string = f"Closing {self.menu_name} with" - if self.close_method == ToggleMethod.BUTTON: - debug_string += f" button {self.close_button_search_args.ref}" - self.select_button(self.close_button_search_args) - elif self.close_method == ToggleMethod.HOTKEY: - debug_string += f" hotkey {self.close_hotkey}" - keyboard.send(self.close_hotkey) - LOGGER.debug(debug_string) - return self.wait_until_closed(timeout=3) - return True - def _check_match(self, res: SearchResult) -> bool: """ Checks if the given TemplateMatch is a match for the menu. @@ -120,17 +70,3 @@ def wait_until_open(self, timeout: float = 10) -> bool: LOGGER.warning(f"Could not find {self.menu_name} after {timeout} seconds") time.sleep(self.delay) return success - - def wait_until_closed(self, timeout: float = 10, mode: str = "all") -> bool: - """ - Waits until the menu is closed. - :param timeout: The number of seconds to wait before timing out. - :param mode: The mode to use when searching for the menu. See template_finder.py for more info. - :return: True if the menu is closed, False otherwise. - """ - args: SearchArgs = self.is_open_search_args - args.mode = mode - _, success = run_until_condition(lambda: args.is_visible(), lambda res: not res, timeout) - if not success: - LOGGER.warning(f"{self.menu_name} still visible after {timeout} seconds") - return success diff --git a/src/utils/mouse_selector.py b/src/utils/mouse_selector.py deleted file mode 100644 index 45417ffd..00000000 --- a/src/utils/mouse_selector.py +++ /dev/null @@ -1,26 +0,0 @@ -import logging -import time - -from src.template_finder import SearchResult, TemplateMatch -from src.utils.custom_mouse import mouse - -LOGGER = logging.getLogger(__name__) - - -def select_search_result(result: SearchResult | TemplateMatch, delay_factor: tuple[float, float] = (0.9, 1.1), click: str = "left") -> None: - move_to_search_result(result, delay_factor) - time.sleep(0.05) - mouse.click(click) - time.sleep(0.05) - - -def move_to_search_result(result: SearchResult | TemplateMatch, delay_factor: tuple[float, float] = (0.9, 1.1)) -> None: - if isinstance(result, SearchResult): - # default to first match - match = result.matches[0] - elif isinstance(result, TemplateMatch): - match = result - else: - LOGGER.error(f"move_to_search_result: Invalid type {type(result)} for result") - return - mouse.move(*match.center_monitor, delay_factor=delay_factor) From b197d1efda1160d2646310e46058bee9eec9885a Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 23 Oct 2024 21:24:10 +0200 Subject: [PATCH 2/2] allow greater affixes --- README.md | 4 +- src/item/data/affix.py | 6 +- src/item/descr/__init__.py | 2 + src/item/descr/read_descr_tts.py | 180 ++++++++++++++++++------------- src/main.py | 2 +- src/tts.py | 14 ++- 6 files changed, 123 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index 6f324498..6133e194 100644 --- a/README.md +++ b/README.md @@ -77,9 +77,9 @@ To use TTS, you need to: Currently, `use_tts` enables either a mixed mode where image processing is still used for item and affix position detection, but TTS is used for everything text-related. This results in a small improvement in performance and a major improvement -in accuracy. Or a full mode where only TTS is used. +in accuracy. Or a full mode where only TTS is used. This will be super fast but loses the overlay. -**IT IS NOT POSSIBLE TO DETECT GREATER AFFIXES USING TTS!!! YOU NEED TO SPECIFY THEM USING THEIR VALUES!** +**YOU NEED TO ENABLE ADVANCED TOOLTIP INFORMATION** **The following is currently supported using any form of tts:** diff --git a/src/item/data/affix.py b/src/item/data/affix.py index b5e5da1e..dfa0134c 100644 --- a/src/item/data/affix.py +++ b/src/item/data/affix.py @@ -12,10 +12,12 @@ class AffixType(enum.Enum): @dataclass class Affix: - name: str - type: AffixType = AffixType.normal loc: tuple[int, int] = None + max_value: float | None = None + min_value: float | None = None + name: str = "" text: str = "" + type: AffixType = AffixType.normal value: float | None = None def __eq__(self, other: "Affix") -> bool: diff --git a/src/item/descr/__init__.py b/src/item/descr/__init__.py index e69de29b..11067109 100644 --- a/src/item/descr/__init__.py +++ b/src/item/descr/__init__.py @@ -0,0 +1,2 @@ +def keep_letters_and_spaces(text): + return "".join(char for char in text if char.isalpha() or char.isspace()).strip().replace(" ", " ") diff --git a/src/item/descr/read_descr_tts.py b/src/item/descr/read_descr_tts.py index ebd85559..23de2f20 100644 --- a/src/item/descr/read_descr_tts.py +++ b/src/item/descr/read_descr_tts.py @@ -1,5 +1,6 @@ import copy import logging +import re import numpy as np import rapidfuzz @@ -12,15 +13,63 @@ from src.item.data.aspect import Aspect from src.item.data.item_type import ItemType, is_armor, is_consumable, is_jewelry, is_mapping, is_socketable, is_weapon from src.item.data.rarity import ItemRarity +from src.item.descr import keep_letters_and_spaces from src.item.descr.text import clean_str, closest_match, find_number from src.item.descr.texture import find_affix_bullets, find_seperator_short, find_seperators_long from src.item.models import Item from src.template_finder import TemplateMatch from src.utils.window import screenshot +_AFFIX_RE = re.compile( + r"(?P[0-9]+)[^0-9]+\[(?P[0-9]+) - (?P[0-9]+)\]|" + r"(?P[0-9]+\.[0-9]+).+?\[(?P[0-9]+\.[0-9]+) - (?P[0-9]+\.[0-9]+)\]|" + r"(?P[.0-9]+)[^0-9]+\[(?P[.0-9]+)\]|" + r".?![^\[\]]*[\[\]](?P\d+.?:\.\d+?)(?P[ ]*)|" + r"(?P\d+)(?![^\[]*\[).*", + re.DOTALL, +) + +_AFFIX_REPLACEMENTS = [ + "%", + "+", + ",", + "[+]", + "[x]", +] LOGGER = logging.getLogger(__name__) +def _add_affixes_from_tts(tts_section: list[str], item: Item) -> Item: + inherent_num = 0 + affixes_num = 3 if item.rarity == ItemRarity.Legendary else 4 + if is_weapon(item.item_type) or item.item_type in [ItemType.Amulet, ItemType.Boots]: + inherent_num = 1 + elif item.item_type in [ItemType.Ring]: + inherent_num = 2 + elif item.item_type in [ItemType.Shield]: + inherent_num = 4 + affixes = _get_affixes_from_tts_section(tts_section, item, inherent_num + affixes_num) + for i, affix_text in enumerate(affixes): + if i < inherent_num: + affix = Affix(text=affix_text) + affix.type = AffixType.inherent + affix.name = rapidfuzz.process.extractOne( + keep_letters_and_spaces(affix_text), list(Dataloader().affix_dict), scorer=rapidfuzz.distance.Levenshtein.distance + )[0] + item.inherent.append(affix) + elif i < inherent_num + affixes_num: + affix = _get_affix_from_text(affix_text) + item.affixes.append(affix) + else: + name = closest_match(clean_str(affix_text)[:AFFIX_COMPARISON_CHARS], Dataloader().aspect_unique_dict) + item.aspect = Aspect( + name=name, + text=affix_text, + value=find_number(affix_text), + ) + return item + + def _add_affixes_from_tts_mixed( tts_section: list[str], item: Item, inherent_affix_bullets: list[TemplateMatch], affix_bullets: list[TemplateMatch] ) -> Item: @@ -30,91 +79,32 @@ def _add_affixes_from_tts_mixed( len(inherent_affix_bullets) + len([x for x in affix_bullets if any(x.name.startswith(s) for s in ["affix", "greater_affix", "rerolled"])]), ) - for i, affix in enumerate(affixes): + for i, affix_text in enumerate(affixes): if i < len(inherent_affix_bullets): - name = rapidfuzz.process.extractOne( - clean_str(affix), list(Dataloader().affix_dict), scorer=rapidfuzz.distance.Levenshtein.distance - ) - item.inherent.append( - Affix( - name=name[0], - loc=inherent_affix_bullets[i].center, - text=affix, - type=AffixType.inherent, - value=find_number(affix), - ) - ) + affix = Affix(text=affix_text) + affix.type = AffixType.inherent + affix.name = rapidfuzz.process.extractOne( + keep_letters_and_spaces(affix_text), list(Dataloader().affix_dict), scorer=rapidfuzz.distance.Levenshtein.distance + )[0] + affix.loc = (inherent_affix_bullets[i].center,) + item.inherent.append(affix) elif i < len(inherent_affix_bullets) + len(affix_bullets): - name = rapidfuzz.process.extractOne( - clean_str(affix), list(Dataloader().affix_dict), scorer=rapidfuzz.distance.Levenshtein.distance - ) + affix = _get_affix_from_text(affix_text) + affix.loc = (affix_bullets[i - len(inherent_affix_bullets)].center,) if affix_bullets[i - len(inherent_affix_bullets)].name.startswith("greater_affix"): - a_type = AffixType.greater + affix.type = AffixType.greater elif affix_bullets[i - len(inherent_affix_bullets)].name.startswith("rerolled"): - a_type = AffixType.rerolled + affix.type = AffixType.rerolled else: - a_type = AffixType.normal - item.affixes.append( - Affix( - name=name[0], - loc=affix_bullets[i - len(inherent_affix_bullets)].center, - text=affix, - type=a_type, - value=find_number(affix), - ) - ) + affix.type = AffixType.normal + item.affixes.append(affix) else: - name = closest_match(clean_str(affix)[:AFFIX_COMPARISON_CHARS], Dataloader().aspect_unique_dict) + name = closest_match(clean_str(affix_text)[:AFFIX_COMPARISON_CHARS], Dataloader().aspect_unique_dict) item.aspect = Aspect( name=name, loc=affix_bullets[i - len(inherent_affix_bullets) - len(affix_bullets)].center, - text=affix, - value=find_number(affix), - ) - return item - - -def _add_affixes_from_tts(tts_section: list[str], item: Item) -> Item: - inherent_num = 0 - affixes_num = 3 if item.rarity == ItemRarity.Legendary else 4 - if is_weapon(item.item_type) or item.item_type in [ItemType.Amulet, ItemType.Boots]: - inherent_num = 1 - elif item.item_type in [ItemType.Ring]: - inherent_num = 2 - elif item.item_type in [ItemType.Shield]: - inherent_num = 4 - affixes = _get_affixes_from_tts_section(tts_section, item, inherent_num + affixes_num) - for i, affix in enumerate(affixes): - if i < inherent_num: - name = rapidfuzz.process.extractOne( - clean_str(affix), list(Dataloader().affix_dict), scorer=rapidfuzz.distance.Levenshtein.distance - ) - item.inherent.append( - Affix( - name=name[0], - text=affix, - type=AffixType.inherent, - value=find_number(affix), - ) - ) - elif i < inherent_num + affixes_num: - name = rapidfuzz.process.extractOne( - clean_str(affix), list(Dataloader().affix_dict), scorer=rapidfuzz.distance.Levenshtein.distance - ) - item.affixes.append( - Affix( - name=name[0], - text=affix, - type=AffixType.normal, - value=find_number(affix), - ) - ) - else: - name = closest_match(clean_str(affix)[:AFFIX_COMPARISON_CHARS], Dataloader().aspect_unique_dict) - item.aspect = Aspect( - name=name, - text=affix, - value=find_number(affix), + text=affix_text, + value=find_number(affix_text), ) return item @@ -190,6 +180,42 @@ def _get_affixes_from_tts_section(tts_section: list[str], item: Item, length: in return tts_section[start : start + length] +def _get_affix_from_text(text: str) -> Affix: + result = Affix(text=text) + for x in _AFFIX_REPLACEMENTS: + text = text.replace(x, "") + matched_groups = {} + for match in _AFFIX_RE.finditer(text): + matched_groups = {name: value for name, value in match.groupdict().items() if value is not None} + if not matched_groups: + raise Exception(f"Could not match affix text: {text}") + for x in ["minvalue1", "minvalue2"]: + if matched_groups.get(x) is not None: + result.min_value = float(matched_groups[x]) + break + for x in ["maxvalue1", "maxvalue2"]: + if matched_groups.get(x) is not None: + result.max_value = float(matched_groups[x]) + break + for x in ["affixvalue1", "affixvalue2", "affixvalue3", "affixvalue4"]: + if matched_groups.get(x) is not None: + result.value = float(matched_groups[x]) + break + for x in ["greateraffix1", "greateraffix2"]: + if matched_groups.get(x) is not None: + result.type = AffixType.greater + if x == "greateraffix2": + result.value = float(matched_groups[x]) + break + if matched_groups.get("onlyvalue") is not None: + result.min_value = float(matched_groups.get("onlyvalue")) + result.max_value = float(matched_groups.get("onlyvalue")) + result.name = rapidfuzz.process.extractOne( + keep_letters_and_spaces(text), list(Dataloader().affix_dict), scorer=rapidfuzz.distance.Levenshtein.distance + )[0] + return result + + def _get_item_rarity(data: str) -> ItemRarity | None: res = rapidfuzz.process.extractOne(data, [rar.value for rar in ItemRarity], scorer=rapidfuzz.distance.Levenshtein.distance) try: @@ -294,6 +320,8 @@ def read_descr() -> Item | None: return item if all([not is_armor(item.item_type), not is_jewelry(item.item_type), not is_weapon(item.item_type)]): return None + if item.rarity not in [ItemRarity.Legendary, ItemRarity.Mythic, ItemRarity.Unique]: + return item item.codex_upgrade = _is_codex_upgrade(tts_section, item) return _add_affixes_from_tts(tts_section, item) diff --git a/src/main.py b/src/main.py index 52c0f3a8..c9d49c4f 100644 --- a/src/main.py +++ b/src/main.py @@ -57,7 +57,7 @@ def main(): ScriptHandler() if IniConfigLoader().general.use_tts in [UseTTSType.full, UseTTSType.mixed]: - LOGGER.debug(f"tts: {IniConfigLoader().general.use_tts.value}") + LOGGER.debug(f"TTS mode: {IniConfigLoader().general.use_tts.value}") tts.start_connection() overlay = Overlay() diff --git a/src/tts.py b/src/tts.py index 558e6e4f..e8608017 100644 --- a/src/tts.py +++ b/src/tts.py @@ -72,12 +72,18 @@ def read_pipe() -> None: while True: try: - data = win32file.ReadFile(handle, 512)[1].decode().strip() - + # Block until data is available (assumes PIPE_WAIT) + win32file.ReadFile(handle, 0, None) + # Query message size + _, _, message_size = win32pipe.PeekNamedPipe(handle, 0) + # Read message + _, data = win32file.ReadFile(handle, message_size, None) + data = data.decode().replace("\x00", "") + if not data: + continue if "DISCONNECTED" in data: break - for s in [s for s in data.split("\x00") if s]: - _DATA_QUEUE.put(s) + _DATA_QUEUE.put(data) except Exception as e: print(f"Error while reading data: {e}")