From 14b93db0c34a26767a1d4737ffe9d24f3745d827 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 3 Aug 2024 19:25:56 +0200 Subject: [PATCH 01/10] Refactoring & test unittests --- README.md | 67 ++++++++++- main.py | 20 ++-- {screen_service => screen}/__init__.py | 6 +- .../_output_service.py => screen/_mouse.py | 55 ++++++--- .../_screen_input.py | 108 +++++++++++------- {screen_service => screen}/_visualization.py | 2 +- terminal_interaction.py | 3 + test/__init__.py | 29 +++++ test/_test_mouse.py | 85 ++++++++++++++ timer.py | 63 ++++++++++ 10 files changed, 370 insertions(+), 68 deletions(-) rename {screen_service => screen}/__init__.py (87%) rename screen_service/_output_service.py => screen/_mouse.py (54%) rename screen_service/_input_service.py => screen/_screen_input.py (50%) rename {screen_service => screen}/_visualization.py (99%) create mode 100644 test/__init__.py create mode 100644 test/_test_mouse.py create mode 100644 timer.py diff --git a/README.md b/README.md index fdb67c5..7ff3e72 100644 --- a/README.md +++ b/README.md @@ -1 +1,66 @@ -# AutoHackerPDA \ No newline at end of file +# AutoHackerPDA + +AutoHackerPDA is a tool designed to automate the process of hacking PDAs in the game Thief Simulator. It simplifies the hacking mini-game, allowing you to focus on the main aspects of the game. + +## Features + +- Automatically hacks PDAs in Thief Simulator +- Easy to use interface +- Configurable settings for different hacking scenarios + +## Requirements + +- Thief Simulator from Steam + +## Installation + +1. Download the latest version of AutoHackerPDA from the [Releases](https://github.com/yourusername/AutoHackerPDA/releases) page. +2. Extract the downloaded zip file to a location of your choice. +3. Run `AutoHackerPDA.exe` to start the application. + +## Usage + +1. Launch Thief Simulator and navigate to the PDA hacking mini-game. +2. Run `AutoHackerPDA.exe`. +3. Configure the settings as per your requirement. +4. Click the "Start Hacking" button. +5. The tool will automatically complete the hacking mini-game for you. + +## Configuration + +AutoHackerPDA comes with several configuration options to customize the hacking process. You can adjust these settings in the application interface: + +- **Speed**: Adjust the speed of the hacking process. +- **Retries**: Set the number of retries in case of a failed attempt. +- **Logging**: Enable or disable logging for debugging purposes. + +## Contributing + +We welcome contributions to improve AutoHackerPDA. If you would like to contribute, please follow these steps: + +1. Fork the repository. +2. Create a new branch for your feature or bugfix. +3. Make your changes and commit them with a clear message. +4. Submit a pull request to the main repository. + +Please read our [Contributing Guidelines](CONTRIBUTING.md) for more details. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Support + +If you encounter any issues or have questions about AutoHackerPDA, please open an issue in the [issue tracker](https://github.com/yourusername/AutoHackerPDA/issues) or contact us at support@yourproject.com. + +## Disclaimer + +AutoHackerPDA is intended for use in Thief Simulator only. Use of this tool in any other context may violate the terms of service of the software or platform. + +## About me + +If you want to know more about me or my other projects, visit my [GitHub profile](https://github.com/LukasKrah) + +--- + +Thank you for using AutoHackerPDA! We hope you enjoy the enhanced gameplay experience. diff --git a/main.py b/main.py index eeaa0a8..e30d2c0 100644 --- a/main.py +++ b/main.py @@ -29,7 +29,7 @@ from os import kill, getpid from numpy import ndarray -from screen_service import InputService, OutputService +from screen import ScreenInput, Mouse from algorithm import AlgorithmService from cv_analyze import CompareService from terminal_interaction import Licensing @@ -44,21 +44,21 @@ def __run_pda_hack() -> None: Runs the PDA hack. :return: None """ - pads: list[list[ndarray]] = InputService.pda_single() - io_pads: list[list[ndarray]] = InputService.pda_io() - + pads: list[list[ndarray]] = ScreenInput.circuits_crop() + io_pads: list[list[ndarray]] = ScreenInput.io_arrows_crop() + styles: list[list[str]] = CompareService.compare_pads(pads) - io: tuple[int, int] = CompareService.compare_io(io_pads) - clicks: dict[tuple[int, int], int] = AlgorithmService.path_finder(pads, styles, io) - - OutputService.pad_clicks(clicks) + io: tuple[int, int] = CompareService.compare_io(io_pads) + clicks: dict[tuple[int, int], int] = AlgorithmService.path_finder(pads, styles, io) + + Mouse.click_on_circuits(clicks) # Start main program if __name__ == '__main__': Licensing.show_information() - + add_hotkey("ctrl+x", __run_pda_hack) add_hotkey("ctrl+c", lambda: kill(getpid(), 9)) - + Licensing.wait_for_commands() diff --git a/screen_service/__init__.py b/screen/__init__.py similarity index 87% rename from screen_service/__init__.py rename to screen/__init__.py index e049914..45a223f 100644 --- a/screen_service/__init__.py +++ b/screen/__init__.py @@ -17,10 +17,10 @@ Contact me: l.krahbichler@proton.me -File: /screen_service/__init__.py +File: /screen/__init__.py Created: 18/02/023 """ -from ._output_service import OutputService -from ._input_service import InputService +from ._mouse import Mouse, MouseEvent +from ._screen_input import ScreenInput from ._visualization import Window diff --git a/screen_service/_output_service.py b/screen/_mouse.py similarity index 54% rename from screen_service/_output_service.py rename to screen/_mouse.py index aa3f485..98b3055 100644 --- a/screen_service/_output_service.py +++ b/screen/_mouse.py @@ -17,7 +17,7 @@ Contact me: l.krahbichler@proton.me -File: screen_service/_output_service.py +File: screen/_mouse.py Created: 18/04/2023 """ @@ -25,6 +25,7 @@ # Imports # ################################################## +from typing import TypedDict from time import sleep import pyautogui import autoit @@ -34,28 +35,54 @@ # Code # ################################################## -class OutputService: +class MouseEvent(TypedDict): """ - Do something one the screen + Represents a mouse event + + Attributes: + row (int): The row of the circuit. (0 is the topmost row) + col (int): The column of the circuit. (0 is the leftmost column) + clicks (int): The number of clicks. """ + row: int + col: int + clicks: int + +class Mouse: + """ + The Mouse class provides a method to click on the circuits. + + Methods: + click_on_circuits(arrows: list[MouseEvent]) + """ + @classmethod - def pad_clicks(cls, click: dict[tuple[int, int], int]) -> None: + def click_on_circuits(cls, circuits: list[MouseEvent]) -> None: + """ + Clicks on the circuits based on their position and number of clicks. + + :param circuits: A list of circuit events. + :type circuits: list[MouseEvent] + :return: None + :rtype: None + """ width: int height: int width, height = pyautogui.size() - + x_left: int = (width // 3) + 40 x_right: int = int(width / 1.536) - 30 y_top: int = int(height / 3.85714) y_bot: int = int(height / 1.30120) - - field_width = (x_right - x_left) // 8 - field_height = (y_bot - y_top) // 8 - - for row, col in click: - x: int = x_left + (field_width * col) + (field_width // 2) - y: int = y_top + (field_height * row) + (field_height // 2) - for i in range(click[(row, col)]): - autoit.mouse_click("left", x, y, speed=0) + + field_width: int = (x_right - x_left) // 8 + field_height: int = (y_bot - y_top) // 8 + + circuit: MouseEvent + for circuit in circuits: + x: int = x_left + (field_width * circuit["col"]) + (field_width // 2) + y: int = y_top + (field_height * circuit["row"]) + (field_height // 2) + for _ in range(circuit["clicks"]): + autoit.mouse_click(x=x, y=y, speed=0) sleep(0.02) diff --git a/screen_service/_input_service.py b/screen/_screen_input.py similarity index 50% rename from screen_service/_input_service.py rename to screen/_screen_input.py index bdd38d9..fa5bafd 100644 --- a/screen_service/_input_service.py +++ b/screen/_screen_input.py @@ -17,7 +17,7 @@ Contact me: l.krahbichler@proton.me -File: screen_service/_input_service.py +File: screen/_screen_input.py Created: 18/04/2023 """ @@ -25,8 +25,12 @@ # Imports # ################################################## -import numpy as np -import pyautogui +from __future__ import annotations + +from numpy import array, ndarray +from pyautogui import screenshot +from PIL.Image import Image +from time import time import cv2 @@ -34,61 +38,87 @@ # Code # ################################################## -class InputService: - """ - Input something from the screen - """ - - @staticmethod - def screenshot() -> np.ndarray: - return cv2.cvtColor(np.array(pyautogui.screenshot()), cv2.COLOR_BGR2GRAY) - # img = cv2.imread("imgs/screen.png") - # return np.array(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)) - +class ScreenInput: + __screenshot: cv2.typing.MatLike | None = None + __screenshot_timestamp: float = 0.0 + @classmethod - def pda_whole(cls) -> np.ndarray: + def __take_screenshot(cls) -> bool: + """ + Take a screenshot and convert it to grayscale. + Caches the screenshot for 0.5 seconds. + + :return: Whether a new screenshot was taken or not + :rtype: bool + """ + if not cls.__screenshot or time() - cls.__screenshot_timestamp > 0.5: + cls.__screenshot = cv2.cvtColor(array(screenshot()), cv2.COLOR_BGR2GRAY) + cls.__screenshot_timestamp = time() + return True + return False + + @classmethod + def __pda_screen_crop(cls) -> ndarray: # 640, 280 - 1250, 830 - screen: np.ndarray = cls.screenshot() - + cls.__take_screenshot() + height: int width: int - height, width = screen.shape[:2] - + height, width = cls.__screenshot.shape[:2] + x_left: int = width // 3 x_right: int = int(width / 1.536) y_top: int = int(height / 3.85714) y_bot: int = int(height / 1.30120) - - return screen[y_top:y_bot, x_left:x_right] - - @classmethod - def pda_io(cls) -> list[list[np.ndarray]]: - pdas: list[list[np.ndarray]] = cls.pda_single(0, 1, 20, 8) - - return [[row[0] for row in pdas], [row[-1] for row in pdas]] - + + return cls.__screenshot[y_top:y_bot, x_left:x_right] + @classmethod - def pda_single( + def circuits_crop( cls, cut_left: int | None = 40, cut_right: int | None = 30, pad_x: int | None = 8, pad_y: int | None = 8 - ) -> list[list[np.ndarray]]: - pda: np.ndarray = cls.pda_whole()[:, cut_left:-cut_right] - + ) -> list[list[ndarray]]: + + pda: ndarray = cls.__pda_screen_crop()[:, cut_left:-cut_right] + PAD_X: int = pad_x PAD_Y: int = pad_y - + pad_height: float = pda.shape[0] / PAD_Y pad_width: float = pda.shape[1] / PAD_X - - pdas: list[list[np.ndarray]] = [] + + pdas: list[list[ndarray]] = [] for y in range(PAD_Y): - row_list: list[np.ndarray] = [] + row_list: list[ndarray] = [] for x in range(PAD_X): - row_list.append(pda[int(pad_height*y):int(pad_height*(y+1)), int(pad_width*x):int(pad_width*(x+1))]) - + row_list.append( + pda[int(pad_height * y):int(pad_height * (y + 1)), int(pad_width * x):int(pad_width * (x + 1))]) + pdas.append(row_list) - + return pdas + + @classmethod + def io_arrows_crop(cls) -> list[list[ndarray]]: + pdas: list[list[ndarray]] = cls.circuits_crop(0, 1, 20, 8) + + return [[row[0] for row in pdas], [row[-1] for row in pdas]] + + @classmethod + def reset(cls) -> None: + """ + Reset the cached screenshot. + + :return: None + :rtype: None + """ + cls.__screenshot = None + cls.__screenshot_timestamp = 0.0 + + +if __name__ == '__main__': + print(ScreenInput.circuits_crop()) + print(ScreenInput.io_arrows_crop()) diff --git a/screen_service/_visualization.py b/screen/_visualization.py similarity index 99% rename from screen_service/_visualization.py rename to screen/_visualization.py index 9045139..326130e 100644 --- a/screen_service/_visualization.py +++ b/screen/_visualization.py @@ -17,7 +17,7 @@ Contact me: l.krahbichler@proton.me -File: screen_service/_output_service.py +File: screen/_mouse.py Created: 18/04/2023 """ diff --git a/terminal_interaction.py b/terminal_interaction.py index f7e7f9b..37fee18 100644 --- a/terminal_interaction.py +++ b/terminal_interaction.py @@ -59,6 +59,7 @@ def __get_license_file(cls) -> None: and extracts specific sections from it using regular expressions. :return: None + :rtype: None """ file: TextIO with open("LICENSE", "r") as file: @@ -83,6 +84,7 @@ def show_information() -> None: Display program license information. :return: None + :rtype: None """ print(" Copyright (C) 2024 Lukas Krahbichler") print("This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.") @@ -95,6 +97,7 @@ def wait_for_commands(cls) -> None: Wait for commands in cmd. :return: None + :rtype: None """ cls.__get_license_file() diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..89682c3 --- /dev/null +++ b/test/__init__.py @@ -0,0 +1,29 @@ +""" +AutoHackerPDA: A program that automates the process of "Hacking PDA" in the game "Thief Simulator". +Copyright (C) 2024 Lukas Krahbichler + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Contact me: l.krahbichler@proton.me + +File: screen/test/__init__.py +Created: 03/08/2024 +""" + +import os +import sys + +PROJECT_PATH = os.getcwd() +SOURCE_PATH = os.path.join(PROJECT_PATH, "src") +sys.path.append(SOURCE_PATH) diff --git a/test/_test_mouse.py b/test/_test_mouse.py new file mode 100644 index 0000000..e1ecf10 --- /dev/null +++ b/test/_test_mouse.py @@ -0,0 +1,85 @@ +""" +AutoHackerPDA: A program that automates the process of "Hacking PDA" in the game "Thief Simulator". +Copyright (C) 2024 Lukas Krahbichler + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Contact me: l.krahbichler@proton.me + +File: screen/_test_mouse.py +Created: 03/08/2024 +""" + +################################################## +# Imports # +################################################## + +import unittest +from unittest.mock import patch, call +from screen._mouse import Mouse, MouseEvent # noqa + + +################################################## +# Code # +################################################## + +class TestMouse(unittest.TestCase): + + @patch('screen._mouse.pyautogui.size', return_value=(1920, 1080)) + @patch('screen._mouse.autoit.mouse_click') + @patch('screen._mouse.sleep', return_value=None) + def test_click_on_circuits(self, mock_sleep, mock_mouse_click, mock_pyautogui_size): + # Define the mouse events + circuits = [ + MouseEvent(row=0, col=0, clicks=1), + MouseEvent(row=1, col=1, clicks=2), + MouseEvent(row=2, col=2, clicks=3) + ] + + # Call the method + Mouse.click_on_circuits(circuits) + + # Expected positions + width, height = mock_pyautogui_size.return_value + x_left = (width // 3) + 40 + x_right = int(width / 1.536) - 30 + y_top = int(height / 3.85714) + y_bot = int(height / 1.30120) + + field_width = (x_right - x_left) // 8 + field_height = (y_bot - y_top) // 8 + + expected_positions = [ + (x_left + (field_width * 0) + (field_width // 2), y_top + (field_height * 0) + (field_height // 2)), + (x_left + (field_width * 1) + (field_width // 2), y_top + (field_height * 1) + (field_height // 2)), + (x_left + (field_width * 2) + (field_width // 2), y_top + (field_height * 2) + (field_height // 2)) + ] + + # Check if mouse_click was called with the correct positions and number of times + expected_calls = [ + call(x=expected_positions[0][0], y=expected_positions[0][1], speed=0), + call(x=expected_positions[1][0], y=expected_positions[1][1], speed=0), + call(x=expected_positions[1][0], y=expected_positions[1][1], speed=0), + call(x=expected_positions[2][0], y=expected_positions[2][1], speed=0), + call(x=expected_positions[2][0], y=expected_positions[2][1], speed=0), + call(x=expected_positions[2][0], y=expected_positions[2][1], speed=0) + ] + + mock_mouse_click.assert_has_calls(expected_calls, any_order=False) + self.assertEqual(mock_mouse_click.call_count, len(expected_calls)) + self.assertEqual(mock_sleep.call_count, len(expected_calls)) + + +if __name__ == '__main__': + unittest.main() diff --git a/timer.py b/timer.py new file mode 100644 index 0000000..7c048ce --- /dev/null +++ b/timer.py @@ -0,0 +1,63 @@ +""" +AutoHackerPDA: A program that automates the process of "Hacking PDA" in the game "Thief Simulator". +Copyright (C) 2024 Lukas Krahbichler + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Contact me: l.krahbichler@proton.me + +File: /timer.py +Created: 03/08/2024 +""" + +################################################## +# Imports # +################################################## + +from typing import Any, Callable +from time import time + + +################################################## +# Code # +################################################## + +def timer(func: Callable[..., Any]) -> Callable[..., Any]: + """ + A decorator function that measures the execution time of a given function. + + :param func: The function to be measured. + :type func: Callable[[Any], Any] + :return: The wrapper function. + :rtype: Callable[[Any], Any] + """ + + def wrapper(*args: Any, **kwargs: Any) -> Any: + """ + Decorator to measure the execution time of a function. + + :param args: The positional arguments to be passed to the function. + :type args: list[Any] + :param kwargs: The keyword arguments to be passed to the function. + :type kwargs: dict[str, Any] + :return: The result of the function. + :rtype: Any + """ + start = time() + result = func(*args, **kwargs) + end = time() + print(f"Function {func.__name__} ran in: {end - start} seconds") + return result + + return wrapper From bf2d4c6c610e39b9067f86dea5f270e502117e2e Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 3 Aug 2024 19:37:28 +0200 Subject: [PATCH 02/10] Update dependencies --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b7267af..ccdba2d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,5 @@ keyboard pygame future Pillow -autoit +PyAutoIt numpy From 9b322947888011122e6ca78fd12888a0b6879bf3 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 3 Aug 2024 19:40:39 +0200 Subject: [PATCH 03/10] Update job --- .github/workflows/run-tests.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 57522e8..2f2ec6a 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -27,7 +27,4 @@ jobs: run: | sudo Xvfb :99 -ac -screen 0 1920x1080x24 & export DISPLAY=:99 - touch /tmp/.Xauthority - export XAUTHORITY=/tmp/.Xauthority python -m unittest discover -s test -p "_test_*.py" - From c15ddf780ebb9e14d42a0b65223558d930ffa29d Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 3 Aug 2024 19:43:09 +0200 Subject: [PATCH 04/10] Update job again --- .github/workflows/run-tests.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 2f2ec6a..e54c3a8 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -4,7 +4,7 @@ on: pull_request jobs: test: - runs-on: ubuntu-latest + runs-on: windows-latest steps: - name: Checkout code @@ -20,11 +20,6 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt - - name: Install Xvfb - run: sudo apt-get install -y xvfb - - name: Run tests run: | - sudo Xvfb :99 -ac -screen 0 1920x1080x24 & - export DISPLAY=:99 python -m unittest discover -s test -p "_test_*.py" From 971e00017fc9e3a91aaee4a27008d5756332c55d Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 4 Aug 2024 03:57:22 +0200 Subject: [PATCH 05/10] Refactoring, Performance, Tests, ... --- .github/workflows/run-tests.yml | 7 +- .gitignore | 4 + algorithm/__init__.py | 2 +- algorithm/_algorithm.py | 61 +++++ algorithm/_circuit.py | 141 ++++++++++ .../{_algorithm_service.py => _simulation.py} | 243 ++++-------------- algorithm/_types.py | 38 +++ cv_analyze/_compare.py | 107 -------- {cv_analyze => image_processing}/__init__.py | 4 +- image_processing/_image_processing.py | 112 ++++++++ imgs/arrows.py | 70 +++++ main.py | 14 +- screen/__init__.py | 5 +- screen/_mouse.py | 18 +- screen/_screen_input.py | 95 +++++-- screen/_visualization.py | 106 -------- settings/__init__.py | 24 ++ settings/_migrator.py | 129 ++++++++++ settings/_settings.py | 190 ++++++++++++++ terminal_interaction.py | 43 ++-- test/__init__.py | 7 - test/_test_mouse.py | 85 ------ test/screen/__init__.py | 22 ++ test/screen/_test_mouse.py | 176 +++++++++++++ test/screen/_test_screen_input.py | 171 ++++++++++++ test/settings/__init__.py | 22 ++ test/settings/_test_migrator.py | 176 +++++++++++++ test/settings/_test_settings.py | 198 ++++++++++++++ timer.py | 34 ++- 29 files changed, 1735 insertions(+), 569 deletions(-) create mode 100644 algorithm/_algorithm.py create mode 100644 algorithm/_circuit.py rename algorithm/{_algorithm_service.py => _simulation.py} (56%) create mode 100644 algorithm/_types.py delete mode 100644 cv_analyze/_compare.py rename {cv_analyze => image_processing}/__init__.py (90%) create mode 100644 image_processing/_image_processing.py create mode 100644 imgs/arrows.py delete mode 100644 screen/_visualization.py create mode 100644 settings/__init__.py create mode 100644 settings/_migrator.py create mode 100644 settings/_settings.py delete mode 100644 test/_test_mouse.py create mode 100644 test/screen/__init__.py create mode 100644 test/screen/_test_mouse.py create mode 100644 test/screen/_test_screen_input.py create mode 100644 test/settings/__init__.py create mode 100644 test/settings/_test_migrator.py create mode 100644 test/settings/_test_settings.py diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index e54c3a8..2f2ec6a 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -4,7 +4,7 @@ on: pull_request jobs: test: - runs-on: windows-latest + runs-on: ubuntu-latest steps: - name: Checkout code @@ -20,6 +20,11 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt + - name: Install Xvfb + run: sudo apt-get install -y xvfb + - name: Run tests run: | + sudo Xvfb :99 -ac -screen 0 1920x1080x24 & + export DISPLAY=:99 python -m unittest discover -s test -p "_test_*.py" diff --git a/.gitignore b/.gitignore index 969fbf1..17c2958 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,7 @@ dmypy.json # JetBrains .idea + +# Project +/test/*/settings/ +/settings/*.json diff --git a/algorithm/__init__.py b/algorithm/__init__.py index cd872f2..fb35bae 100644 --- a/algorithm/__init__.py +++ b/algorithm/__init__.py @@ -21,4 +21,4 @@ Created: 19/04/2023 """ -from ._algorithm_service import AlgorithmService +from ._algorithm import Algorithm diff --git a/algorithm/_algorithm.py b/algorithm/_algorithm.py new file mode 100644 index 0000000..4354057 --- /dev/null +++ b/algorithm/_algorithm.py @@ -0,0 +1,61 @@ +""" +AutoHackerPDA: A program that automates the process of "Hacking PDA" in the game "Thief Simulator". +Copyright (C) 2024 Lukas Krahbichler + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Contact me: l.krahbichler@proton.me + +File: algorithm/_algorithm.py +Created: 04/08/2024 +""" + +################################################## +# Imports # +################################################## + +from cv2.typing import MatLike + +from ._simulation import Simulation + + +################################################## +# Code # +################################################## + +class _Algorithm: + __sim: Simulation + + def __init__(self) -> None: + self.__sim = Simulation() + + def path_finder( + self, + _pads: list[list[MatLike]], + pad_directions: list[list[str]], + io_ports: tuple[int, int] + ) -> dict[tuple[int, int], int]: + self.__sim.update_pads(pad_directions) + self.__sim.update_io_ports(io_ports) + + his = self.__sim.simulate() + + diffs = self.__sim.found_diffs(his) + # row | column + + # Window.grid_pads(_pads, self.__sim.get_directions(), io_ports) + return diffs + + +Algorithm = _Algorithm() diff --git a/algorithm/_circuit.py b/algorithm/_circuit.py new file mode 100644 index 0000000..6fb8db4 --- /dev/null +++ b/algorithm/_circuit.py @@ -0,0 +1,141 @@ +""" +AutoHackerPDA: A program that automates the process of "Hacking PDA" in the game "Thief Simulator". +Copyright (C) 2024 Lukas Krahbichler + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Contact me: l.krahbichler@proton.me + +File: algorithm/_circuit.py +Created: 04/08/2024 +""" + +################################################## +# Imports # +################################################## + +from __future__ import annotations + +from typing import Literal + +from ._types import CIRCUIT, CIRCUIT_TYPE, DIRECTIONS, DIRECTIONS_TYPE + + +################################################## +# Code # +################################################## + +# Todo: Performance measurements and improvements +# Todo: Tests +class Circuit: + _type: CIRCUIT_TYPE | None + _direction: DIRECTIONS_TYPE | None + _is_powered: bool + + _top_port_power: bool + _bot_port_power: bool + _right_port_power: bool + _left_port_power: bool + + def __init__(self): + self._type = None + self._direction = None + self._is_powered = False + + self._top_port_power = False + self._bot_port_power = False + self._right_port_power = False + self._left_port_power = False + + def __update(self) -> None: + # Facing: Top -> top, left, bot, right + emitting: tuple[bool, bool, bool, bool] = (False,) * 4 + + if self._type == "oneway": + emitting = (True, False, True, False) + elif self._type == "corner": + emitting = (True, False, False, True) + elif self._type == "junction": + emitting = (True, True, False, True) + + if self._direction: + self._top_port_power = emitting[(0 - DIRECTIONS.index(self._direction)) % 4] + self._left_port_power = emitting[(1 - DIRECTIONS.index(self._direction)) % 4] + self._bot_port_power = emitting[(2 - DIRECTIONS.index(self._direction)) % 4] + self._right_port_power = emitting[(3 - DIRECTIONS.index(self._direction)) % 4] + + def __repr__(self) -> str: + return f"Model: {self._type} - Facing: {self._direction} - Ports(T, L, B, R): " \ + f"{self._top_port_power}, {self._left_port_power}, {self._bot_port_power}, {self._right_port_power}" + + def set_attributes(self, pad_string: str) -> None: + cut_string: list[Literal["oneway", "corner", "junction", "", "top", "bot", "left", "right"]] + cut_string = pad_string.split("_") # noqa + + if len(cut_string) >= 2: + self._type = cut_string[0] if cut_string[0] in CIRCUIT else "" + self._direction = cut_string[1] if cut_string[1] in DIRECTIONS else "" + else: + self._type = None + self._direction = None + self.__update() + + def rotate_by( + self, + by: int | None = 1 + ) -> None: + if self._direction: + self._direction = DIRECTIONS[(DIRECTIONS.index(self._direction) + by) % 4] + self.__update() + + @property + def state(self) -> bool: + return self._is_powered + + @state.setter + def state(self, value: bool) -> None: + self._is_powered = value + + def empower(self, direction: DIRECTIONS_TYPE | None) -> None: + self._is_powered = self.__dict__[f"_Pad__port_{direction}"] + self.__update() + + @property + def port_top(self) -> bool: + return self._top_port_power + + @property + def port_left(self) -> bool: + return self._left_port_power + + @property + def port_bot(self) -> bool: + return self._bot_port_power + + @property + def port_right(self) -> bool: + return self._right_port_power + + @property + def facing(self) -> DIRECTIONS_TYPE | None: + return self._direction + + @facing.setter + def facing(self, value: DIRECTIONS_TYPE | None) -> None: + self._direction = value + self.__update() + + @property + def model(self) -> CIRCUIT_TYPE | None: + return self._type diff --git a/algorithm/_algorithm_service.py b/algorithm/_simulation.py similarity index 56% rename from algorithm/_algorithm_service.py rename to algorithm/_simulation.py index a390812..a6a4e13 100644 --- a/algorithm/_algorithm_service.py +++ b/algorithm/_simulation.py @@ -17,7 +17,7 @@ Contact me: l.krahbichler@proton.me -File: algorithm/_algorithm_service.py +File: algorithm/_simulation.py Created: 19/04/2023 """ @@ -25,194 +25,90 @@ # Imports # ################################################## +from __future__ import annotations + import numpy as np + from typing import Literal from copy import deepcopy +from ._types import DIRECTIONS_TYPE +from ._circuit import Circuit + ################################################## # Code # ################################################## -PADS: Literal["oneway_up", "oneway_right", - "corner_top_right", "corner_right_bot", "corner_bot_left", "corner_left_top", - "junction_top", "junction_bot", "junction_left", "junction_right"] - - -class _Pad: - __model: Literal["oneway", "corner", "junction", ""] - __facing: Literal["top", "right", "bot", "left", ""] - __state: bool - - __DIRECTIONS: list[Literal["top", "left", "bot", "right"]] = ["top", "left", "bot", "right"] - - __port_top: bool - __port_bot: bool - __port_right: bool - __port_left: bool - - def __init__(self): - self.__model = "" - self.__facing = "" - self.__state = False - - self.__port_top = False - self.__port_bot = False - self.__port_right = False - self.__port_left = False - - def __update(self) -> None: - # Facing: Top -> top, left, bot, right - emitting: tuple[bool, bool, bool, bool] = (False,) * 4 - - if self.__model == "oneway": - emitting = (True, False, True, False) - elif self.__model == "corner": - emitting = (True, False, False, True) - elif self.__model == "junction": - emitting = (True, True, False, True) - - if self.__facing != "": - self.__port_top = emitting[(0-self.__DIRECTIONS.index(self.__facing)) % 4] - self.__port_left = emitting[(1-self.__DIRECTIONS.index(self.__facing)) % 4] - self.__port_bot = emitting[(2-self.__DIRECTIONS.index(self.__facing)) % 4] - self.__port_right = emitting[(3-self.__DIRECTIONS.index(self.__facing)) % 4] - - def __repr__(self) -> str: - return f"Model: {self.__model} - Facing: {self.__facing} - Ports(T, L, B, R): " \ - f"{self.__port_top}, {self.__port_left}, {self.__port_bot}, {self.__port_right}" - - def set_attributes(self, pad_string: str) -> None: - cut_string: list[Literal["oneway", "corner", "junction", "", "top", "bot", "left", "right"]] - cut_string = pad_string.split("_") # noqa - - if len(cut_string) >= 2: - self.__model = cut_string[0] - self.__facing = cut_string[1] - else: - self.__model = "" - self.__facing = "" - self.__update() - - def rotate_by( - self, - by: int | None = 1 - ) -> None: - if self.__facing != "": - self.__facing = self.__DIRECTIONS[(self.__DIRECTIONS.index(self.__facing) + by) % 4] - self.__update() - - @property - def state(self) -> bool: - return self.__state - - @state.setter - def state(self, value: bool) -> None: - self.__state = value - - def empower(self, direction: Literal["top", "left", "bot", "right"]) -> None: - self.__state = self.__dict__[f"_Pad__port_{direction}"] - self.__update() - - @property - def port_top(self) -> bool: - return self.__port_top - - @property - def port_left(self) -> bool: - return self.__port_left - - @property - def port_bot(self) -> bool: - return self.__port_bot - - @property - def port_right(self) -> bool: - return self.__port_right - - @property - def facing(self) -> Literal["top", "left", "bot", "right"]: - return self.__facing - - @facing.setter - def facing(self, value: Literal["top", "left", "bot", "right"]) -> None: - self.__facing = value - self.__update() - - @property - def model(self) -> Literal["oneway", "corner", "junction", ""]: - return self.__model - - -class _Simulation: +# Todo: Performance measurements and improvements +# Todo: Tests +class Simulation: # rows[columns[]] - __pads: list[list[_Pad]] + __pads: list[list[Circuit]] __io_ports: tuple[int, int] - __img_directions: list[list[Literal["top", "left", "bot", "right"]]] - __initial_directions: list[list[Literal["top", "left", "bot", "right"]]] - + __img_directions: list[list[DIRECTIONS_TYPE]] + __initial_directions: list[list[DIRECTIONS_TYPE]] + __found: bool __found_history: list[tuple[int, int]] - __found_directions: list[list[Literal["top", "left", "bot", "right"]]] - + __found_directions: list[list[DIRECTIONS_TYPE]] + __histories: list[list[tuple[int, int]]] - - __DIRECTIONS: list[Literal["top", "left", "bot", "right"]] = ["top", "left", "bot", "right"] - + def __init__(self): self.__pads = [] self.__io_ports = (0, 0) self.__init_directions = [] self.__img_directions = [] - + self.__histories = [] - + self.__found = False self.__found_history = [] self.__found_directions = [] - + for x in range(8): - row_list: list[_Pad] = [] - + row_list: list[Circuit] = [] + for y in range(8): - row_list.append(_Pad()) - + row_list.append(Circuit()) + self.__pads.append(row_list) - + def update_pads(self, pad_strings: list[list[str]]) -> None: for x in range(8): for y in range(8): self.__pads[x][y].set_attributes(pad_strings[x][y]) - + def update_io_ports(self, io_ports: tuple[int, int]) -> None: self.__io_ports = io_ports - + def set_initial_directions(self) -> None: self.__init_directions = self.get_directions(False) - + def simulate(self) -> list[tuple[int, int]]: print("START SIM") self.__found = False self.__found_history = [] self.__found_directions = [] self.__histories = [] - + row: int = self.__io_ports[0] col: int = 0 - - pad: _Pad = self.__pads[row][col] - + + pad: Circuit = self.__pads[row][col] + self.set_initial_directions() - + pad.empower("left") while not pad.state: pad.rotate_by() pad.empower("left") - + self.algorithm_basic(row, col, self.get_directions(False), "left", []) print("END SIM") return self.__found_history - + def algorithm_basic( self, row: int, @@ -224,33 +120,33 @@ def algorithm_basic( # Deecopy lists to not influnce outer lists pad_turns = deepcopy(pad_turns) history = deepcopy(history2) - + # Set pad instances to wanted directions for r, pad_row in enumerate(self.__pads): for c, pad in enumerate(pad_row): pad.facing = pad_turns[r][c] - + # Add self to history history.append((row, col)) - - pad: _Pad = self.__pads[row][col] - + + pad: Circuit = self.__pads[row][col] + if history in self.__histories: return self.__histories.append(history) - + for direction in range(4): if self.__found: return - + # Rest state, rotate and evaluate state pad.state = False if direction != 0: pad.rotate_by(1) pad.empower(input_direction) - + pad_turns[row][col] = pad.facing - + # Check if pda is solved if row == self.__io_ports[1] and col == len(self.__pads[0]) - 1: if pad.port_right and pad.state: @@ -258,10 +154,10 @@ def algorithm_basic( self.__found_history = history self.__found_directions = pad_turns return - + # Determine influenced neighbors influenced_neighbors: list[tuple[int, int, Literal["top", "left", "bot", "right"]]] = [] - + if pad.port_top and pad.state and row != 0 and input_direction != "top": influenced_neighbors.append((row - 1, col, "bot")) if pad.port_left and pad.state and col != 0 and input_direction != "left": @@ -270,22 +166,22 @@ def algorithm_basic( influenced_neighbors.append((row + 1, col, "top")) if pad.port_right and pad.state and col != len(self.__pads[0]) - 1 and input_direction != "right": influenced_neighbors.append((row, col + 1, "left")) - + # Resume with every influenced neighbor for n_row, n_col, n_input in influenced_neighbors: # Cancel if neighbor is already in path if (n_row, n_col) in history: return self.algorithm_basic(n_row, n_col, pad_turns, n_input, history) - + def found_diffs(self, history: list[tuple[int, int]]) -> dict[tuple[int, int], int]: result = {} - + # Set pad instances to initial directions for r, pad_row in enumerate(self.__pads): for c, pad in enumerate(pad_row): pad.facing = self.__init_directions[r][c] - + for row, pad_row in enumerate(self.__found_directions): for col, pad in enumerate(pad_row): if (row, col) in history: @@ -294,42 +190,15 @@ def found_diffs(self, history: list[tuple[int, int]]) -> dict[tuple[int, int], i if diff != 0: result[(row, col)] = diff self.__pads[row][col].rotate_by(diff) - + return result - - def get_directions(self, with_model: bool | None = True) -> list[list[Literal["top", "left", "bot", "right"] | str]]: - directions = [] + + def get_directions(self, with_model: bool | None = True) -> list[list[DIRECTIONS_TYPE]]: + directions: list[list[DIRECTIONS_TYPE]] = [] for row in range(8): - row_directions = [] + row_directions: list[DIRECTIONS_TYPE] = [] for col in range(8): - row_directions.append(f"{self.__pads[row][col].model+'_' if with_model else ''}" + row_directions.append(f"{self.__pads[row][col].model + '_' if with_model else ''}" f"{self.__pads[row][col].facing}") directions.append(row_directions) return directions - - -class _AlgorithmService: - __sim: _Simulation - - def __init__(self) -> None: - self.__sim = _Simulation() - - def path_finder( - self, - _pads: list[list[np.ndarray]], - pad_directions: list[list[str]], - io_ports: tuple[int, int] - ) -> dict[tuple[int, int], int]: - self.__sim.update_pads(pad_directions) - self.__sim.update_io_ports(io_ports) - - his = self.__sim.simulate() - - diffs = self.__sim.found_diffs(his) - # row | column - - # Window.grid_pads(_pads, self.__sim.get_directions(), io_ports) - return diffs - - -AlgorithmService = _AlgorithmService() diff --git a/algorithm/_types.py b/algorithm/_types.py new file mode 100644 index 0000000..e09559c --- /dev/null +++ b/algorithm/_types.py @@ -0,0 +1,38 @@ +""" +AutoHackerPDA: A program that automates the process of "Hacking PDA" in the game "Thief Simulator". +Copyright (C) 2024 Lukas Krahbichler + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Contact me: l.krahbichler@proton.me + +File: algorithm/_types.py +Created: 04/08/2024 +""" + +################################################## +# Imports # +################################################## + +from typing import Literal + + +################################################## +# Code # +################################################## + +CIRCUIT_TYPE = Literal["oneway", "corner", "junction"] +CIRCUIT: list[CIRCUIT_TYPE] = ["oneway", "corner", "junction"] +DIRECTIONS_TYPE = Literal["top", "left", "bot", "right"] +DIRECTIONS: list[DIRECTIONS_TYPE] = ["top", "left", "bot", "right"] diff --git a/cv_analyze/_compare.py b/cv_analyze/_compare.py deleted file mode 100644 index c83b66c..0000000 --- a/cv_analyze/_compare.py +++ /dev/null @@ -1,107 +0,0 @@ -""" -AutoHackerPDA: A program that automates the process of "Hacking PDA" in the game "Thief Simulator". -Copyright (C) 2024 Lukas Krahbichler - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . - -Contact me: l.krahbichler@proton.me - -File: cv_analyze/_compare.py -Created: 18/04/2023 -""" - -################################################## -# Imports # -################################################## - -from typing import Literal -import numpy as np -import cv2 - - -################################################## -# Code # -################################################## - -PADS: Literal["oneway_top", "oneway_right", - "corner_top_right", "corner_right_bot", "corner_bot_left", "corner_left_top", - "junction_top", "junction_bot", "junction_left", "junction_right"] - - -class _CompareService: - THRESHOLD: float = 0.80 - - def compare_io(self, pads: list[list[np.ndarray]]) -> tuple[int, int]: - imgs = ["arrows_in.png", "arrows_out.png"] - - compared = self.compare_pads(pads, imgs=imgs, threshold=0.5) - res = [] - - for in_out in compared: - for i, comp in enumerate(in_out): - if comp != "": - res.append(i) - break - else: - res.append(0) - - return res[0], res[1] - - def compare_pads( - self, - pads: list[list[np.ndarray]], - imgs: list[str] | None = None, - threshold: float | None = None - ) -> list[list[str]]: - results: list[list[str]] = [] - - if not threshold: - threshold = self.THRESHOLD - - if not imgs: - imgs = ["oneway_top.png", "oneway_right.png", - "corner_top_right.png", "corner_right_bot.png", - "corner_bot_left.png", "corner_left_top.png", - "junction_top.png", "junction_bot.png", - "junction_left.png", "junction_right.png"] - - imgs_to_compare: list[tuple[np.ndarray, PADS]] = [] - - for img in imgs: - imgs_to_compare.append(( - cv2.cvtColor(cv2.imread("imgs/"+img), cv2.COLOR_BGR2GRAY), - "".join(img.split(".")[:-1]) - )) - - for pad_col in pads: - col_list: list[str] = [] - - for pad_row in pad_col: - for img_to_compare in imgs_to_compare: - res = cv2.matchTemplate(pad_row, img_to_compare[0], cv2.TM_CCOEFF_NORMED) - - loc = np.where(res >= threshold) - loc_list = [*zip(*loc[::-1])] - if loc_list: - col_list.append(img_to_compare[1]) - break - else: - col_list.append("") - - results.append(col_list) - - return results - - -CompareService = _CompareService() diff --git a/cv_analyze/__init__.py b/image_processing/__init__.py similarity index 90% rename from cv_analyze/__init__.py rename to image_processing/__init__.py index 730ce2d..e393422 100644 --- a/cv_analyze/__init__.py +++ b/image_processing/__init__.py @@ -17,8 +17,8 @@ Contact me: l.krahbichler@proton.me -File: cv_analyze/__init__.py +File: image_processing/__init__.py Created: 18/04/2023 """ -from ._compare import CompareService +from ._image_processing import ImageProcessing diff --git a/image_processing/_image_processing.py b/image_processing/_image_processing.py new file mode 100644 index 0000000..b3d26a3 --- /dev/null +++ b/image_processing/_image_processing.py @@ -0,0 +1,112 @@ +""" +AutoHackerPDA: A program that automates the process of "Hacking PDA" in the game "Thief Simulator". +Copyright (C) 2024 Lukas Krahbichler + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Contact me: l.krahbichler@proton.me + +File: image_processing/_image_processing.py +Created: 18/04/2023 +""" + +################################################## +# Imports # +################################################## + +from __future__ import annotations + +import cv2 + +from imgs.arrows import ARROWS_TYPE, ARROW_FILES, ARROW_FILES_TYPE, ARROW_IO_FILES, ARROW_IO_FILES_TYPE, ARROW_IO_TYPE +from numpy import any as numpy_any +from cv2.typing import MatLike +from typing import List, Tuple + + +################################################## +# Code # +################################################## + +# Todo: Measure performance and improve @apply_to_methods(timer) +# Todo: Tests +class ImageProcessing: + """ + The ImageProcessing class provides methods for comparing PDA screen segments with reference images + to identify circuit types and input/output arrows. + + Attributes: + __DEFAULT_THRESHOLD (float): Default matching threshold for template comparison. + """ + __DEFAULT_THRESHOLD: float = 0.80 + + @staticmethod + def io_arrows(pads: List[List[MatLike]]) -> tuple[int, ...]: + """ + Compare input and output arrows on the PDA screen. + + :param pads: 2D list of cropped images representing the circuits. + :type pads: List[List[cv2.typing.MatLike]] + :return: Tuple indicating the index of the matched input and output arrow. + :rtype: Tuple[int, int] + """ + comparisons = ImageProcessing.circuits(pads, ARROW_IO_FILES, threshold=0.5) + + return tuple( + next((i for i, comp in enumerate(comp_list) if comp), 0) + for comp_list in comparisons + ) + + @staticmethod + def circuits( + pads: List[List[MatLike]], + images: List[ARROW_IO_FILES_TYPE | ARROW_FILES_TYPE] | None = None, + threshold: float | None = None + ) -> List[List[str]]: + """ + Compare the cropped circuits with reference images to identify the type of circuit. + + :param pads: 2D list of cropped images representing the circuits. + :type pads: List[List[cv2.typing.MatLike]] + :param images: List of image filenames to compare against. + :type images: List[str] | None + :param threshold: Matching threshold for template comparison. + :type threshold: float | None + :return: 2D list of identified circuit types. + :rtype: List[List[str]] + """ + if threshold is None: + threshold = ImageProcessing.__DEFAULT_THRESHOLD + + if images is None: + images = ARROW_FILES + + reference_images: list[tuple[MatLike, ARROWS_TYPE | ARROW_IO_TYPE]] = [ + (cv2.cvtColor(cv2.imread(f"imgs/{img}"), cv2.COLOR_BGR2GRAY), img.split(".", 1)[0]) + for img in images + ] + + results = [ + [ + next( + (ref_name for ref_img, ref_name in reference_images + if numpy_any(cv2.matchTemplate(pad, ref_img, cv2.TM_CCOEFF_NORMED) >= threshold)), + "" + ) + for pad in pad_col + ] + for pad_col in pads + ] + + return results diff --git a/imgs/arrows.py b/imgs/arrows.py new file mode 100644 index 0000000..5d7a103 --- /dev/null +++ b/imgs/arrows.py @@ -0,0 +1,70 @@ +""" +AutoHackerPDA: A program that automates the process of "Hacking PDA" in the game "Thief Simulator". +Copyright (C) 2024 Lukas Krahbichler + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Contact me: l.krahbichler@proton.me + +File: imgs/arrows.py +Created: 04/08/2024 +""" + +################################################## +# Imports # +################################################## + +from typing import Literal + + +################################################## +# Code # +################################################## + +ARROWS_TYPE = Literal[ + "oneway_top", "oneway_right", + "corner_top_right", "corner_right_bot", + "corner_bot_left", "corner_left_top", + "junction_top", "junction_bot", + "junction_left", "junction_right" +] +ARROWS: list[ARROWS_TYPE] = [ + "oneway_top", "oneway_right", + "corner_top_right", "corner_right_bot", + "corner_bot_left", "corner_left_top", + "junction_top", "junction_bot", + "junction_left", "junction_right" +] + +ARROW_IO_TYPE = Literal["arrow_in", "arrow_out"] +ARROW_IO: list[ARROW_IO_TYPE] = ["arrow_in", "arrow_out"] + + +ARROW_FILES_TYPE = Literal[ + "oneway_top.png", "oneway_right.png", + "corner_top_right.png", "corner_right_bot.png", + "corner_bot_left.png", "corner_left_top.png", + "junction_top.png", "junction_bot.png", + "junction_left.png", "junction_right.png" +] +ARROW_FILES: list[ARROW_FILES_TYPE] = [ + "oneway_top.png", "oneway_right.png", + "corner_top_right.png", "corner_right_bot.png", + "corner_bot_left.png", "corner_left_top.png", + "junction_top.png", "junction_bot.png", + "junction_left.png", "junction_right.png" +] + +ARROW_IO_FILES_TYPE = Literal["arrow_in.png", "arrow_out.png"] +ARROW_IO_FILES: list[ARROW_IO_FILES_TYPE] = ["arrow_in.png", "arrow_out.png"] diff --git a/main.py b/main.py index e30d2c0..3618fed 100644 --- a/main.py +++ b/main.py @@ -17,7 +17,7 @@ Contact me: l.krahbichler@proton.me -File: /main.py +File: main.py Created: 18/04/2023 """ @@ -29,10 +29,10 @@ from os import kill, getpid from numpy import ndarray -from screen import ScreenInput, Mouse -from algorithm import AlgorithmService -from cv_analyze import CompareService +from image_processing import ImageProcessing from terminal_interaction import Licensing +from screen import ScreenInput, Mouse +from algorithm import Algorithm ################################################## @@ -47,9 +47,9 @@ def __run_pda_hack() -> None: pads: list[list[ndarray]] = ScreenInput.circuits_crop() io_pads: list[list[ndarray]] = ScreenInput.io_arrows_crop() - styles: list[list[str]] = CompareService.compare_pads(pads) - io: tuple[int, int] = CompareService.compare_io(io_pads) - clicks: dict[tuple[int, int], int] = AlgorithmService.path_finder(pads, styles, io) + styles: list[list[str]] = ImageProcessing.circuits(pads) + io: tuple[int, ...] = ImageProcessing.io_arrows(io_pads) + clicks: dict[tuple[int, int], int] = Algorithm.path_finder(pads, styles, io) Mouse.click_on_circuits(clicks) diff --git a/screen/__init__.py b/screen/__init__.py index 45a223f..772f154 100644 --- a/screen/__init__.py +++ b/screen/__init__.py @@ -17,10 +17,9 @@ Contact me: l.krahbichler@proton.me -File: /screen/__init__.py +File: screen/__init__.py Created: 18/02/023 """ -from ._mouse import Mouse, MouseEvent from ._screen_input import ScreenInput -from ._visualization import Window +from ._mouse import Mouse, MouseEvent diff --git a/screen/_mouse.py b/screen/_mouse.py index 98b3055..4c35eea 100644 --- a/screen/_mouse.py +++ b/screen/_mouse.py @@ -27,8 +27,11 @@ from typing import TypedDict from time import sleep -import pyautogui -import autoit +from pyautogui import size +# noinspection PyPackageRequirements +from autoit import mouse_click + +from settings import Settings ################################################## @@ -52,9 +55,6 @@ class MouseEvent(TypedDict): class Mouse: """ The Mouse class provides a method to click on the circuits. - - Methods: - click_on_circuits(arrows: list[MouseEvent]) """ @classmethod @@ -69,7 +69,7 @@ def click_on_circuits(cls, circuits: list[MouseEvent]) -> None: """ width: int height: int - width, height = pyautogui.size() + width, height = size() x_left: int = (width // 3) + 40 x_right: int = int(width / 1.536) - 30 @@ -84,5 +84,7 @@ def click_on_circuits(cls, circuits: list[MouseEvent]) -> None: x: int = x_left + (field_width * circuit["col"]) + (field_width // 2) y: int = y_top + (field_height * circuit["row"]) + (field_height // 2) for _ in range(circuit["clicks"]): - autoit.mouse_click(x=x, y=y, speed=0) - sleep(0.02) + mouse_click(x=x, y=y, speed=0) + if Settings["mouse"]["click_delay"] == 0.0: + continue + sleep(Settings["mouse"]["click_delay"]) diff --git a/screen/_screen_input.py b/screen/_screen_input.py index fa5bafd..3305205 100644 --- a/screen/_screen_input.py +++ b/screen/_screen_input.py @@ -27,11 +27,12 @@ from __future__ import annotations +import cv2 + from numpy import array, ndarray from pyautogui import screenshot -from PIL.Image import Image +from cv2.typing import MatLike from time import time -import cv2 ################################################## @@ -39,11 +40,18 @@ ################################################## class ScreenInput: - __screenshot: cv2.typing.MatLike | None = None - __screenshot_timestamp: float = 0.0 + """ + The ScreenInput class handles taking and processing screenshots for the PDA hacking interface. + + Attributes: + _screenshot (cv2.typing.MatLike | None): Cached screenshot in grayscale. + _screenshot_timestamp (float | None): Timestamp of the last taken screenshot. + """ + _screenshot: MatLike | None = None + _screenshot_timestamp: float | None = None @classmethod - def __take_screenshot(cls) -> bool: + def _take_screenshot(cls) -> bool: """ Take a screenshot and convert it to grayscale. Caches the screenshot for 0.5 seconds. @@ -51,27 +59,34 @@ def __take_screenshot(cls) -> bool: :return: Whether a new screenshot was taken or not :rtype: bool """ - if not cls.__screenshot or time() - cls.__screenshot_timestamp > 0.5: - cls.__screenshot = cv2.cvtColor(array(screenshot()), cv2.COLOR_BGR2GRAY) - cls.__screenshot_timestamp = time() + if cls._screenshot is None or time() - cls._screenshot_timestamp > 0.5: + cls._screenshot = cv2.cvtColor(array(screenshot()), cv2.COLOR_BGR2GRAY) + cls._screenshot_timestamp = time() return True return False @classmethod - def __pda_screen_crop(cls) -> ndarray: - # 640, 280 - 1250, 830 - cls.__take_screenshot() + def _pda_screen_crop(cls) -> MatLike: + """ + Crop the screenshot to the PDA screen area. + + :return: Cropped image of the PDA screen + :rtype: cv2.typing.MatLike + """ + # Take a new screenshot if necessary + cls._take_screenshot() height: int width: int - height, width = cls.__screenshot.shape[:2] + height, width = cls._screenshot.shape[:2] + # Define the crop area x_left: int = width // 3 x_right: int = int(width / 1.536) y_top: int = int(height / 3.85714) y_bot: int = int(height / 1.30120) - return cls.__screenshot[y_top:y_bot, x_left:x_right] + return cls._screenshot[y_top:y_bot, x_left:x_right] @classmethod def circuits_crop( @@ -80,20 +95,34 @@ def circuits_crop( cut_right: int | None = 30, pad_x: int | None = 8, pad_y: int | None = 8 - ) -> list[list[ndarray]]: - - pda: ndarray = cls.__pda_screen_crop()[:, cut_left:-cut_right] + ) -> list[list[MatLike]]: + """ + Crop the PDA screen into smaller segments representing circuits. + + :param cut_left: Pixels to cut from the left side + :type cut_left: int | None + :param cut_right: Pixels to cut from the right side + :type cut_right: int | None + :param pad_x: Number of horizontal segments + :type pad_x: int | None + :param pad_y: Number of vertical segments + :type pad_y: int | None + :return: List of cropped segments + :rtype: list[list[cv2.typing.MatLike]] + """ + # Crop the PDA screen + pda: MatLike = cls._pda_screen_crop()[:, cut_left:-cut_right] - PAD_X: int = pad_x - PAD_Y: int = pad_y + pad_x: int = pad_x + pad_y: int = pad_y - pad_height: float = pda.shape[0] / PAD_Y - pad_width: float = pda.shape[1] / PAD_X + pad_height: float = pda.shape[0] / pad_y + pad_width: float = pda.shape[1] / pad_x pdas: list[list[ndarray]] = [] - for y in range(PAD_Y): + for y in range(pad_y): row_list: list[ndarray] = [] - for x in range(PAD_X): + for x in range(pad_x): row_list.append( pda[int(pad_height * y):int(pad_height * (y + 1)), int(pad_width * x):int(pad_width * (x + 1))]) @@ -102,8 +131,14 @@ def circuits_crop( return pdas @classmethod - def io_arrows_crop(cls) -> list[list[ndarray]]: - pdas: list[list[ndarray]] = cls.circuits_crop(0, 1, 20, 8) + def io_arrows_crop(cls) -> list[list[MatLike]]: + """ + Crop the input/output arrows from the PDA screen. + + :return: List of input and output arrows + :rtype: list[list[cv2.typing.MatLike]] + """ + pdas: list[list[MatLike]] = cls.circuits_crop(0, 1, 20) return [[row[0] for row in pdas], [row[-1] for row in pdas]] @@ -115,10 +150,14 @@ def reset(cls) -> None: :return: None :rtype: None """ - cls.__screenshot = None - cls.__screenshot_timestamp = 0.0 + cls._screenshot = None + cls._screenshot_timestamp = 0.0 if __name__ == '__main__': - print(ScreenInput.circuits_crop()) - print(ScreenInput.io_arrows_crop()) + t1 = time() + + ScreenInput.circuits_crop() + ScreenInput.io_arrows_crop() + + print("\nTOTAL TIME:", time() - t1) diff --git a/screen/_visualization.py b/screen/_visualization.py deleted file mode 100644 index 326130e..0000000 --- a/screen/_visualization.py +++ /dev/null @@ -1,106 +0,0 @@ -""" -AutoHackerPDA: A program that automates the process of "Hacking PDA" in the game "Thief Simulator". -Copyright (C) 2024 Lukas Krahbichler - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . - -Contact me: l.krahbichler@proton.me - -File: screen/_mouse.py -Created: 18/04/2023 -""" - -################################################## -# Imports # -################################################## - -from tkinter import Tk, Canvas -from PIL import Image, ImageTk -from typing import Any -import numpy as np - - -################################################## -# Code # -################################################## - -class _Window(Tk): - __imgs: list[ImageTk.PhotoImage] - - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - - self.__imgs = [] - - def grid_pads(self, images: list[list[np.ndarray]], pad_styles: list[list[str]] | None = None, - io_ports: tuple[int, int] | None = None) -> None: - for slave in self.grid_slaves(): - slave.grid_forget() - slave.destroy() - self.__imgs = [] - - for row, image_row in enumerate(images): - for col, image_col in enumerate(image_row): - self.__imgs.append(ImageTk.PhotoImage(image=Image.fromarray(image_col))) - can = Canvas(self) - width = self.__imgs[-1].width() - height = self.__imgs[-1].height() - - can.configure(width=width, height=height) - - can.create_image(0, 0, anchor="nw", image=self.__imgs[-1]) - - if pad_styles: - if pad_styles[row][col] in "oneway_toponeway_bot": - can.create_line(width//2, 0, width//2, height, fill="red", width=5) - elif pad_styles[row][col] in "oneway_rightoneway_left": - can.create_line(0, height//2, width, height//2, fill="red", width=5) - elif pad_styles[row][col] in "corner_top_right": - can.create_line(width//2, 0, width//2, height//2, fill="red", width=5) - can.create_line(width//2, height//2, width, height//2, fill="red", width=5) - elif pad_styles[row][col] in "corner_right_bot": - can.create_line(width//2, height//2, width, height//2, fill="red", width=5) - can.create_line(width//2, height//2, width//2, height, fill="red", width=5) - elif pad_styles[row][col] in "corner_bot_left": - can.create_line(width//2, height//2, width//2, height, fill="red", width=5) - can.create_line(0, height//2, width//2, height//2, fill="red", width=5) - elif pad_styles[row][col] in "corner_left_top": - can.create_line(0, height//2, width//2, height//2, fill="red", width=5) - can.create_line(width//2, 0, width//2, height//2, fill="red", width=5) - elif pad_styles[row][col] in "junction_top": - can.create_line(0, height//2, width, height//2, fill="red", width=5) - can.create_line(width//2, 0, width//2, height//2, fill="red", width=5) - elif pad_styles[row][col] in "junction_bot": - can.create_line(0, height//2, width, height//2, fill="red", width=5) - can.create_line(width//2, height//2, width//2, height, fill="red", width=5) - elif pad_styles[row][col] in "junction_right": - can.create_line(width//2, 0, width//2, height, fill="red", width=5) - can.create_line(width//2, height//2, width, height//2, fill="red", width=5) - elif pad_styles[row][col] in "junction_left": - can.create_line(width//2, 0, width//2, height, fill="red", width=5) - can.create_line(0, height//2, width//2, height//2, fill="red", width=5) - - if io_ports: - if col == 0 and row == io_ports[0]: - can.create_line(0, 0, 0, height, fill="green", width=15) - - if col == len(image_row)-1 and row == io_ports[1]: - can.create_line(width, 0, width, height, fill="blue", width=15) - can.grid(row=row, column=col, sticky="NSEW") - - self.grid_rowconfigure("all", weight=1) - self.grid_columnconfigure("all", weight=1) - - -Window = _Window() diff --git a/settings/__init__.py b/settings/__init__.py new file mode 100644 index 0000000..84a5e96 --- /dev/null +++ b/settings/__init__.py @@ -0,0 +1,24 @@ +""" +AutoHackerPDA: A program that automates the process of "Hacking PDA" in the game "Thief Simulator". +Copyright (C) 2024 Lukas Krahbichler + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Contact me: l.krahbichler@proton.me + +File: settings/__init__.py +Created: 03/08/2024 +""" + +from ._settings import Settings diff --git a/settings/_migrator.py b/settings/_migrator.py new file mode 100644 index 0000000..42abd33 --- /dev/null +++ b/settings/_migrator.py @@ -0,0 +1,129 @@ +""" +AutoHackerPDA: A program that automates the process of "Hacking PDA" in the game "Thief Simulator". +Copyright (C) 2024 Lukas Krahbichler + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Contact me: l.krahbichler@proton.me + +File: settings/_migrator.py +Created: 03/08/2024 +""" + +################################################## +# Imports # +################################################## + +from __future__ import annotations + +from typing import Any, Dict, Callable, TextIO +from os import path + + +################################################## +# Code # +################################################## + +class Migrator: + """ + The Migrator class handles the migration of settings from one version to another. + + Attributes: + _FILE (str): Path to the file storing the current version. + _version (str | None): Current version of the settings. + _migrated_version (str | None): Version to which settings have been migrated. + """ + _FILE: str = "settings/version" + _version: str | None = None + _migrated_version: str | None = None + + def __init__(self): + """ + Initialize the Migrator object and read the current version from a file. + """ + if path.isfile(self._FILE): + file: TextIO + with open(self._FILE) as file: + self._version = file.read().strip() + + @staticmethod + def _migrate_1_0_0(settings: Dict[Any, Any]) -> Dict[Any, Any]: + """ + Migrate settings to version 1.0.0. + + :param settings: A dictionary containing the settings to be migrated. + :type settings: Dict[Any, Any] + :return: The migrated settings as a dictionary. + :rtype: Dict[Any, Any] + """ + print("Migrating to 1.0.0") + + if "mouse" not in settings: + settings["mouse"] = { + "click_delay": 0.02 + } + + return settings + + def migrate_settings(self, settings: Dict[Any, Any]) -> Dict[Any, Any]: + """ + Migrate the given settings to the latest version. + + :param settings: The current settings. + :type settings: Dict[Any, Any] + :return: The migrated settings. + :rtype: Dict[Any, Any] + """ + migrations: list[tuple[str, Callable[[Dict[Any, Any]], Dict[Any, Any]]]] = [ + ("1.0.0", self._migrate_1_0_0), + ] + + current_version: str = self._version or "0.0.0" + + version: str + migrate_func: Callable[[Dict[Any, Any]], Dict[Any, Any]] + for version, migrate_func in migrations: + if self._compare_versions(version, current_version): + settings = migrate_func(settings) + current_version = version + + self._migrated_version = current_version + + return settings + + def __del__(self) -> None: + """ + Write the migrated version to the version file upon deletion of the object. + """ + if self._migrated_version: + file: TextIO + with open(self._FILE, "w") as file: + file.write(self._migrated_version) + del self + + @staticmethod + def _compare_versions(new_version: str, current_version: str) -> bool: + """ + Compare two version strings to determine if the new version is greater than the current version. + + :param new_version: The new version string. + :type new_version: str + :param current_version: The current version string. + :type current_version: str + :return: True if the new version is greater than the current version, False otherwise. + :rtype: bool + """ + new_version_tuple: tuple[int, ...] = tuple(map(int, new_version.split('.'))) + current_version_tuple: tuple[int, ...] = tuple(map(int, current_version.split('.'))) + return new_version_tuple > current_version_tuple diff --git a/settings/_settings.py b/settings/_settings.py new file mode 100644 index 0000000..0ba3510 --- /dev/null +++ b/settings/_settings.py @@ -0,0 +1,190 @@ +""" +AutoHackerPDA: A program that automates the process of "Hacking PDA" in the game "Thief Simulator". +Copyright (C) 2024 Lukas Krahbichler + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Contact me: l.krahbichler@proton.me + +File: settings/_settings.py +Created: 03/08/2024 +""" + +################################################## +# Imports # +################################################## + +from __future__ import annotations + +from typing import Any, Dict, TextIO +from os.path import isfile +from pathlib import Path +from json import dump + +from ._migrator import Migrator + + +################################################## +# Code # +################################################## + +class _Settings: + """ + A class for managing settings. + + Attributes: + __path (str): The path where the settings file is located. + __file (str): The name of the settings file. + __default_file (str): The name of the default settings file. + __default_values (dict[Any, Any]): The default settings values. + __values (dict[Any, Any]): The current settings values. + __migrator (Migrator | None): The migrator instance for handling settings migrations. + """ + __path: str + __file: str + __default_file: str + __migrator: Migrator | None + __default_values: dict[Any, Any] + __values: dict[Any, Any] + + def __init__( + self, + path: str = "settings", + file: str = "settings.json", + default_file: str = "default.json" + ) -> None: + """ + Initializes the settings object. + + :param path: The path to the directory where the settings file is located. + :type path: str + :param file: The name of the settings file. + :type file: str + :param default_file: The name of the default settings file. + :type default_file: str + :return: None + :rtype: None + """ + self.__path = f"{path.rstrip('/')}/" + self.__file = file + self.__default_file = default_file + + self.__migrator = Migrator() + + self.__values = {} + self.__default_values = {} + + self.__load_defaults() + self.__load_values() + + self.__migrator = None + + @property + def values(self) -> Dict[Any, Any]: + """ + Returns the current settings values. + + :return: A dictionary containing the values. + :rtype: Dict[Any, Any] + """ + return self.__values + + def __setitem__(self, key: Any, value: Any) -> None: + """ + Set the value of the specified key. + + :param key: The key of the value to be set. + :type key: Any + :param value: The new value to set. + :type value: Any + :return: None + :rtype: None + """ + self.__values[key] = value + self.write() + + def __getitem__(self, item: Any) -> Any: + """ + Retrieve the value associated with the specified item. + + :param item: The item to be retrieved. + :type item: Any + :return: The value associated with the item. + :rtype: Any + """ + return self.__values.get(item) + + def __load_defaults(self) -> None: + """ + Reads the default values from the 'default.json' file. + + :return: None + :rtype: None + """ + if isfile(f"{self.__path}{self.__default_file}"): + file: TextIO + with open(f"{self.__path}{self.__default_file}") as file: + self.__default_values = self.__migrator.migrate_settings(json.load(file)) + else: + Path(self.__path).mkdir(parents=True, exist_ok=True) + self.__default_values = {} + + def __load_values(self) -> None: + """ + Reads the current settings values from the settings file, if it exists. + + :return: None + :rtype: None + """ + if isfile(f"{self.__path}{self.__file}"): + file: TextIO + with open(f"{self.__path}{self.__file}") as file: + self.__values = self.__migrator.migrate_settings(json.load(file)) + else: + Path(self.__path).mkdir(parents=True, exist_ok=True) + self.__values = self.__default_values.copy() + self.write() + + def write(self) -> None: + """ + Writes the current settings values to the settings file. + + :return: None + :rtype: None + """ + try: + file: TextIO + with open(f"{self.__path}{self.__file}", 'w') as file: + dump(self.values, file, indent=4) + except NameError: + pass + + def __del__(self) -> None: + """ + Writes the current settings values to the settings file before deletion. + + :return: None + :rtype: None + """ + self.write() + try: + file: TextIO + with open(f"{self.__path}{self.__default_file}", "w") as file: + dump(self.__default_values, file, indent=4) + except NameError: + pass + del self + + +Settings = _Settings() diff --git a/terminal_interaction.py b/terminal_interaction.py index 37fee18..4f1a793 100644 --- a/terminal_interaction.py +++ b/terminal_interaction.py @@ -17,7 +17,7 @@ Contact me: l.krahbichler@proton.me -File: /terminal_interaction.py +File: terminal_interaction.py Created: 01/08/2024 """ @@ -41,17 +41,11 @@ class Licensing: Attributes: __warranty (str): Stores the warranty information extracted from the license file. __conditions (str): Stores the terms and conditions extracted from the license file. - - Methods: - show_information() - wait_for_commands() - - Please note that this documentation is in reStructuredText format, which is commonly used for Python docstrings. """ - + __warranty: str = "" __conditions: str = "" - + @classmethod def __get_license_file(cls) -> None: """ @@ -62,22 +56,22 @@ def __get_license_file(cls) -> None: :rtype: None """ file: TextIO - with open("LICENSE", "r") as file: + with open("LICENSE") as file: content: str = file.read() - + # Regex for extracting "TERMS AND CONDITIONS" section conditions_pattern: Pattern[str] = compile(r"TERMS AND CONDITIONS.*?END OF TERMS AND CONDITIONS", DOTALL) conditions_match = conditions_pattern.search(content) if conditions_match: - cls.__conditions = conditions_match.group(0) - + cls.__conditions = conditions_match.group() + # Regex for extracting "Disclaimer of Warranty" section warranty_pattern = compile(r"15\. Disclaimer of Warranty\..*?(?=16\. Limitation of Liability\.)", DOTALL) warranty_match = warranty_pattern.search(content) if warranty_match: - cls.__warranty = warranty_match.group(0) - + cls.__warranty = warranty_match.group() + @staticmethod def show_information() -> None: """ @@ -87,10 +81,10 @@ def show_information() -> None: :rtype: None """ print(" Copyright (C) 2024 Lukas Krahbichler") - print("This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.") + print("This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.") # noqa print("This is free software, and you are welcome to redistribute it") - print("under certain conditions; type `show c' for details.") - + print("under certain conditions; type `show c' for details.") # noqa + @classmethod def wait_for_commands(cls) -> None: """ @@ -100,14 +94,13 @@ def wait_for_commands(cls) -> None: :rtype: None """ cls.__get_license_file() - + while True: - command = input("> ") - match command.lower(): - case "show w": - print(cls.__warranty) - case "show c": - print(cls.__conditions) + command = input("> ").lower() + if command == "show w": + print(cls.__warranty) + elif command == "show c": + print(cls.__conditions) # Example usage diff --git a/test/__init__.py b/test/__init__.py index 89682c3..be7cd6a 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -20,10 +20,3 @@ File: screen/test/__init__.py Created: 03/08/2024 """ - -import os -import sys - -PROJECT_PATH = os.getcwd() -SOURCE_PATH = os.path.join(PROJECT_PATH, "src") -sys.path.append(SOURCE_PATH) diff --git a/test/_test_mouse.py b/test/_test_mouse.py deleted file mode 100644 index e1ecf10..0000000 --- a/test/_test_mouse.py +++ /dev/null @@ -1,85 +0,0 @@ -""" -AutoHackerPDA: A program that automates the process of "Hacking PDA" in the game "Thief Simulator". -Copyright (C) 2024 Lukas Krahbichler - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . - -Contact me: l.krahbichler@proton.me - -File: screen/_test_mouse.py -Created: 03/08/2024 -""" - -################################################## -# Imports # -################################################## - -import unittest -from unittest.mock import patch, call -from screen._mouse import Mouse, MouseEvent # noqa - - -################################################## -# Code # -################################################## - -class TestMouse(unittest.TestCase): - - @patch('screen._mouse.pyautogui.size', return_value=(1920, 1080)) - @patch('screen._mouse.autoit.mouse_click') - @patch('screen._mouse.sleep', return_value=None) - def test_click_on_circuits(self, mock_sleep, mock_mouse_click, mock_pyautogui_size): - # Define the mouse events - circuits = [ - MouseEvent(row=0, col=0, clicks=1), - MouseEvent(row=1, col=1, clicks=2), - MouseEvent(row=2, col=2, clicks=3) - ] - - # Call the method - Mouse.click_on_circuits(circuits) - - # Expected positions - width, height = mock_pyautogui_size.return_value - x_left = (width // 3) + 40 - x_right = int(width / 1.536) - 30 - y_top = int(height / 3.85714) - y_bot = int(height / 1.30120) - - field_width = (x_right - x_left) // 8 - field_height = (y_bot - y_top) // 8 - - expected_positions = [ - (x_left + (field_width * 0) + (field_width // 2), y_top + (field_height * 0) + (field_height // 2)), - (x_left + (field_width * 1) + (field_width // 2), y_top + (field_height * 1) + (field_height // 2)), - (x_left + (field_width * 2) + (field_width // 2), y_top + (field_height * 2) + (field_height // 2)) - ] - - # Check if mouse_click was called with the correct positions and number of times - expected_calls = [ - call(x=expected_positions[0][0], y=expected_positions[0][1], speed=0), - call(x=expected_positions[1][0], y=expected_positions[1][1], speed=0), - call(x=expected_positions[1][0], y=expected_positions[1][1], speed=0), - call(x=expected_positions[2][0], y=expected_positions[2][1], speed=0), - call(x=expected_positions[2][0], y=expected_positions[2][1], speed=0), - call(x=expected_positions[2][0], y=expected_positions[2][1], speed=0) - ] - - mock_mouse_click.assert_has_calls(expected_calls, any_order=False) - self.assertEqual(mock_mouse_click.call_count, len(expected_calls)) - self.assertEqual(mock_sleep.call_count, len(expected_calls)) - - -if __name__ == '__main__': - unittest.main() diff --git a/test/screen/__init__.py b/test/screen/__init__.py new file mode 100644 index 0000000..5c3bf07 --- /dev/null +++ b/test/screen/__init__.py @@ -0,0 +1,22 @@ +""" +AutoHackerPDA: A program that automates the process of "Hacking PDA" in the game "Thief Simulator". +Copyright (C) 2024 Lukas Krahbichler + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Contact me: l.krahbichler@proton.me + +File: test/screen/__init__.py +Created: 03/08/2024 +""" diff --git a/test/screen/_test_mouse.py b/test/screen/_test_mouse.py new file mode 100644 index 0000000..e180f20 --- /dev/null +++ b/test/screen/_test_mouse.py @@ -0,0 +1,176 @@ +""" +AutoHackerPDA: A program that automates the process of "Hacking PDA" in the game "Thief Simulator". +Copyright (C) 2024 Lukas Krahbichler + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Contact me: l.krahbichler@proton.me + +File: screen/_test_mouse.py +Created: 03/08/2024 +""" + +################################################## +# Imports # +################################################## + +import unittest + +from unittest.mock import call, patch +from os import remove, removedirs + +# noinspection PyProtectedMember +from screen._mouse import Mouse, MouseEvent +# noinspection PyProtectedMember +from settings._settings import _Settings + + +################################################## +# Code # +################################################## + +class TestMouse(unittest.TestCase): + """ + Unit tests for the Mouse class in the screen._mouse module. + """ + + def tearDown(self) -> None: + """ + Cleans up the test environment by removing the "settings" directory. + If the directory does not exist, no action is taken. + + :return: None + :rtype: None + """ + file: str + for file in ["settings/version", "settings/settings.json", "settings/default.json", "version"]: + try: + remove(file) + except FileNotFoundError: + pass + try: + removedirs("settings") + except OSError: + pass + + @patch('screen._mouse.size', return_value=(1920, 1080)) + @patch('screen._mouse.mouse_click') + @patch('screen._mouse.sleep', return_value=None) + def test_click_on_circuits( + self, + mock_sleep: unittest.mock.MagicMock, + mock_mouse_click: unittest.mock.MagicMock, + mock_size: unittest.mock.MagicMock + ) -> None: + """ + Tests the Mouse.click_on_circuits() method to ensure it performs + the expected mouse clicks based on provided MouseEvent instances. + + :param mock_sleep: Mock object for the sleep function. + :type: unittest.mock.MagicMock + :param mock_mouse_click: Mock object for the mouse_click function. + :type: unittest.mock.MagicMock + :param mock_size: Mock object for the screen size function. + :type: unittest.mock.MagicMock + :return: None + :rtype: None + """ + # Mock settings instance + settings = _Settings() + + with patch('screen._mouse.Settings', settings): + # Define the mouse events + circuits = [ + MouseEvent(row=0, col=0, clicks=1), + MouseEvent(row=1, col=1, clicks=2), + MouseEvent(row=2, col=2, clicks=3) + ] + + # Call the method + Mouse.click_on_circuits(circuits) + + # Expected positions based on the calculations in the click_on_circuits method + width, height = mock_size.return_value + x_left = (width // 3) + 40 + x_right = int(width / 1.536) - 30 + y_top = int(height / 3.85714) + y_bot = int(height / 1.30120) + + field_width = (x_right - x_left) // 8 + field_height = (y_bot - y_top) // 8 + + expected_positions = [ + (x_left + (field_width * 0) + (field_width // 2), y_top + (field_height * 0) + (field_height // 2)), + (x_left + (field_width * 1) + (field_width // 2), y_top + (field_height * 1) + (field_height // 2)), + (x_left + (field_width * 2) + (field_width // 2), y_top + (field_height * 2) + (field_height // 2)) + ] + + # Check if mouse_click was called with the correct positions and number of times + expected_calls = [ + call(x=expected_positions[0][0], y=expected_positions[0][1], speed=0), + call(x=expected_positions[1][0], y=expected_positions[1][1], speed=0), + call(x=expected_positions[1][0], y=expected_positions[1][1], speed=0), + call(x=expected_positions[2][0], y=expected_positions[2][1], speed=0), + call(x=expected_positions[2][0], y=expected_positions[2][1], speed=0), + call(x=expected_positions[2][0], y=expected_positions[2][1], speed=0) + ] + + mock_mouse_click.assert_has_calls(expected_calls) + self.assertEqual(mock_mouse_click.call_count, len(expected_calls)) + self.assertEqual(mock_sleep.call_count, len(expected_calls)) + + @patch('screen._mouse.size', return_value=(1920, 1080)) + @patch('screen._mouse.mouse_click') + @patch('screen._mouse.sleep', return_value=None) + def test_click_on_circuits_with_no_delay( + self, + mock_sleep: unittest.mock.MagicMock, + mock_mouse_click: unittest.mock.MagicMock, + _mock_size: unittest.mock.MagicMock + ) -> None: + """ + Tests the Mouse.click_on_circuits() method with a click delay of 0.0 seconds. + Ensures that no sleep is called and the correct number of mouse clicks are performed. + + :param mock_sleep: Mock object for the sleep function. + :type: unittest.mock.MagicMock + :param mock_mouse_click: Mock object for the mouse_click function. + :type: unittest.mock.MagicMock + :param _mock_size: Mock object for the screen size function. + :type: unittest.mock.MagicMock + :return: None + :rtype: None + """ + # Mock settings instance + settings = _Settings() + settings["mouse"] = {"click_delay": 0.0} + + with patch('screen._mouse.Settings', settings): + # Define the mouse events + circuits = [ + MouseEvent(row=0, col=0, clicks=1), + MouseEvent(row=1, col=1, clicks=2), + MouseEvent(row=2, col=2, clicks=3) + ] + + # Call the method + Mouse.click_on_circuits(circuits) + + # Check if mouse_click was called with the correct positions and number of times + self.assertEqual(mock_mouse_click.call_count, 6) + self.assertEqual(mock_sleep.call_count, 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/screen/_test_screen_input.py b/test/screen/_test_screen_input.py new file mode 100644 index 0000000..065c9c1 --- /dev/null +++ b/test/screen/_test_screen_input.py @@ -0,0 +1,171 @@ +""" +AutoHackerPDA: A program that automates the process of "Hacking PDA" in the game "Thief Simulator". +Copyright (C) 2024 Lukas Krahbichler + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Contact me: l.krahbichler@proton.me + +File: test/screen/_test_screen_input.py +Created: 04/08/2024 +""" + +################################################## +# Imports # +################################################## + +import numpy as np +import unittest + +from unittest.mock import patch, MagicMock +from os import remove, removedirs + +# noinspection PyProtectedMember +from screen._screen_input import ScreenInput + + +################################################## +# Code # +################################################## + +class TestScreenInput(unittest.TestCase): + """ + Unit tests for the ScreenInput class in the screen._screen_input module. + """ + + def tearDown(self) -> None: + """ + Cleans up the test environment by removing the "settings" directory. + If the directory does not exist, no action is taken. + + :return: None + :rtype: None + """ + file: str + for file in ["settings/version", "settings/settings.json", "settings/default.json", "version"]: + try: + remove(file) + except FileNotFoundError: + pass + try: + removedirs("settings") + except OSError: + pass + + @patch('screen._screen_input.screenshot', return_value=np.zeros((1080, 1920, 3), dtype=np.uint8)) + @patch('screen._screen_input.cv2.cvtColor', return_value=np.zeros((1080, 1920), dtype=np.uint8)) + @patch('screen._screen_input.array', return_value=np.zeros((1080, 1920, 3), dtype=np.uint8)) + @patch('screen._screen_input.time', return_value=1000) + def test_take_screenshot( + self, + _mock_time: MagicMock, + _mock_array: MagicMock, + _mock_cv2_cvt_color: MagicMock, + _mock_screenshot: MagicMock + ) -> None: + """ + Tests the __take_screenshot method to ensure it captures and processes the screenshot correctly. + + :param _mock_time: Mock object for the time function. + :type: MagicMock + :param _mock_array: Mock object for the numpy array function. + :type: MagicMock + :param _mock_cv2_cvt_color: Mock object for the cv2.cvtColor function. + :type: MagicMock + :param _mock_screenshot: Mock object for the screenshot function. + :type: MagicMock + :return: None + :rtype: None + """ + result = ScreenInput._take_screenshot() + + self.assertTrue(result) + self.assertIsNotNone(ScreenInput._screenshot) + self.assertEqual(ScreenInput._screenshot_timestamp, 1000) + + @patch('screen._screen_input.ScreenInput._take_screenshot', return_value=True) + @patch('screen._screen_input.ScreenInput._screenshot', np.zeros((1080, 1920), dtype=np.uint8)) + def test_pda_screen_crop(self, _mock_take_screenshot: MagicMock) -> None: + """ + Tests the __pda_screen_crop method to ensure it crops the screenshot correctly. + + :param _mock_take_screenshot: Mock object for the __take_screenshot method. + :type: MagicMock + :return: None + :rtype: None + """ + result = ScreenInput._pda_screen_crop() + + self.assertIsNotNone(result) + self.assertEqual(result.shape[0], int(1080 / 1.30120) - int(1080 / 3.85714)) + self.assertEqual(result.shape[1], int(1920 / 1.536) - (1920 // 3)) + + @patch('screen._screen_input.ScreenInput._take_screenshot') + @patch('screen._screen_input.ScreenInput._pda_screen_crop', return_value=np.zeros((550, 600), dtype=np.uint8)) + def test_circuits_crop(self, _mock_pda_screen_crop: MagicMock, _mock_take_screenshot: MagicMock) -> None: + """ + Tests the circuits_crop method to ensure it splits the cropped PDA screen into smaller segments correctly. + + :param _mock_pda_screen_crop: Mock object for the __pda_screen_crop method. + :type: MagicMock + :param _mock_take_screenshot: Mock object for the _take_screenshot method. + :type: MagicMock + :return: None + :rtype: None + """ + result = ScreenInput.circuits_crop() + + self.assertIsNotNone(result) + self.assertEqual(len(result), 8) + self.assertEqual(len(result[0]), 8) + self.assertEqual(result[0][0].shape, (550 // 8, 530 // 8)) + + @patch('screen._screen_input.ScreenInput._take_screenshot') + @patch('screen._screen_input.ScreenInput.circuits_crop', + return_value=[[np.zeros((100, 100), dtype=np.uint8) for _ in range(20)] for _ in range(8)]) + def test_io_arrows_crop(self, _mock_circuits_crop: MagicMock, _mock_take_screenshot: MagicMock) -> None: + """ + Tests the io_arrows_crop method to ensure it extracts the input/output arrows from the circuits segments. + + :param _mock_circuits_crop: Mock object for the circuits_crop method. + :type: MagicMock + :return: None + :rtype: None + """ + result = ScreenInput.io_arrows_crop() + + self.assertIsNotNone(result) + self.assertEqual(len(result), 2) + self.assertEqual(len(result[0]), 8) + self.assertEqual(result[0][0].shape, (100, 100)) + self.assertEqual(result[1][0].shape, (100, 100)) + + def test_reset(self) -> None: + """ + Tests the reset method to ensure it clears the cached screenshot and timestamp. + + :return: None + :rtype: None + """ + ScreenInput._ScreenInput__screenshot = np.zeros((1080, 1920), dtype=np.uint8) + ScreenInput._ScreenInput__screenshot_timestamp = 1000.0 + + ScreenInput.reset() + + self.assertIsNone(ScreenInput._screenshot) + self.assertEqual(ScreenInput._screenshot_timestamp, 0.0) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/settings/__init__.py b/test/settings/__init__.py new file mode 100644 index 0000000..53cd04a --- /dev/null +++ b/test/settings/__init__.py @@ -0,0 +1,22 @@ +""" +AutoHackerPDA: A program that automates the process of "Hacking PDA" in the game "Thief Simulator". +Copyright (C) 2024 Lukas Krahbichler + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Contact me: l.krahbichler@proton.me + +File: test/settings/__init__.py +Created: 04/08/2024 +""" diff --git a/test/settings/_test_migrator.py b/test/settings/_test_migrator.py new file mode 100644 index 0000000..4ce14e5 --- /dev/null +++ b/test/settings/_test_migrator.py @@ -0,0 +1,176 @@ +""" +AutoHackerPDA: A program that automates the process of "Hacking PDA" in the game "Thief Simulator". +Copyright (C) 2024 Lukas Krahbichler + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Contact me: l.krahbichler@proton.me + +File: test/settings/_test_migrator.py +Created: 04/08/2024 +""" + +################################################## +# Imports # +################################################## + +import unittest + +from unittest.mock import patch, mock_open, MagicMock +from os import remove, removedirs +from typing import Any + +# noinspection PyProtectedMember +from settings._migrator import Migrator + + +################################################## +# Code # +################################################## + +class TestMigrator(unittest.TestCase): + """ + Unit tests for the Migrator class in the settings._migrator module. + """ + + @classmethod + def tearDownClass(cls) -> None: + """ + Cleans up the test environment by removing the "settings" directory. + + :return: None + :rtype: None + """ + file: str + for file in ["settings/version", "settings/settings.json", "settings/default.json", "version"]: + try: + remove(file) + except FileNotFoundError: + pass + try: + removedirs("settings") + except OSError: + pass + + # noinspection PyShadowingNames + @patch('settings._migrator.open', new_callable=mock_open, read_data="0.0.0") + @patch('settings._migrator.path.isfile', return_value=True) + def test_init( + self, + _mock_isfile: MagicMock, + mock_open: MagicMock + ) -> None: + """ + Tests the initialization of the Migrator class. + + :param _mock_isfile: Mock object for the path.isfile function. + :type: MagicMock + :param mock_open: Mock object for the open function with read_data parameter. + :type: MagicMock + :return: None + :rtype: None + """ + migrator: Migrator = Migrator() + self.assertEqual(migrator._version, "0.0.0") + mock_open.assert_called_with("settings/version") + + def test_migrate_1_0_0(self) -> None: + """ + Tests migration of settings from version 1.0.0 to the latest version. + + :return: None + :rtype: None + """ + settings: dict[Any, Any] = {} + expected_settings = { + "mouse": { + "click_delay": 0.02 + } + } + migrated_settings: dict[Any, Any] = Migrator._migrate_1_0_0(settings) + self.assertEqual(migrated_settings, expected_settings) + + # noinspection PyShadowingNames + @patch('settings._migrator.open', new_callable=mock_open, read_data="0.0.0") + @patch('settings._migrator.path.isfile', return_value=True) + @patch('settings._migrator.Migrator._migrate_1_0_0', side_effect=Migrator._migrate_1_0_0) + def test_migrate_settings( + self, + mock_migrate_1_0_0: MagicMock, + _mock_isfile: MagicMock, + mock_open: MagicMock + ) -> None: + """ + Tests the migrate_settings method of the Migrator class. + + :param mock_migrate_1_0_0: Mock object for the _migrate_1_0_0 method. + :type: MagicMock + :param _mock_isfile: Mock object for the path.isfile function. + :type: MagicMock + :param mock_open: Mock object for the open function. + :type: MagicMock + :return: None + :rtype: None + """ + migrator: Migrator = Migrator() + settings: dict[Any, Any] = {} + expected_settings = { + "mouse": { + "click_delay": 0.02 + } + } + migrated_settings: dict[Any, Any] = migrator.migrate_settings(settings) + self.assertEqual(migrated_settings, expected_settings) + mock_migrate_1_0_0.assert_called_once() + + def test_compare_versions(self) -> None: + """ + Tests the _compare_versions method of the Migrator class. + + :return: None + :rtype: None + """ + self.assertTrue(Migrator._compare_versions("1.0.0", "0.0.0")) + self.assertFalse(Migrator._compare_versions("0.0.0", "1.0.0")) + self.assertFalse(Migrator._compare_versions("1.0.0", "1.0.0")) + + # noinspection PyShadowingNames + @patch('settings._migrator.open', new_callable=mock_open) + @patch('settings._migrator.path.isfile', return_value=True) + def test_del( + self, + _mock_isfile: MagicMock, + mock_open: MagicMock + ) -> None: + """ + Tests the __del__ method of the Migrator class. + + :param _mock_isfile: Mock object for the path.isfile function. + :type: MagicMock + :param mock_open: Mock object for the open function. + :type: MagicMock + :return: None + :rtype: None + """ + mock_open().read.return_value = "0.0.0" + + migrator: Migrator = Migrator() + migrator._migrated_version = "1.0.0" + del migrator + mock_open.assert_any_call("settings/version", "w") + mock_open().write.assert_called_with("1.0.0") + + +if __name__ == '__main__': + unittest.main() diff --git a/test/settings/_test_settings.py b/test/settings/_test_settings.py new file mode 100644 index 0000000..241cb9e --- /dev/null +++ b/test/settings/_test_settings.py @@ -0,0 +1,198 @@ +""" +AutoHackerPDA: A program that automates the process of "Hacking PDA" in the game "Thief Simulator". +Copyright (C) 2024 Lukas Krahbichler + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Contact me: l.krahbichler@proton.me + +File: test/settings/_test_settings.py +Created: 04/08/2024 +""" + +################################################## +# Imports # +################################################## + +import unittest + +from unittest.mock import patch, mock_open, MagicMock +from os import remove, removedirs + +# noinspection PyProtectedMember +from settings._settings import _Settings + + +################################################## +# Code # +################################################## + +class TestSettings(unittest.TestCase): + @classmethod + def tearDownClass(cls) -> None: + """ + Cleans up the test environment by removing the "settings" directory. + If the directory does not exist, no action is taken. + + :return: None + :rtype: None + """ + file: str + for file in ["settings/version", "settings/settings.json", "settings/default.json", "version"]: + try: + remove(file) + except FileNotFoundError: + pass + try: + removedirs("settings") + except OSError: + pass + + # noinspection PyShadowingNames + @patch('settings._settings.open', new_callable=mock_open, read_data='{"key": "value"}') + @patch('settings._settings.isfile', return_value=True) + @patch('settings._settings.Path.mkdir') + @patch('settings._settings.Migrator.migrate_settings', side_effect=lambda x: x) + def test_load_defaults( + self, + mock_migrate: MagicMock, + _mock_mkdir: MagicMock, + _mock_isfile: MagicMock, + mock_open: MagicMock + ) -> None: + """ + Tests loading default settings from the default configuration file. + + :param mock_migrate: Mock object for the Migrator.migrate_settings function. + :type: MagicMock + :param _mock_mkdir: Mock object for the Path.mkdir function. + :type: MagicMock + :param _mock_isfile: Mock object for the isfile function. + :type: MagicMock + :param mock_open: Mock object for the open function. + :type: MagicMock + :return: None + :rtype: None + """ + settings: _Settings = _Settings() + self.assertEqual(settings.values, {"key": "value"}) + mock_open.assert_any_call("settings/default.json") + mock_migrate.assert_called() + + # noinspection PyShadowingNames + @patch('settings._settings.open', new_callable=mock_open, read_data='{"key": "value"}') + @patch('settings._settings.isfile', return_value=True) + @patch('settings._settings.Path.mkdir') + @patch('settings._settings.Migrator.migrate_settings', side_effect=lambda x: x) + def test_load_values( + self, + mock_migrate: MagicMock, + _mock_mkdir: MagicMock, + _mock_isfile: MagicMock, + mock_open: MagicMock + ) -> None: + """ + Tests loading settings from the settings configuration file. + + :param mock_migrate: Mock object for the Migrator.migrate_settings function. + :type: MagicMock + :param _mock_mkdir: Mock object for the Path.mkdir function. + :type: MagicMock + :param _mock_isfile: Mock object for the isfile function. + :type: MagicMock + :param mock_open: Mock object for the open function. + :type: MagicMock + :return: None + :rtype: None + """ + settings: _Settings = _Settings() + self.assertEqual(settings.values, {"key": "value"}) + mock_open.assert_any_call("settings/settings.json") + mock_migrate.assert_called() + + # noinspection PyShadowingNames + @patch('settings._settings.open', new_callable=mock_open) + @patch('settings._settings.json.dump') + @patch('settings._settings.isfile', return_value=False) + @patch('settings._settings.Path.mkdir') + @patch('settings._settings.Migrator.migrate_settings', side_effect=lambda x: x) + def test_write( + self, + _mock_migrate: MagicMock, + _mock_mkdir: MagicMock, + _mock_isfile: MagicMock, + mock_json_dump: MagicMock, + mock_open: MagicMock + ) -> None: + """ + Tests writing settings to the configuration file. + + :param _mock_migrate: Mock object for the Migrator.migrate_settings function. + :type: MagicMock + :param _mock_mkdir: Mock object for the Path.mkdir function. + :type: MagicMock + :param _mock_isfile: Mock object for the isfile function. + :type: MagicMock + :param mock_json_dump: Mock object for the json.dump function. + :type: MagicMock + :param mock_open: Mock object for the open function. + :type: MagicMock + :return: None + :rtype: None + """ + mock_open().read.side_effect = ['{"key": "value"}'] + + settings: _Settings = _Settings() + settings["key"] = "new_value" + mock_open.assert_any_call("settings/settings.json", "w") + mock_json_dump.assert_called_with({"key": "new_value"}, mock_open(), indent=4) + + # noinspection PyShadowingNames + @patch('settings._settings.open', new_callable=mock_open) + @patch('settings._settings.json.dump') + @patch('settings._settings.Path.mkdir') + @patch('settings._settings.Migrator.migrate_settings', side_effect=lambda x: x) + def test_del( + self, + _mock_migrate: MagicMock, + _mock_mkdir: MagicMock, + mock_json_dump: MagicMock, + mock_open: MagicMock + ) -> None: + """ + Tests deletion of settings and ensures settings files are written. + + :param _mock_migrate: Mock object for the Migrator.migrate_settings function. + :type: MagicMock + :param _mock_mkdir: Mock object for the Path.mkdir function. + :type: MagicMock + :param mock_json_dump: Mock object for the json.dump function. + :type: MagicMock + :param mock_open: Mock object for the open function. + :type: MagicMock + :return: None + :rtype: None + """ + mock_open().read.side_effect = ['{"key": "value"}', '{"key": "value"}'] + + # noinspection PyUnusedLocal + settings: _Settings = _Settings() + settings.__del__() + mock_open.assert_any_call("settings/settings.json", "w") + mock_open.assert_any_call("settings/default.json", "w") + self.assertTrue(mock_json_dump.called) + + +if __name__ == '__main__': + unittest.main() diff --git a/timer.py b/timer.py index 7c048ce..bb999f3 100644 --- a/timer.py +++ b/timer.py @@ -17,7 +17,7 @@ Contact me: l.krahbichler@proton.me -File: /timer.py +File: timer.py Created: 03/08/2024 """ @@ -25,7 +25,7 @@ # Imports # ################################################## -from typing import Any, Callable +from typing import Any, Callable, Type from time import time @@ -33,6 +33,36 @@ # Code # ################################################## +def apply_to_methods(decorator: Callable[[Callable[..., Any]], Callable[..., Any]]) -> Callable[[Type], Type]: + """ + Class decorator that applies the given decorator to all methods in the class. + + :param decorator: The decorator to apply to each method. + :type decorator: Callable[[Callable[..., Any]], Callable[..., Any]] + :return: The class with decorated methods. + :rtype: Callable[[Type], Type] + """ + + def class_decorator(cls: Type) -> Type: + """ + Apply a decorator to all methods of a class. + + :return: The class decorator. + :rtype: Callable[[Type], Type] + """ + # Iterate through all attributes of the class + for attr_name in dir(cls): + attr = getattr(cls, attr_name) + # Check if the attribute is a method + if callable(attr) and not attr_name.startswith("__"): + # Decorate the method + decorated_method = decorator(attr) + setattr(cls, attr_name, decorated_method) + return cls + + return class_decorator + + def timer(func: Callable[..., Any]) -> Callable[..., Any]: """ A decorator function that measures the execution time of a given function. From a2c81af68b370facd2f082b13a81085499944459 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 4 Aug 2024 04:12:51 +0200 Subject: [PATCH 06/10] Try fix --- .github/workflows/run-tests.yml | 4 +++- settings/_settings.py | 6 +++--- test/settings/_test_settings.py | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 2f2ec6a..46d5867 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -4,7 +4,7 @@ on: pull_request jobs: test: - runs-on: ubuntu-latest + runs-on: windows-latest steps: - name: Checkout code @@ -24,6 +24,8 @@ jobs: run: sudo apt-get install -y xvfb - name: Run tests + env: + PYTHONPATH: ${{ github.workspace }} run: | sudo Xvfb :99 -ac -screen 0 1920x1080x24 & export DISPLAY=:99 diff --git a/settings/_settings.py b/settings/_settings.py index 0ba3510..0cb1634 100644 --- a/settings/_settings.py +++ b/settings/_settings.py @@ -30,7 +30,7 @@ from typing import Any, Dict, TextIO from os.path import isfile from pathlib import Path -from json import dump +from json import dump, load from ._migrator import Migrator @@ -135,7 +135,7 @@ def __load_defaults(self) -> None: if isfile(f"{self.__path}{self.__default_file}"): file: TextIO with open(f"{self.__path}{self.__default_file}") as file: - self.__default_values = self.__migrator.migrate_settings(json.load(file)) + self.__default_values = self.__migrator.migrate_settings(load(file)) else: Path(self.__path).mkdir(parents=True, exist_ok=True) self.__default_values = {} @@ -150,7 +150,7 @@ def __load_values(self) -> None: if isfile(f"{self.__path}{self.__file}"): file: TextIO with open(f"{self.__path}{self.__file}") as file: - self.__values = self.__migrator.migrate_settings(json.load(file)) + self.__values = self.__migrator.migrate_settings(load(file)) else: Path(self.__path).mkdir(parents=True, exist_ok=True) self.__values = self.__default_values.copy() diff --git a/test/settings/_test_settings.py b/test/settings/_test_settings.py index 241cb9e..a6db6df 100644 --- a/test/settings/_test_settings.py +++ b/test/settings/_test_settings.py @@ -123,7 +123,7 @@ def test_load_values( # noinspection PyShadowingNames @patch('settings._settings.open', new_callable=mock_open) - @patch('settings._settings.json.dump') + @patch('settings._settings.dump') @patch('settings._settings.isfile', return_value=False) @patch('settings._settings.Path.mkdir') @patch('settings._settings.Migrator.migrate_settings', side_effect=lambda x: x) @@ -160,7 +160,7 @@ def test_write( # noinspection PyShadowingNames @patch('settings._settings.open', new_callable=mock_open) - @patch('settings._settings.json.dump') + @patch('settings._settings.dump') @patch('settings._settings.Path.mkdir') @patch('settings._settings.Migrator.migrate_settings', side_effect=lambda x: x) def test_del( From 820241da7d899dae001afd3c239e52b516aa8ae4 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 4 Aug 2024 04:17:16 +0200 Subject: [PATCH 07/10] Try fix --- .github/workflows/run-tests.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 46d5867..5e2d3b7 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,20 +13,16 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.12' # Use the appropriate version of Python + python-version: '3.12' - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt - - name: Install Xvfb - run: sudo apt-get install -y xvfb - - name: Run tests env: PYTHONPATH: ${{ github.workspace }} run: | - sudo Xvfb :99 -ac -screen 0 1920x1080x24 & - export DISPLAY=:99 + set PYTHONPATH=${{ github.workspace }} python -m unittest discover -s test -p "_test_*.py" From e2b75974b221f6043d93f34df4ae82dcc22b24bb Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 4 Aug 2024 04:19:53 +0200 Subject: [PATCH 08/10] Other fix? --- .github/workflows/run-tests.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 5e2d3b7..a6d8f8f 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,16 +13,21 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.12' + python-version: '3.12' # Use the appropriate version of Python - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt + - name: Set PYTHONPATH + run: | + echo PYTHONPATH=${{ github.workspace }} >> $GITHUB_ENV + + - name: Verify PYTHONPATH + run: | + echo %PYTHONPATH% + - name: Run tests - env: - PYTHONPATH: ${{ github.workspace }} run: | - set PYTHONPATH=${{ github.workspace }} python -m unittest discover -s test -p "_test_*.py" From ccb6816632520c4fd40129a35d1951d1327b827c Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 4 Aug 2024 04:22:42 +0200 Subject: [PATCH 09/10] Next fix? --- .github/workflows/run-tests.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index a6d8f8f..fc003d0 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -20,14 +20,12 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt - - name: Set PYTHONPATH - run: | - echo PYTHONPATH=${{ github.workspace }} >> $GITHUB_ENV - - - name: Verify PYTHONPATH - run: | - echo %PYTHONPATH% + - name: Install Xvfb (optional) + run: echo 'No Xvfb needed for Windows' - name: Run tests run: | + $env:PYTHONPATH = "${{ github.workspace }}" + echo "PYTHONPATH is set to: $env:PYTHONPATH" python -m unittest discover -s test -p "_test_*.py" + shell: pwsh From 963e563dbbb871a8c51b838f27c783ac626e9d62 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 4 Aug 2024 04:35:23 +0200 Subject: [PATCH 10/10] Try again folder fix --- .github/workflows/run-tests.yml | 8 +------- test/{screen => test_screen}/__init__.py | 0 test/{screen => test_screen}/_test_mouse.py | 0 test/{screen => test_screen}/_test_screen_input.py | 0 test/{settings => test_settings}/__init__.py | 0 test/{settings => test_settings}/_test_migrator.py | 0 test/{settings => test_settings}/_test_settings.py | 0 7 files changed, 1 insertion(+), 7 deletions(-) rename test/{screen => test_screen}/__init__.py (100%) rename test/{screen => test_screen}/_test_mouse.py (100%) rename test/{screen => test_screen}/_test_screen_input.py (100%) rename test/{settings => test_settings}/__init__.py (100%) rename test/{settings => test_settings}/_test_migrator.py (100%) rename test/{settings => test_settings}/_test_settings.py (100%) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index fc003d0..fff7cc9 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,19 +13,13 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.12' # Use the appropriate version of Python + python-version: '3.12' - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt - - name: Install Xvfb (optional) - run: echo 'No Xvfb needed for Windows' - - name: Run tests run: | - $env:PYTHONPATH = "${{ github.workspace }}" - echo "PYTHONPATH is set to: $env:PYTHONPATH" python -m unittest discover -s test -p "_test_*.py" - shell: pwsh diff --git a/test/screen/__init__.py b/test/test_screen/__init__.py similarity index 100% rename from test/screen/__init__.py rename to test/test_screen/__init__.py diff --git a/test/screen/_test_mouse.py b/test/test_screen/_test_mouse.py similarity index 100% rename from test/screen/_test_mouse.py rename to test/test_screen/_test_mouse.py diff --git a/test/screen/_test_screen_input.py b/test/test_screen/_test_screen_input.py similarity index 100% rename from test/screen/_test_screen_input.py rename to test/test_screen/_test_screen_input.py diff --git a/test/settings/__init__.py b/test/test_settings/__init__.py similarity index 100% rename from test/settings/__init__.py rename to test/test_settings/__init__.py diff --git a/test/settings/_test_migrator.py b/test/test_settings/_test_migrator.py similarity index 100% rename from test/settings/_test_migrator.py rename to test/test_settings/_test_migrator.py diff --git a/test/settings/_test_settings.py b/test/test_settings/_test_settings.py similarity index 100% rename from test/settings/_test_settings.py rename to test/test_settings/_test_settings.py