diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..e05fdb254 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,25 @@ +name: Bug Report +description: File a bug report +body: + - type: textarea + id: details + attributes: + label: Details + description: Try to be as detailed as possible, what happened? What did you expect to happen? What steps can we take to reproduce the issue? + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: What version are you using? + placeholder: v0.4.0 + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs + description: Check latest logs (logs directory) for any errors, if information is relevant, please include it here. + validations: + required: true \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 106256da6..09f486d55 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,37 +9,16 @@ jobs: name: "Build Koolo binary" runs-on: windows-2022 steps: - # Remove this once Windows image get fixed - - name: Remove Strawberry Perl from PATH - run: | - $env:PATH = $env:PATH -replace "C:\\Strawberry\\c\\bin;", "" - "PATH=$env:PATH" | Out-File -FilePath $env:GITHUB_ENV -Append - rm C:\mingw64 -force -r - - name: "Install CMake" - run: | - choco install -y --force cmake 7zip.install wget - - name: "Install MinGW" - run: | - wget -nv https://download.qt.io/development_releases/prebuilt/mingw_64/x86_64-8.1.0-release-posix-seh-rt_v6-rev0.7z - 7z x -aoa -oC: x86_64-8.1.0-release-posix-seh-rt_v6-rev0.7z - "C:\mingw64\bin" >> $env:GITHUB_PATH - name: "Checkout" uses: actions/checkout@v4 - name: "Setup Go" - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: - go-version: '1.21' - - name: "Prepare env" - run: mkdir C:\go\pkg - - name: "Building OpenCV" - continue-on-error: true - env: - GOPATH: C:\go - run: .\prepare_env.bat + go-version: '1.23' - name: "Building Koolo artifacts" env: GOPATH: C:\go - run: .\build.bat + run: .\build.bat "${{ github.ref_name }}" - name: "Packing the release" run: 7z a -tzip koolo_${{ github.ref_name }}.zip .\build\* - name: Release diff --git a/.gitignore b/.gitignore index 472a04353..2157437b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,13 @@ /.idea +/.vscode +/.github +/bin /build /stats /screenshots -/log.txt -/config/*.yaml -/config/*.yml \ No newline at end of file +/logs + +/config/* +!/config/template +!/config/koolo.yaml.dist +!/config/Settings.json diff --git a/README.md b/README.md index 36c8f4a8a..602cbcdd0 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,30 @@ -![Koolo](images/you_died.png) -

Koolo

+
+

⚠️ Warning: People using this tool have been reporting bans. Do not use it.

+
+

+ Koolo +

+

Koolo

--- -Koolo is a small bot for Diablo II: Resurrected. Koolo project was built for informational and educational purposes +Koolo is a small bot for Diablo II: Resurrected (Expansion). Koolo project was built for informational and educational purposes only, it's not intended for online usage. Feel free to contribute opening pull requests with new features or bugfixes. +Koolo reads game memory and interacts with the game injecting clicks/keystrokes to the game window. As good as it can. + +Feel free to join our Discord community to report bugs, ask for help or just to chat: [Koolo Discord]( https://discord.gg/zgFMyzAFHE) ## Disclaimer Can I get banned for using Koolo? The answer is a crystal clear yes, you can get banned although at this point I'm not aware of any ban for using it. I'm not responsible for any ban or any other consequence that may arise from it. ## Features -- Blizzard Sorceress, Nova Sorceress and Hammerdin are currently supported -- Supported runs: Countess, Andariel, Ancient Tunnels, Summoner, Mephisto, Council, Eldritch, Pindleskin, Nihlathak, - Tristram, Lower Kurast, Stony Tomb, The Pit, Arachnid Lair, Baal, Tal Rasha Tombs, Diablo (WIP), Cows +- Blizzard Sorceress, Nova Sorceress, FoH, Berserk Barbarian Hork (Travincal), Mosaic are currently supported. Hammerdin, Javazon and Winddruid are WIP +- Supported runs: Countess, Andariel, Ancient Tunnels, Summoner, Mephisto, Council, Eldritch-Shenk, Endugu, Drifter Cavern, Pindleskin, Nihlathak, + Tristram, Lower Kurast and Superchests, Stony Tomb, The Pit, Arachnid Lair, Baal, Duriel, Tal Rasha Tombs, Diablo, Cows, Treshsocket +- Multi window support (run multiple bots at the same time) - Bot integration for Discord and Telegram -- "Companion mode" one leader bot will be creating games and the rest of the bots will join the game... and sometimes it - works +- "Companion mode" one leader bot will be creating games and the rest of the bots will join the game... (not working currently) - Pickit based on NIP files - Auto potion for health and mana (also mercenary) - Chicken when low health @@ -25,34 +33,44 @@ not aware of any ban for using it. I'm not responsible for any ban or any other - CTA buff and class buffs - Auto repair - Skip on immune -- Auto leveling sorceress and paladin (WIP) +- Auto leveling sorceress and paladin (WIP) this feature is not finished. - Auto gambling +- Auto cubing and crafting (WIP) +- Terror Zones (WIP) +- Classic is not supported ## Requirements -- Diablo II: Resurrected -- Diablo II: LOD 1.13c (required by https://github.com/blacha/diablo2/tree/master/packages/map) +- Diablo II: Resurrected (1280x720 required, windowed mode, ensure accessibility large fonts disabled) +- **Diablo II: LOD 1.13c** (IMPORTANT: It will **NOT** work without it, this step is not optional) ## Quick Start -Koolo will be clicking, moving the mouse, pressing keys... Basically this makes the computer unusable while running the -bot. It's recommended to run it on a [virtual machine](https://github.com/jamesstringerparsec/Easy-GPU-PV). +### Preparing the character +- Koolo will read game keybindings in order to use the skills, doesn't matter what key is used, but the skills for the build must be set. +- For blizzard sorceress, set the **left** skill to Glacial Spike or Ice Blast, and for Hammerdin to Blessed Hammer. +- Foh set keybind for FOH and holybolt on left skill, conviction on right skill +- Berserk barb set berserk as left skill. Also to use FindItem you need higher goldfind on secondary weapons slot. Alibaba + anything will work. +- Buy TP and ID tomes and one stack of keys and keep them in the inventory, additionally set the TP tome to a key binding, this is **required**. +- Horadric Cube can be stashed or kept in inventory, Koolo will use it to cube recipes if enabled. +- Keep the charms in the inventory, Koolo can be configured to lock specific inventory slots. -- If you haven't done yet, install **Diablo II: LOD 1.13c** -- [Download](https://github.com/hectorgimenez/koolo/releases) the latest Koolo release, or alternatively you can [build it from source](#development-environment) +### Running the tool +- If you haven't done yet, install **Diablo II: LOD 1.13c** (required) +- [Download](https://github.com/hectorgimenez/koolo/releases) the latest Koolo release (recommended for most users), or alternatively you can [build it from source](#development-environment) - Extract the zip file in a directory of your choice. -- Open `config/config.yaml` with your favorite text editor and ensure `D2LoDPath` config is pointing to your **Diablo II: LOD 1.13c** installation directory. -- Configure custom bot settings under `config/config.yaml` and `config/pickit/*.nip` files for pickit rules. -- Open Diablo II: Resurrected and ensure the game resolution is set to windowed mode and 1280x720 or 1920x1080 in HiDPI mode. -- Enter the game with your character. - Run `koolo.exe`. -- It should start the configured runs. You can pause/resume execution with ```,``` or stop it with ```.``` keys +- Follow the setup wizard, it will guide you through the process of setting up the bot, you will need to setup some directories and character configuration. +- If you want to back up/restore your configuration, and for manual setup, you can find the configuration files in the `config` directory. + +## Pickit rules +Item pickit is based on [NIP files](https://github.com/blizzhackers/pickits/blob/master/NipGuide.md), you can find them in the `config/{character}/pickit` directory. -## How it works -Koolo reads game memory and interacts with the game simulating clicks/keystrokes. -It uses the following third party libraries: +All the .nip files contained in the pickit directory will be loaded, so you can have multiple pickit files. -- https://github.com/hectorgimenez/d2go -- https://github.com/hectorgimenez/diablo2 (forked - from [blacha's repo](https://github.com/blacha/diablo2/tree/master/packages/map)) +There are some considerations to take into account: +- If item fully matches the pickit rule before being identified, it will be picked up and stashed unidentified. +- If item doesn't match the full rule, will be identified and checked again, if fully matches a rule it will be stashed otherwise sold to vendor. +- If there is an error on the NIP file or Koolo can not understand it, the application will not start. +- Pickit rules can not be changed in runtime (yet), you will need to restart Koolo to apply changes. ## Development environment **Note:** This is only required if you want to build the project from source. If you want to run the bot, you can just download the [latest release](https://github.com/hectorgimenez/koolo/releases). @@ -60,11 +78,7 @@ It uses the following third party libraries: Setting the development environment is pretty straightforward, but the following dependencies are **required** to build the project. ### Dependencies -- [Download MingGW](https://sourceforge.net/projects/mingw-w64/files/) ```x86_64-posix-seh``` should be fine, extract it - and add it to the system PATH on Windows environment variables. For example if extracted in ```C:\mingw64``` add - ```C:\mingw64\bin``` to the PATH. [Robotgo](https://github.com/go-vgo/robotgo) and [GoCV](https://github.com/hybridgroup/gocv#windows) dependencies requires MinGW to build. -- [Download CMake](https://cmake.org/download/) select add it to the user path during the installation process. -- [Download Go >= 1.21](https://go.dev/dl/) +- [Download Go >= 1.23](https://go.dev/dl/) - [Install git](https://gitforwindows.org/) ### Building from source @@ -72,7 +86,6 @@ Open the terminal and run the following commands in project root directory: ```shell git clone https://github.com/hectorgimenez/koolo.git cd koolo -prepare_env.bat build.bat ``` This will produce the "build" directory with the executable file and all the required assets. diff --git a/assets/koolo.webp b/assets/koolo.webp new file mode 100644 index 000000000..b1b933a73 Binary files /dev/null and b/assets/koolo.webp differ diff --git a/assets/quests/a1_1.webp b/assets/quests/a1_1.webp deleted file mode 100644 index 00c596bb3..000000000 Binary files a/assets/quests/a1_1.webp and /dev/null differ diff --git a/assets/quests/a1_2.webp b/assets/quests/a1_2.webp deleted file mode 100644 index a754f3462..000000000 Binary files a/assets/quests/a1_2.webp and /dev/null differ diff --git a/assets/quests/a1_3.webp b/assets/quests/a1_3.webp deleted file mode 100644 index 40b27d614..000000000 Binary files a/assets/quests/a1_3.webp and /dev/null differ diff --git a/assets/quests/a1_4.webp b/assets/quests/a1_4.webp deleted file mode 100644 index 6494586cc..000000000 Binary files a/assets/quests/a1_4.webp and /dev/null differ diff --git a/assets/quests/a1_5.webp b/assets/quests/a1_5.webp deleted file mode 100644 index c91b452a2..000000000 Binary files a/assets/quests/a1_5.webp and /dev/null differ diff --git a/assets/quests/a1_6.webp b/assets/quests/a1_6.webp deleted file mode 100644 index 1fb4c7240..000000000 Binary files a/assets/quests/a1_6.webp and /dev/null differ diff --git a/assets/quests/a2_1.webp b/assets/quests/a2_1.webp deleted file mode 100644 index cfb5e28b2..000000000 Binary files a/assets/quests/a2_1.webp and /dev/null differ diff --git a/assets/quests/a2_2.webp b/assets/quests/a2_2.webp deleted file mode 100644 index 95b427234..000000000 Binary files a/assets/quests/a2_2.webp and /dev/null differ diff --git a/assets/quests/a2_3.webp b/assets/quests/a2_3.webp deleted file mode 100644 index 28270f700..000000000 Binary files a/assets/quests/a2_3.webp and /dev/null differ diff --git a/assets/quests/a2_4.webp b/assets/quests/a2_4.webp deleted file mode 100644 index da6b21039..000000000 Binary files a/assets/quests/a2_4.webp and /dev/null differ diff --git a/assets/quests/a2_5.webp b/assets/quests/a2_5.webp deleted file mode 100644 index c6bf34abc..000000000 Binary files a/assets/quests/a2_5.webp and /dev/null differ diff --git a/assets/quests/a2_6.webp b/assets/quests/a2_6.webp deleted file mode 100644 index 8edd48c67..000000000 Binary files a/assets/quests/a2_6.webp and /dev/null differ diff --git a/assets/quests/a3_1.webp b/assets/quests/a3_1.webp deleted file mode 100644 index 80025721a..000000000 Binary files a/assets/quests/a3_1.webp and /dev/null differ diff --git a/assets/quests/a3_2.webp b/assets/quests/a3_2.webp deleted file mode 100644 index c640714ea..000000000 Binary files a/assets/quests/a3_2.webp and /dev/null differ diff --git a/assets/quests/a3_3.webp b/assets/quests/a3_3.webp deleted file mode 100644 index 84b2b4739..000000000 Binary files a/assets/quests/a3_3.webp and /dev/null differ diff --git a/assets/quests/a3_4.webp b/assets/quests/a3_4.webp deleted file mode 100644 index f45289674..000000000 Binary files a/assets/quests/a3_4.webp and /dev/null differ diff --git a/assets/quests/a3_5.webp b/assets/quests/a3_5.webp deleted file mode 100644 index 19669de76..000000000 Binary files a/assets/quests/a3_5.webp and /dev/null differ diff --git a/assets/quests/a3_6.webp b/assets/quests/a3_6.webp deleted file mode 100644 index a708b78c0..000000000 Binary files a/assets/quests/a3_6.webp and /dev/null differ diff --git a/assets/quests/a4_1.webp b/assets/quests/a4_1.webp deleted file mode 100644 index 62f82360e..000000000 Binary files a/assets/quests/a4_1.webp and /dev/null differ diff --git a/assets/quests/a4_2.webp b/assets/quests/a4_2.webp deleted file mode 100644 index 7afc413a2..000000000 Binary files a/assets/quests/a4_2.webp and /dev/null differ diff --git a/assets/quests/a4_3.webp b/assets/quests/a4_3.webp deleted file mode 100644 index 31d22ef05..000000000 Binary files a/assets/quests/a4_3.webp and /dev/null differ diff --git a/assets/quests/a5_1.webp b/assets/quests/a5_1.webp deleted file mode 100644 index c5fe59d1e..000000000 Binary files a/assets/quests/a5_1.webp and /dev/null differ diff --git a/assets/quests/a5_2.webp b/assets/quests/a5_2.webp deleted file mode 100644 index d51df2222..000000000 Binary files a/assets/quests/a5_2.webp and /dev/null differ diff --git a/assets/quests/a5_3.webp b/assets/quests/a5_3.webp deleted file mode 100644 index ecd198c53..000000000 Binary files a/assets/quests/a5_3.webp and /dev/null differ diff --git a/assets/quests/a5_4.webp b/assets/quests/a5_4.webp deleted file mode 100644 index f92c405b6..000000000 Binary files a/assets/quests/a5_4.webp and /dev/null differ diff --git a/assets/quests/a5_5.webp b/assets/quests/a5_5.webp deleted file mode 100644 index e91abd5bd..000000000 Binary files a/assets/quests/a5_5.webp and /dev/null differ diff --git a/assets/quests/a5_6.webp b/assets/quests/a5_6.webp deleted file mode 100644 index 377cd71b2..000000000 Binary files a/assets/quests/a5_6.webp and /dev/null differ diff --git a/assets/skills/102.webp b/assets/skills/102.webp deleted file mode 100644 index 91cd3d21d..000000000 Binary files a/assets/skills/102.webp and /dev/null differ diff --git a/assets/skills/106.webp b/assets/skills/106.webp deleted file mode 100644 index ea70c3aae..000000000 Binary files a/assets/skills/106.webp and /dev/null differ diff --git a/assets/skills/112.webp b/assets/skills/112.webp deleted file mode 100644 index c916e78d7..000000000 Binary files a/assets/skills/112.webp and /dev/null differ diff --git a/assets/skills/113.webp b/assets/skills/113.webp deleted file mode 100644 index 4c9b5812c..000000000 Binary files a/assets/skills/113.webp and /dev/null differ diff --git a/assets/skills/115.webp b/assets/skills/115.webp deleted file mode 100644 index e3617b466..000000000 Binary files a/assets/skills/115.webp and /dev/null differ diff --git a/assets/skills/117.webp b/assets/skills/117.webp deleted file mode 100644 index 3b7a1931e..000000000 Binary files a/assets/skills/117.webp and /dev/null differ diff --git a/assets/skills/220.webp b/assets/skills/220.webp deleted file mode 100644 index 568eb4e8f..000000000 Binary files a/assets/skills/220.webp and /dev/null differ diff --git a/assets/skills/36.webp b/assets/skills/36.webp deleted file mode 100644 index 7957697db..000000000 Binary files a/assets/skills/36.webp and /dev/null differ diff --git a/assets/skills/37.webp b/assets/skills/37.webp deleted file mode 100644 index 8aa1c79d9..000000000 Binary files a/assets/skills/37.webp and /dev/null differ diff --git a/assets/skills/38.webp b/assets/skills/38.webp deleted file mode 100644 index f1c6dae30..000000000 Binary files a/assets/skills/38.webp and /dev/null differ diff --git a/assets/skills/39.webp b/assets/skills/39.webp deleted file mode 100644 index 0f1915bdf..000000000 Binary files a/assets/skills/39.webp and /dev/null differ diff --git a/assets/skills/40.webp b/assets/skills/40.webp deleted file mode 100644 index 45c7fb4b0..000000000 Binary files a/assets/skills/40.webp and /dev/null differ diff --git a/assets/skills/41.webp b/assets/skills/41.webp deleted file mode 100644 index 2fef54a64..000000000 Binary files a/assets/skills/41.webp and /dev/null differ diff --git a/assets/skills/42.webp b/assets/skills/42.webp deleted file mode 100644 index 6d4e5849c..000000000 Binary files a/assets/skills/42.webp and /dev/null differ diff --git a/assets/skills/43.webp b/assets/skills/43.webp deleted file mode 100644 index 149fb29c4..000000000 Binary files a/assets/skills/43.webp and /dev/null differ diff --git a/assets/skills/44.webp b/assets/skills/44.webp deleted file mode 100644 index 80ef7ccef..000000000 Binary files a/assets/skills/44.webp and /dev/null differ diff --git a/assets/skills/45.webp b/assets/skills/45.webp deleted file mode 100644 index d4bfbd7af..000000000 Binary files a/assets/skills/45.webp and /dev/null differ diff --git a/assets/skills/46.webp b/assets/skills/46.webp deleted file mode 100644 index b102f46bf..000000000 Binary files a/assets/skills/46.webp and /dev/null differ diff --git a/assets/skills/47.webp b/assets/skills/47.webp deleted file mode 100644 index a146b30b5..000000000 Binary files a/assets/skills/47.webp and /dev/null differ diff --git a/assets/skills/48.webp b/assets/skills/48.webp deleted file mode 100644 index 1e75ff1c2..000000000 Binary files a/assets/skills/48.webp and /dev/null differ diff --git a/assets/skills/49.webp b/assets/skills/49.webp deleted file mode 100644 index 7b7bd1808..000000000 Binary files a/assets/skills/49.webp and /dev/null differ diff --git a/assets/skills/50.webp b/assets/skills/50.webp deleted file mode 100644 index d9bdfda83..000000000 Binary files a/assets/skills/50.webp and /dev/null differ diff --git a/assets/skills/51.webp b/assets/skills/51.webp deleted file mode 100644 index 76fbddda0..000000000 Binary files a/assets/skills/51.webp and /dev/null differ diff --git a/assets/skills/52.webp b/assets/skills/52.webp deleted file mode 100644 index 1eeb68536..000000000 Binary files a/assets/skills/52.webp and /dev/null differ diff --git a/assets/skills/53.webp b/assets/skills/53.webp deleted file mode 100644 index b3cba6315..000000000 Binary files a/assets/skills/53.webp and /dev/null differ diff --git a/assets/skills/54.webp b/assets/skills/54.webp deleted file mode 100644 index 64e37c8fe..000000000 Binary files a/assets/skills/54.webp and /dev/null differ diff --git a/assets/skills/55.webp b/assets/skills/55.webp deleted file mode 100644 index ae4ced52a..000000000 Binary files a/assets/skills/55.webp and /dev/null differ diff --git a/assets/skills/56.webp b/assets/skills/56.webp deleted file mode 100644 index 2ecae45a1..000000000 Binary files a/assets/skills/56.webp and /dev/null differ diff --git a/assets/skills/57.webp b/assets/skills/57.webp deleted file mode 100644 index 6c0524041..000000000 Binary files a/assets/skills/57.webp and /dev/null differ diff --git a/assets/skills/58.webp b/assets/skills/58.webp deleted file mode 100644 index 24f2794f5..000000000 Binary files a/assets/skills/58.webp and /dev/null differ diff --git a/assets/skills/59.webp b/assets/skills/59.webp deleted file mode 100644 index b5d267e3d..000000000 Binary files a/assets/skills/59.webp and /dev/null differ diff --git a/assets/skills/60.webp b/assets/skills/60.webp deleted file mode 100644 index 364283657..000000000 Binary files a/assets/skills/60.webp and /dev/null differ diff --git a/assets/skills/61.webp b/assets/skills/61.webp deleted file mode 100644 index 3617f06fb..000000000 Binary files a/assets/skills/61.webp and /dev/null differ diff --git a/assets/skills/62.webp b/assets/skills/62.webp deleted file mode 100644 index 5f7366165..000000000 Binary files a/assets/skills/62.webp and /dev/null differ diff --git a/assets/skills/63.webp b/assets/skills/63.webp deleted file mode 100644 index e5a84c187..000000000 Binary files a/assets/skills/63.webp and /dev/null differ diff --git a/assets/skills/64.webp b/assets/skills/64.webp deleted file mode 100644 index ebfc7ba97..000000000 Binary files a/assets/skills/64.webp and /dev/null differ diff --git a/assets/skills/65.webp b/assets/skills/65.webp deleted file mode 100644 index ba2158fdc..000000000 Binary files a/assets/skills/65.webp and /dev/null differ diff --git a/assets/skills/98.webp b/assets/skills/98.webp deleted file mode 100644 index 67471d38c..000000000 Binary files a/assets/skills/98.webp and /dev/null differ diff --git a/assets/skills/merc_104.webp b/assets/skills/merc_104.webp deleted file mode 100644 index b61cde4fd..000000000 Binary files a/assets/skills/merc_104.webp and /dev/null differ diff --git a/assets/skills/merc_99.webp b/assets/skills/merc_99.webp deleted file mode 100644 index d34fedaa9..000000000 Binary files a/assets/skills/merc_99.webp and /dev/null differ diff --git a/assets/ui/config_gear.webp b/assets/ui/config_gear.webp deleted file mode 100644 index 2d8336643..000000000 Binary files a/assets/ui/config_gear.webp and /dev/null differ diff --git a/assets/ui/discovered_wp.webp b/assets/ui/discovered_wp.webp deleted file mode 100644 index 552e9205e..000000000 Binary files a/assets/ui/discovered_wp.webp and /dev/null differ diff --git a/assets/ui/undiscovered_wp.webp b/assets/ui/undiscovered_wp.webp deleted file mode 100644 index e4c8a897f..000000000 Binary files a/assets/ui/undiscovered_wp.webp and /dev/null differ diff --git a/better_build.bat b/better_build.bat new file mode 100644 index 000000000..dc4a13138 --- /dev/null +++ b/better_build.bat @@ -0,0 +1,287 @@ +@echo off +setlocal enabledelayedexpansion + +:: Change to the script's directory +cd /d "%~dp0" + +call :print_header "Starting Koolo Build Process" + +:: Check for Go installation +call :check_go_installation + +:: Main script execution +call :main +if !errorlevel! neq 0 ( + call :print_error "Build process failed with error code !errorlevel!" + exit /b !errorlevel! +) +echo. +powershell -Command "Write-Host 'Press any key to exit...' -ForegroundColor Yellow" +pause > nul +exit /b 0 + +:check_go_installation +call :print_info "Checking if Go is installed" +where go >nul 2>&1 +if %errorlevel% neq 0 ( + call :print_error "Go is not installed or not in the system PATH." + call :print_info "You can download Go from https://golang.org/dl/" + call :get_user_input "Do you want to attempt automatic installation using Chocolatey? (Y/N) " install_go + if /i "!install_go!"=="Y" ( + call :install_go_with_chocolatey + ) else ( + call :print_info "Please install Go manually and run this script again." + exit /b 1 + ) +) else ( + for /f "tokens=3" %%v in ('go version') do set go_version=%%v + call :print_success "Go version !go_version! is installed." +) +goto :eof + +:install_go_with_chocolatey +call :print_step "Attempting to install Go using Chocolatey..." +where choco >nul 2>&1 +if %errorlevel% neq 0 ( + call :print_error "Chocolatey is not installed. Please install Go manually." + call :print_info "You can install Chocolatey from https://chocolatey.org/install" + exit /b 1 +) +powershell -Command "Start-Process powershell -Verb runAs -ArgumentList 'choco install golang -y' -Wait" +where go >nul 2>&1 +if %errorlevel% neq 0 ( + call :print_error "Failed to install Go. Please install it manually." + exit /b 1 +) else ( + call :print_success "Go has been successfully installed." +) +goto :eof + +:main +:: Initial validation checks +call :validate_environment +if !errorlevel! neq 0 exit /b !errorlevel! + +:: Build Koolo binary +call :print_header "Building Koolo Binary" +call :print_step "Compiling Koolo" +if "%1"=="" (set VERSION=dev) else (set VERSION=%1) +go build -trimpath -tags static --ldflags -extldflags="-static" -ldflags="-s -w -H windowsgui -X 'github.com/hectorgimenez/koolo/internal/config.Version=%VERSION%'" -o build/koolo.exe ./cmd/koolo +if !errorlevel! neq 0 ( + call :print_error "Failed to build Koolo binary" + exit /b 1 +) +call :print_success "Successfully built koolo.exe" + +:: Handle tools folder first +call :print_header "Handling Tools" +if exist build\tools ( + call :print_step "Removing existing tools folder" + rmdir /s /q build\tools + if exist build\tools ( + call :print_error "Failed to delete tools folder" + call :check_folder_permissions "build\tools" + exit /b 1 + ) +) +call :print_step "Copying tools folder" +xcopy /q /E /I /y tools build\tools > nul +if !errorlevel! neq 0 ( + call :print_error "Failed to copy tools folder" + call :check_folder_permissions "tools" + call :check_folder_permissions "build" + exit /b 1 +) +call :print_success "Tools folder successfully copied" + +:: Handle Settings.json +call :print_header "Handling Configuration Files" +if not exist build\config mkdir build\config +if exist build\config\Settings.json ( + call :print_step "Checking Settings.json" + call :print_info "Settings.json found in %cd%\build\config" + call :get_user_input " Do you want to replace it? (Y/N) " replace_settings + if /i "!replace_settings!"=="Y" ( + call :print_step "Replacing Settings.json" + copy /y config\Settings.json build\config\Settings.json > nul + if !errorlevel! equ 0 ( + call :print_success "Settings.json successfully replaced" + ) else ( + call :print_error "Failed to copy Settings.json" + exit /b 1 + ) + ) else ( + call :print_info "Keeping existing Settings.json" + ) +) else ( + call :print_info "No existing Settings.json found in %cd%\build\config" + call :print_step "Copying Settings.json" + copy /y config\Settings.json build\config\Settings.json > nul + if !errorlevel! neq 0 ( + call :print_error "Failed to copy Settings.json" + exit /b 1 + ) + call :print_success "Settings.json successfully copied" +) + +:: Handle koolo.yaml +if not exist build\config\koolo.yaml ( + call :print_step "Copying koolo.yaml.dist" + copy config\koolo.yaml.dist build\config\koolo.yaml > nul + if !errorlevel! neq 0 ( + call :print_error "Failed to copy koolo.yaml.dist" + exit /b 1 + ) + call :print_success "koolo.yaml.dist successfully copied" +) else ( + call :print_info "koolo.yaml already exists in build\config, skipping copy" +) + +:: Copy template folder +call :print_step "Copying template folder" +if exist build\config\template rmdir /s /q build\config\template +xcopy /q /E /I /y config\template build\config\template > nul +if !errorlevel! neq 0 ( + call :print_error "Failed to copy template folder" + exit /b 1 +) +call :print_success "Template folder successfully copied" + +:: Copy README +call :print_step "Copying README.md" +copy README.md build > nul +if !errorlevel! neq 0 ( + call :print_error "Failed to copy README.md" + exit /b 1 +) +call :print_success "README.md successfully copied" + +call :print_header "Build Process Completed" +call :print_success "Artifacts are in the build directory" +goto :eof + +:error +call :print_header "Build Process Failed" +call :print_error "Error occurred during the build process" +goto :eof + +:: Function to get user input +:get_user_input +setlocal enabledelayedexpansion +call :print_prompt "%~1" +set /p "user_input=" +endlocal & set "%~2=%user_input%" +goto :eof + +:: Function to print a colored prompt +:print_prompt +powershell -Command "Write-Host '%~1' -ForegroundColor Yellow -NoNewline" +goto :eof + +:: Function to print a header +:print_header +echo. +powershell -Command "Write-Host '=== %~1 ===' -ForegroundColor Magenta" +echo. +goto :eof + +:: Function to print a step +:print_step +powershell -Command "Write-Host ' - %~1' -ForegroundColor Cyan" +goto :eof + +:: Function to print a success message +:print_success +powershell -Command "Write-Host ' SUCCESS: %~1' -ForegroundColor Green" +goto :eof + +:: Function to print an error message +:print_error +powershell -Command "Write-Host ' ERROR: %~1' -ForegroundColor Red" +goto :eof + +:: Function to print an info message +:print_info +powershell -Command "Write-Host ' INFO: %~1' -ForegroundColor Yellow" +goto :eof + +:: Function to check file permissions +:check_file_permissions +dir "%~1" >nul 2>&1 +if !errorlevel! neq 0 ( + call :print_error "Cannot access file: %~1" +) else ( + call :print_info "File %~1 is accessible" +) +goto :eof + +:: Function to check folder permissions +:check_folder_permissions +dir "%~1\*" >nul 2>&1 +if !errorlevel! neq 0 ( + call :print_error "Cannot access directory: %~1" +) else ( + call :print_info "Directory %~1 is accessible" +) +goto :eof + +:: Function to validate environment +:validate_environment +call :print_header "Validating Environment" + +:: Check for required source files and folders +if not exist config ( + call :print_error "Config directory is missing" + exit /b 1 +) + +if not exist config\koolo.yaml.dist ( + call :print_error "koolo.yaml.dist is missing from config directory" + exit /b 1 +) + +if not exist config\Settings.json ( + call :print_error "Settings.json is missing from config directory" + exit /b 1 +) + +if not exist tools ( + call :print_error "Tools directory is missing" + exit /b 1 +) + +:: Check for required tools +if not exist tools\handle64.exe ( + call :print_error "handle64.exe is missing from tools directory" + exit /b 1 +) + +if not exist tools\koolo-map.exe ( + call :print_error "koolo-map.exe is missing from tools directory" + exit /b 1 +) + +:: Check for required build dependencies +call :print_step "Checking build dependencies" +go version >nul 2>&1 +if !errorlevel! neq 0 ( + call :print_error "Go is not installed or not in PATH" + exit /b 1 +) + +:: Verify write permissions in current directory +call :print_step "Checking write permissions" +echo. > test_write.tmp 2>nul +if !errorlevel! neq 0 ( + call :print_error "No write permissions in current directory" + exit /b 1 +) +del test_write.tmp >nul 2>&1 + +call :print_success "Environment validation completed" +goto :eof + +:: Function to print a warning message +:print_warning +powershell -Command "Write-Host ' WARNING: %~1' -ForegroundColor Yellow" +goto :eof \ No newline at end of file diff --git a/build.bat b/build.bat index 05aa3c464..32c2f995e 100644 --- a/build.bat +++ b/build.bat @@ -5,16 +5,15 @@ echo Cleaning up previous artifacts... if exist build rmdir /s /q build > NUL || goto :error echo Building Koolo binary... -set PATH=%PATH%;C:\Program Files (x86)\CMake\bin;C:\mingw-w64\x86_64-8.1.0-posix-seh-rt_v6-rev0\mingw64\bin;C:\opencv\build\install\x64\mingw\bin -go build -trimpath -tags static --ldflags -extldflags="-static" -o build/koolo.exe ./cmd/koolo/main.go > NUL || goto :error +if "%1"=="" (set VERSION=dev) else (set VERSION=%1) +go build -trimpath -tags static --ldflags -extldflags="-static" -ldflags="-s -w -H windowsgui -X 'github.com/hectorgimenez/koolo/internal/config.Version=%VERSION%'" -o build/koolo.exe ./cmd/koolo > NUL || goto :error echo Copying assets... mkdir build\config > NUL || goto :error -copy config\config.yaml.dist build\config\config.yaml > NUL || goto :error -xcopy /q /E /I /y config\pickit build\config\pickit > NUL || goto :error -xcopy /q /E /I /y config\pickit_leveling build\config\pickit_leveling > NUL || goto :error -xcopy /q /E /I /y assets build\assets > NUL || goto :error -xcopy /q /y koolo-map.exe build > NUL || goto :error +copy config\koolo.yaml.dist build\config\koolo.yaml > NUL || goto :error +copy config\Settings.json build\config\Settings.json > NUL || goto :error +xcopy /q /E /I /y config\template build\config\template > NUL || goto :error +xcopy /q /E /I /y tools build\tools > NUL || goto :error xcopy /q /y README.md build > NUL || goto :error echo Done! Artifacts are in build directory. @@ -23,4 +22,4 @@ echo Done! Artifacts are in build directory. if %errorlevel% neq 0 ( echo Error occurred #%errorlevel%. exit /b %errorlevel% -) +) \ No newline at end of file diff --git a/cmd/koolo/koolo.exe.manifest b/cmd/koolo/koolo.exe.manifest new file mode 100644 index 000000000..e5542863c --- /dev/null +++ b/cmd/koolo/koolo.exe.manifest @@ -0,0 +1,17 @@ + + + +Koolo + + + + + + + + \ No newline at end of file diff --git a/cmd/koolo/koolo.ico b/cmd/koolo/koolo.ico new file mode 100644 index 000000000..c5fb7f1d2 Binary files /dev/null and b/cmd/koolo/koolo.ico differ diff --git a/cmd/koolo/log/logger.go b/cmd/koolo/log/logger.go index 3ebfffe06..cad28cdb3 100644 --- a/cmd/koolo/log/logger.go +++ b/cmd/koolo/log/logger.go @@ -1,20 +1,73 @@ package log import ( - "go.uber.org/zap" + "errors" + "fmt" + "io" + "log/slog" + "os" + "time" ) -func NewLogger(debug bool, logFilePath string) (*zap.Logger, error) { - cfg := zap.Config{ - Level: zap.NewAtomicLevelAt(zap.DebugLevel), - Development: debug, - DisableCaller: false, - DisableStacktrace: false, - Encoding: "console", - EncoderConfig: zap.NewDevelopmentEncoderConfig(), - OutputPaths: []string{"stdout", logFilePath}, - ErrorOutputPaths: []string{"stderr", logFilePath}, +var logFileHandler *os.File + +func FlushLog() { + if logFileHandler != nil { + logFileHandler.Sync() + } +} + +func FlushAndClose() error { + if logFileHandler != nil { + logFileHandler.Sync() + return logFileHandler.Close() + } + + return nil +} + +func NewLogger(debug bool, logDir, supervisor string) (*slog.Logger, error) { + if logDir == "" { + logDir = "logs" + } + + if _, err := os.Stat(logDir); errors.Is(err, os.ErrNotExist) { + err := os.MkdirAll(logDir, os.ModePerm) + if err != nil { + return nil, fmt.Errorf("error creating log directory: %w", err) + } + } + + fileName := "Koolo-log-" + time.Now().Format("2006-01-02-15-04-05") + ".txt" + if supervisor != "" { + fileName = fmt.Sprintf("Supervisor-log-%s-%s.txt", supervisor, time.Now().Format("2006-01-02-15-04-05")) + } + + lfh, err := os.Create(logDir + "/" + fileName) + if err != nil { + return nil, err + } + logFileHandler = lfh + + level := slog.LevelDebug + if !debug { + level = slog.LevelInfo + } + + opts := &slog.HandlerOptions{ + Level: level, + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key != slog.TimeKey { + return a + } + + t := a.Value.Time() + a.Value = slog.StringValue(t.Format(time.TimeOnly)) + + return a + }, } + handler := slog.NewTextHandler(io.MultiWriter(logFileHandler, os.Stdout), opts) - return cfg.Build() + return slog.New(handler), nil } diff --git a/cmd/koolo/main.go b/cmd/koolo/main.go index d5ffb8929..c4b77244b 100644 --- a/cmd/koolo/main.go +++ b/cmd/koolo/main.go @@ -2,146 +2,164 @@ package main import ( "context" - "golang.org/x/sync/errgroup" + "fmt" "log" - "os" - "os/signal" - "syscall" - - "github.com/hectorgimenez/d2go/pkg/memory" - "github.com/hectorgimenez/koolo/internal/run" - "github.com/hectorgimenez/koolo/internal/ui" - hook "github.com/robotn/gohook" - - zapLogger "github.com/hectorgimenez/koolo/cmd/koolo/log" - koolo "github.com/hectorgimenez/koolo/internal" - "github.com/hectorgimenez/koolo/internal/action" - "github.com/hectorgimenez/koolo/internal/character" + "log/slog" + _ "net/http/pprof" + "runtime/debug" + + sloggger "github.com/hectorgimenez/koolo/cmd/koolo/log" + "github.com/hectorgimenez/koolo/internal/bot" "github.com/hectorgimenez/koolo/internal/config" "github.com/hectorgimenez/koolo/internal/event" - "github.com/hectorgimenez/koolo/internal/health" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/reader" "github.com/hectorgimenez/koolo/internal/remote/discord" "github.com/hectorgimenez/koolo/internal/remote/telegram" - "github.com/hectorgimenez/koolo/internal/town" - "go.uber.org/zap" + "github.com/hectorgimenez/koolo/internal/server" + "github.com/hectorgimenez/koolo/internal/utils" + "github.com/hectorgimenez/koolo/internal/utils/winproc" + "github.com/inkeliz/gowebview" + "golang.org/x/sync/errgroup" ) +// wrapWithRecover wraps a function with panic recovery logic +func wrapWithRecover(logger *slog.Logger, f func() error) func() error { + return func() error { + defer func() { + if r := recover(); r != nil { + stackTrace := debug.Stack() + errMsg := fmt.Sprintf("panic recovered: %v\nStacktrace: %s", r, stackTrace) + logger.Error(errMsg) + sloggger.FlushLog() + } + }() + return f() + } +} + func main() { err := config.Load() if err != nil { + utils.ShowDialog("Error loading configuration", err.Error()) log.Fatalf("Error loading configuration: %s", err.Error()) + return } - logger, err := zapLogger.NewLogger(config.Config.Debug.Log, config.Config.LogFilePath) + logger, err := sloggger.NewLogger(config.Koolo.Debug.Log, config.Koolo.LogSaveDirectory, "") if err != nil { log.Fatalf("Error starting logger: %s", err.Error()) } - defer logger.Sync() + defer sloggger.FlushAndClose() + + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("fatal error detected, Koolo will close with the following error: %v\n Stacktrace: %s", r, debug.Stack()) + logger.Error(err.Error()) + sloggger.FlushAndClose() + utils.ShowDialog("Koolo error :(", fmt.Sprintf("Koolo will close due to an expected error, please check the latest log file for more info!\n %s", err.Error())) + } + }() ctx, cancel := context.WithCancel(context.Background()) + defer cancel() - ch := make(chan os.Signal, 1) - signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT) - go func() { - <-ch - logger.Info("Shutting down...") - signal.Stop(ch) - cancel() - }() g, ctx := errgroup.WithContext(ctx) - process, err := memory.NewProcess() - if err != nil { - logger.Fatal("Error finding D2R.exe process", zap.Error(err)) - } - - gr := &reader.GameReader{ - GameReader: memory.NewGameReader(process), - } + winproc.SetProcessDpiAware.Call() // Set DPI awareness to be able to read the correct scale and show the window correctly - tf, err := ui.NewTemplateFinder(logger, "assets") - if err != nil { - logger.Fatal("Error creating template finder", zap.Error(err)) - } - - bm := health.NewBeltManager(logger) - gm := helper.NewGameManager(gr, tf) - hm := health.NewHealthManager(logger, bm, gm) - sm := town.NewShopManager(logger, bm) - char, err := character.BuildCharacter(logger) + eventListener := event.NewListener(logger) + manager := bot.NewSupervisorManager(logger, eventListener) + scheduler := bot.NewScheduler(manager, logger) + go scheduler.Start() + srv, err := server.New(logger, manager) if err != nil { - logger.Fatal("Error creating character", zap.Error(err)) + log.Fatalf("Error starting local server: %s", err.Error()) } - ab := action.NewBuilder(logger, sm, bm, gr, char, tf) - bot := koolo.NewBot(logger, hm, ab, gr) + // Use wrapWithRecover for all goroutines to handle panics + g.Go(wrapWithRecover(logger, func() error { + defer cancel() + displayScale := config.GetCurrentDisplayScale() + w, err := gowebview.New(&gowebview.Config{URL: "http://localhost:8087", WindowConfig: &gowebview.WindowConfig{ + Title: "Koolo", + Size: &gowebview.Point{ + X: int64(1280 * displayScale), + Y: int64(720 * displayScale), + }, + }}) + if err != nil { + w.Destroy() + return fmt.Errorf("error creating webview: %w", err) + } - var supervisor koolo.Supervisor - var companion koolo.Companion - if config.Config.Companion.Enabled { - supervisor = koolo.NewCompanionSupervisor(logger, bot, gr, gm) - companion = supervisor.(koolo.Companion) - } else { - supervisor = koolo.NewSinglePlayerSupervisor(logger, bot, gr, gm) - } + w.SetSize(&gowebview.Point{ + X: int64(1280 * displayScale), + Y: int64(720 * displayScale), + }, gowebview.HintFixed) - g.Go(func() error { - return supervisor.Start(ctx, run.NewFactory(logger, ab, char, gr, bm)) - }) + defer w.Destroy() + w.Run() - eventListener := event.NewListener(logger) + return nil + })) // Discord Bot initialization - if config.Config.Discord.Enabled { - discordBot, err := discord.NewBot(config.Config.Discord.Token, config.Config.Discord.ChannelID, supervisor, companion) + if config.Koolo.Discord.Enabled { + discordBot, err := discord.NewBot(config.Koolo.Discord.Token, config.Koolo.Discord.ChannelID, manager) if err != nil { - logger.Fatal("Discord could not been initialized", zap.Error(err)) + logger.Error("Discord could not been initialized", slog.Any("error", err)) + return } - eventListener.Register(discordBot.Handle) - g.Go(func() error { + eventListener.Register(discordBot.Handle) + g.Go(wrapWithRecover(logger, func() error { return discordBot.Start(ctx) - }) + })) } // Telegram Bot initialization - if config.Config.Telegram.Enabled { - telegramBot, err := telegram.NewBot(config.Config.Telegram.Token, config.Config.Telegram.ChatID, logger, supervisor) + if config.Koolo.Telegram.Enabled { + telegramBot, err := telegram.NewBot(config.Koolo.Telegram.Token, config.Koolo.Telegram.ChatID, logger) if err != nil { - logger.Fatal("Telegram could not been initialized", zap.Error(err)) + logger.Error("Telegram could not been initialized", slog.Any("error", err)) + return } - eventListener.Register(telegramBot.Handle) - g.Go(func() error { + eventListener.Register(telegramBot.Handle) + g.Go(wrapWithRecover(logger, func() error { return telegramBot.Start(ctx) - }) + })) } - g.Go(func() error { + g.Go(wrapWithRecover(logger, func() error { + defer cancel() + return srv.Listen(8087) + })) + + g.Go(wrapWithRecover(logger, func() error { + defer cancel() return eventListener.Listen(ctx) - }) + })) - registerKeyboardHooks(supervisor) + g.Go(wrapWithRecover(logger, func() error { + <-ctx.Done() + logger.Info("Koolo shutting down...") + cancel() + manager.StopAll() + scheduler.Stop() + err = srv.Stop() + if err != nil { + logger.Error("error stopping local server", slog.Any("error", err)) + } + + return err + })) err = g.Wait() if err != nil { - logger.Fatal("Error running Koolo", zap.Error(err)) + cancel() + logger.Error("Error running Koolo", slog.Any("error", err)) + return } -} - -func registerKeyboardHooks(s koolo.Supervisor) { - // TogglePause Hook - hook.Register(hook.KeyDown, []string{"."}, func(e hook.Event) { - s.Stop() - }) - - // TogglePause Hook - hook.Register(hook.KeyDown, []string{","}, func(e hook.Event) { - s.TogglePause() - }) - evt := hook.Start() - <-hook.Process(evt) + sloggger.FlushAndClose() } diff --git a/cmd/koolo/rsrc_windows_amd64.syso b/cmd/koolo/rsrc_windows_amd64.syso new file mode 100644 index 000000000..e1ea885d5 Binary files /dev/null and b/cmd/koolo/rsrc_windows_amd64.syso differ diff --git a/cmd/memory/main.go b/cmd/memory/main.go deleted file mode 100644 index 270eea581..000000000 --- a/cmd/memory/main.go +++ /dev/null @@ -1,89 +0,0 @@ -package main - -import ( - "fmt" - "log" - "time" - - "github.com/go-vgo/robotgo" - "github.com/hectorgimenez/d2go/pkg/memory" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" - "github.com/hectorgimenez/koolo/internal/reader" - "github.com/hectorgimenez/koolo/internal/ui" - "github.com/lxn/win" - "go.uber.org/zap" -) - -func main() { - window := robotgo.FindWindow("Diablo II: Resurrected") - if window == win.HWND_TOP { - panic("Diablo II: Resurrected window not found") - } - win.SetForegroundWindow(window) - - // Exclude border offsets - // TODO: Improve this, maybe getting window content coordinates? - pos := win.WINDOWPLACEMENT{} - win.GetWindowPlacement(window, &pos) - hid.WindowLeftX = int(pos.RcNormalPosition.Left) + 8 - hid.WindowTopY = int(pos.RcNormalPosition.Top) + 31 - hid.GameAreaSizeX = int(pos.RcNormalPosition.Right) - hid.WindowLeftX - 10 - hid.GameAreaSizeY = int(pos.RcNormalPosition.Bottom) - hid.WindowTopY - 10 - - err := config.Load() - config.Config.Debug.RenderMap = true - if err != nil { - log.Fatalf("Error loading configuration: %s", err.Error()) - } - process, err := memory.NewProcess() - if err != nil { - panic(err) - } - - gd := memory.NewGameReader(process) - gr := reader.GameReader{ - GameReader: gd, - } - - start := time.Now() - gr.GetData(true) - logger, _ := zap.NewDevelopment() - tf, err := ui.NewTemplateFinder(logger, "assets") - if err != nil { - panic(err) - } - - for true { - d := gr.GetData(false) - - tf.Find("59", helper.Screenshot()) - //screenshot := helper.Screenshot() - //matScreen, _ := gocv.ImageToMatRGB(screenshot) - // - //skillMat := gocv.IMRead("assets/skills/blizzard.png", gocv.IMReadUnchanged) - // - //rgb := skillMat.Clone() - //gocv.CvtColor(skillMat, &rgb, gocv.ColorBGRAToBGR) - // - //res := gocv.NewMat() - //gocv.MatchTemplate(matScreen, rgb, &res, gocv.TmCcoeffNormed, gocv.NewMat()) - // - //_, maxVal, _, maxPos := gocv.MinMaxLoc(res) - - //rs := gcv.FindAllImg(img1, skill, 0.4) - - //fmt.Println(res, maxVal, maxPos) - //f, _ := os.Create("data.bin") - //enc := gob.NewEncoder(f) - //err := enc.Encode(&d) - //fmt.Println(err) - //f.Close() - d.Roster.FindByName("Ayuso") - fmt.Println(d.PlayerUnit.HPPercent()) - time.Sleep(time.Millisecond * 500) - } - - fmt.Printf("Read time: %dms\n", time.Since(start).Milliseconds()) -} diff --git a/cmd/pickup/main.go b/cmd/pickup/main.go deleted file mode 100644 index 9b4070ca8..000000000 --- a/cmd/pickup/main.go +++ /dev/null @@ -1,73 +0,0 @@ -package main - -import ( - "log" - - "github.com/go-vgo/robotgo" - "github.com/hectorgimenez/d2go/pkg/memory" - "github.com/hectorgimenez/koolo/internal/action" - "github.com/hectorgimenez/koolo/internal/character" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/health" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" - "github.com/hectorgimenez/koolo/internal/reader" - "github.com/hectorgimenez/koolo/internal/town" - "github.com/hectorgimenez/koolo/internal/ui" - "github.com/lxn/win" - "go.uber.org/zap" -) - -func main() { - err := config.Load() - if err != nil { - log.Fatalf("Error loading configuration: %s", err.Error()) - } - - logger, _ := zap.NewDevelopment() - - window := robotgo.FindWindow("Diablo II: Resurrected") - if window == win.HWND_TOP { - panic(err) - } - win.SetForegroundWindow(window) - - pos := win.WINDOWPLACEMENT{} - point := win.POINT{} - win.ClientToScreen(window, &point) - win.GetWindowPlacement(window, &pos) - - hid.WindowLeftX = int(point.X + 1) - hid.WindowTopY = int(point.Y) - hid.GameAreaSizeX = int(pos.RcNormalPosition.Right) - hid.WindowLeftX - 9 - hid.GameAreaSizeY = int(pos.RcNormalPosition.Bottom) - hid.WindowTopY - 9 - helper.Sleep(1000) - - process, err := memory.NewProcess() - if err != nil { - panic(err) - } - - gd := memory.NewGameReader(process) - gr := &reader.GameReader{GameReader: gd} - bm := health.NewBeltManager(logger) - sm := town.NewShopManager(logger, bm) - char, err := character.BuildCharacter(logger) - if err != nil { - panic(err) - } - tf, err := ui.NewTemplateFinder(logger, "assets") - if err != nil { - panic(err) - } - - b := action.NewBuilder(logger, sm, bm, gr, char, tf) - a := b.ItemPickup(true, -1) - - gr.GetData(true) - - for err == nil { - data := gr.GetData(false) - err = a.NextStep(logger, data) - } -} diff --git a/config/Settings.json b/config/Settings.json new file mode 100644 index 000000000..195247811 --- /dev/null +++ b/config/Settings.json @@ -0,0 +1,99 @@ +{ + "Advanced Audio Preset": 0, + "Allow Bnet Friends Join": 1, + "Allow Chat": 1, + "Always Run": 1, + "Ambient Occlusion Quality": 0, + "Anti Aliasing": 0, + "Atmospherics Quality": 4, + "Audio AdvancedAmbient Preset 0": 100, + "Audio AdvancedCombat Preset 0": 100, + "Audio AdvancedItems Preset 0": 100, + "Audio AdvancedMonsters Preset 0": 100, + "Audio AdvancedPlayerSkills Preset 0": 100, + "Audio AdvancedSFX Preset 0": 100, + "Audio AdvancedUI Preset 0": 100, + "Audio AdvancedVO Preset 0": 100, + "Auto Gold Enabled": 1, + "Auto Party Invite": 1, + "Automap Room Name and Password": 1, + "Blended Shadows": 0, + "Camera Shake": 1, + "Character Detail": 2, + "Chat Background": 0, + "Chat Font Size": 1, + "Chat Gem Mode": 1, + "Color Blind Mode": 0, + "Color Blind Strength": 100, + "Combat Feedback": 0, + "Confine Cursor": 0, + "Contrast": 100, + "Controller Cursor Speed": 2800, + "Controller Feedback": 0, + "Controller Rumble Enabled": 1, + "Controller Swap Thumbsticks": 0, + "Controller Weapon Swap Separate Bindings": 0, + "Default Key Bindings": 0, + "Desecrated Zones Enabled": 1, + "Display Active Skill Bindings": 0, + "Dynamic Resolution Scaling": 0, + "Environment Detail": 2, + "First Time": 1, + "Framerate Cap": 60, + "Framerate Target": 0, + "Game Resolution": 1, + "Gamma": 155, + "GammaHD": 2400, + "Graphic Presets": 4, + "Graphics Mode": 0, + "GreetingsSubtitles": 0, + "HDRContrast": 200, + "Help Menu": 1, + "Item Name Display": 0, + "Item Tooltip Hotkey Appender": 1, + "Legacy Graphics Mode": 0, + "Light Quality": 2, + "Lobby Allow Bnet Friends Join": 1, + "Lobby Difficulty": 0, + "Lobby Level Difference Enabled": 0, + "Lobby Max Difference": 10, + "Lobby Max Players": 8, + "Lobby Wide Item Drop Enabled": 1, + "Low Vision Mode": 0, + "Master Volume": 0, + "Max Difference": 10, + "Max Difference Enabled": 0, + "Max Players": 8, + "MaxLuminance": 600, + "Mono Mode": 0, + "Music Volume": 0, + "NPC Speech": 2, + "NVIDIA DLSS": 5, + "Offline Difficulty Scaling": 1, + "Options Music": 1, + "PaperWhiteNits": 200, + "Persistent Ignore": 1, + "Perspective": 0, + "Private Game Security": 0, + "Quick Cast Enabled": 0, + "Resolution Scale": 100, + "Safe Screen Percent": 100, + "Screen Resolution (Windowed)": "1280x720", + "Shadow Quality": 1, + "Sharpening": 6, + "Show Clock": 0, + "Show HP Text": 1, + "Show MP Text": 0, + "Skill Ammo Feedback": 0, + "Texture Anisotropy": 0, + "Texture Quality": 2, + "Transparency Quality": 3, + "Tutorial": 3, + "VSync": 0, + "Verb Disable": 0, + "Vfx Lighting Quality": 0, + "Vfx Quality": 0, + "VoiceLanguage": 0, + "Wide Item Drop Enabled": 1, + "Window Mode": 0 +} \ No newline at end of file diff --git a/config/koolo.yaml.dist b/config/koolo.yaml.dist new file mode 100644 index 000000000..9b8f4f20f --- /dev/null +++ b/config/koolo.yaml.dist @@ -0,0 +1,22 @@ +firstRun: true # If set to true next time the bot starts it will show the setup wizard +useCustomSettings: true # If set to true, koolo will use config/Settings.json file to load game settings instead of default one. +gameWindowArrangement: true # If set to true, game windows will be automatically repositioned to avoid overlapping +debug: + log: true # Prints extra log information + screenshots: false # Saves screenshots of the game in case of errors + renderMap: false # Render current map data into 'cg.png' file + +logSaveDirectory: logs +D2LoDPath: 'E:\games\Diablo II' # Path to Diablo II Lord of Destruction 1.13c directory +D2RPath: 'C:\Program Files (x86)\Diablo II Resurrected' # Path to Diablo II Resurrected directory + +# In order to use to Discord Bot, you need the Application Token. https://discord.com/developers/docs/intro +discord: + enabled: false + channelId: '' + token: '' + +telegram: + enabled: false + chatId: 0 + token: '' \ No newline at end of file diff --git a/config/config.yaml.dist b/config/template/config.yaml similarity index 60% rename from config/config.yaml.dist rename to config/template/config.yaml index cb34b6fd8..14fef4883 100644 --- a/config/config.yaml.dist +++ b/config/template/config.yaml @@ -1,21 +1,35 @@ -debug: - log: true # Prints extra log information - renderMap: false # Render current map data into 'cg.png' file - -logFilePath: log.txt maxGameLength: 500 # Max game length (in seconds), bot will try to quit game arrived that point -D2LoDPath: 'E:\games\Diablo II' -# In order to use to Discord Bot, you need the Application Token. https://discord.com/developers/docs/intro -discord: - enabled: false - channelId: '' - token: '' +# Required to avoid the 30 days not logged issue, since the game requires internet connection even to play offline +username: '' # Battle.net username +password: '' # Battle.net pwd +realm: 'eu.actual.battle.net' # Battle.net realm (kr.actual.battle.net, us.actual.battle.net, eu.actual.battle.net) +authMethod: 'None' # Authentication method the bot will use (None, BattleNetClient, UsernamePassword) +characterName: '' # If left empty, koolo will use first listed character, if name is wrong, it will fail to create the game +commandLineArgs: '' # Command line arguments for D2 +killD2OnStop: true # Terminate D2 process on bot stop +classicMode: false # Set to true to use legacy graphics +closeMiniPanel: false # Set to true to close the mini panel at start of game in legacy graphics +hidePortraits: true # Set to true to hide mercenary and other players portraits (avatar) +enableCubeRecipes: true # Enable cubing of flawlesses and tokens -telegram: +scheduler: enabled: false - chatId: 0 - token: '' + days: + - dayOfWeek: 0 + timeRange: [] + - dayOfWeek: 1 + timeRange: [] + - dayOfWeek: 2 + timeRange: [] + - dayOfWeek: 3 + timeRange: [] + - dayOfWeek: 4 + timeRange: [] + - dayOfWeek: 5 + timeRange: [] + - dayOfWeek: 6 + timeRange: [] health: # Healing configuration, all values in % healingPotionAt: 75 @@ -27,41 +41,6 @@ health: # Healing configuration, all values in % chickenAt: 30 mercChickenAt: 10 -bindings: - openInventory: i - openCharacterScreen: c - openSkillTree: t - openQuestLog: q - potion1: 1 - potion2: 2 - potion3: 3 - potion4: 4 - - # Skills - forceMove: e - standStill: shift - swapWeapon: w - teleport: f1 - tp: f4 - CTABattleCommand: f6 - CTABattleOrders: f7 - - # Class specific bindings - sorceress: - blizzard: f2 - staticField: f3 - frozenArmor: f5 - fireBall: f2 # During leveling only - nova: f2 - energyShield: f8 - - paladin: - concentration: f2 # Will be used for Might during leveling - holyShield: f5 - vigor: f1 - redemption: f8 - cleansing: f4 - inventory: inventoryLock: - [ 1, 1, 1, 1, 1, 1, 1, 0, 0, 0 ] # 0: Item locked and won't be moved. @@ -69,30 +48,47 @@ inventory: - [ 1, 1, 1, 1, 1, 1, 1, 0, 0, 0 ] - [ 1, 1, 1, 1, 1, 1, 1, 0, 0, 0 ] - beltColumns: # Amount of columns for each type of potion, sum of all should not be more than 4 - healing: 2 - mana: 1 - rejuvenation: 1 + beltColumns: [healing, healing, mana, rejuvenation] # 4 values, each represents the belt column type, allowed values: healing, mana, rejuvenation character: - class: sorceress # Allowed values: sorceress, lightning, hammerdin, paladin (leveling only) - castingFrames: 8 # https://diablo2.diablowiki.net/Breakpoints#Faster_Cast_Rate + class: sorceress # Allowed values: sorceress, lightning, hammerdin, foh, paladin (leveling only) useMerc: true + stashToShared: false + useTeleport: true # If set to false, bot will not use teleport skill and will walk to the destination game: + minGoldPickupThreshold: 500000 # If total gold amount is less than this, bot will pick up and sell magic+ items clearTPArea: true # Will clear the TP area before clicking it difficulty: hell # Allowed values: normal, nightmare, hell randomizeRuns: true # Will randomize the order of the runs each game # Just add the runs you want to do and they will be executed respecting the order, unless randomizeRuns is set to true # Available runs: countess, andariel, ancient_tunnels, summoner, mephisto, council, eldritch, pindleskin, nihlathak, - # tristram, lower_kurast, stony_tomb, pit, arachnid_lair, tal_rasha_tombs, baal, diablo, cows, terror_zone + # tristram, lower_kurast, lower_kurast_chest, stony_tomb, pit, arachnid_lair, tal_rasha_tombs, baal, diablo, cows, terror_zone # leveling: there is a "leveling" run, in combination with "sorceress or paladin" class will be able to start leveling character from level 1 (don't expect too much) # terror_zone: will detect current TZ and clear it - runs: [stony_tomb, pit, arachnid_lair] + runs: [ stony_tomb, pit, arachnid_lair ] # Specific runs settings pindleskin: skipOnImmunities: [ ] # Allowed values: cold, fire, light, poison + stony_tomb: + openChests: true + focusOnElitePacks: false + ancient_tunnels: + openChests: true + focusOnElitePacks: false + drifter_cavern: + openChests: true + focusOnElitePacks: false + spider_cavern: + openChests: true + focusOnElitePacks: false + pit: + # default - Outer Cloister -> Monastery Gates -> Tamoe Highland + moveThroughBlackMarsh: false # Use Black Marsh -> Tamoe Highland route + openChests: true + focusOnElitePacks: false + onlyClearLevel2: false mephisto: killCouncilMembers: true # Will kill the council members after killing Mephisto openChests: true # Will open chests after killing Mephisto @@ -101,9 +97,20 @@ game: clearPortal: true # Kills Rakanishu and makes easier selecting the portal nihlathak: clearArea: true + diablo: + killDiablo: true # Should bot kill Diablo after seals + clearArea: true # Should bot clear Chaos Sanctuary + onlyElites: true # Should bot target only elites baal: killBaal: false - terrorZone: + dollQuit: false + soulQuit: false + eldritch: + killShenk: true + leveling: + ensurePointsAllocation: true # Bot will allocate skill and stat points by itself or perform stat/skill reset. Set to false if you do NOT want it + ensureKeyBinding: true # Bot will set key bindings by itself. Set to false if you want to do it manually + terror_zone: focusOnElitePacks: false # Will clear only Elite monsters skipOnImmunities: [ ] # Allowed values: cold, fire, light, poison skipOtherRuns: false # If current TZ is allowed, will skip other runs and only do TZ instead @@ -186,8 +193,10 @@ companion: enabled: false leader: true leaderName: '' - remote: discord # Only discord supported, telegram doesn't allow interacting between bots - gameNameTemplate: + attack: true # If set to true, character will try to attack the same target as the leader + followLeader: true # If set to true, character will follow the leader, otherwise will stay in the same area + gameNameTemplate: game- # Template for the game name, for example "game-" will lead to "game-1", "game-2", etc. + gamePassword: xxx # Gambling settings. If enabled, bot will start gambling when all the gold stash tabs are full. # While gold > 500k it will iterate over the items list trying to buy one of each item type. @@ -195,3 +204,8 @@ companion: gambling: enabled: true # If gambling is disabled, bot will stop picking up gold when can not carry more items: [ coronet, amulet, ring ] # Items to gamble, same value as [name] in pickit files. + +backtotown: + noHpPotions: true + noMpPotions: false + mercDied: true diff --git a/config/pickit/crafted.nip b/config/template/pickit/crafted.nip similarity index 98% rename from config/pickit/crafted.nip rename to config/template/pickit/crafted.nip index 272732c23..3d784a343 100644 --- a/config/pickit/crafted.nip +++ b/config/template/pickit/crafted.nip @@ -1,5 +1,5 @@ // blood rings -[type] == ring && [quality] == crafted # ([fcr]*18 + [strength]*8 + [dexterity]*10 + [maxhp]*4.5 + [coldresist]*2.8 + [fireresist]*2.8 + [lightresist]*2.8 + [poisonresist]*2.2 + [maxmana]*1.3 + [hpregen]*12 + [tohit]*1 + [mindamage]*12) >= 575 +[type] == ring && [quality] == crafted # ([fcr]*18 + [strength]*8 + [dexterity]*10 + [maxhp]*4.5 + [coldresist]*2.8 + [fireresist]*2.8 + [lightresist]*2.8 + [poisonresist]*2.2 + [maxmana]*1.3 + [hpregen]*12 + [tohit]*1) >= 575 // safety amulets //[type] == amulet && [quality] == crafted # [itemaddclassskills] >= 2 && [toblock] >= 5 && ([fcr]*18 + [strength]*6.7 + [dexterity]*7.5 + [maxhp]*4.5 + [coldresist]*2.1 + [fireresist]*2.1 + [lightresist]*2.1 + [poisonresist]*1.65 + [maxmana]*1.3 + [hpregen]*10.8 + [toblock]*18 + [frw]*18) >= 575 diff --git a/config/pickit/magic.nip b/config/template/pickit/magic.nip similarity index 100% rename from config/pickit/magic.nip rename to config/template/pickit/magic.nip diff --git a/config/pickit/misc.nip b/config/template/pickit/misc.nip similarity index 100% rename from config/pickit/misc.nip rename to config/template/pickit/misc.nip diff --git a/config/pickit/rare.nip b/config/template/pickit/rare.nip similarity index 99% rename from config/pickit/rare.nip rename to config/template/pickit/rare.nip index 064656cf3..d89f1afcc 100644 --- a/config/pickit/rare.nip +++ b/config/template/pickit/rare.nip @@ -3,7 +3,7 @@ //[type] == jewel && [quality] == rare # (([plusdefense] >= 35) + ([maxdamage] >= 7) + ([enhanceddamage] >= 26) + ([itemdamagetomana] >= 10) + ([tohit] >= 50) + ([itemmagicbonus] >= 7) + ([maxmana] >= 12) + ([coldresist]+[fireresist]+[lightresist]+[poisonresist] >= 32) + ([coldresist] >= 25) + ([fireresist] >= 25) + ([lightresist] >= 25) + ([poisonresist] >= 28) + ([mindamage] >= 8) + ([fhr] >= 7) + ([hpregen] >= 3) + ([itemgoldbonus] >= 30) + ([maxhp] >= 7) + ([itemreqpercent] == -15) + ([strength] >= 7) + ([dexterity] >= 7) + ([energy] >= 8)) >= 4 // rare rings -[type] == ring && [quality] == rare # ([fcr]*18 + [strength]*8 + [dexterity]*10 + [maxhp]*4.5 + [coldresist]*2.8 + [fireresist]*2.8 + [lightresist]*2.8 + [poisonresist]*2.2 + [maxmana]*1.3 + [hpregen]*12 + [tohit]*1 + [mindamage]*12) >= 500 +[type] == ring && [quality] == rare # ([fcr]*18 + [strength]*8 + [dexterity]*10 + [maxhp]*4.5 + [coldresist]*2.8 + [fireresist]*2.8 + [lightresist]*2.8 + [poisonresist]*2.2 + [maxmana]*1.3 + [hpregen]*12 + [tohit]*1) >= 500 // rare amulets [type] == amulet && [quality] == rare # [itemaddclassskills] >= 2 && ([fcr]*18 + [strength]*6.7 + [dexterity]*7.5 + [maxhp]*4.5 + [coldresist]*2.1 + [fireresist]*2.1 + [lightresist]*2.1 + [poisonresist]*1.65 + [maxmana]*1.3 + [hpregen]*10.8) >= 500 diff --git a/config/pickit/set.nip b/config/template/pickit/set.nip similarity index 100% rename from config/pickit/set.nip rename to config/template/pickit/set.nip diff --git a/config/pickit/unid.nip b/config/template/pickit/unid.nip similarity index 100% rename from config/pickit/unid.nip rename to config/template/pickit/unid.nip diff --git a/config/pickit/unique.nip b/config/template/pickit/unique.nip similarity index 100% rename from config/pickit/unique.nip rename to config/template/pickit/unique.nip diff --git a/config/pickit/white.nip b/config/template/pickit/white.nip similarity index 100% rename from config/pickit/white.nip rename to config/template/pickit/white.nip diff --git a/config/pickit_leveling/leveling.nip b/config/template/pickit_leveling/leveling.nip similarity index 70% rename from config/pickit_leveling/leveling.nip rename to config/template/pickit_leveling/leveling.nip index 33ced4062..1aac7cb26 100644 --- a/config/pickit_leveling/leveling.nip +++ b/config/template/pickit_leveling/leveling.nip @@ -48,3 +48,19 @@ [type] == armor # [sockets] == 3 // Treachery [type] == armor # [sockets] == 2 // Stealth [type] == helm # [sockets] == 2 // Lore + +// Paladin Shields +[name] == rondache && [flag] != ethereal // Spirit +[name] == akarantarge && [flag] != ethereal // Spirit +[name] == aerinshield && [flag] != ethereal // Spirit +[name] == akaranrondache && [flag] != ethereal // Spirit +[name] == heraldicshield && [flag] != ethereal // Spirit + + +[type] == gloves && [quality] >= rare +[type] == boots && [quality] >= rare +[type] == amulet && [quality] >= rare +[type] == ring && [quality] >= rare +[type] == orb && [quality] >= rare +[type] == helm && [quality] >= rare +[type] == polearm && [quality] >= rare diff --git a/config/pickit_leveling/quest.nip b/config/template/pickit_leveling/quest.nip similarity index 100% rename from config/pickit_leveling/quest.nip rename to config/template/pickit_leveling/quest.nip diff --git a/go.mod b/go.mod index 049a378a4..1781317b7 100644 --- a/go.mod +++ b/go.mod @@ -1,45 +1,25 @@ module github.com/hectorgimenez/koolo -go 1.21 +go 1.23 require ( - github.com/beefsack/go-astar v0.0.0-20200827232313-4ecf9e304482 - github.com/bwmarrin/discordgo v0.27.1 + github.com/billgraziano/dpapi v0.5.0 + github.com/bwmarrin/discordgo v0.28.1 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 - github.com/go-vgo/robotgo v0.110.0 - github.com/hectorgimenez/d2go v0.0.0-20231024132949-8ae0eb55c201 + github.com/gorilla/websocket v1.5.3 + github.com/hectorgimenez/d2go v0.0.0-20250314185000-169a76515285 + github.com/inkeliz/gowebview v1.0.1 github.com/lxn/win v0.0.0-20210218163916-a377121e959e - github.com/robotn/gohook v0.41.0 - go.uber.org/zap v1.26.0 - gocv.io/x/gocv v0.35.0 - golang.org/x/sync v0.5.0 - golang.org/x/sys v0.14.0 + github.com/otiai10/copy v1.14.0 + golang.org/x/sync v0.10.0 + golang.org/x/sys v0.28.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/gen2brain/shm v0.0.0-20230802011745-f2460f5984f7 // indirect - github.com/go-ole/go-ole v1.3.0 // indirect - github.com/gorilla/websocket v1.5.1 // indirect - github.com/jezek/xgb v1.1.0 // indirect - github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237 // indirect - github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a // indirect - github.com/otiai10/gosseract v2.2.1+incompatible // indirect - github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b // indirect - github.com/robotn/xgb v0.0.0-20190912153532-2cb92d044934 // indirect - github.com/robotn/xgbutil v0.0.0-20190912154524-c861d6f87770 // indirect - github.com/shirou/gopsutil/v3 v3.23.10 // indirect - github.com/shoenig/go-m1cpu v0.1.6 // indirect - github.com/tklauser/go-sysconf v0.3.12 // indirect - github.com/tklauser/numcpus v0.6.1 // indirect - github.com/vcaesar/gops v0.30.2 // indirect - github.com/vcaesar/imgo v0.40.0 // indirect - github.com/vcaesar/keycode v0.10.1 // indirect - github.com/vcaesar/tt v0.20.0 // indirect - github.com/winlabs/gowin32 v0.0.0-20221003142512-0d265587d3c9 // indirect - github.com/yusufpapurcu/wmi v1.2.3 // indirect - go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.15.0 // indirect - golang.org/x/image v0.14.0 // indirect - golang.org/x/net v0.18.0 // indirect + git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect + github.com/expr-lang/expr v1.16.9 // indirect + github.com/inkeliz/w32 v1.0.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + golang.org/x/crypto v0.31.0 // indirect ) diff --git a/go.sum b/go.sum index a53632f3a..25e9672e0 100644 --- a/go.sum +++ b/go.sum @@ -1,119 +1,76 @@ -github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298/go.mod h1:D+QujdIlUNfa0igpNMk6UIvlb6C252URs4yupRUV4lQ= -github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966/go.mod h1:Mid70uvE93zn9wgF92A/r5ixgnvX8Lh68fxp9KQBaI0= -github.com/beefsack/go-astar v0.0.0-20200827232313-4ecf9e304482 h1:p4g4uok3+r6Tg6fxXEQUAcMAX/WdK6WhkQW9s0jaT7k= -github.com/beefsack/go-astar v0.0.0-20200827232313-4ecf9e304482/go.mod h1:Cu3t5VeqE8kXjUBeNXWQprfuaP5UCIc5ggGjgMx9KFc= -github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY= -github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +git.wow.st/gmp/jni v0.0.0-20200827154156-014cd5c7c4c0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo= +git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI= +git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo= +github.com/billgraziano/dpapi v0.5.0 h1:pcxA17vyjbDqYuxCFZbgL9tYIk2xgbRZjRaIbATwh+8= +github.com/billgraziano/dpapi v0.5.0/go.mod h1:lmEcZjRfLCSbUTsRu8V2ti6Q17MvnKn3N9gQqzDdTh0= +github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4= +github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gen2brain/shm v0.0.0-20230802011745-f2460f5984f7 h1:VLEKvjGJYAMCXw0/32r9io61tEXnMWDRxMk+peyRVFc= -github.com/gen2brain/shm v0.0.0-20230802011745-f2460f5984f7/go.mod h1:uF6rMu/1nvu+5DpiRLwusA6xB8zlkNoGzKn8lmYONUo= -github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= -github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/expr-lang/expr v1.16.9 h1:WUAzmR0JNI9JCiF0/ewwHB1gmcGw5wW7nWt8gc6PpCI= +github.com/expr-lang/expr v1.16.9/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= -github.com/go-vgo/robotgo v0.110.0 h1:655fmWO4yD5dn/y70Z50wdxnqg2LvmRD+MZ1iOXuGI0= -github.com/go-vgo/robotgo v0.110.0/go.mod h1:DdJUdi6mEU8ttHMbow6hKD1TjgsfgJC/H+4dusok8Uw= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= -github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= -github.com/hectorgimenez/d2go v0.0.0-20231024132949-8ae0eb55c201 h1:fz5W8/MM6EsujQhD2M40rStUFxMKqNKH1sGM6Br5LxA= -github.com/hectorgimenez/d2go v0.0.0-20231024132949-8ae0eb55c201/go.mod h1:HANIsWVCTQeNdEC6whvHegpOlRckU2TPqXr+nEXvoc8= -github.com/hybridgroup/mjpeg v0.0.0-20140228234708-4680f319790e/go.mod h1:eagM805MRKrioHYuU7iKLUyFPVKqVV6um5DAvCkUtXs= -github.com/jezek/xgb v1.1.0 h1:wnpxJzP1+rkbGclEkmwpVFQWpuE2PUGNUzP8SbfFobk= -github.com/jezek/xgb v1.1.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= -github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237 h1:YOp8St+CM/AQ9Vp4XYm4272E77MptJDHkwypQHIRl9Q= -github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237/go.mod h1:e7qQlOY68wOz4b82D7n+DdaptZAi+SHW0+yKiWZzEYE= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= -github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a h1:N9zuLhTvBSRt0gWSiJswwQ2HqDmtX/ZCDJURnKUt1Ik= -github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hectorgimenez/d2go v0.0.0-20241216152730-04f45d306978 h1:nfC7EZCCDEXNisTgE8LuCPRRxBbtnhP3hX1nxmZaS3s= +github.com/hectorgimenez/d2go v0.0.0-20241216152730-04f45d306978/go.mod h1:EOVayMaK8D13wsZiZ6n8AK3+Qflm1wHZsCqnzlVIci0= +github.com/hectorgimenez/d2go v0.0.0-20241220085033-444f4b0e9b01 h1:2sXMzraTLYD5E6eY9sHA6OkDfYrqu3l2sbgAXENk7cg= +github.com/hectorgimenez/d2go v0.0.0-20241220085033-444f4b0e9b01/go.mod h1:EOVayMaK8D13wsZiZ6n8AK3+Qflm1wHZsCqnzlVIci0= +github.com/hectorgimenez/d2go v0.0.0-20241230114702-591fbf16055e h1:gal4qtJv+5ue0NB+PxIN4E8bx3f2p3w7aSRUCkq/D+s= +github.com/hectorgimenez/d2go v0.0.0-20241230114702-591fbf16055e/go.mod h1:EOVayMaK8D13wsZiZ6n8AK3+Qflm1wHZsCqnzlVIci0= +github.com/hectorgimenez/d2go v0.0.0-20250106015039-8bce23b03973 h1:dRqPFWAf0yb1T2d/NQLOLTJkega8E8MZlVUuMKOrPkA= +github.com/hectorgimenez/d2go v0.0.0-20250106015039-8bce23b03973/go.mod h1:EOVayMaK8D13wsZiZ6n8AK3+Qflm1wHZsCqnzlVIci0= +github.com/hectorgimenez/d2go v0.0.0-20250119163231-415ac29ab1ab h1:vrZ5hlN1qnUXPHjVRCvSuWyjfauNopuMLU+/YmBge2c= +github.com/hectorgimenez/d2go v0.0.0-20250119163231-415ac29ab1ab/go.mod h1:EOVayMaK8D13wsZiZ6n8AK3+Qflm1wHZsCqnzlVIci0= +github.com/hectorgimenez/d2go v0.0.0-20250219111533-438f80b9113e h1:upmLVX6I3rYRJZXwVJ0ZDv0O0zcSpVLUKeCucbuHjyg= +github.com/hectorgimenez/d2go v0.0.0-20250219111533-438f80b9113e/go.mod h1:EOVayMaK8D13wsZiZ6n8AK3+Qflm1wHZsCqnzlVIci0= +github.com/hectorgimenez/d2go v0.0.0-20250221161127-5455fd977276 h1:A4LnLo5dsLmUzoDtndegivn5hx2TfiXL/18bepMxW70= +github.com/hectorgimenez/d2go v0.0.0-20250221161127-5455fd977276/go.mod h1:EOVayMaK8D13wsZiZ6n8AK3+Qflm1wHZsCqnzlVIci0= +github.com/hectorgimenez/d2go v0.0.0-20250227150416-4204d68f233f h1:Qp4gJANcIkJJdnImJ9G1LRLqZh0gXnONm9awBEMEjPk= +github.com/hectorgimenez/d2go v0.0.0-20250227150416-4204d68f233f/go.mod h1:EOVayMaK8D13wsZiZ6n8AK3+Qflm1wHZsCqnzlVIci0= +github.com/hectorgimenez/d2go v0.0.0-20250314185000-169a76515285 h1:UOpB9azve0kdL5/E/qBT725Fft+fk33o0NUIP8GsFUo= +github.com/hectorgimenez/d2go v0.0.0-20250314185000-169a76515285/go.mod h1:EOVayMaK8D13wsZiZ6n8AK3+Qflm1wHZsCqnzlVIci0= +github.com/inkeliz/gowebview v1.0.1 h1:4gpLE2qt4kV3DB+xHkHKUeLLiGPN5Xw3or9A3hVqYyA= +github.com/inkeliz/gowebview v1.0.1/go.mod h1:4SNjXp/fogE11MwvJD67kMBmSObY2BBqinEgH8+8eM8= +github.com/inkeliz/w32 v1.0.2 h1:Es8Bmw9ApOY0PVRpGs7wsqIKdK5C3xBkP5TOATfVmtU= +github.com/inkeliz/w32 v1.0.2/go.mod h1:S/jA64It0hxx6uFFOwWBfAjNqvekp0iv1n98qLKd89I= github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc= github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= -github.com/otiai10/gosseract v2.2.1+incompatible h1:Ry5ltVdpdp4LAa2bMjsSJH34XHVOV7XMi41HtzL8X2I= -github.com/otiai10/gosseract v2.2.1+incompatible/go.mod h1:XrzWItCzCpFRZ35n3YtVTgq5bLAhFIkascoRo8G32QE= -github.com/otiai10/mint v1.3.0 h1:Ady6MKVezQwHBkGzLFbrsywyp09Ah7rkmfjV3Bcr5uc= -github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= -github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= +github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= +github.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks= +github.com/otiai10/mint v1.5.1/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b h1:0LFwY6Q3gMACTjAbMZBjXAqTOzOwFaj2Ld6cjeQ7Rig= -github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/robotn/gohook v0.41.0 h1:h1vK3w/UQpq0YkIiGnxm9Awv85W54esL0/NUYGueggo= -github.com/robotn/gohook v0.41.0/go.mod h1:FedpuAkVqzM5t67L5fcf3hSSCUDO9cM5YkWCw1U+nuc= -github.com/robotn/xgb v0.0.0-20190912153532-2cb92d044934 h1:2lhSR8N3T6I30q096DT7/5AKEIcf1vvnnWAmS0wfnNY= -github.com/robotn/xgb v0.0.0-20190912153532-2cb92d044934/go.mod h1:SxQhJskUJ4rleVU44YvnrdvxQr0tKy5SRSigBrCgyyQ= -github.com/robotn/xgbutil v0.0.0-20190912154524-c861d6f87770 h1:2uX8QRLkkxn2EpAQ6I3KhA79BkdRZfvugJUzJadiJwk= -github.com/robotn/xgbutil v0.0.0-20190912154524-c861d6f87770/go.mod h1:svkDXUDQjUiWzLrA0OZgHc4lbOts3C+uRfP6/yjwYnU= -github.com/shirou/gopsutil/v3 v3.23.10 h1:/N42opWlYzegYaVkWejXWJpbzKv2JDy3mrgGzKsh9hM= -github.com/shirou/gopsutil/v3 v3.23.10/go.mod h1:JIE26kpucQi+innVlAUnIEOSBhBUkirr5b44yr55+WE= -github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= -github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= -github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= -github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= -github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= -github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= -github.com/vcaesar/gops v0.30.2 h1:fANyUGCjLkfcYmJRVnXv+QZhT8cL2e0GWpRFZe58p/4= -github.com/vcaesar/gops v0.30.2/go.mod h1:2NSA2Q9M1irGnGD9tWdo0Z+MwKjUj4Q4EgUDukN/Vsk= -github.com/vcaesar/imgo v0.40.0 h1:okI1eonRAfGLzjqgTIBkUwhm4j/rH19qGno4eFOBQsc= -github.com/vcaesar/imgo v0.40.0/go.mod h1:E5uI53XkEfbI20VvcIZ/19G2hHidPfH9h4NtQooEY+8= -github.com/vcaesar/keycode v0.10.1 h1:0DesGmMAPWpYTCYddOFiCMKCDKgNnwiQa2QXindVUHw= -github.com/vcaesar/keycode v0.10.1/go.mod h1:JNlY7xbKsh+LAGfY2j4M3znVrGEm5W1R8s/Uv6BJcfQ= -github.com/vcaesar/tt v0.20.0 h1:9t2Ycb9RNHcP0WgQgIaRKJBB+FrRdejuaL6uWIHuoBA= -github.com/vcaesar/tt v0.20.0/go.mod h1:GHPxQYhn+7OgKakRusH7KJ0M5MhywoeLb8Fcffs/Gtg= -github.com/winlabs/gowin32 v0.0.0-20221003142512-0d265587d3c9 h1:U8aCPFEMnxAEyj9IonhMVV1gSL4nzelh8uvoXp0hrq0= -github.com/winlabs/gowin32 v0.0.0-20221003142512-0d265587d3c9/go.mod h1:N51TYkG9JGR5sytj0EoPl31Xg2kuB507lxEmrwSNvfQ= -github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= -github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= -go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= -go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= -gocv.io/x/gocv v0.35.0 h1:Qaxb5KdVyy8Spl4S4K0SMZ6CVmKtbfoSGQAxRD3FZlw= -gocv.io/x/gocv v0.35.0/go.mod h1:oc6FvfYqfBp99p+yOEzs9tbYF9gOrAQSeL/dyIPefJU= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= -golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= -golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= -golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= -golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= -golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= -golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201101102859-da207088b7d1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= -golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/images/you_died.png b/images/you_died.png deleted file mode 100644 index c3e2acc79..000000000 Binary files a/images/you_died.png and /dev/null differ diff --git a/internal/action/action.go b/internal/action/action.go deleted file mode 100644 index d4f65af65..000000000 --- a/internal/action/action.go +++ /dev/null @@ -1,61 +0,0 @@ -package action - -import ( - "errors" - - "github.com/hectorgimenez/d2go/pkg/data" - "go.uber.org/zap" -) - -const maxRetries = 5 - -var ErrWillBeRetried = errors.New("error occurred, but it will be retried") -var ErrNoRecover = errors.New("unrecoverable error occurred, game can not continue") -var ErrCanBeSkipped = errors.New("error occurred, but this action is not critical and game can continue") -var ErrNoMoreSteps = errors.New("action finished, no more steps remaining") -var ErrLogAndContinue = errors.New("error occurred, but marking action as completed") - -type Action interface { - NextStep(logger *zap.Logger, d data.Data) error - Skip() -} - -type basicAction struct { - retries int - canBeSkipped bool - resetStepsOnError bool - markSkipped bool - ignoreErrors bool - repeatUntilNoMoreSteps bool -} - -type Option func(action *basicAction) - -func CanBeSkipped() Option { - return func(action *basicAction) { - action.canBeSkipped = true - } -} - -// IgnoreErrors will ignore the errors and mark the action as succeeded -func IgnoreErrors() Option { - return func(action *basicAction) { - action.ignoreErrors = true - } -} - -func Resettable() Option { - return func(action *basicAction) { - action.resetStepsOnError = true - } -} - -func RepeatUntilNoSteps() Option { - return func(action *basicAction) { - action.repeatUntilNoMoreSteps = true - } -} - -func (b *basicAction) Skip() { - b.markSkipped = true -} diff --git a/internal/action/action_builder.go b/internal/action/action_builder.go deleted file mode 100644 index 24e85274b..000000000 --- a/internal/action/action_builder.go +++ /dev/null @@ -1,29 +0,0 @@ -package action - -import ( - "github.com/hectorgimenez/koolo/internal/health" - "github.com/hectorgimenez/koolo/internal/reader" - "github.com/hectorgimenez/koolo/internal/town" - "github.com/hectorgimenez/koolo/internal/ui" - "go.uber.org/zap" -) - -type Builder struct { - logger *zap.Logger - sm town.ShopManager - bm health.BeltManager - gr *reader.GameReader - ch Character - tf *ui.TemplateFinder -} - -func NewBuilder(logger *zap.Logger, sm town.ShopManager, bm health.BeltManager, gr *reader.GameReader, ch Character, tf *ui.TemplateFinder) *Builder { - return &Builder{ - logger: logger, - sm: sm, - bm: bm, - gr: gr, - ch: ch, - tf: tf, - } -} diff --git a/internal/action/action_chain.go b/internal/action/action_chain.go deleted file mode 100644 index 983e69013..000000000 --- a/internal/action/action_chain.go +++ /dev/null @@ -1,58 +0,0 @@ -package action - -import ( - "errors" - - "github.com/hectorgimenez/d2go/pkg/data" - "go.uber.org/zap" -) - -type Chain struct { - basicAction - builder func(d data.Data) []Action - actions []Action -} - -func NewChain(builder func(d data.Data) []Action, opts ...Option) *Chain { - a := &Chain{ - builder: builder, - } - - for _, opt := range opts { - opt(&a.basicAction) - } - - return a -} - -func (a *Chain) NextStep(logger *zap.Logger, d data.Data) error { - if a.markSkipped { - return ErrNoMoreSteps - } - - if a.actions == nil { - a.actions = a.builder(d) - if a.actions == nil || len(a.actions) == 0 { - a.Skip() - return ErrNoMoreSteps - } - } - - var err error - for _, action := range a.actions { - err = action.NextStep(logger, d) - if errors.Is(err, ErrNoMoreSteps) || errors.Is(err, ErrLogAndContinue) { - continue - } - - return err - } - - // Reset actions, next iteration will try to build them again, if empty it will skip - if (errors.Is(err, ErrNoMoreSteps) || errors.Is(err, ErrLogAndContinue)) && a.repeatUntilNoMoreSteps { - a.actions = nil - return nil - } - - return ErrNoMoreSteps -} diff --git a/internal/action/action_step_chain.go b/internal/action/action_step_chain.go deleted file mode 100644 index 1c615be88..000000000 --- a/internal/action/action_step_chain.go +++ /dev/null @@ -1,90 +0,0 @@ -package action - -import ( - "errors" - "fmt" - "reflect" - - "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/koolo/internal/action/step" - "go.uber.org/zap" -) - -type StepChainAction struct { - basicAction - Steps []step.Step - builder func(d data.Data) []step.Step - builderExecuted bool -} - -func NewStepChain(builder func(d data.Data) []step.Step, opts ...Option) *StepChainAction { - a := &StepChainAction{ - builder: builder, - } - - for _, opt := range opts { - opt(&a.basicAction) - } - - return a -} - -func (a *StepChainAction) NextStep(logger *zap.Logger, d data.Data) error { - if a.markSkipped { - return ErrNoMoreSteps - } - - if a.builder != nil && !a.builderExecuted { - a.Steps = a.builder(d) - a.builderExecuted = true - } - - for _, s := range a.Steps { - if s.Status(d) != step.StatusCompleted { - lastRun := s.LastRun() - err := s.Run(d) - if s.LastRun().After(lastRun) { - //logger.Debug("Executed step", zap.String("step_name", reflect.TypeOf(s).Elem().Name())) - } - if err != nil { - a.retries++ - a.resetSteps() - } - - if a.retries >= maxRetries { - if a.ignoreErrors { - a.markSkipped = true - return errors.Join(err, ErrLogAndContinue) - } - if a.canBeSkipped { - return fmt.Errorf("%w: attempt limit reached on step: %s: %v", ErrCanBeSkipped, reflect.TypeOf(s).Elem().Name(), err) - } - return fmt.Errorf("%w: attempt limit reached on step: %s: %v", ErrNoRecover, reflect.TypeOf(s).Elem().Name(), err) - } - - return nil - } - } - - if a.repeatUntilNoMoreSteps { - a.Steps = a.builder(d) - // Continue execution if builder still returns steps - if a.Steps != nil && len(a.Steps) > 0 { - return nil - } else { - a.Skip() - } - } - - return ErrNoMoreSteps -} - -func (a *StepChainAction) resetSteps() { - if !a.resetStepsOnError { - return - } - - for _, s := range a.Steps { - s.Reset() - } -} diff --git a/internal/action/belt_manage.go b/internal/action/belt_manage.go new file mode 100644 index 000000000..151a61aa8 --- /dev/null +++ b/internal/action/belt_manage.go @@ -0,0 +1,89 @@ +package action + +import ( + "log/slog" + "strings" + "time" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/koolo/internal/context" +) + +func ManageBelt() error { + + ctx := context.Get() + ctx.SetLastAction("ManageBelt") + + // Check for misplaced potions + misplacedPotions := checkMisplacedPotions() + haveMisplacedPotions := len(misplacedPotions) > 0 + + // Consume misplaced potions + for haveMisplacedPotions { + + for _, potion := range misplacedPotions { + slog.Info("Consuming misplaced potion", "potion", potion.Name, "position", potion.Position) + ctx.HID.PressKey(ctx.Data.KeyBindings.UseBelt[potion.Position.X].Key1[0]) + time.Sleep(500 * time.Millisecond) + } + + misplacedPotions = checkMisplacedPotions() + haveMisplacedPotions = len(misplacedPotions) > 0 + + if !haveMisplacedPotions { + break + } + } + + return nil +} + +func checkMisplacedPotions() []data.Item { + ctx := context.Get() + ctx.SetLastAction("CheckMisplacedPotions") + + // Get list of potions in the first row + potions := []data.Item{} + + for _, pot := range ctx.Data.Inventory.Belt.Items { + if pot.Position.Y == 0 && (pot.Position.X == 0 || pot.Position.X == 1 || pot.Position.X == 2 || pot.Position.X == 3) { + potions = append(potions, pot) + } + } + + expectedPotions := ctx.Data.CharacterCfg.Inventory.BeltColumns + + // Count occurrences of each potion type + expectedCounts := make(map[string]int) + for _, potion := range expectedPotions { + expectedCounts[potion]++ + } + + // Helper function to match potion types + matchPotionType := func(actualPotion, expectedType string) bool { + return strings.Contains(strings.ToLower(actualPotion), expectedType) + } + + // Check for misplaced potions + misplacedPotions := []data.Item{} + + for _, potion := range potions { + matched := false + for expectedType, count := range expectedCounts { + if matchPotionType(string(potion.Name), expectedType) { + if count > 0 { + expectedCounts[expectedType]-- + } else { + misplacedPotions = append(misplacedPotions, potion) + } + matched = true + break + } + } + if !matched { + misplacedPotions = append(misplacedPotions, potion) + } + } + + return misplacedPotions +} diff --git a/internal/action/buff.go b/internal/action/buff.go index 3a3ef9183..d80242084 100644 --- a/internal/action/buff.go +++ b/internal/action/buff.go @@ -1,6 +1,7 @@ package action import ( + "log/slog" "time" "github.com/hectorgimenez/d2go/pkg/data" @@ -9,78 +10,126 @@ import ( "github.com/hectorgimenez/d2go/pkg/data/stat" "github.com/hectorgimenez/d2go/pkg/data/state" "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/utils" ) -var lastBuffedAt = time.Time{} +func BuffIfRequired() { + ctx := context.Get() -func (b *Builder) BuffIfRequired(d data.Data) *StepChainAction { - if !b.IsRebuffRequired(d) { - return nil + if !IsRebuffRequired() || ctx.Data.PlayerUnit.Area.IsTown() { + return } - return b.Buff() + // Don't buff if we have 2 or more monsters close to the character. + // Don't merge with the previous if, because we want to avoid this expensive check if we don't need to buff + closeMonsters := 0 + for _, m := range ctx.Data.Monsters { + if ctx.PathFinder.DistanceFromMe(m.Position) < 15 { + closeMonsters++ + } + // cheaper to check here and end function if say first 2 already < 15 + // so no need to compute the rest + if closeMonsters >= 2 { + return + } + } + + Buff() } -func (b *Builder) Buff() *StepChainAction { - return NewStepChain(func(d data.Data) (steps []step.Step) { - if d.PlayerUnit.Area.IsTown() || time.Since(lastBuffedAt) < time.Second*30 { - return nil - } +func Buff() { + ctx := context.Get() + ctx.SetLastAction("Buff") - steps = append(steps, b.buffCTA(d)...) + if ctx.Data.PlayerUnit.Area.IsTown() || time.Since(ctx.LastBuffAt) < time.Second*30 { + return + } - for buff, kb := range b.ch.BuffSkills() { - if _, found := d.PlayerUnit.Skills[buff]; !found { - return nil - } + // Check if we're in loading screen + if ctx.Data.OpenMenus.LoadingScreen { + ctx.Logger.Debug("Loading screen detected. Waiting for game to load before buffing...") + ctx.WaitForGameToLoad() - if kb != "" { - steps = append(steps, - step.SyncStep(func(_ data.Data) error { - hid.PressKey(kb) - helper.Sleep(300) - hid.Click(hid.RightButton) - helper.Sleep(300) - - return nil - }), - ) - } + // Give it half a second more + utils.Sleep(500) + } + + preKeys := make([]data.KeyBinding, 0) + for _, buff := range ctx.Char.PreCTABuffSkills() { + kb, found := ctx.Data.KeyBindings.KeyBindingForSkill(buff) + if !found { + ctx.Logger.Info("Key binding not found, skipping buff", slog.String("skill", buff.Desc().Name)) + } else { + preKeys = append(preKeys, kb) + } + } + + if len(preKeys) > 0 { + ctx.Logger.Debug("PRE CTA Buffing...") + for _, kb := range preKeys { + utils.Sleep(100) + ctx.HID.PressKeyBinding(kb) + utils.Sleep(180) + ctx.HID.Click(game.RightButton, 640, 340) + utils.Sleep(100) } + } + + buffCTA() - if len(steps) > 0 { - b.logger.Debug("Buffing...") - lastBuffedAt = time.Now() + postKeys := make([]data.KeyBinding, 0) + for _, buff := range ctx.Char.BuffSkills() { + kb, found := ctx.Data.KeyBindings.KeyBindingForSkill(buff) + if !found { + ctx.Logger.Info("Key binding not found, skipping buff", slog.String("skill", buff.Desc().Name)) + } else { + postKeys = append(postKeys, kb) } + } + + if len(postKeys) > 0 { + ctx.Logger.Debug("Post CTA Buffing...") - return steps - }) + for _, kb := range postKeys { + utils.Sleep(100) + ctx.HID.PressKeyBinding(kb) + utils.Sleep(180) + ctx.HID.Click(game.RightButton, 640, 340) + utils.Sleep(100) + } + ctx.LastBuffAt = time.Now() + } } -func (b *Builder) IsRebuffRequired(d data.Data) bool { +func IsRebuffRequired() bool { + ctx := context.Get() + ctx.SetLastAction("IsRebuffRequired") + // Don't buff if we are in town, or we did it recently (it prevents double buffing because of network lag) - if d.PlayerUnit.Area.IsTown() || time.Since(lastBuffedAt) < time.Second*30 { + if ctx.Data.PlayerUnit.Area.IsTown() || time.Since(ctx.LastBuffAt) < time.Second*30 { return false } - if b.isCTAEnabled(d) && (!d.PlayerUnit.States.HasState(state.Battleorders) || !d.PlayerUnit.States.HasState(state.Battlecommand)) { + if ctaFound(*ctx.Data) && (!ctx.Data.PlayerUnit.States.HasState(state.Battleorders) || !ctx.Data.PlayerUnit.States.HasState(state.Battlecommand)) { return true } // TODO: Find a better way to convert skill to state - buffs := b.ch.BuffSkills() - for buff, kb := range buffs { - if kb != "" { - if buff == skill.HolyShield && !d.PlayerUnit.States.HasState(state.Holyshield) { + buffs := ctx.Char.BuffSkills() + for _, buff := range buffs { + if _, found := ctx.Data.KeyBindings.KeyBindingForSkill(buff); found { + if buff == skill.HolyShield && !ctx.Data.PlayerUnit.States.HasState(state.Holyshield) { return true } - if buff == skill.FrozenArmor && (!d.PlayerUnit.States.HasState(state.Frozenarmor) && !d.PlayerUnit.States.HasState(state.Shiverarmor) && !d.PlayerUnit.States.HasState(state.Chillingarmor)) { + if buff == skill.FrozenArmor && (!ctx.Data.PlayerUnit.States.HasState(state.Frozenarmor) && !ctx.Data.PlayerUnit.States.HasState(state.Shiverarmor) && !ctx.Data.PlayerUnit.States.HasState(state.Chillingarmor)) { return true } - if buff == skill.EnergyShield && !d.PlayerUnit.States.HasState(state.Energyshield) { + if buff == skill.EnergyShield && !ctx.Data.PlayerUnit.States.HasState(state.Energyshield) { + return true + } + if buff == skill.CycloneArmor && !ctx.Data.PlayerUnit.States.HasState(state.Cyclonearmor) { return true } } @@ -89,43 +138,38 @@ func (b *Builder) IsRebuffRequired(d data.Data) bool { return false } -func (b *Builder) buffCTA(d data.Data) (steps []step.Step) { - if b.isCTAEnabled(d) { - b.logger.Debug("CTA found: swapping weapon and casting Battle Command / Battle Orders") +func buffCTA() { + ctx := context.Get() + ctx.SetLastAction("buffCTA") + + if ctaFound(*ctx.Data) { + ctx.Logger.Debug("CTA found: swapping weapon and casting Battle Command / Battle Orders") // Swap weapon only in case we don't have the CTA, sometimes CTA is already equipped (for example chicken previous game during buff stage) - if _, found := d.PlayerUnit.Skills[skill.BattleCommand]; !found { - steps = append(steps, step.SwapToCTA()) + if _, found := ctx.Data.PlayerUnit.Skills[skill.BattleCommand]; !found { + step.SwapToCTA() } - steps = append(steps, - step.SyncStep(func(d data.Data) error { - hid.PressKey(config.Config.Bindings.CTABattleCommand) - helper.Sleep(100) - hid.Click(hid.RightButton) - helper.Sleep(300) - hid.PressKey(config.Config.Bindings.CTABattleOrders) - helper.Sleep(100) - hid.Click(hid.RightButton) - helper.Sleep(100) - - return nil - }), - step.Wait(time.Millisecond*500), - step.SwapToMainWeapon(), - ) + ctx.HID.PressKeyBinding(ctx.Data.KeyBindings.MustKBForSkill(skill.BattleCommand)) + utils.Sleep(180) + ctx.HID.Click(game.RightButton, 300, 300) + utils.Sleep(100) + ctx.HID.PressKeyBinding(ctx.Data.KeyBindings.MustKBForSkill(skill.BattleOrders)) + utils.Sleep(180) + ctx.HID.Click(game.RightButton, 300, 300) + utils.Sleep(100) + + utils.Sleep(500) + step.SwapToMainWeapon() } - - return } -func (b *Builder) isCTAEnabled(d data.Data) bool { - if config.Config.Bindings.CTABattleCommand == "" || config.Config.Bindings.CTABattleOrders == "" { - return false - } +func ctaFound(d game.Data) bool { + for _, itm := range d.Inventory.ByLocation(item.LocationEquipped) { + _, boFound := itm.FindStat(stat.NonClassSkill, int(skill.BattleOrders)) + _, bcFound := itm.FindStat(stat.NonClassSkill, int(skill.BattleCommand)) - for _, itm := range d.Items.ByLocation(item.LocationEquipped) { - if itm.Stats[stat.NumSockets].Value == 5 && itm.Stats[stat.ReplenishLife].Value == 12 && itm.Stats[stat.NonClassSkill].Value > 0 && itm.Stats[stat.PreventMonsterHeal].Value > 0 { + if boFound && bcFound { return true } } diff --git a/internal/action/character.go b/internal/action/character.go deleted file mode 100644 index 3d5074ffd..000000000 --- a/internal/action/character.go +++ /dev/null @@ -1,39 +0,0 @@ -package action - -import ( - "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/d2go/pkg/data/skill" - "github.com/hectorgimenez/d2go/pkg/data/stat" - "github.com/hectorgimenez/koolo/internal/action/step" -) - -type Character interface { - BuffSkills() map[skill.Skill]string - KillCountess() Action - KillAndariel() Action - KillSummoner() Action - KillDuriel() Action - KillMephisto() Action - KillPindle(skipOnImmunities []stat.Resist) Action - KillNihlathak() Action - KillCouncil() Action - KillDiablo() Action - KillIzual() Action - KillBaal() Action - KillMonsterSequence( - monsterSelector func(d data.Data) (data.UnitID, bool), - skipOnImmunities []stat.Resist, - opts ...step.AttackOption, - ) Action -} - -type LevelingCharacter interface { - Character - // StatPoints Stats will be assigned in the order they are returned by this function. - StatPoints(data.Data) map[stat.ID]int - SkillPoints(data.Data) []skill.Skill - GetKeyBindings(data.Data) map[skill.Skill]string - ShouldResetSkills(data.Data) bool - KillAncients() Action - GetSkillTree() skill.Tree -} diff --git a/internal/action/clear_area.go b/internal/action/clear_area.go index 84f881203..4596f4b6f 100644 --- a/internal/action/clear_area.go +++ b/internal/action/clear_area.go @@ -1,22 +1,27 @@ package action import ( + "fmt" + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/koolo/internal/action/step" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" "github.com/hectorgimenez/koolo/internal/pather" - "go.uber.org/zap" ) -func (b *Builder) ClearAreaAroundPlayer(distance int) Action { - originalPosition := data.Position{} - return b.ch.KillMonsterSequence(func(d data.Data) (data.UnitID, bool) { - if originalPosition.X == 0 && originalPosition.Y == 0 { - originalPosition = d.PlayerUnit.Position - } +func ClearAreaAroundPlayer(radius int, filter data.MonsterFilter) error { + return ClearAreaAroundPosition(context.Get().Data.PlayerUnit.Position, radius, filter) +} - for _, m := range d.Monsters.Enemies() { - d := pather.DistanceFromPoint(originalPosition, m.Position) - if d <= distance { - b.logger.Debug("Clearing area...", zap.Int("monsterID", int(m.Name))) +func ClearAreaAroundPosition(pos data.Position, radius int, filter data.MonsterFilter) error { + ctx := context.Get() + ctx.SetLastAction("ClearAreaAroundPosition") + + return ctx.Char.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + for _, m := range d.Monsters.Enemies(filter) { + distanceToTarget := pather.DistanceFromPoint(pos, m.Position) + if ctx.Data.AreaData.IsWalkable(m.Position) && distanceToTarget <= radius { return m.UnitID, true } } @@ -24,3 +29,46 @@ func (b *Builder) ClearAreaAroundPlayer(distance int) Action { return 0, false }, nil) } + +func ClearThroughPath(pos data.Position, radius int, filter data.MonsterFilter) error { + ctx := context.Get() + + lastMovement := false + for { + ctx.PauseIfNotPriority() + + ClearAreaAroundPosition(ctx.Data.PlayerUnit.Position, radius, filter) + + if lastMovement { + return nil + } + + path, _, found := ctx.PathFinder.GetPath(pos) + if !found { + return fmt.Errorf("path could not be calculated") + } + + movementDistance := radius + if radius > len(path) { + movementDistance = len(path) + } + + dest := data.Position{ + X: path[movementDistance-1].X + ctx.Data.AreaData.OffsetX, + Y: path[movementDistance-1].Y + ctx.Data.AreaData.OffsetY, + } + + // Let's handle the last movement logic to MoveTo function, we will trust the pathfinder because + // it can finish within a bigger distance than we expect (because blockers), so we will just check how far + // we should be after the latest movement in a theoretical way + if len(path)-movementDistance <= step.DistanceToFinishMoving { + lastMovement = true + } + // Increasing DistanceToFinishMoving prevent not being to able to finish movement if our destination is center of a large object like Seal in diablo run. + // is used only for pathing, attack.go will use default DistanceToFinishMoving + err := step.MoveTo(dest, step.WithDistanceToFinish(7)) + if err != nil { + return err + } + } +} diff --git a/internal/action/clear_level.go b/internal/action/clear_level.go index a9c5c61d4..98ffde844 100644 --- a/internal/action/clear_level.go +++ b/internal/action/clear_level.go @@ -1,168 +1,117 @@ package action import ( - "time" + "errors" + "fmt" "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/d2go/pkg/data/area" - "github.com/hectorgimenez/d2go/pkg/data/object" - "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/pather" + "github.com/hectorgimenez/d2go/pkg/data/stat" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/utils" ) -func (b *Builder) ClearArea(openChests bool, filter data.MonsterFilter) *Chain { - var clearedRooms []data.Room - openedDoors := make(map[object.Name]data.Position) +func ClearCurrentLevel(openChests bool, filter data.MonsterFilter) error { + ctx := context.Get() + ctx.SetLastAction("ClearCurrentLevel") - return NewChain(func(d data.Data) []Action { - var currentRoom data.Room - for _, r := range d.Rooms { - if r.IsInside(d.PlayerUnit.Position) { - currentRoom = r - break - } + rooms := ctx.PathFinder.OptimizeRoomsTraverseOrder() + for _, r := range rooms { + err := clearRoom(r, filter) + if err != nil { + ctx.Logger.Warn("Failed to clear room: %v", err) } - // Let's go pickup more pots if we have less than 2 (only during leveling) - _, isLevelingChar := b.ch.(LevelingCharacter) - if isLevelingChar { - _, healingPotsFound := d.Items.Belt.GetFirstPotion(data.HealingPotion) - _, manaPotsFound := d.Items.Belt.GetFirstPotion(data.ManaPotion) - if (!healingPotsFound || !manaPotsFound) && d.PlayerUnit.TotalGold() > 1000 { - return b.InRunReturnTownRoutine() - } + if !openChests { + continue } - // Check if there is a door blocking our path - if !helper.CanTeleport(d) { - for _, o := range d.Objects { - if o.IsDoor() && pather.DistanceFromMe(d, o.Position) < 10 && openedDoors[o.Name] != o.Position { - if o.Selectable { - b.logger.Info("Door detected and teleport is not available, trying to open it...") - return []Action{b.InteractObject(o.Name, func(d data.Data) bool { - for _, obj := range d.Objects { - if obj.Name == o.Name && obj.Position == o.Position && !obj.Selectable { - openedDoors[o.Name] = o.Position - return true - } - } - return false - })} - } + for _, o := range ctx.Data.Objects { + if o.IsChest() && o.Selectable && r.IsInside(o.Position) { + err = MoveToCoords(o.Position) + if err != nil { + ctx.Logger.Warn("Failed moving to chest: %v", err) + continue } + err = InteractObject(o, func() bool { + chest, _ := ctx.Data.Objects.FindByID(o.ID) + return !chest.Selectable + }) + if err != nil { + ctx.Logger.Warn("Failed interacting with chest: %v", err) + } + utils.Sleep(500) // Add small delay to allow the game to open the chest and drop the content } } + } - monstersInRoom := make([]data.Monster, 0) - for _, m := range d.Monsters.Enemies(filter) { - if currentRoom.IsInside(m.Position) || pather.DistanceFromMe(d, m.Position) < 30 { - monstersInRoom = append(monstersInRoom, m) - } - } + return nil +} - if len(monstersInRoom) > 0 { - targetMonster := monstersInRoom[0] - // Check if there are monsters that can summon new monsters, and kill them first - for _, m := range monstersInRoom { - if m.IsMonsterRaiser() { - targetMonster = m - } - } +func clearRoom(room data.Room, filter data.MonsterFilter) error { + ctx := context.Get() + ctx.SetLastAction("clearRoom") - path, _, mPathFound := pather.GetPath(d, targetMonster.Position) - if mPathFound { - doorIsBlocking := false - if !helper.CanTeleport(d) { - for _, o := range d.Objects { - if o.IsDoor() && o.Selectable && path.Intersects(d, o.Position, 4) { - b.logger.Debug("Door is blocking the path to the monster, skipping attack sequence") - doorIsBlocking = true - } - } - } + path, _, found := ctx.PathFinder.GetClosestWalkablePath(room.GetCenter()) + if !found { + return errors.New("failed to find a path to the room center") + } - if !doorIsBlocking { - return []Action{b.ch.KillMonsterSequence(func(d data.Data) (data.UnitID, bool) { - return targetMonster.UnitID, true - }, nil)} - } else { - b.logger.Debug("Door is blocking the path to the monster, skipping attack sequence") - } - } + to := data.Position{ + X: path.To().X + ctx.Data.AreaOrigin.X, + Y: path.To().Y + ctx.Data.AreaOrigin.Y, + } + err := MoveToCoords(to) + if err != nil { + return fmt.Errorf("failed moving to room center: %w", err) + } + + for { + monsters := getMonstersInRoom(room, filter) + if len(monsters) == 0 { + return nil } - if alreadyCleared(currentRoom, clearedRooms) { - // Finished, all rooms are clear - if len(clearedRooms) == len(d.Rooms) { - b.logger.Debug("All the rooms for this level have been cleared, finishing run.") - return nil + // Check if there are monsters that can summon new monsters, and kill them first + targetMonster := monsters[0] + for _, m := range monsters { + if m.IsMonsterRaiser() { + targetMonster = m } + } - // Move to the closest room - previousDistance := 999999 - closestRoom := data.Room{} - for _, r := range d.Rooms { - if alreadyCleared(r, clearedRooms) { - continue - } - d := pather.DistanceFromMe(d, r.GetCenter()) - if d < previousDistance { - previousDistance = d - closestRoom = r + path, _, mPathFound := ctx.PathFinder.GetPath(targetMonster.Position) + if mPathFound { + if !ctx.Data.CanTeleport() { + for _, o := range ctx.Data.Objects { + if o.IsDoor() && o.Selectable && path.Intersects(*ctx.Data, o.Position, 4) { + ctx.Logger.Debug("Door is blocking the path to the monster, moving closer") + MoveToCoords(targetMonster.Position) + } } } - return []Action{NewStepChain(func(d data.Data) []step.Step { - _, distance, found := pather.GetPath(d, closestRoom.GetCenter()) - // We don't need to be very precise, usually chests are not close to the map border tiles - if !found && d.PlayerUnit.Area != area.LowerKurast { - _, distance, found = pather.GetClosestWalkablePath(d, closestRoom.GetCenter()) - } - if !found || distance <= 5 { - b.logger.Debug("Next room is not walkable, skipping it.") - clearedRooms = append(clearedRooms, closestRoom) - return []step.Step{} - } - - return []step.Step{step.MoveTo( - closestRoom.GetCenter(), - step.ClosestWalkable(), - step.WithTimeout(time.Second), - )} - })} - } - - clearedRooms = append(clearedRooms, currentRoom) - - // Open chests if are inside this room - if openChests { - for _, o := range d.Objects { - if o.IsChest() && o.Selectable && currentRoom.IsInside(o.Position) { - chest := o - return []Action{b.InteractObject(chest.Name, func(d data.Data) bool { - for _, obj := range d.Objects { - if obj.Name == chest.Name && obj.Position.X == chest.Position.X && obj.Position.Y == chest.Position.Y && obj.Selectable { - return false - } - } - - return true - })} + ctx.Char.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + m, found := d.Monsters.FindByID(targetMonster.UnitID) + if found && m.Stats[stat.Life] > 0 { + return targetMonster.UnitID, true } - } + return 0, false + }, nil) } - - return []Action{b.ItemPickup(false, 60)} - }, RepeatUntilNoSteps()) + } } -func alreadyCleared(room data.Room, clearedRooms []data.Room) bool { - for _, r := range clearedRooms { - if r.X == room.X && r.Y == room.Y { - return true +func getMonstersInRoom(room data.Room, filter data.MonsterFilter) []data.Monster { + ctx := context.Get() + ctx.SetLastAction("getMonstersInRoom") + + monstersInRoom := make([]data.Monster, 0) + for _, m := range ctx.Data.Monsters.Enemies(filter) { + if m.Stats[stat.Life] > 0 && room.IsInside(m.Position) || ctx.PathFinder.DistanceFromMe(m.Position) < 30 { + monstersInRoom = append(monstersInRoom, m) } } - return false + return monstersInRoom } diff --git a/internal/action/cube_recipes.go b/internal/action/cube_recipes.go new file mode 100644 index 000000000..914e33530 --- /dev/null +++ b/internal/action/cube_recipes.go @@ -0,0 +1,589 @@ +package action + +import ( + "slices" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/item" + "github.com/hectorgimenez/d2go/pkg/nip" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/utils" +) + +type CubeRecipe struct { + Name string + Items []string + PurchaseRequired bool + PurchaseItems []string +} + +var ( + Recipes = []CubeRecipe{ + + // Perfects + { + Name: "Perfect Amethyst", + Items: []string{"FlawlessAmethyst", "FlawlessAmethyst", "FlawlessAmethyst"}, + }, + { + Name: "Perfect Diamond", + Items: []string{"FlawlessDiamond", "FlawlessDiamond", "FlawlessDiamond"}, + }, + { + Name: "Perfect Emerald", + Items: []string{"FlawlessEmerald", "FlawlessEmerald", "FlawlessEmerald"}, + }, + { + Name: "Perfect Ruby", + Items: []string{"FlawlessRuby", "FlawlessRuby", "FlawlessRuby"}, + }, + { + Name: "Perfect Sapphire", + Items: []string{"FlawlessSapphire", "FlawlessSapphire", "FlawlessSapphire"}, + }, + { + Name: "Perfect Topaz", + Items: []string{"FlawlessTopaz", "FlawlessTopaz", "FlawlessTopaz"}, + }, + { + Name: "Perfect Skull", + Items: []string{"FlawlessSkull", "FlawlessSkull", "FlawlessSkull"}, + }, + + // Token + { + Name: "Token of Absolution", + Items: []string{"TwistedEssenceOfSuffering", "ChargedEssenceOfHatred", "BurningEssenceOfTerror", "FesteringEssenceOfDestruction"}, + }, + + // Runes + { + Name: "Upgrade El", + Items: []string{"ElRune", "ElRune", "ElRune"}, + }, + { + Name: "Upgrade Eld", + Items: []string{"EldRune", "EldRune", "EldRune"}, + }, + { + Name: "Upgrade Tir", + Items: []string{"TirRune", "TirRune", "TirRune"}, + }, + { + Name: "Upgrade Nef", + Items: []string{"NefRune", "NefRune", "NefRune"}, + }, + { + Name: "Upgrade Eth", + Items: []string{"EthRune", "EthRune", "EthRune"}, + }, + { + Name: "Upgrade Ith", + Items: []string{"IthRune", "IthRune", "IthRune"}, + }, + { + Name: "Upgrade Tal", + Items: []string{"TalRune", "TalRune", "TalRune"}, + }, + { + Name: "Upgrade Ral", + Items: []string{"RalRune", "RalRune", "RalRune"}, + }, + { + Name: "Upgrade Ort", + Items: []string{"OrtRune", "OrtRune", "OrtRune"}, + }, + { + Name: "Upgrade Thul", + Items: []string{"ThulRune", "ThulRune", "ThulRune", "ChippedTopaz"}, + }, + { + Name: "Upgrade Amn", + Items: []string{"AmnRune", "AmnRune", "AmnRune", "ChippedAmethyst"}, + }, + { + Name: "Upgrade Sol", + Items: []string{"SolRune", "SolRune", "SolRune", "ChippedSapphire"}, + }, + { + Name: "Upgrade Shael", + Items: []string{"ShaelRune", "ShaelRune", "ShaelRune", "ChippedRuby"}, + }, + { + Name: "Upgrade Dol", + Items: []string{"DolRune", "DolRune", "DolRune", "ChippedEmerald"}, + }, + { + Name: "Upgrade Hel", + Items: []string{"HelRune", "HelRune", "HelRune", "ChippedDiamond"}, + }, + { + Name: "Upgrade Io", + Items: []string{"IoRune", "IoRune", "IoRune", "FlawedTopaz"}, + }, + { + Name: "Upgrade Lum", + Items: []string{"LumRune", "LumRune", "LumRune", "FlawedAmethyst"}, + }, + { + Name: "Upgrade Ko", + Items: []string{"KoRune", "KoRune", "KoRune", "FlawedSapphire"}, + }, + { + Name: "Upgrade Fal", + Items: []string{"FalRune", "FalRune", "FalRune", "FlawedRuby"}, + }, + { + Name: "Upgrade Lem", + Items: []string{"LemRune", "LemRune", "LemRune", "FlawedEmerald"}, + }, + { + Name: "Upgrade Pul", + Items: []string{"PulRune", "PulRune", "FlawedDiamond"}, + }, + { + Name: "Upgrade Um", + Items: []string{"UmRune", "UmRune", "Topaz"}, + }, + { + Name: "Upgrade Mal", + Items: []string{"MalRune", "MalRune", "Amethyst"}, + }, + { + Name: "Upgrade Ist", + Items: []string{"IstRune", "IstRune", "Sapphire"}, + }, + { + Name: "Upgrade Gul", + Items: []string{"GulRune", "GulRune", "Ruby"}, + }, + { + Name: "Upgrade Vex", + Items: []string{"VexRune", "VexRune", "Emerald"}, + }, + { + Name: "Upgrade Ohm", + Items: []string{"OhmRune", "OhmRune", "Diamond"}, + }, + { + Name: "Upgrade Lo", + Items: []string{"LoRune", "LoRune", "FlawlessTopaz"}, + }, + { + Name: "Upgrade Sur", + Items: []string{"SurRune", "SurRune", "FlawlessAmethyst"}, + }, + { + Name: "Upgrade Ber", + Items: []string{"BerRune", "BerRune", "FlawlessSapphire"}, + }, + { + Name: "Upgrade Jah", + Items: []string{"JahRune", "JahRune", "FlawlessRuby"}, + }, + { + Name: "Upgrade Cham", + Items: []string{"ChamRune", "ChamRune", "FlawlessEmerald"}, + }, + + // Crafting + { + Name: "Reroll GrandCharms", + Items: []string{"GrandCharm", "Perfect", "Perfect", "Perfect"}, // Special handling in hasItemsForRecipe + }, + + // Caster Amulet + { + Name: "Caster Amulet", + Items: []string{"RalRune", "PerfectAmethyst", "Jewel"}, + PurchaseRequired: true, + PurchaseItems: []string{"Amulet"}, + }, + + // Caster Ring + { + Name: "Caster Ring", + Items: []string{"AmnRune", "PerfectAmethyst", "Jewel"}, + PurchaseRequired: true, + PurchaseItems: []string{"Ring"}, + }, + + // Blood Gloves + { + Name: "Blood Gloves", + Items: []string{"NefRune", "PerfectRuby", "Jewel"}, + PurchaseRequired: true, + PurchaseItems: []string{"HeavyGloves", "SharkskinGloves", "VampireboneGloves"}, + }, + + // Blood Boots + { + Name: "Blood Boots", + Items: []string{"EthRune", "PerfectRuby", "Jewel"}, + PurchaseRequired: true, + PurchaseItems: []string{"LightPlatedBoots", "BattleBoots", "MirroredBoots"}, + }, + + // Blood Belt + { + Name: "Blood Belt", + Items: []string{"TalRune", "PerfectRuby", "Jewel"}, + PurchaseRequired: true, + PurchaseItems: []string{"Belt", "MeshBelt", "MithrilCoil"}, + }, + + // Blood Helm + { + Name: "Blood Helm", + Items: []string{"RalRune", "PerfectRuby", "Jewel"}, + PurchaseRequired: true, + PurchaseItems: []string{"Helm", "Casque", "Armet"}, + }, + + // Blood Armor + { + Name: "Blood Armor", + Items: []string{"ThulRune", "PerfectRuby", "Jewel"}, + PurchaseRequired: true, + PurchaseItems: []string{"PlateMail", "TemplarPlate", "HellforgePlate"}, + }, + + // Blood Weapon + { + Name: "Blood Weapon", + Items: []string{"OrtRune", "PerfectRuby", "Jewel"}, + PurchaseRequired: true, + PurchaseItems: []string{"Axe"}, + }, + + // Safety Shield + { + Name: "Safety Shield", + Items: []string{"NefRune", "PerfectEmerald", "Jewel"}, + PurchaseRequired: true, + PurchaseItems: []string{"KiteShield", "DragonShield", "Monarch"}, + }, + + // Safety Armor + { + Name: "Safety Armor", + Items: []string{"EthRune", "PerfectEmerald", "Jewel"}, + PurchaseRequired: true, + PurchaseItems: []string{"BreastPlate", "Curiass", "GreatHauberk"}, + }, + + // Safety Boots + { + Name: "Safety Boots", + Items: []string{"OrtRune", "PerfectEmerald", "Jewel"}, + PurchaseRequired: true, + PurchaseItems: []string{"Greaves", "WarBoots", "MyrmidonBoots"}, + }, + + // Safety Gloves + { + Name: "Safety Gloves", + Items: []string{"RalRune", "PerfectEmerald", "Jewel"}, + PurchaseRequired: true, + PurchaseItems: []string{"Gauntlets", "WarGauntlets", "OgreGauntlets"}, + }, + + // Safety Belt + { + Name: "Safety Belt", + Items: []string{"TalRune", "PerfectEmerald", "Jewel"}, + PurchaseRequired: true, + PurchaseItems: []string{"Sash", "DemonhideSash", "SpiderwebSash"}, + }, + + // Safety Helm + { + Name: "Safety Helm", + Items: []string{"IthRune", "PerfectEmerald", "Jewel"}, + PurchaseRequired: true, + PurchaseItems: []string{"Crown", "GrandCrown", "Corona"}, + }, + + // Hitpower Gloves + { + Name: "Hitpower Gloves", + Items: []string{"OrtRune", "PerfectSapphire", "Jewel"}, + PurchaseRequired: true, + PurchaseItems: []string{"ChainGloves", "HeavyBracers", "Vambraces"}, + }, + + // Hitpower Boots + { + Name: "Hitpower Boots", + Items: []string{"RalRune", "PerfectSapphire", "Jewel"}, + PurchaseRequired: true, + PurchaseItems: []string{"ChainBoots", "MeshBoots", "Boneweave"}, + }, + + // Hitpower Belt + { + Name: "Hitpower Belt", + Items: []string{"TalRune", "PerfectSapphire", "Jewel"}, + PurchaseRequired: true, + PurchaseItems: []string{"HeavyBelt", "BattleBelt", "TrollBelt"}, + }, + + // Hitpower Helm + { + Name: "Hitpower Helm", + Items: []string{"NefRune", "PerfectSapphire", "Jewel"}, + PurchaseRequired: true, + PurchaseItems: []string{"FullHelm", "Basinet", "GiantConch"}, + }, + + // Hitpower Armor + { + Name: "Hitpower Armor", + Items: []string{"EthRune", "PerfectSapphire", "Jewel"}, + PurchaseRequired: true, + PurchaseItems: []string{"FieldPlate", "Sharktooth", "KrakenShell"}, + }, + + // Hitpower Shield + { + Name: "Hitpower Shield", + Items: []string{"IthRune", "PerfectSapphire", "Jewel"}, + PurchaseRequired: true, + PurchaseItems: []string{"GothicShield", "AncientShield", "Ward"}, + }, + } +) + +func CubeRecipes() error { + ctx := context.Get() + ctx.SetLastAction("CubeRecipes") + + // If cubing is disabled from settings just return nil + if !ctx.CharacterCfg.CubeRecipes.Enabled { + ctx.Logger.Debug("Cube recipes are disabled, skipping") + return nil + } + + itemsInStash := ctx.Data.Inventory.ByLocation(item.LocationStash, item.LocationSharedStash) + for _, recipe := range Recipes { + // Check if the current recipe is Enabled + if !slices.Contains(ctx.CharacterCfg.CubeRecipes.EnabledRecipes, recipe.Name) { + // is this really needed ? making huge logs + // ctx.Logger.Debug("Cube recipe is not enabled, skipping", "recipe", recipe.Name) + continue + } + + ctx.Logger.Debug("Cube recipe is enabled, processing", "recipe", recipe.Name) + + continueProcessing := true + for continueProcessing { + if items, hasItems := hasItemsForRecipe(ctx, recipe); hasItems { + + // TODO: Check if we have the items in our storage and if not, purchase them, else take the item from the storage + if recipe.PurchaseRequired { + err := GambleSingleItem(recipe.PurchaseItems, item.QualityMagic) + if err != nil { + ctx.Logger.Error("Error gambling item, skipping recipe", "error", err, "recipe", recipe.Name) + break + } + + purchasedItem := getPurchasedItem(ctx, recipe.PurchaseItems) + if purchasedItem.Name == "" { + ctx.Logger.Debug("Could not find purchased item. Skipping recipe", "recipe", recipe.Name) + break + } + + // Add the purchased item the list of items to cube + items = append(items, purchasedItem) + } + + // Add items to the cube and perform the transmutation + err := CubeAddItems(items...) + if err != nil { + return err + } + if err = CubeTransmute(); err != nil { + return err + } + + // Get a list of items that are in our inventory + itemsInInv := ctx.Data.Inventory.ByLocation(item.LocationInventory) + + stashingRequired := false + stashingGrandCharm := false + + // Check if the items that are not in the protected invetory slots should be stashed + for _, item := range itemsInInv { + // If item is not in the protected slots, check if it should be stashed + if ctx.CharacterCfg.Inventory.InventoryLock[item.Position.Y][item.Position.X] == 1 { + if item.Name == "Key" { + continue + } + + shouldStash, reason, _ := shouldStashIt(item, false) + + if shouldStash { + ctx.Logger.Debug("Stashing item after cube recipe.", "item", item.Name, "recipe", recipe.Name, "reason", reason) + stashingRequired = true + } else if item.Name == "GrandCharm" { + ctx.Logger.Debug("Checking if we need to stash a GrandCharm that doesn't match any NIP rules.", "recipe", recipe.Name) + // Check if we have a GrandCharm in stash that doesn't match any NIP rules + hasUnmatchedGrandCharm := false + for _, stashItem := range itemsInStash { + if stashItem.Name == "GrandCharm" { + if _, result := ctx.CharacterCfg.Runtime.Rules.EvaluateAll(stashItem); result != nip.RuleResultFullMatch { + hasUnmatchedGrandCharm = true + break + } + } + } + if !hasUnmatchedGrandCharm { + + ctx.Logger.Debug("GrandCharm doesn't match any NIP rules and we don't have any in stash to be used for this recipe. Stashing it.", "recipe", recipe.Name) + stashingRequired = true + stashingGrandCharm = true + + } else { + DropInventoryItem(item) + utils.Sleep(500) + } + } else { + DropInventoryItem(item) + utils.Sleep(500) + } + } + } + + // Add items to the stash if needed + if stashingRequired && !stashingGrandCharm { + _ = Stash(false) + } else if stashingGrandCharm { + // Force stashing of the invetory + _ = Stash(true) + } + + // Remove or decrement the used items from itemsInStash + itemsInStash = removeUsedItems(itemsInStash, items) + } else { + continueProcessing = false + } + } + } + + return nil +} + +func hasItemsForRecipe(ctx *context.Status, recipe CubeRecipe) ([]data.Item, bool) { + + ctx.RefreshGameData() + items := ctx.Data.Inventory.ByLocation(item.LocationStash, item.LocationSharedStash) + // Special handling for "Reroll GrandCharms" recipe + if recipe.Name == "Reroll GrandCharms" { + return hasItemsForGrandCharmReroll(ctx, items) + } + + recipeItems := make(map[string]int) + for _, item := range recipe.Items { + recipeItems[item]++ + } + + itemsForRecipe := []data.Item{} + + // Iterate over the items in our stash to see if we have the items for the recipie. + for _, item := range items { + if count, ok := recipeItems[string(item.Name)]; ok { + + // Let's make sure we don't use an item we don't want to. Add more if needed (depending on the recipes we have) + if item.Name == "Jewel" { + if _, result := ctx.CharacterCfg.Runtime.Rules.EvaluateAll(item); result == nip.RuleResultFullMatch { + continue + } + } + + itemsForRecipe = append(itemsForRecipe, item) + + // Check if we now have exactly the needed count before decrementing + count -= 1 + if count == 0 { + delete(recipeItems, string(item.Name)) + if len(recipeItems) == 0 { + return itemsForRecipe, true + } + } else { + recipeItems[string(item.Name)] = count + } + } + } + + // We don't have all the items for the recipie. + return nil, false +} + +func hasItemsForGrandCharmReroll(ctx *context.Status, items []data.Item) ([]data.Item, bool) { + var grandCharm data.Item + perfectGems := make([]data.Item, 0, 3) + + for _, itm := range items { + if itm.Name == "GrandCharm" { + if _, result := ctx.CharacterCfg.Runtime.Rules.EvaluateAll(itm); result != nip.RuleResultFullMatch && itm.Quality == item.QualityMagic { + grandCharm = itm + } + } else if isPerfectGem(itm) && len(perfectGems) < 3 { + // Skip perfect amethysts and rubies if configured + if (ctx.CharacterCfg.CubeRecipes.SkipPerfectAmethysts && itm.Name == "PerfectAmethyst") || + (ctx.CharacterCfg.CubeRecipes.SkipPerfectRubies && itm.Name == "PerfectRuby") { + continue + } + perfectGems = append(perfectGems, itm) + } + + if grandCharm.Name != "" && len(perfectGems) == 3 { + return append([]data.Item{grandCharm}, perfectGems...), true + } + } + + return nil, false +} + +func isPerfectGem(item data.Item) bool { + perfectGems := []string{"PerfectAmethyst", "PerfectDiamond", "PerfectEmerald", "PerfectRuby", "PerfectSapphire", "PerfectTopaz", "PerfectSkull"} + for _, gemName := range perfectGems { + if string(item.Name) == gemName { + return true + } + } + return false +} + +func removeUsedItems(stash []data.Item, usedItems []data.Item) []data.Item { + remainingItems := make([]data.Item, 0) + usedItemMap := make(map[string]int) + + // Populate a map with the count of used items + for _, item := range usedItems { + usedItemMap[string(item.Name)] += 1 // Assuming 'ID' uniquely identifies items in 'usedItems' + } + + // Filter the stash by excluding used items based on the count in the map + for _, item := range stash { + if count, exists := usedItemMap[string(item.Name)]; exists && count > 0 { + usedItemMap[string(item.Name)] -= 1 + } else { + remainingItems = append(remainingItems, item) + } + } + + return remainingItems +} + +func getPurchasedItem(ctx *context.Status, purchaseItems []string) data.Item { + itemsInInv := ctx.Data.Inventory.ByLocation(item.LocationInventory) + for _, citem := range itemsInInv { + for _, pi := range purchaseItems { + if string(citem.Name) == pi && citem.Quality == item.QualityMagic { + return citem + } + } + } + return data.Item{} +} diff --git a/internal/action/gambling.go b/internal/action/gambling.go index fe1edd694..9cc6715f8 100644 --- a/internal/action/gambling.go +++ b/internal/action/gambling.go @@ -1,133 +1,250 @@ package action import ( - "time" + "errors" + "log/slog" "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/item" "github.com/hectorgimenez/d2go/pkg/data/npc" "github.com/hectorgimenez/d2go/pkg/data/stat" - "github.com/hectorgimenez/d2go/pkg/itemfilter" + "github.com/hectorgimenez/d2go/pkg/nip" "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/hid" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" "github.com/hectorgimenez/koolo/internal/town" "github.com/hectorgimenez/koolo/internal/ui" - "go.uber.org/zap" + "github.com/hectorgimenez/koolo/internal/utils" + "github.com/lxn/win" ) -func (b *Builder) Gamble() *Chain { - return NewChain(func(d data.Data) (actions []Action) { - if config.Config.Gambling.Enabled && d.PlayerUnit.Stats[stat.StashGold] >= 2500000 { - b.logger.Info("Time to gamble! Visiting vendor...") +func Gamble() error { + ctx := context.Get() + ctx.SetLastAction("Gamble") - openShopStep := step.KeySequence("home", "down", "down", "enter") - vendorNPC := town.GetTownByArea(d.PlayerUnit.Area).GamblingNPC() + stashedGold, _ := ctx.Data.PlayerUnit.FindStat(stat.StashGold, 0) + if ctx.CharacterCfg.Gambling.Enabled && stashedGold.Value >= 2480000 { + ctx.Logger.Info("Time to gamble! Visiting vendor...") - // Jamella gamble button is the second one - if vendorNPC == npc.Jamella { - openShopStep = step.KeySequence("home", "down", "enter") - } + vendorNPC := town.GetTownByArea(ctx.Data.PlayerUnit.Area).GamblingNPC() - // Fix for Anya position - if vendorNPC == npc.Drehya { - actions = append(actions, b.MoveToCoords(data.Position{ - X: 5107, - Y: 5119, - })) - } + // Fix for Anya position + if vendorNPC == npc.Drehya { + _ = MoveToCoords(data.Position{ + X: 5107, + Y: 5119, + }) + } - return append(actions, - b.InteractNPC(vendorNPC, - openShopStep, - step.Wait(time.Second), - ), - b.gambleItems(), - ) + InteractNPC(vendorNPC) + // Jamella gamble button is the second one + if vendorNPC == npc.Jamella { + ctx.HID.KeySequence(win.VK_HOME, win.VK_DOWN, win.VK_RETURN) + } else { + ctx.HID.KeySequence(win.VK_HOME, win.VK_DOWN, win.VK_DOWN, win.VK_RETURN) } - return nil - }) + if !ctx.Data.OpenMenus.NPCShop { + return errors.New("failed opening gambling window") + } + + return gambleItems() + } + + return nil } -func (b *Builder) gambleItems() *StepChainAction { +func GambleSingleItem(items []string, desiredQuality item.Quality) error { + ctx := context.Get() + ctx.SetLastAction("GambleSingleItem") + + charGold := ctx.Data.PlayerUnit.TotalPlayerGold() var itemBought data.Item - currentIdx := 0 - lastStep := false - return NewStepChain(func(d data.Data) []step.Step { - if lastStep { - if d.OpenMenus.Inventory { - return []step.Step{step.SyncStep(func(d data.Data) error { - hid.PressKey("esc") - return nil - })} - } - b.logger.Info("Finished gambling", zap.Int("currentGold", d.PlayerUnit.TotalGold())) + // Check if we have enough gold to gamble + if charGold >= 150000 { + ctx.Logger.Info("Gambling for items", slog.Any("items", items)) + + vendorNPC := town.GetTownByArea(ctx.Data.PlayerUnit.Area).GamblingNPC() + + // Fix for Anya position + if vendorNPC == npc.Drehya { + _ = MoveToCoords(data.Position{ + X: 5107, + Y: 5119, + }) + } + + InteractNPC(vendorNPC) + // Jamella gamble button is the second one + if vendorNPC == npc.Jamella { + ctx.HID.KeySequence(win.VK_HOME, win.VK_DOWN, win.VK_RETURN) + } else { + ctx.HID.KeySequence(win.VK_HOME, win.VK_DOWN, win.VK_DOWN, win.VK_RETURN) + } - return nil + if !ctx.Data.OpenMenus.NPCShop { + return errors.New("failed opening gambling window") } + } + for { if itemBought.Name != "" { - // For any reason item is still detected as located in vendor, I will take a look later into this - for _, itm := range d.Items.ByLocation(item.LocationVendor) { + for _, itm := range ctx.Data.Inventory.ByLocation(item.LocationInventory) { if itm.UnitID == itemBought.UnitID { itemBought = itm - itemBought.Location = item.LocationInventory - b.logger.Debug("Gambled for item", zap.Any("item", itemBought)) + ctx.Logger.Debug("Gambled for item", slog.Any("item", itemBought)) break } } - if itemfilter.Evaluate(itemBought, config.Config.Runtime.Rules) { - lastStep = true - return []step.Step{step.Wait(time.Millisecond * 200)} - } else { + // Check if the item matches our NIP rules + if _, result := ctx.Data.CharacterCfg.Runtime.Rules.EvaluateAll(itemBought); result == nip.RuleResultFullMatch { // Filter not pass, selling the item - return []step.Step{step.SyncStep(func(d data.Data) error { - b.sm.SellItem(itemBought) + ctx.Logger.Info("Found item matching nip rules, will be kept", slog.Any("item", itemBought)) + itemBought = data.Item{} + continue + } else { + // Doesn't match NIP rules but check if the item matches our desired quality + if itemBought.Quality == desiredQuality { + ctx.Logger.Info("Found item matching desired quality, will be kept", slog.Any("item", itemBought)) + return step.CloseAllMenus() + } else { + town.SellItem(itemBought) itemBought = data.Item{} - return nil - })} + } } } - if d.PlayerUnit.TotalGold() < 500000 { - lastStep = true - return []step.Step{step.Wait(time.Millisecond * 200)} + if ctx.Data.PlayerUnit.TotalPlayerGold() < 150000 { + return errors.New("gold is below 150000, stopping gamble") } - for idx, itmName := range config.Config.Gambling.Items { - // Let's try to get one of each every time - if currentIdx == len(config.Config.Gambling.Items) { - currentIdx = 0 + // Check for any of the desired items in the vendor's inventory + for _, itmName := range items { + itm, found := ctx.Data.Inventory.Find(item.Name(itmName), item.LocationVendor) + if found { + town.BuyItem(itm, 1) + itemBought = itm + break } + } - if currentIdx > idx { - continue + // If no desired item was found, refresh the gambling window + if itemBought.Name == "" { + ctx.Logger.Debug("Desired items not found in gambling window, refreshing...", slog.Any("items", items)) + + if ctx.Data.LegacyGraphics { + ctx.HID.Click(game.LeftButton, ui.GambleRefreshButtonXClassic, ui.GambleRefreshButtonYClassic) + } else { + ctx.HID.Click(game.LeftButton, ui.GambleRefreshButtonX, ui.GambleRefreshButtonY) } - itm, found := d.Items.Find(itmName, item.LocationVendor) - if !found { - b.logger.Debug("Item not found in gambling window, refreshing...", zap.String("item", string(itmName))) + utils.Sleep(500) + } + } +} + +func gambleItems() error { + ctx := context.Get() + ctx.SetLastAction("gambleItems") - return []step.Step{step.SyncStep(func(d data.Data) error { - hid.MovePointer(ui.GambleRefreshButtonX, ui.GambleRefreshButtonY) - hid.Click(hid.LeftButton) - return nil - }), - step.Wait(time.Millisecond * 500), + var itemBought data.Item + var refreshAttempts int + var currentItemIndex int + const maxRefreshAttempts = 11 + + for { + ctx.PauseIfNotPriority() + ctx.RefreshGameData() + + // Check if we should stop gambling due to low gold + if ctx.Data.PlayerUnit.TotalPlayerGold() < 500000 { + ctx.Logger.Info("Finished gambling - gold below 500k", + slog.Int("currentGold", ctx.Data.PlayerUnit.TotalPlayerGold())) + return step.CloseAllMenus() + } + + // Process bought item if we have one + if itemBought.Name != "" { + // Find the bought item in inventory + for _, itm := range ctx.Data.Inventory.ByLocation(item.LocationInventory) { + if itm.UnitID == itemBought.UnitID { + itemBought = itm + ctx.Logger.Debug("Gambled for item", slog.Any("item", itemBought)) + break } } - return []step.Step{step.SyncStep(func(d data.Data) error { - b.sm.BuyItem(itm, 1) + // Check if item matches NIP rules + if _, result := ctx.Data.CharacterCfg.Runtime.Rules.EvaluateAll(itemBought); result == nip.RuleResultFullMatch { + ctx.Logger.Info("Found item matching NIP rules, keeping", slog.Any("item", itemBought)) + } else { + // Filter not pass, selling the item + ctx.Logger.Debug("Item doesn't match NIP rules, selling", slog.Any("item", itemBought)) + town.SellItem(itemBought) + } + + itemBought = data.Item{} // Reset itemBought after processing + refreshAttempts = 0 // Reset refresh counter after successful purchase + + // Move to next item in the gambling list + currentItemIndex = (currentItemIndex + 1) % len(ctx.Data.CharacterCfg.Gambling.Items) + continue + } + + // Try to find and buy items + itemFound := false + if len(ctx.Data.CharacterCfg.Gambling.Items) > 0 { + // Get current item to gamble for + currentItem := ctx.Data.CharacterCfg.Gambling.Items[currentItemIndex] + itm, found := ctx.Data.Inventory.Find(currentItem, item.LocationVendor) + if found { + town.BuyItem(itm, 1) itemBought = itm - currentIdx++ - return nil - })} + itemFound = true + } } - return nil - }, RepeatUntilNoSteps()) + // If no items found, try refreshing the gambling window + if !itemFound { + refreshAttempts++ + if refreshAttempts >= maxRefreshAttempts { + ctx.Logger.Info("Too many refresh attempts without finding items, reopening gambling window") + // Close and reopen gambling window + if err := step.CloseAllMenus(); err != nil { + return err + } + utils.Sleep(200) + + vendorNPC := town.GetTownByArea(ctx.Data.PlayerUnit.Area).GamblingNPC() + if err := InteractNPC(vendorNPC); err != nil { + return err + } + + // Select gamble option + if vendorNPC == npc.Jamella { + ctx.HID.KeySequence(win.VK_HOME, win.VK_DOWN, win.VK_RETURN) + } else { + ctx.HID.KeySequence(win.VK_HOME, win.VK_DOWN, win.VK_DOWN, win.VK_RETURN) + } + + refreshAttempts = 0 + continue + } + + ctx.Logger.Debug("Refreshing.. ", + slog.Int("Attempt", refreshAttempts), + slog.String("Looking For ", string(ctx.Data.CharacterCfg.Gambling.Items[currentItemIndex]))) + RefreshGamblingWindow(ctx) + utils.Sleep(500) + } + } +} +func RefreshGamblingWindow(ctx *context.Status) { + if ctx.Data.LegacyGraphics { + ctx.HID.Click(game.LeftButton, ui.GambleRefreshButtonXClassic, ui.GambleRefreshButtonYClassic) + } else { + ctx.HID.Click(game.LeftButton, ui.GambleRefreshButtonX, ui.GambleRefreshButtonY) + } } diff --git a/internal/action/heal.go b/internal/action/heal.go deleted file mode 100644 index 77360dcdd..000000000 --- a/internal/action/heal.go +++ /dev/null @@ -1,40 +0,0 @@ -package action - -import ( - "fmt" - - "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" - "github.com/hectorgimenez/koolo/internal/town" -) - -func (b *Builder) Heal() *Chain { - return NewChain(func(d data.Data) []Action { - shouldHeal := false - if d.PlayerUnit.HPPercent() < 80 { - b.logger.Info(fmt.Sprintf("Current life is %d%%, healing on NPC", d.PlayerUnit.HPPercent())) - shouldHeal = true - } - - if d.PlayerUnit.HasDebuff() { - b.logger.Info(fmt.Sprintf("Debuff detected, healing on NPC")) - shouldHeal = true - } - - if shouldHeal { - return []Action{b.InteractNPC( - town.GetTownByArea(d.PlayerUnit.Area).HealNPC(), - step.SyncStep(func(d data.Data) error { - helper.Sleep(300) - hid.PressKey("esc") - helper.Sleep(100) - return nil - }), - )} - } - - return nil - }) -} diff --git a/internal/action/heal_at_npc.go b/internal/action/heal_at_npc.go new file mode 100644 index 000000000..b1a87947d --- /dev/null +++ b/internal/action/heal_at_npc.go @@ -0,0 +1,34 @@ +package action + +import ( + "fmt" + + "github.com/hectorgimenez/koolo/internal/action/step" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/town" +) + +func HealAtNPC() error { + ctx := context.Get() + ctx.SetLastAction("HealAtNPC") + + shouldHeal := false + if ctx.Data.PlayerUnit.HPPercent() < 80 { + ctx.Logger.Info(fmt.Sprintf("Current life is %d, healing on NPC", ctx.Data.PlayerUnit.HPPercent())) + shouldHeal = true + } + + if ctx.Data.PlayerUnit.HasDebuff() { + ctx.Logger.Info("Debuff detected, healing on NPC") + shouldHeal = true + } + + if shouldHeal { + err := InteractNPC(town.GetTownByArea(ctx.Data.PlayerUnit.Area).HealNPC()) + if err != nil { + ctx.Logger.Warn("Failed to heal on NPC: %v", err) + } + } + + return step.CloseAllMenus() +} diff --git a/internal/action/horadric_cube.go b/internal/action/horadric_cube.go index 77031a83a..c280c6ece 100644 --- a/internal/action/horadric_cube.go +++ b/internal/action/horadric_cube.go @@ -1,146 +1,182 @@ package action import ( + "errors" + "fmt" + "log/slog" + "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/item" "github.com/hectorgimenez/d2go/pkg/data/object" "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" "github.com/hectorgimenez/koolo/internal/ui" - "go.uber.org/zap" + "github.com/hectorgimenez/koolo/internal/utils" + "github.com/lxn/win" ) -func (b *Builder) CubeAddItems(items ...data.Item) *Chain { - return NewChain(func(d data.Data) (actions []Action) { - cube, found := d.Items.Find("HoradricCube", item.LocationInventory, item.LocationStash) - if !found { - b.logger.Info("No Horadric Cube found in inventory") - return nil +func CubeAddItems(items ...data.Item) error { + ctx := context.Get() + ctx.SetLastAction("CubeAddItems") + + // Ensure stash is open + if !ctx.Data.OpenMenus.Stash { + bank, _ := ctx.Data.Objects.FindOne(object.Bank) + err := InteractObject(bank, func() bool { + return ctx.Data.OpenMenus.Stash + }) + if err != nil { + return err + } + } + // Clear messages like TZ change or public game spam. Prevent bot from clicking on messages + ClearMessages() + ctx.Logger.Info("Adding items to the Horadric Cube", slog.Any("items", items)) + + // If items are on the Stash, pickup them to the inventory + for _, itm := range items { + nwIt := itm + if nwIt.Location.LocationType != item.LocationStash && nwIt.Location.LocationType != item.LocationSharedStash { + continue } - // Ensure stash is open - if !d.OpenMenus.Stash { - actions = append(actions, b.InteractObject(object.Bank, func(d data.Data) bool { - return d.OpenMenus.Stash - })) + // Check in which tab the item is and switch to it + switch nwIt.Location.LocationType { + case item.LocationStash: + SwitchStashTab(1) + case item.LocationSharedStash: + SwitchStashTab(nwIt.Location.Page + 1) } - b.logger.Info("Adding items to the Horadric Cube", zap.Any("items", items)) + ctx.Logger.Debug("Item found on the stash, picking it up", slog.String("Item", string(nwIt.Name))) + screenPos := ui.GetScreenCoordsForItem(nwIt) - // If items are on the Stash, pickup them to the inventory (only personal stash is supported for now) - for _, itm := range items { - nwIt := itm - if nwIt.Location != item.LocationStash && nwIt.Location != item.LocationSharedStash1 && nwIt.Location != item.LocationSharedStash2 && nwIt.Location != item.LocationSharedStash3 { - continue - } + ctx.HID.ClickWithModifier(game.LeftButton, screenPos.X, screenPos.Y, game.CtrlKey) + utils.Sleep(300) + } - b.logger.Debug("Item found on the stash, picking it up", zap.String("Item", string(nwIt.Name))) - actions = append(actions, NewStepChain(func(d data.Data) []step.Step { - screenPos := ui.GetScreenCoordsForItem(nwIt) - hid.MovePointer(screenPos.X, screenPos.Y) + err := ensureCubeIsOpen() + if err != nil { + return err + } - hid.KeyDown("control") - helper.Sleep(300) - hid.Click(hid.LeftButton) - helper.Sleep(200) - hid.KeyUp("control") - helper.Sleep(300) + err = ensureCubeIsEmpty() + if err != nil { + return err + } - return nil - })) - } + for _, itm := range items { + for _, updatedItem := range ctx.Data.Inventory.AllItems { + if itm.UnitID == updatedItem.UnitID { + ctx.Logger.Debug("Moving Item to the Horadric Cube", slog.String("Item", string(itm.Name))) - actions = append(actions, b.ensureCubeIsOpen(cube)) - - for _, itm := range items { - nwIt := itm - actions = append(actions, NewStepChain(func(d data.Data) []step.Step { - for _, updatedItem := range d.Items.AllItems { - if nwIt.UnitID == updatedItem.UnitID { - b.logger.Debug("Moving Item to the Horadric Cube", zap.String("Item", string(nwIt.Name))) - screenPos := ui.GetScreenCoordsForItem(updatedItem) - hid.MovePointer(screenPos.X, screenPos.Y) - - hid.KeyDown("control") - helper.Sleep(300) - hid.Click(hid.LeftButton) - helper.Sleep(200) - hid.KeyUp("control") - helper.Sleep(300) - } - } - - return nil - })) + screenPos := ui.GetScreenCoordsForItem(updatedItem) + + ctx.HID.ClickWithModifier(game.LeftButton, screenPos.X, screenPos.Y, game.CtrlKey) + utils.Sleep(500) + } } + } - return - }) + return nil } -func (b *Builder) CubeTransmute() *Chain { - return NewChain(func(d data.Data) (actions []Action) { - cube, found := d.Items.Find("HoradricCube", item.LocationInventory, item.LocationStash) - if !found { - b.logger.Info("No Horadric Cube found in inventory") - return nil - } +func CubeTransmute() error { + ctx := context.Get() - actions = append(actions, b.ensureCubeIsOpen(cube)) - - actions = append(actions, NewStepChain(func(d data.Data) []step.Step { - b.logger.Debug("Transmuting items in the Horadric Cube") - hid.MovePointer(ui.CubeTransmuteBtnX, ui.CubeTransmuteBtnY) - helper.Sleep(150) - hid.Click(hid.LeftButton) - helper.Sleep(3000) - - // Move the Item back to the inventory - hid.MovePointer(238, 262) - hid.KeyDown("control") - helper.Sleep(300) - hid.Click(hid.LeftButton) - helper.Sleep(200) - hid.KeyUp("control") - helper.Sleep(300) - - return []step.Step{ - step.SyncStepWithCheck(func(d data.Data) error { - hid.PressKey("esc") - helper.Sleep(300) - return nil - }, func(d data.Data) step.Status { - if d.OpenMenus.Inventory { - return step.StatusInProgress - } - return step.StatusCompleted - }), - } - })) + err := ensureCubeIsOpen() + if err != nil { + return err + } - return - }) + ctx.Logger.Debug("Transmuting items in the Horadric Cube") + utils.Sleep(150) + + if ctx.Data.LegacyGraphics { + ctx.HID.Click(game.LeftButton, ui.CubeTransmuteBtnXClassic, ui.CubeTransmuteBtnYClassic) + } else { + ctx.HID.Click(game.LeftButton, ui.CubeTransmuteBtnX, ui.CubeTransmuteBtnY) + } + + utils.Sleep(2000) + + // Take the items out of the cube + for _, itm := range ctx.Data.Inventory.ByLocation(item.LocationCube) { + ctx.Logger.Debug("Moving Item to the inventory", slog.String("Item", string(itm.Name))) + + screenPos := ui.GetScreenCoordsForItem(itm) + + ctx.HID.ClickWithModifier(game.LeftButton, screenPos.X, screenPos.Y, game.CtrlKey) + utils.Sleep(500) + } + + return step.CloseAllMenus() } -func (b *Builder) ensureCubeIsOpen(cube data.Item) Action { - return NewStepChain(func(d data.Data) []step.Step { - b.logger.Debug("Opening Horadric Cube...") - return []step.Step{ - step.SyncStepWithCheck(func(d data.Data) error { - screenPos := ui.GetScreenCoordsForItem(cube) - hid.MovePointer(screenPos.X, screenPos.Y) - helper.Sleep(300) - hid.Click(hid.RightButton) - helper.Sleep(200) - return nil - }, func(d data.Data) step.Status { - if d.OpenMenus.Cube { - b.logger.Debug("Horadric Cube window detected") - return step.StatusCompleted - } - return step.StatusInProgress - }), +func ensureCubeIsEmpty() error { + ctx := context.Get() + if !ctx.Data.OpenMenus.Cube { + return errors.New("horadric Cube window not detected") + } + + cubeItems := ctx.Data.Inventory.ByLocation(item.LocationCube) + if len(cubeItems) == 0 { + return nil + } + + ctx.Logger.Debug("Emptying the Horadric Cube") + for _, itm := range cubeItems { + ctx.Logger.Debug("Moving Item to the inventory", slog.String("Item", string(itm.Name))) + + screenPos := ui.GetScreenCoordsForItem(itm) + + ctx.HID.ClickWithModifier(game.LeftButton, screenPos.X, screenPos.Y, game.CtrlKey) + utils.Sleep(700) + + itm, _ = ctx.Data.Inventory.FindByID(itm.UnitID) + if itm.Location.LocationType == item.LocationCube { + return fmt.Errorf("item %s could not be removed from the cube", itm.Name) } - }) + } + + ctx.HID.PressKey(win.VK_ESCAPE) + utils.Sleep(300) + + stashInventory(true) + + return ensureCubeIsOpen() +} + +func ensureCubeIsOpen() error { + ctx := context.Get() + ctx.Logger.Debug("Opening Horadric Cube...") + + if ctx.Data.OpenMenus.Cube { + ctx.Logger.Debug("Horadric Cube window already open") + return nil + } + + cube, found := ctx.Data.Inventory.Find("HoradricCube", item.LocationInventory, item.LocationStash) + if !found { + return errors.New("horadric cube not found in inventory") + } + + // If cube is in stash, switch to the correct tab + if cube.Location.LocationType == item.LocationStash || cube.Location.LocationType == item.LocationSharedStash { + SwitchStashTab(cube.Location.Page + 1) + } + + screenPos := ui.GetScreenCoordsForItem(cube) + + utils.Sleep(300) + ctx.HID.Click(game.RightButton, screenPos.X, screenPos.Y) + utils.Sleep(500) + + if ctx.Data.OpenMenus.Cube { + ctx.Logger.Debug("Horadric Cube window detected") + return nil + } + + return errors.New("horadric Cube window not detected") } diff --git a/internal/action/identify.go b/internal/action/identify.go index fd5c8463e..5f21d7c3c 100644 --- a/internal/action/identify.go +++ b/internal/action/identify.go @@ -2,89 +2,163 @@ package action import ( "fmt" + "time" "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/item" "github.com/hectorgimenez/d2go/pkg/data/stat" + "github.com/hectorgimenez/d2go/pkg/nip" "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/town" "github.com/hectorgimenez/koolo/internal/ui" + "github.com/hectorgimenez/koolo/internal/utils" + "github.com/lxn/win" ) -func (b *Builder) IdentifyAll(skipIdentify bool) *Chain { - return NewChain(func(d data.Data) (actions []Action) { - items := b.itemsToIdentify(d) +func IdentifyAll(skipIdentify bool) error { + ctx := context.Get() + ctx.SetLastAction("IdentifyAll") - b.logger.Debug("Checking for items to identify...") - if len(items) == 0 || skipIdentify { - b.logger.Debug("No items to identify...") - return - } + items := itemsToIdentify() + + ctx.Logger.Debug("Checking for items to identify...") + if len(items) == 0 || skipIdentify { + ctx.Logger.Debug("No items to identify...") + return nil + } - idTome, found := d.Items.Find(item.TomeOfIdentify, item.LocationInventory) - if !found { - b.logger.Warn("ID Tome not found, not identifying items") - return + if ctx.CharacterCfg.Game.UseCainIdentify { + ctx.Logger.Debug("Identifying all item with Cain...") + // Close any open menus first + step.CloseAllMenus() + utils.Sleep(500) + + err := CainIdentify() + // if identifying with cain fails then we should continue to identify using tome + if err == nil { + return nil } + ctx.Logger.Debug("Identifying with Cain failed, continuing with identifying with tome", "err", err) + } + + idTome, found := ctx.Data.Inventory.Find(item.TomeOfIdentify, item.LocationInventory) + if !found { + ctx.Logger.Warn("ID Tome not found, not identifying items") + return nil + } + + if st, statFound := idTome.FindStat(stat.Quantity, 0); !statFound || st.Value < len(items) { + ctx.Logger.Info("Not enough ID scrolls, refilling...") + VendorRefill(true, false) + } + + ctx.Logger.Info(fmt.Sprintf("Identifying %d items...", len(items))) + + // Close all menus to prevent issues + step.CloseAllMenus() + for !ctx.Data.OpenMenus.Inventory { + ctx.HID.PressKeyBinding(ctx.Data.KeyBindings.Inventory) + utils.Sleep(1000) // Add small delay to allow the game to open the inventory + } + + for _, i := range items { + identifyItem(idTome, i) + } + step.CloseAllMenus() + + return nil +} + +func CainIdentify() error { + ctx := context.Get() + ctx.SetLastAction("CainIdentify") + + stayAwhileAndListen := town.GetTownByArea(ctx.Data.PlayerUnit.Area).IdentifyNPC() + + // Close any open menus first + step.CloseAllMenus() + utils.Sleep(200) + + err := InteractNPC(stayAwhileAndListen) + if err != nil { + return fmt.Errorf("error interacting with Cain: %w", err) + } - if st, statFound := idTome.Stats[stat.Quantity]; !statFound || st.Value < len(items) { - b.logger.Info("Not enough ID scrolls, refilling...") - actions = append(actions, b.VendorRefill(true, false)) + // Verify menu opened + menuWait := time.Now().Add(2 * time.Second) + for time.Now().Before(menuWait) { + ctx.PauseIfNotPriority() + ctx.RefreshGameData() + if ctx.Data.OpenMenus.NPCInteract { + break } + utils.Sleep(100) + } - b.logger.Info(fmt.Sprintf("Identifying %d items...", len(items))) - actions = append(actions, NewStepChain(func(d data.Data) []step.Step { - return []step.Step{ - step.SyncStepWithCheck(func(d data.Data) error { - hid.PressKey(config.Config.Bindings.OpenInventory) - return nil - }, func(d data.Data) step.Status { - if d.OpenMenus.Inventory { - return step.StatusCompleted - } - return step.StatusInProgress - }), - step.SyncStep(func(d data.Data) error { - - for _, i := range items { - identifyItem(idTome, i) - } - - hid.PressKey("esc") - - return nil - }), - } - })) + if !ctx.Data.OpenMenus.NPCInteract { + return fmt.Errorf("NPC menu did not open") + } + + // Select identify option + ctx.HID.KeySequence(win.VK_HOME, win.VK_DOWN, win.VK_RETURN) + utils.Sleep(800) - return - }, Resettable(), CanBeSkipped()) + // Close menu if still open + if ctx.Data.OpenMenus.NPCInteract { + step.CloseAllMenus() + } + + return nil } -func (b *Builder) itemsToIdentify(d data.Data) (items []data.Item) { - for _, i := range d.Items.ByLocation(item.LocationInventory) { +func itemsToIdentify() (items []data.Item) { + ctx := context.Get() + ctx.SetLastAction("itemsToIdentify") + + for _, i := range ctx.Data.Inventory.ByLocation(item.LocationInventory) { if i.Identified || i.Quality == item.QualityNormal || i.Quality == item.QualitySuperior { continue } + // Skip identifying items that fully match a rule when unid + if _, result := ctx.CharacterCfg.Runtime.Rules.EvaluateAll(i); result == nip.RuleResultFullMatch { + continue + } + items = append(items, i) } return } +func HaveItemsToStashUnidentified() bool { + ctx := context.Get() + ctx.SetLastAction("HaveItemsToStashUnidentified") + + items := ctx.Data.Inventory.ByLocation(item.LocationInventory) + for _, i := range items { + if !i.Identified { + if _, result := ctx.CharacterCfg.Runtime.Rules.EvaluateAll(i); result == nip.RuleResultFullMatch { + return true + } + } + } + + return false +} + func identifyItem(idTome data.Item, i data.Item) { + ctx := context.Get() screenPos := ui.GetScreenCoordsForItem(idTome) - hid.MovePointer(screenPos.X, screenPos.Y) - helper.Sleep(350) - hid.Click(hid.RightButton) - helper.Sleep(200) + + utils.Sleep(500) + ctx.HID.Click(game.RightButton, screenPos.X, screenPos.Y) + utils.Sleep(1000) screenPos = ui.GetScreenCoordsForItem(i) - hid.MovePointer(screenPos.X, screenPos.Y) - helper.Sleep(300) - hid.Click(hid.LeftButton) - helper.Sleep(350) + + ctx.HID.Click(game.LeftButton, screenPos.X, screenPos.Y) + utils.Sleep(350) } diff --git a/internal/action/interaction.go b/internal/action/interaction.go index d2c53fabd..310a9f89a 100644 --- a/internal/action/interaction.go +++ b/internal/action/interaction.go @@ -1,73 +1,94 @@ package action import ( + "fmt" + "strings" + "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/d2go/pkg/data/npc" - "github.com/hectorgimenez/d2go/pkg/data/object" "github.com/hectorgimenez/koolo/internal/action/step" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/event" + "github.com/hectorgimenez/koolo/internal/game" ) -func (b *Builder) InteractNPC(npc npc.ID, additionalSteps ...step.Step) *Chain { - return NewChain(func(d data.Data) []Action { - return []Action{ - b.MoveTo(func(d data.Data) (data.Position, bool) { - return b.getNPCPosition(npc, d) - }, step.StopAtDistance(7)), - NewStepChain(func(d data.Data) []step.Step { - steps := []step.Step{step.InteractNPC(npc)} - steps = append(steps, additionalSteps...) - - return steps - }), +func InteractNPC(npc npc.ID) error { + ctx := context.Get() + ctx.SetLastAction("InteractNPC") + + pos, found := getNPCPosition(npc, ctx.Data) + if !found { + return fmt.Errorf("npc with ID %d not found", npc) + } + + var err error + for range 5 { + err = step.MoveTo(pos) + if err != nil { + continue } - }) -} -func (b *Builder) InteractNPCWithCheck(npc npc.ID, isCompletedFn func(d data.Data) bool, additionalSteps ...step.Step) *Chain { - return NewChain(func(d data.Data) []Action { - return []Action{ - b.MoveTo(func(d data.Data) (data.Position, bool) { - return b.getNPCPosition(npc, d) - }, step.StopAtDistance(7)), - NewStepChain(func(d data.Data) []step.Step { - steps := []step.Step{step.InteractNPCWithCheck(npc, isCompletedFn)} - steps = append(steps, additionalSteps...) - - return steps - }), + err = step.InteractNPC(npc) + if err != nil { + continue } - }) + break + } + if err != nil { + return err + } + + event.Send(event.InteractedTo(event.Text(ctx.Name, ""), int(npc), event.InteractionTypeNPC)) + + return nil } -func (b *Builder) InteractObject(name object.Name, isCompletedFn func(data.Data) bool, additionalSteps ...step.Step) *Chain { - return NewChain(func(d data.Data) []Action { - o, _ := d.Objects.FindOne(name) - //if !found { - // return fmt.Errorf("NPC not found") - //} +func InteractObject(o data.Object, isCompletedFn func() bool) error { + ctx := context.Get() + ctx.SetLastAction("InteractObject") - pos := o.Position - if d.PlayerUnit.Area == area.RiverOfFlame && o.IsWaypoint() { - pos = data.Position{X: 7800, Y: 5919} + pos := o.Position + distFinish := step.DistanceToFinishMoving + if ctx.Data.PlayerUnit.Area == area.RiverOfFlame && o.IsWaypoint() { + pos = data.Position{X: 7800, Y: 5919} + // Special case for seals: we cant teleport directly to center. Interaction range is bigger then DistanceToFinishMoving so we modify it + } else if strings.Contains(o.Desc().Name, "Seal") { + distFinish = 10 + } + + var err error + for range 5 { + err = step.MoveTo(pos, step.WithDistanceToFinish(distFinish)) + if err != nil { + continue + } + err = step.InteractObject(o, isCompletedFn) + if err != nil { + continue } + break + } + + return err +} - return []Action{ - b.MoveToCoords(pos, step.StopAtDistance(7)), - NewStepChain(func(d data.Data) []step.Step { - steps := []step.Step{step.InteractObject(o.Name, isCompletedFn)} - steps = append(steps, additionalSteps...) +func InteractObjectByID(id data.UnitID, isCompletedFn func() bool) error { + ctx := context.Get() + ctx.SetLastAction("InteractObjectByID") - return steps - }), - } - }) + o, found := ctx.Data.Objects.FindByID(id) + if !found { + return fmt.Errorf("object with ID %d not found", id) + } + + return InteractObject(o, isCompletedFn) } -func (b *Builder) getNPCPosition(npc npc.ID, d data.Data) (data.Position, bool) { +func getNPCPosition(npc npc.ID, d *game.Data) (data.Position, bool) { monster, found := d.Monsters.FindOne(npc, data.MonsterTypeNone) if found { - return data.Position{X: monster.Position.X, Y: monster.Position.Y}, true + return monster.Position, true } n, found := d.NPCs.FindOne(npc) diff --git a/internal/action/item.go b/internal/action/item.go new file mode 100644 index 000000000..0c65c16e9 --- /dev/null +++ b/internal/action/item.go @@ -0,0 +1,120 @@ +package action + +import ( + "fmt" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/item" + "github.com/hectorgimenez/d2go/pkg/nip" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/ui" + "github.com/hectorgimenez/koolo/internal/utils" +) + +func doesExceedQuantity(rule nip.Rule) bool { + ctx := context.Get() + ctx.SetLastAction("doesExceedQuantity") + + stashItems := ctx.Data.Inventory.ByLocation(item.LocationStash, item.LocationSharedStash) + + maxQuantity := rule.MaxQuantity() + if maxQuantity == 0 { + return false + } + + if maxQuantity == 0 { + return false + } + + matchedItemsInStash := 0 + + for _, stashItem := range stashItems { + res, _ := rule.Evaluate(stashItem) + if res == nip.RuleResultFullMatch { + matchedItemsInStash += 1 + } + } + + return matchedItemsInStash >= maxQuantity +} + +func DropMouseItem() { + ctx := context.Get() + ctx.SetLastAction("DropMouseItem") + + if len(ctx.Data.Inventory.ByLocation(item.LocationCursor)) > 0 { + utils.Sleep(1000) + ctx.HID.Click(game.LeftButton, 500, 500) + utils.Sleep(1000) + } +} + +func DropInventoryItem(i data.Item) error { + ctx := context.Get() + ctx.SetLastAction("DropInventoryItem") + + closeAttempts := 0 + + // Check if any other menu is open, except the inventory + for ctx.Data.OpenMenus.IsMenuOpen() { + + // Press escape to close it + ctx.HID.PressKey(0x1B) // ESC + utils.Sleep(500) + closeAttempts++ + + if closeAttempts >= 5 { + return fmt.Errorf("failed to close open menu after 5 attempts") + } + } + + if i.Location.LocationType == item.LocationInventory { + + // Check if the inventory is open, if not open it + if !ctx.Data.OpenMenus.Inventory { + ctx.HID.PressKeyBinding(ctx.Data.KeyBindings.Inventory) + } + + // Wait a second + utils.Sleep(1000) + + screenPos := ui.GetScreenCoordsForItem(i) + ctx.HID.MovePointer(screenPos.X, screenPos.Y) + utils.Sleep(250) + ctx.HID.ClickWithModifier(game.LeftButton, screenPos.X, screenPos.Y, game.CtrlKey) + utils.Sleep(500) + + // Close the inventory if its still open, which should be at this point + if ctx.Data.OpenMenus.Inventory { + ctx.HID.PressKeyBinding(ctx.Data.KeyBindings.Inventory) + } + } + + return nil +} +func IsInLockedInventorySlot(itm data.Item) bool { + // Check if item is in inventory + if itm.Location.LocationType != item.LocationInventory { + return false + } + + // Get the lock configuration from character config + ctx := context.Get() + lockConfig := ctx.CharacterCfg.Inventory.InventoryLock + if len(lockConfig) == 0 { + return false + } + + // Calculate row and column in inventory + row := itm.Position.Y + col := itm.Position.X + + // Check if position is within bounds + if row >= len(lockConfig) || col >= len(lockConfig[0]) { + return false + } + + // 0 means locked, 1 means unlocked + return lockConfig[row][col] == 0 +} diff --git a/internal/action/item_pickup.go b/internal/action/item_pickup.go index 359de8497..ce70657c1 100644 --- a/internal/action/item_pickup.go +++ b/internal/action/item_pickup.go @@ -1,159 +1,325 @@ package action import ( + "errors" "fmt" - "time" + "log/slog" + "slices" "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/d2go/pkg/data/item" "github.com/hectorgimenez/d2go/pkg/data/stat" - "github.com/hectorgimenez/d2go/pkg/itemfilter" + "github.com/hectorgimenez/d2go/pkg/nip" "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/pather" - "go.uber.org/zap" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/event" ) -func (b *Builder) ItemPickup(waitForDrop bool, maxDistance int) *Chain { - firstCallTime := time.Time{} - var itemBeingPickedUp data.UnitID +func itemFitsInventory(i data.Item) bool { + invMatrix := context.Get().Data.Inventory.Matrix() - return NewChain(func(d data.Data) []Action { - if firstCallTime.IsZero() { - firstCallTime = time.Now() + for y := 0; y <= len(invMatrix)-i.Desc().InventoryHeight; y++ { + for x := 0; x <= len(invMatrix[0])-i.Desc().InventoryWidth; x++ { + freeSpace := true + for dy := 0; dy < i.Desc().InventoryHeight; dy++ { + for dx := 0; dx < i.Desc().InventoryWidth; dx++ { + if invMatrix[y+dy][x+dx] { + freeSpace = false + break + } + } + if !freeSpace { + break + } + } + + if freeSpace { + return true + } + } + } + + return false +} + +func ItemPickup(maxDistance int) error { + ctx := context.Get() + ctx.SetLastAction("ItemPickup") + + const maxRetries = 5 + const maxItemTooFarAttempts = 5 + + for { + ctx.PauseIfNotPriority() + + itemsToPickup := GetItemsToPickup(maxDistance) + if len(itemsToPickup) == 0 { + return nil + } + + // Find first item that fits in inventory + var itemToPickup data.Item + for _, i := range itemsToPickup { + if itemFitsInventory(i) { + itemToPickup = i + break + } } - itemsToPickup := b.getItemsToPickup(d, maxDistance) - if len(itemsToPickup) > 0 { - for _, m := range d.Monsters.Enemies() { - if dist := pather.DistanceFromMe(d, m.Position); dist < 7 { - b.logger.Debug("Aborting item pickup, monster nearby", zap.Any("monster", m)) - itemBeingPickedUp = -1 - return []Action{b.ch.KillMonsterSequence(func(d data.Data) (data.UnitID, bool) { - return m.UnitID, true - }, nil)} + if itemToPickup.UnitID == 0 { + ctx.Logger.Debug("Inventory is full, returning to town to sell junk and stash items") + InRunReturnTownRoutine() + continue + } + + ctx.Logger.Debug(fmt.Sprintf( + "Item Detected: %s [%d] at X:%d Y:%d", + itemToPickup.Name, + itemToPickup.Quality, + itemToPickup.Position.X, + itemToPickup.Position.Y, + )) + + // Try to pick up the item with retries + var lastError error + attempt := 1 + attemptItemTooFar := 1 + for attempt <= maxRetries { + // Clear monsters on each attempt + ClearAreaAroundPosition(itemToPickup.Position, 4, data.MonsterAnyFilter()) + + // Calculate position to move to based on attempt number + // on 2nd and 3rd attempt try position left/right of item + // on 4th and 5th attempt try position further away + pickupPosition := itemToPickup.Position + moveDistance := 3 + if attempt > 1 { + switch attempt { + case 2: + pickupPosition = data.Position{ + X: itemToPickup.Position.X + moveDistance, + Y: itemToPickup.Position.Y - 1, + } + case 3: + pickupPosition = data.Position{ + X: itemToPickup.Position.X - moveDistance, + Y: itemToPickup.Position.Y + 1, + } + case 4: + pickupPosition = data.Position{ + X: itemToPickup.Position.X + moveDistance + 2, + Y: itemToPickup.Position.Y - 3, + } + case 5: + MoveToCoords(ctx.PathFinder.BeyondPosition(ctx.Data.PlayerUnit.Position, itemToPickup.Position, 4)) } } - i := itemsToPickup[0] + distance := ctx.PathFinder.DistanceFromMe(itemToPickup.Position) + if distance >= 7 || attempt > 1 { + distanceToFinish := 3 + if attempt > 1 { + distanceToFinish = 2 + } + if err := step.MoveTo(pickupPosition, step.WithDistanceToFinish(distanceToFinish)); err != nil { + ctx.Logger.Debug(fmt.Sprintf("Failed moving to item on attempt %d: %v", attempt, err)) + lastError = err + attempt++ + continue + } + } - // Error picking up Item, go back to town, sell junk, stash and try again. - if itemBeingPickedUp == i.UnitID { - b.logger.Debug("Item could not be picked up, going back to town to sell junk and stash") - return []Action{NewChain(func(d data.Data) []Action { - itemBeingPickedUp = -1 - return b.InRunReturnTownRoutine() - })} + // Try to pick up the item + err := step.PickupItem(itemToPickup, attempt) + if err == nil { + break // Success! } - b.logger.Debug(fmt.Sprintf( - "Item Detected: %s [%d] at X:%d Y:%d", - i.Name, - i.Quality, - i.Position.X, - i.Position.Y, - )) - - itemBeingPickedUp = i.UnitID - return []Action{ - b.MoveToCoords(i.Position), - NewStepChain(func(d data.Data) []step.Step { - return []step.Step{step.PickupItem(b.logger, i)} - }, IgnoreErrors()), + lastError = err + // Skip logging when casting moving error and don't count these specific errors as retry attempts + if errors.Is(err, step.ErrCastingMoving) { + continue } - } + ctx.Logger.Debug(fmt.Sprintf("Pickup attempt %d failed: %v", attempt, err)) - // Add small delay, drop is not instant - if waitForDrop && time.Since(firstCallTime) < time.Second { - msToWait := time.Second - time.Since(firstCallTime) - b.logger.Debug("No items detected, waiting a bit and will try again", zap.Int("waitMs", int(msToWait.Milliseconds()))) + // Don't count these specific errors as retry attempts + if errors.Is(err, step.ErrMonsterAroundItem) { + continue + } + + // Item too far retry logic + if errors.Is(err, step.ErrItemTooFar) { + // Use default retries first, if we hit last attempt retry add random movement and continue until maxItemTooFarAttempts + if attempt >= maxRetries && attemptItemTooFar <= maxItemTooFarAttempts { + ctx.Logger.Debug(fmt.Sprintf("Item too far pickup attempt %d", attemptItemTooFar)) + attemptItemTooFar++ + ctx.PathFinder.RandomMovement() + continue + } + } + + if errors.Is(err, step.ErrNoLOSToItem) { + ctx.Logger.Debug("No line of sight to item, moving closer", + slog.String("item", itemToPickup.Desc().Name)) + + // Try moving beyond the item for better line of sight + beyondPos := ctx.PathFinder.BeyondPosition(ctx.Data.PlayerUnit.Position, itemToPickup.Position, 2+attempt) + if mvErr := MoveToCoords(beyondPos); mvErr == nil { + err = step.PickupItem(itemToPickup, attempt) + if err == nil { + break + } + lastError = err + } else { + lastError = mvErr + } + } + + attempt++ - return []Action{b.Wait(msToWait)} } - return nil - }, RepeatUntilNoSteps()) + // If all attempts failed, blacklist the item + if attempt > maxRetries && lastError != nil { + ctx.CurrentGame.BlacklistedItems = append(ctx.CurrentGame.BlacklistedItems, itemToPickup) + + // Screenshot with show items on + ctx.HID.KeyDown(ctx.Data.KeyBindings.ShowItems) + screenshot := ctx.GameReader.Screenshot() + event.Send(event.ItemBlackListed(event.WithScreenshot(ctx.Name, fmt.Sprintf("Item %s [%s] BlackListed in Area:%s", itemToPickup.Name, itemToPickup.Quality.ToString(), ctx.Data.PlayerUnit.Area.Area().Name), screenshot), data.Drop{Item: itemToPickup})) + ctx.HID.KeyUp(ctx.Data.KeyBindings.ShowItems) + + ctx.Logger.Warn( + "Failed picking up item after all attempts, blacklisting it", + slog.String("itemName", itemToPickup.Desc().Name), + slog.Int("unitID", int(itemToPickup.UnitID)), + slog.String("lastError", lastError.Error()), + ) + } + } } +func GetItemsToPickup(maxDistance int) []data.Item { + ctx := context.Get() + ctx.SetLastAction("GetItemsToPickup") + + missingHealingPotions := ctx.BeltManager.GetMissingCount(data.HealingPotion) + missingManaPotions := ctx.BeltManager.GetMissingCount(data.ManaPotion) + missingRejuvenationPotions := ctx.BeltManager.GetMissingCount(data.RejuvenationPotion) -func (b *Builder) getItemsToPickup(d data.Data, maxDistance int) []data.Item { - missingHealingPotions := b.bm.GetMissingCount(d, data.HealingPotion) - missingManaPotions := b.bm.GetMissingCount(d, data.ManaPotion) - missingRejuvenationPotions := b.bm.GetMissingCount(d, data.RejuvenationPotion) var itemsToPickup []data.Item - for _, itm := range d.Items.ByLocation(item.LocationGround) { - // Skip items that are outside pickup radius, this is useful when clearing big areas to prevent - // character going back to pickup potions all the time after using them - itemDistance := pather.DistanceFromMe(d, itm.Position) - if maxDistance > 0 && itemDistance > maxDistance { + _, isLevelingChar := ctx.Char.(context.LevelingCharacter) + + for _, itm := range ctx.Data.Inventory.ByLocation(item.LocationGround) { + // Skip itempickup on party leveling Maggot Lair, is too narrow and causes characters to get stuck + if isLevelingChar && !itm.IsFromQuest() && (ctx.Data.PlayerUnit.Area == area.MaggotLairLevel1 || + ctx.Data.PlayerUnit.Area == area.MaggotLairLevel2 || + ctx.Data.PlayerUnit.Area == area.MaggotLairLevel3 || + ctx.Data.PlayerUnit.Area == area.ArcaneSanctuary) { continue } - // Pickup potions only if they are required - if itm.IsHealingPotion() { - if missingHealingPotions == 0 { - continue - } - if b.shouldBePickedUp(d, itm) { - itemsToPickup = append(itemsToPickup, itm) - missingHealingPotions-- - } + // Skip potion pickup for Berserker Barb in Travincal if configured + if ctx.CharacterCfg.Character.Class == "berserker" && + ctx.CharacterCfg.Character.BerserkerBarb.SkipPotionPickupInTravincal && + ctx.Data.PlayerUnit.Area == area.Travincal && + itm.IsPotion() { continue } - if itm.IsManaPotion() { - if missingManaPotions == 0 { - continue - } - if b.shouldBePickedUp(d, itm) { - itemsToPickup = append(itemsToPickup, itm) - missingManaPotions-- - } + + // Skip items that are outside pickup radius, this is useful when clearing big areas to prevent + // character going back to pickup potions all the time after using them + itemDistance := ctx.PathFinder.DistanceFromMe(itm.Position) + if maxDistance > 0 && itemDistance > maxDistance && itm.IsPotion() { continue } - if itm.IsRejuvPotion() { - if missingRejuvenationPotions == 0 { - continue - } - if b.shouldBePickedUp(d, itm) { - itemsToPickup = append(itemsToPickup, itm) - missingRejuvenationPotions-- + + if itm.IsPotion() { + if (itm.IsHealingPotion() && missingHealingPotions > 0) || + (itm.IsManaPotion() && missingManaPotions > 0) || + (itm.IsRejuvPotion() && missingRejuvenationPotions > 0) { + if shouldBePickedUp(itm) { + itemsToPickup = append(itemsToPickup, itm) + switch { + case itm.IsHealingPotion(): + missingHealingPotions-- + case itm.IsManaPotion(): + missingManaPotions-- + case itm.IsRejuvPotion(): + missingRejuvenationPotions-- + } + } } - continue + } else if shouldBePickedUp(itm) { + itemsToPickup = append(itemsToPickup, itm) } + } - if b.shouldBePickedUp(d, itm) { - itemsToPickup = append(itemsToPickup, itm) - continue + // Remove blacklisted items from the list, we don't want to pick them up + filteredItems := make([]data.Item, 0, len(itemsToPickup)) + for _, itm := range itemsToPickup { + isBlacklisted := false + for _, blacklistedItem := range ctx.CurrentGame.BlacklistedItems { + if itm.UnitID == blacklistedItem.UnitID { + isBlacklisted = true + break + } + } + if !isBlacklisted { + filteredItems = append(filteredItems, itm) } } - return itemsToPickup + return filteredItems } -func (b *Builder) shouldBePickedUp(d data.Data, i data.Item) bool { - // Skip picking up gold if we can not carry more - if d.PlayerUnit.Stats[stat.Gold] >= d.PlayerUnit.MaxGold() { - b.logger.Debug("Skipping gold pickup, inventory full") - return false - } +func shouldBePickedUp(i data.Item) bool { + ctx := context.Get() + ctx.SetLastAction("shouldBePickedUp") - // Always pickup WirtsLeg! - if i.Name == "WirtsLeg" { + // Always pickup Runewords and Wirt's Leg + if i.IsRuneword || i.Name == "WirtsLeg" { return true } - // Only during leveling if gold amount is low pickup items to sell as junk - _, isLevelingChar := b.ch.(LevelingCharacter) + // Pick up quest items if we're in leveling or questing run + specialRuns := slices.Contains(ctx.CharacterCfg.Game.Runs, "quests") || slices.Contains(ctx.CharacterCfg.Game.Runs, "leveling") + if specialRuns { + switch i.Name { + case "Scrollofinifuss", "LamEsensTome", "HoradricCube", "AmuletoftheViper", "StaffofKings", "HoradricStaff", "AJadeFigurine", "KhalimsEye", "KhalimsBrain", "KhalimsHeart", "KhalimsFlail": + return true + } + } + if i.ID == 552 { // Book of Skill doesnt work by name, so we find it by ID + return true + } + // Skip picking up gold if we can not carry more + gold, _ := ctx.Data.PlayerUnit.FindStat(stat.Gold, 0) + if gold.Value >= ctx.Data.PlayerUnit.MaxGold() && i.Name == "Gold" { + ctx.Logger.Debug("Skipping gold pickup, inventory full") + return false + } // Skip picking up gold, usually early game there are small amounts of gold in many places full of enemies, better // stay away of that - if isLevelingChar && d.PlayerUnit.TotalGold() < 50000 && i.Name != "Gold" { + _, isLevelingChar := ctx.Char.(context.LevelingCharacter) + if isLevelingChar && ctx.Data.PlayerUnit.TotalPlayerGold() < 50000 && i.Name != "Gold" { return true } // Pickup all magic or superior items if total gold is low, filter will not pass and items will be sold to vendor - if d.PlayerUnit.TotalGold() < 500000 && i.Quality >= item.QualityMagic { + minGoldPickupThreshold := ctx.CharacterCfg.Game.MinGoldPickupThreshold + if ctx.Data.PlayerUnit.TotalPlayerGold() < minGoldPickupThreshold && i.Quality >= item.QualityMagic { return true } - return itemfilter.Evaluate(i, config.Config.Runtime.Rules) + // Evaluate item based on NIP rules + matchedRule, result := ctx.Data.CharacterCfg.Runtime.Rules.EvaluateAll(i) + if result == nip.RuleResultNoMatch { + return false + } + if result == nip.RuleResultPartial { + return true + } + return !doesExceedQuantity(matchedRule) } diff --git a/internal/action/legacy_mode.go b/internal/action/legacy_mode.go new file mode 100644 index 000000000..8e1c006b5 --- /dev/null +++ b/internal/action/legacy_mode.go @@ -0,0 +1,26 @@ +package action + +import ( + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/ui" + "github.com/hectorgimenez/koolo/internal/utils" +) + +func SwitchToLegacyMode() { + ctx := context.Get() + ctx.SetLastAction("SwitchToLegacyMode") + + if ctx.CharacterCfg.ClassicMode && !ctx.Data.LegacyGraphics { + ctx.Logger.Debug("Switching to legacy mode...") + ctx.HID.PressKey(ctx.Data.KeyBindings.LegacyToggle.Key1[0]) + utils.Sleep(500) // Add small delay to allow the game to switch + + // Close the mini panel if option is enabled + if ctx.CharacterCfg.CloseMiniPanel { + utils.Sleep(100) + ctx.HID.Click(game.LeftButton, ui.CloseMiniPanelClassicX, ui.CloseMiniPanelClassicY) + utils.Sleep(100) + } + } +} diff --git a/internal/action/leveling_tools.go b/internal/action/leveling_tools.go index 9b8b84ca7..dd4a55360 100644 --- a/internal/action/leveling_tools.go +++ b/internal/action/leveling_tools.go @@ -1,8 +1,15 @@ package action import ( - "fmt" - "time" + "slices" + + "github.com/hectorgimenez/koolo/internal/action/step" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/town" + "github.com/hectorgimenez/koolo/internal/ui" + "github.com/hectorgimenez/koolo/internal/utils" + "github.com/lxn/win" "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" @@ -10,13 +17,6 @@ import ( "github.com/hectorgimenez/d2go/pkg/data/npc" "github.com/hectorgimenez/d2go/pkg/data/skill" "github.com/hectorgimenez/d2go/pkg/data/stat" - "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" - "github.com/hectorgimenez/koolo/internal/town" - "github.com/hectorgimenez/koolo/internal/ui" - "go.uber.org/zap" ) var uiStatButtonPosition = map[stat.ID]data.Position{ @@ -26,277 +26,383 @@ var uiStatButtonPosition = map[stat.ID]data.Position{ stat.Energy: {X: 240, Y: 430}, } -var uiSkillTabPosition = []data.Position{ - {X: 910, Y: 140}, - {X: 1010, Y: 140}, +var uiSkillPagePosition = [3]data.Position{ {X: 1100, Y: 140}, + {X: 1010, Y: 140}, + {X: 910, Y: 140}, } var uiSkillRowPosition = [6]int{190, 250, 310, 365, 430, 490} var uiSkillColumnPosition = [3]int{920, 1010, 1095} -var previousTotalSkillNumber = 0 - -func (b *Builder) EnsureStatPoints() *StepChainAction { - return NewStepChain(func(d data.Data) []step.Step { - char, isLevelingChar := b.ch.(LevelingCharacter) - _, unusedStatPoints := d.PlayerUnit.Stats[stat.StatPoints] - if !isLevelingChar || !unusedStatPoints { - if d.OpenMenus.Character { - return []step.Step{ - step.SyncStep(func(_ data.Data) error { - hid.PressKey("esc") - return nil - }), - } - } +var uiStatButtonPositionLegacy = map[stat.ID]data.Position{ + stat.Strength: {X: 430, Y: 180}, + stat.Dexterity: {X: 430, Y: 250}, + stat.Vitality: {X: 430, Y: 360}, + stat.Energy: {X: 430, Y: 435}, +} - return nil - } +var uiSkillPagePositionLegacy = [3]data.Position{ + {X: 970, Y: 510}, + {X: 970, Y: 390}, + {X: 970, Y: 260}, +} - for st, targetPoints := range char.StatPoints(d) { - currentPoints, found := d.PlayerUnit.Stats[st] - if !found || currentPoints >= targetPoints { - continue - } +var uiSkillRowPositionLegacy = [6]int{110, 195, 275, 355, 440, 520} +var uiSkillColumnPositionLegacy = [3]int{690, 770, 855} + +func EnsureStatPoints() error { + // TODO finish this + return nil + //return NewStepChain(func(d game.Data) []step.Step { + // char, isLevelingChar := b.ch.(LevelingCharacter) + // _, unusedStatPoints := d.PlayerUnit.FindStat(stat.StatPoints, 0) + // if !isLevelingChar || !unusedStatPoints { + // if d.OpenMenus.Character { + // return []step.Step{ + // step.SyncStep(func(_ game.Data) error { + // b.HID.PressKey(win.VK_ESCAPE) + // return nil + // }), + // } + // } + // + // return nil + // } + // + // for st, targetPoints := range char.StatPoints(d) { + // currentPoints, found := d.PlayerUnit.FindStat(st, 0) + // if !found || currentPoints.Value >= targetPoints { + // continue + // } + // + // if !d.OpenMenus.Character { + // return []step.Step{ + // step.SyncStep(func(_ game.Data) error { + // b.HID.PressKeyBinding(d.KeyBindings.CharacterScreen) + // return nil + // }), + // } + // } + // + // var statBtnPosition data.Position + // if d.LegacyGraphics { + // statBtnPosition = uiStatButtonPositionLegacy[st] + // } else { + // statBtnPosition = uiStatButtonPosition[st] + // } + // + // return []step.Step{ + // step.SyncStep(func(_ game.Data) error { + // utils.Sleep(100) + // b.HID.Click(game.LeftButton, statBtnPosition.X, statBtnPosition.Y) + // utils.Sleep(500) + // return nil + // }), + // } + // } + // + // return nil + //}, RepeatUntilNoSteps()) +} - if !d.OpenMenus.Character { - return []step.Step{ - step.SyncStep(func(_ data.Data) error { - hid.PressKey(config.Config.Bindings.OpenCharacterScreen) - return nil - }), - } - } +func EnsureSkillPoints() error { + // TODO finish this + return nil + //ctx := context.Get() + // + //char, isLevelingChar := ctx.Char.(LevelingCharacter) + //availablePoints, unusedSkillPoints := ctx.Data.PlayerUnit.FindStat(stat.SkillPoints, 0) + // + //assignedPoints := make(map[skill.ID]int) + //for _, sk := range char.SkillPoints() { + // currentPoints, found := assignedPoints[sk] + // if !found { + // currentPoints = 0 + // } + // + // assignedPoints[sk] = currentPoints + 1 + // + // characterPoints, found := ctx.Data.PlayerUnit.Skills[sk] + // if !found || int(characterPoints.Level) < assignedPoints[sk] { + // skillDesc, skFound := skill.Desc[sk] + // if !skFound { + // ctx.Logger.Error("skill not found for character", slog.Any("skill", sk)) + // return nil + // } + // + // if !ctx.Data.OpenMenus.SkillTree { + // ctx.HID.PressKeyBinding(ctx.Data.KeyBindings.SkillTree) + // } + // + // utils.Sleep(100) + // if ctx.Data.LegacyGraphics { + // ctx.HID.Click(game.LeftButton, uiSkillPagePositionLegacy[skillDesc.Page-1].X, uiSkillPagePositionLegacy[skillDesc.Page-1].Y) + // } else { + // ctx.HID.Click(game.LeftButton, uiSkillPagePosition[skillDesc.Page-1].X, uiSkillPagePosition[skillDesc.Page-1].Y) + // } + // utils.Sleep(200) + // if ctx.Data.LegacyGraphics { + // ctx.HID.Click(game.LeftButton, uiSkillColumnPositionLegacy[skillDesc.Column-1], uiSkillRowPositionLegacy[skillDesc.Row-1]) + // } else { + // ctx.HID.Click(game.LeftButton, uiSkillColumnPosition[skillDesc.Column-1], uiSkillRowPosition[skillDesc.Row-1]) + // } + // utils.Sleep(500) + // return nil + // } + //} + // + //return nil +} - statBtnPosition := uiStatButtonPosition[st] - return []step.Step{ - step.SyncStep(func(_ data.Data) error { - helper.Sleep(100) - hid.MovePointer(statBtnPosition.X, statBtnPosition.Y) - hid.Click(hid.LeftButton) - helper.Sleep(500) - return nil - }), - } - } +func UpdateQuestLog() error { + ctx := context.Get() + ctx.SetLastAction("UpdateQuestLog") + if _, isLevelingChar := ctx.Char.(context.LevelingCharacter); !isLevelingChar { return nil - }, RepeatUntilNoSteps()) + } + + ctx.HID.PressKeyBinding(ctx.Data.KeyBindings.QuestLog) + utils.Sleep(1000) + + return step.CloseAllMenus() } +func getAvailableSkillKB() []data.KeyBinding { + availableSkillKB := make([]data.KeyBinding, 0) + ctx := context.Get() + ctx.SetLastAction("getAvailableSkillKB") + + for _, sb := range ctx.Data.KeyBindings.Skills { + if sb.SkillID == -1 && (sb.Key1[0] != 0 && sb.Key1[0] != 255) || (sb.Key2[0] != 0 && sb.Key2[0] != 255) { + availableSkillKB = append(availableSkillKB, sb.KeyBinding) + } + } -func (b *Builder) EnsureSkillPoints() *StepChainAction { - assignAttempts := 0 - return NewStepChain(func(d data.Data) []step.Step { - char, isLevelingChar := b.ch.(LevelingCharacter) - availablePoints, unusedSkillPoints := d.PlayerUnit.Stats[stat.SkillPoints] - if !isLevelingChar || !unusedSkillPoints || assignAttempts >= availablePoints { - if d.OpenMenus.SkillTree { - return []step.Step{ - step.SyncStep(func(_ data.Data) error { - hid.PressKey("esc") - return nil - }), - } - } + return availableSkillKB +} - return nil +func EnsureSkillBindings() error { + ctx := context.Get() + ctx.SetLastAction("EnsureSkillBindings") + + char, isLevelingChar := ctx.Char.(context.LevelingCharacter) + if !isLevelingChar { + return nil + } + + mainSkill, skillsToBind := char.SkillsToBind() + skillsToBind = append(skillsToBind, skill.TomeOfTownPortal) + notBoundSkills := make([]skill.ID, 0) + for _, sk := range skillsToBind { + if _, found := ctx.Data.KeyBindings.KeyBindingForSkill(sk); !found && ctx.Data.PlayerUnit.Skills[sk].Level > 0 { + notBoundSkills = append(notBoundSkills, sk) } + } - skillTree := char.GetSkillTree() - assignedPoints := make(map[skill.Skill]int) - for _, sk := range char.SkillPoints(d) { - currentPoints, found := assignedPoints[sk] + if len(notBoundSkills) > 0 { + ctx.HID.Click(game.LeftButton, ui.SecondarySkillButtonX, ui.SecondarySkillButtonY) + utils.Sleep(300) + ctx.HID.MovePointer(10, 10) + utils.Sleep(300) + + availableKB := getAvailableSkillKB() + + for i, sk := range notBoundSkills { + skillPosition, found := calculateSkillPositionInUI(false, sk) if !found { - currentPoints = 0 + continue } - assignedPoints[sk] = currentPoints + 1 - - characterPoints, found := d.PlayerUnit.Skills[sk] - if !found || characterPoints < assignedPoints[sk] { - position, skFound := skillTree[sk] - if !skFound { - b.logger.Error("skill not found for character", zap.Any("skill", sk)) - return nil - } + ctx.HID.MovePointer(skillPosition.X, skillPosition.Y) + utils.Sleep(100) + ctx.HID.PressKeyBinding(availableKB[i]) + utils.Sleep(300) + } - if !d.OpenMenus.SkillTree { - return []step.Step{ - step.SyncStep(func(_ data.Data) error { - hid.PressKey(config.Config.Bindings.OpenSkillTree) - return nil - }), - } - } + } - return []step.Step{ - step.SyncStep(func(_ data.Data) error { - assignAttempts++ - helper.Sleep(100) - hid.MovePointer(uiSkillTabPosition[position.Tab].X, uiSkillTabPosition[position.Tab].Y) - hid.Click(hid.LeftButton) - helper.Sleep(200) - hid.MovePointer(uiSkillColumnPosition[position.Column], uiSkillRowPosition[position.Row]) - hid.Click(hid.LeftButton) - helper.Sleep(500) - return nil - }), - } - } + if ctx.Data.PlayerUnit.LeftSkill != mainSkill { + ctx.HID.Click(game.LeftButton, ui.MainSkillButtonX, ui.MainSkillButtonY) + utils.Sleep(300) + ctx.HID.MovePointer(10, 10) + utils.Sleep(300) + + skillPosition, found := calculateSkillPositionInUI(true, mainSkill) + if found { + ctx.HID.MovePointer(skillPosition.X, skillPosition.Y) + utils.Sleep(100) + ctx.HID.Click(game.LeftButton, skillPosition.X, skillPosition.Y) + utils.Sleep(300) } + } - return nil - }, RepeatUntilNoSteps()) + return nil } -func (b *Builder) EnsureSkillBindings() *StepChainAction { - return NewStepChain(func(d data.Data) []step.Step { - if _, isLevelingChar := b.ch.(LevelingCharacter); !isLevelingChar { - return nil +func calculateSkillPositionInUI(mainSkill bool, skillID skill.ID) (data.Position, bool) { + d := context.Get().Data + + var scrolls = []skill.ID{ + skill.TomeOfTownPortal, skill.ScrollOfTownPortal, skill.TomeOfIdentify, skill.ScrollOfIdentify, + } + + if _, found := d.PlayerUnit.Skills[skillID]; !found { + return data.Position{}, false + } + + targetSkill := skill.Skills[skillID] + descs := make(map[skill.ID]skill.Skill) + row := 0 + totalRows := make([]int, 0) + column := 0 + skillsWithCharges := 0 + for skID, points := range d.PlayerUnit.Skills { + sk := skill.Skills[skID] + // Skip skills that can not be bind + if sk.Desc().ListRow < 0 { + continue + } + + // Skip skills that can not be bind to current mouse button + if (mainSkill == true && !sk.LeftSkill) || (mainSkill == false && !sk.RightSkill) { + continue + } + + if points.Charges > 0 { + skillsWithCharges++ + continue } - char, _ := b.ch.(LevelingCharacter) - skillBindings := char.GetKeyBindings(d) - skillBindings[skill.TomeOfTownPortal] = config.Config.Bindings.TP - - if len(skillBindings) > 0 && len(d.PlayerUnit.Skills) != previousTotalSkillNumber { - return []step.Step{ - step.SyncStep(func(_ data.Data) error { - hid.MovePointer(ui.SecondarySkillButtonX, ui.SecondarySkillButtonY) - hid.Click(hid.LeftButton) - helper.Sleep(300) - hid.MovePointer(10, 10) - helper.Sleep(300) - - sc := helper.Screenshot() - for sk, binding := range skillBindings { - if binding == "" { - continue - } - tm := b.tf.Find(fmt.Sprintf("skills_%d", sk), sc) - if !tm.Found { - continue - } - hid.MovePointer(tm.PositionX+10, tm.PositionY+10) - helper.Sleep(100) - hid.PressKey(binding) - helper.Sleep(300) - } - - previousTotalSkillNumber = len(d.PlayerUnit.Skills) - return nil - }), - step.SyncStep(func(_ data.Data) error { - for sk, binding := range skillBindings { - if binding != "" { - continue - } - hid.MovePointer(ui.MainSkillButtonX, ui.MainSkillButtonY) - hid.Click(hid.LeftButton) - helper.Sleep(300) - hid.MovePointer(10, 10) - helper.Sleep(300) - - sc := helper.Screenshot() - tm := b.tf.Find(fmt.Sprintf("skills_%d", sk), sc) - if !tm.Found { - continue - } - hid.MovePointer(tm.PositionX+10, tm.PositionY+10) - helper.Sleep(100) - hid.Click(hid.LeftButton) - } - return nil - }), + + if slices.Contains(scrolls, sk.ID) { + continue + } + descs[skID] = sk + + if skID != targetSkill.ID && sk.Desc().Page == targetSkill.Desc().Page { + if sk.Desc().ListRow > targetSkill.Desc().ListRow { + column++ + } else if sk.Desc().ListRow == targetSkill.Desc().ListRow && sk.Desc().Column > targetSkill.Desc().Column { + column++ } } - return nil - }, RepeatUntilNoSteps()) -} + totalRows = append(totalRows, sk.Desc().ListRow) + if row == targetSkill.Desc().ListRow { + continue + } + + row++ + } -func (b *Builder) GetCompletedQuests(act int) []bool { - hid.PressKey(config.Config.Bindings.OpenQuestLog) - hid.MovePointer(ui.QuestFirstTabX+(act-1)*ui.QuestTabXInterval, ui.QuestFirstTabY) - helper.Sleep(200) - hid.Click(hid.LeftButton) - helper.Sleep(3000) + slices.Sort(totalRows) + totalRows = slices.Compact(totalRows) - quests := make([]bool, 6) - if act == 4 { - quests = make([]bool, 3) + // If we don't have any skill of a specific tree, the entire row gets one line down + for i, currentRow := range totalRows { + if currentRow == row { + row = i + break + } } - sc := helper.Screenshot() - for i := 0; i < len(quests); i++ { - tm := b.tf.Find(fmt.Sprintf("quests_a%d_%d", act, i+1), sc) - quests[i] = tm.Found + // Scrolls and charges are not in the same list + if slices.Contains(scrolls, skillID) { + column = skillsWithCharges + row = len(totalRows) + for _, skID := range scrolls { + if d.PlayerUnit.Skills[skID].Quantity > 0 { + if skID == skillID { + break + } + column++ + } + } + } + + skillOffsetX := ui.MainSkillListFirstSkillX - (ui.SkillListSkillOffset * column) + if !mainSkill { + skillOffsetX = ui.SecondarySkillListFirstSkillX + (ui.SkillListSkillOffset * column) } - hid.PressKey("esc") - return quests + return data.Position{ + X: skillOffsetX, + Y: ui.SkillListFirstSkillY - ui.SkillListSkillOffset*row, + }, true } -func (b *Builder) HireMerc() *Chain { - return NewChain(func(d data.Data) (actions []Action) { - _, isLevelingChar := b.ch.(LevelingCharacter) - if isLevelingChar && config.Config.Character.UseMerc { - // Hire the merc if we don't have one, we have enough gold, and we are in act 2. We assume that ReviveMerc was called before this. - if config.Config.Game.Difficulty == difficulty.Normal && d.MercHPPercent() <= 0 && d.PlayerUnit.TotalGold() > 30000 && d.PlayerUnit.Area == area.LutGholein { - b.logger.Info("Hiring merc...") - actions = append(actions, - b.InteractNPC(town.GetTownByArea(d.PlayerUnit.Area).MercContractorNPC(), step.KeySequence("home", "down", "enter")), - NewStepChain(func(d data.Data) []step.Step { - sc := helper.Screenshot() - tm := b.tf.Find(fmt.Sprintf("skills_merc_%d", skill.Defiance), sc) - if !tm.Found { - return nil - } - - return []step.Step{ - step.SyncStep(func(d data.Data) error { - hid.MovePointer(tm.PositionX-100, tm.PositionY) - hid.Click(hid.LeftButton) - hid.Click(hid.LeftButton) - - return nil - }), - } - }), - ) +func HireMerc() error { + ctx := context.Get() + ctx.SetLastAction("HireMerc") + + _, isLevelingChar := ctx.Char.(context.LevelingCharacter) + if isLevelingChar && ctx.CharacterCfg.Character.UseMerc { + // Hire the merc if we don't have one, we have enough gold, and we are in act 2. We assume that ReviveMerc was called before this. + if ctx.CharacterCfg.Game.Difficulty == difficulty.Normal && ctx.Data.MercHPPercent() <= 0 && ctx.Data.PlayerUnit.TotalPlayerGold() > 30000 && ctx.Data.PlayerUnit.Area == area.LutGholein { + ctx.Logger.Info("Hiring merc...") + // TODO: Hire Holy Freeze merc if available, if not, hire Defiance merc. + err := InteractNPC(town.GetTownByArea(ctx.Data.PlayerUnit.Area).MercContractorNPC()) + if err != nil { + return err } + ctx.HID.KeySequence(win.VK_HOME, win.VK_DOWN, win.VK_RETURN) + utils.Sleep(2000) + ctx.HID.Click(game.LeftButton, ui.FirstMercFromContractorListX, ui.FirstMercFromContractorListY) + utils.Sleep(500) + ctx.HID.Click(game.LeftButton, ui.FirstMercFromContractorListX, ui.FirstMercFromContractorListY) + } + } - // We will change the Defiance merc to the holy freeze, much better to avoid being hit. - if config.Config.Game.Difficulty == difficulty.Nightmare && d.MercHPPercent() > 0 && d.PlayerUnit.TotalGold() > 50000 && d.PlayerUnit.Area == area.KurastDocks && d.PlayerUnit.Skills[skill.Defiance] > 0 { - b.logger.Info("Changing Defiance merc by Holy Freeze...") - // Remove merc items + return nil +} + +func ResetStats() error { + ctx := context.Get() + ctx.SetLastAction("ResetStats") + + ch, isLevelingChar := ctx.Char.(context.LevelingCharacter) + if isLevelingChar && ch.ShouldResetSkills() { + currentArea := ctx.Data.PlayerUnit.Area + if ctx.Data.PlayerUnit.Area != area.RogueEncampment { + err := WayPoint(area.RogueEncampment) + if err != nil { + return err } } + InteractNPC(npc.Akara) + ctx.HID.KeySequence(win.VK_HOME, win.VK_DOWN, win.VK_DOWN, win.VK_RETURN) + utils.Sleep(1000) + ctx.HID.KeySequence(win.VK_HOME, win.VK_RETURN) + + if currentArea != area.RogueEncampment { + return WayPoint(currentArea) + } + } - return - }) + return nil } -func (b *Builder) ResetStats() *Chain { - return NewChain(func(d data.Data) (actions []Action) { - ch, isLevelingChar := b.ch.(LevelingCharacter) - if isLevelingChar && ch.ShouldResetSkills(d) { - currentArea := d.PlayerUnit.Area - if d.PlayerUnit.Area != area.RogueEncampment { - actions = append(actions, b.WayPoint(area.RogueEncampment)) +func WaitForAllMembersWhenLeveling() error { + ctx := context.Get() + ctx.SetLastAction("WaitForAllMembersWhenLeveling") + + for { + _, isLeveling := ctx.Char.(context.LevelingCharacter) + if ctx.CharacterCfg.Companion.Leader && !ctx.Data.PlayerUnit.Area.IsTown() && isLeveling { + allMembersAreaCloseToMe := true + for _, member := range ctx.Data.Roster { + if member.Name != ctx.Data.PlayerUnit.Name && ctx.PathFinder.DistanceFromMe(member.Position) > 20 { + allMembersAreaCloseToMe = false + } } - actions = append(actions, - b.InteractNPC(npc.Akara, - step.KeySequence("home", "down", "down", "enter"), - step.Wait(time.Second), - step.KeySequence("home", "enter"), - ), - ) - if d.PlayerUnit.Area != area.RogueEncampment { - actions = append(actions, b.WayPoint(currentArea)) + + if allMembersAreaCloseToMe { + return nil } - } - return - }) + ClearAreaAroundPlayer(5, data.MonsterAnyFilter()) + } else { + return nil + } + } } diff --git a/internal/action/move.go b/internal/action/move.go index 6f3fa0516..4aa801736 100644 --- a/internal/action/move.go +++ b/internal/action/move.go @@ -2,190 +2,276 @@ package action import ( "fmt" + "log/slog" + "sort" "time" + "github.com/hectorgimenez/koolo/internal/pather" + "github.com/hectorgimenez/koolo/internal/utils" + "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/d2go/pkg/data/object" "github.com/hectorgimenez/d2go/pkg/data/stat" "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/pather" - "github.com/hectorgimenez/koolo/internal/reader" - "go.uber.org/zap" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/event" + "github.com/hectorgimenez/koolo/internal/game" ) -func (b *Builder) MoveToArea(dst area.Area, opts ...step.MoveToStepOption) *Chain { - // Exception for Arcane Sanctuary, we need to find the portal first - if dst == area.ArcaneSanctuary { - return NewChain(func(d data.Data) []Action { - b.logger.Debug("Arcane Sanctuary detected, finding the Portal") - portal, _ := d.Objects.FindOne(object.ArcaneSanctuaryPortal) - return []Action{ - b.MoveToCoords(portal.Position), - NewStepChain(func(d data.Data) []step.Step { - return []step.Step{ - step.InteractObject(object.ArcaneSanctuaryPortal, func(d data.Data) bool { - return d.PlayerUnit.Area == area.ArcaneSanctuary - }), - } - }), - } +const ( + maxAreaSyncAttempts = 10 + areaSyncDelay = 100 * time.Millisecond +) + +func ensureAreaSync(ctx *context.Status, expectedArea area.ID) error { + // Skip sync check if we're already in the expected area and have valid area data + if ctx.Data.PlayerUnit.Area == expectedArea { + return nil + } + + // Wait for area data to sync + for attempts := 0; attempts < maxAreaSyncAttempts; attempts++ { + ctx.RefreshGameData() + + if ctx.Data.PlayerUnit.Area == expectedArea { + return nil + } + + time.Sleep(areaSyncDelay) + } + + return fmt.Errorf("area sync timeout - expected: %v, current: %v", expectedArea, ctx.Data.PlayerUnit.Area) +} + +func MoveToArea(dst area.ID) error { + ctx := context.Get() + ctx.SetLastAction("MoveToArea") + + if err := ensureAreaSync(ctx, ctx.Data.PlayerUnit.Area); err != nil { + return err + } + + // Exception for Arcane Sanctuary + if dst == area.ArcaneSanctuary && ctx.Data.PlayerUnit.Area == area.PalaceCellarLevel3 { + ctx.Logger.Debug("Arcane Sanctuary detected, finding the Portal") + portal, _ := ctx.Data.Objects.FindOne(object.ArcaneSanctuaryPortal) + MoveToCoords(portal.Position) + + return step.InteractObject(portal, func() bool { + return ctx.Data.PlayerUnit.Area == area.ArcaneSanctuary }) } - toFun := func(d data.Data) (data.Position, bool) { - if d.PlayerUnit.Area == dst { - b.logger.Debug("Already in area", zap.Any("area", dst)) + lvl := data.Level{} + for _, a := range ctx.Data.AdjacentLevels { + if a.Area == dst { + lvl = a + break + } + } + + if lvl.Position.X == 0 && lvl.Position.Y == 0 { + return fmt.Errorf("destination area not found: %s", dst.Area().Name) + } + + toFun := func() (data.Position, bool) { + if ctx.Data.PlayerUnit.Area == dst { + ctx.Logger.Debug("Reached area", slog.String("area", dst.Area().Name)) return data.Position{}, false } - switch dst { - case area.MonasteryGate: - b.logger.Debug("Monastery Gate detected, moving to static coords") + if ctx.Data.PlayerUnit.Area == area.TamoeHighland && dst == area.MonasteryGate { + ctx.Logger.Debug("Monastery Gate detected, moving to static coords") return data.Position{X: 15139, Y: 5056}, true } - for _, a := range d.AdjacentLevels { - if a.Area == dst { - // To correctly detect the two possible exits from Lut Gholein - if dst == area.RockyWaste && d.PlayerUnit.Area == area.LutGholein { - if _, _, found := pather.GetPath(d, data.Position{X: 5004, Y: 5065}); found { - return data.Position{X: 4989, Y: 5063}, true - } else { - return data.Position{X: 5096, Y: 4997}, true - } - } + if ctx.Data.PlayerUnit.Area == area.MonasteryGate && dst == area.TamoeHighland { + ctx.Logger.Debug("Monastery Gate detected, moving to static coords") + return data.Position{X: 15142, Y: 5118}, true + } - // This means it's a cave, we don't want to load the map, just find the entrance and interact - if a.IsEntrance { - return a.Position, true - } + // To correctly detect the two possible exits from Lut Gholein + if dst == area.RockyWaste && ctx.Data.PlayerUnit.Area == area.LutGholein { + if _, _, found := ctx.PathFinder.GetPath(data.Position{X: 5004, Y: 5065}); found { + return data.Position{X: 4989, Y: 5063}, true + } else { + return data.Position{X: 5096, Y: 4997}, true + } + } + + // This means it's a cave, we don't want to load the map, just find the entrance and interact + if lvl.IsEntrance { + return lvl.Position, true + } - lvl, _ := reader.CachedMapData.GetLevelData(a.Area) - _, _, objects, _ := reader.CachedMapData.NPCsExitsAndObjects(lvl.Offset, a.Area) + objects := ctx.Data.Areas[lvl.Area].Objects + // Sort objects by the distance from me + sort.Slice(objects, func(i, j int) bool { + distanceI := ctx.PathFinder.DistanceFromMe(objects[i].Position) + distanceJ := ctx.PathFinder.DistanceFromMe(objects[j].Position) - // Let's try to find any random object to use as a destination point, once we enter the level we will exit this flow - for _, obj := range objects { - _, _, found := pather.GetPath(d, obj.Position) - if found { - return obj.Position, true - } - } + return distanceI < distanceJ + }) - return a.Position, true + // Let's try to find any random object to use as a destination point, once we enter the level we will exit this flow + for _, obj := range objects { + _, _, found := ctx.PathFinder.GetPath(obj.Position) + if found { + return obj.Position, true } } - b.logger.Debug("Destination area not found", zap.Any("area", dst)) + return lvl.Position, true + } - return data.Position{}, false + err := MoveTo(toFun) + if err != nil { + ctx.Logger.Warn("error moving to area, will try to continue", slog.String("error", err.Error())) } - return NewChain(func(d data.Data) []Action { - return []Action{ - b.MoveTo(toFun, opts...), - NewStepChain(func(d data.Data) []step.Step { - return []step.Step{ - step.InteractEntrance(dst), - } - }), + if lvl.IsEntrance { + maxAttempts := 3 + for attempt := 0; attempt < maxAttempts; attempt++ { + // Check current distance + currentDistance := ctx.PathFinder.DistanceFromMe(lvl.Position) + + if currentDistance > 7 { + // For distances > 7, recursively call MoveToArea as it includes the entrance interaction + return MoveToArea(dst) + } else if currentDistance > 3 && currentDistance <= 7 { + // For distances between 4 and 7, use direct click + screenX, screenY := ctx.PathFinder.GameCoordsToScreenCords( + lvl.Position.X-2, + lvl.Position.Y-2, + ) + ctx.HID.Click(game.LeftButton, screenX, screenY) + utils.Sleep(800) + } + + // Try to interact with the entrance + err = step.InteractEntrance(dst) + if err == nil { + break + } + + if attempt < maxAttempts-1 { + ctx.Logger.Debug("Entrance interaction failed, retrying", + slog.Int("attempt", attempt+1), + slog.String("error", err.Error())) + utils.Sleep(1000) + } } - }) + + if err != nil { + return fmt.Errorf("failed to interact with area %s after %d attempts: %v", dst.Area().Name, maxAttempts, err) + } + + // Wait for area transition to complete + if err := ensureAreaSync(ctx, dst); err != nil { + return err + } + } + + event.Send(event.InteractedTo(event.Text(ctx.Name, ""), int(dst), event.InteractionTypeEntrance)) + return nil } -func (b *Builder) MoveToCoords(to data.Position, opts ...step.MoveToStepOption) *Chain { - return b.MoveTo(func(d data.Data) (data.Position, bool) { +func MoveToCoords(to data.Position) error { + ctx := context.Get() + + if err := ensureAreaSync(ctx, ctx.Data.PlayerUnit.Area); err != nil { + return err + } + + return MoveTo(func() (data.Position, bool) { return to, true - }, opts...) + }) } -func (b *Builder) MoveTo(toFunc func(d data.Data) (data.Position, bool), opts ...step.MoveToStepOption) *Chain { - pickupBeforeMoving := false - openedDoors := make(map[object.Name]data.Position) - previousIterationPosition := data.Position{} - previousIterationTo := data.Position{} - var currentStep step.Step +func MoveTo(toFunc func() (data.Position, bool)) error { + ctx := context.Get() + ctx.SetLastAction("MoveTo") - return NewChain(func(d data.Data) []Action { - to, found := toFunc(d) - if !found { - return nil + // Ensure no menus are open that might block movement + for ctx.Data.OpenMenus.IsMenuOpen() { + ctx.Logger.Debug("Found open menus while moving, closing them...") + if err := step.CloseAllMenus(); err != nil { + return err } - if previousIterationTo != to && currentStep != nil { - currentStep = nil - } + utils.Sleep(500) + } + + openedDoors := make(map[object.Name]data.Position) + previousIterationPosition := data.Position{} + lastMovement := false - // To stop the movement, not very accurate - if pather.DistanceFromMe(d, to) < 7 { + // Initial sync check + if err := ensureAreaSync(ctx, ctx.Data.PlayerUnit.Area); err != nil { + return err + } + + for { + ctx.RefreshGameData() + to, found := toFunc() + if !found { return nil } - // Let's go pickup more pots if we have less than 2 (only during leveling) - _, isLevelingChar := b.ch.(LevelingCharacter) - if isLevelingChar && !d.PlayerUnit.Area.IsTown() { - _, healingPotsFound := d.Items.Belt.GetFirstPotion(data.HealingPotion) - _, manaPotsFound := d.Items.Belt.GetFirstPotion(data.ManaPotion) - if (!healingPotsFound || !manaPotsFound) && d.PlayerUnit.TotalGold() > 1000 { - return []Action{NewChain(func(d data.Data) []Action { - return b.InRunReturnTownRoutine() - })} - } + // If we can teleport, don't bother with the rest + if ctx.Data.CanTeleport() { + return step.MoveTo(to) } - if helper.CanTeleport(d) { - // If we can teleport, and we're not on leveling sequence, just return the normal MoveTo step and stop here - if !isLevelingChar { - return []Action{NewStepChain(func(d data.Data) []step.Step { - return []step.Step{step.MoveTo(to, opts...)} - })} - } - // But if we are leveling and have enough money (to buy mana pots), let's teleport. We add the timeout - // to re-trigger this action, so we can get back to town to buy pots in case of empty belt - if d.PlayerUnit.TotalGold() > 30000 { - return []Action{NewStepChain(func(d data.Data) []step.Step { - newOpts := append(opts, step.WithTimeout(5*time.Second)) - return []step.Step{step.MoveTo(to, newOpts...)} - })} + // Check for doors blocking path + for _, o := range ctx.Data.Objects { + if o.IsDoor() && ctx.PathFinder.DistanceFromMe(o.Position) < 10 && openedDoors[o.Name] != o.Position { + if o.Selectable { + ctx.Logger.Info("Door detected and teleport is not available, trying to open it...") + openedDoors[o.Name] = o.Position + err := step.InteractObject(o, func() bool { + obj, found := ctx.Data.Objects.FindByID(o.ID) + return found && !obj.Selectable + }) + if err != nil { + return err + } + } } } - - // Check if there is a door blocking our path - for _, o := range d.Objects { - if o.IsDoor() && pather.DistanceFromMe(d, o.Position) < 10 && openedDoors[o.Name] != o.Position { - if o.Selectable { - return []Action{NewStepChain(func(d data.Data) []step.Step { - b.logger.Info("Door detected and teleport is not available, trying to open it...") - openedDoors[o.Name] = o.Position - return []step.Step{step.InteractObject(o.Name, func(d data.Data) bool { - for _, obj := range d.Objects { - if obj.Name == o.Name && obj.Position == o.Position && !obj.Selectable { - return true - } - } - return false - })} - }, CanBeSkipped())} + // Check if there is any object blocking our path + for _, o := range ctx.Data.Objects { + if o.Name == object.Barrel && ctx.PathFinder.DistanceFromMe(o.Position) < 3 { + err := step.InteractObject(o, func() bool { + obj, found := ctx.Data.Objects.FindByID(o.ID) + //additional click on barrel to avoid getting stuck + x, y := ctx.PathFinder.GameCoordsToScreenCords(o.Position.X, o.Position.Y) + ctx.HID.Click(game.LeftButton, x, y) + return found && !obj.Selectable + }) + if err != nil { + return err } } } - // Detect if there are monsters close to the player + // Check for monsters close to player closestMonster := data.Monster{} closestMonsterDistance := 9999999 targetedNormalEnemies := make([]data.Monster, 0) targetedElites := make([]data.Monster, 0) minDistance := 6 - minDistanceForElites := 20 // This will make the character to kill elites even if they are far away, ONLY during leveling - stuck := pather.DistanceFromMe(d, previousIterationPosition) < 5 // Detect if character was not able to move from last iteration - for _, m := range d.Monsters.Enemies() { + minDistanceForElites := 20 // This will make the character to kill elites even if they are far away, ONLY during leveling + stuck := ctx.PathFinder.DistanceFromMe(previousIterationPosition) < 5 // Detect if character was not able to move from last iteration + + for _, m := range ctx.Data.Monsters.Enemies() { // Skip if monster is already dead if m.Stats[stat.Life] <= 0 { continue } - dist := pather.DistanceFromMe(d, m.Position) + dist := ctx.PathFinder.DistanceFromMe(m.Position) appended := false if m.IsElite() && dist <= minDistanceForElites { targetedElites = append(targetedElites, m) @@ -197,59 +283,53 @@ func (b *Builder) MoveTo(toFunc func(d data.Data) (data.Position, bool), opts .. appended = true } - if appended { - if dist < closestMonsterDistance { - closestMonsterDistance = dist - closestMonster = m - } + if appended && dist < closestMonsterDistance { + closestMonsterDistance = dist + closestMonster = m } } - if len(targetedNormalEnemies) > 5 || len(targetedElites) > 0 || (stuck && (len(targetedNormalEnemies) > 0 || len(targetedElites) > 0)) || (pather.IsNarrowMap(d.PlayerUnit.Area) && (len(targetedNormalEnemies) > 0 || len(targetedElites) > 0)) { + if len(targetedNormalEnemies) > 5 || len(targetedElites) > 0 || (stuck && (len(targetedNormalEnemies) > 0 || len(targetedElites) > 0)) || (pather.IsNarrowMap(ctx.Data.PlayerUnit.Area) && (len(targetedNormalEnemies) > 0 || len(targetedElites) > 0)) { if stuck { - b.logger.Info("Character stuck and monsters detected, trying to kill monsters around") + ctx.Logger.Info("Character stuck and monsters detected, trying to kill monsters around") } else { - b.logger.Info(fmt.Sprintf("At least %d monsters detected close to the character, targeting closest one: %d", len(targetedNormalEnemies)+len(targetedElites), closestMonster.Name)) + ctx.Logger.Info(fmt.Sprintf("At least %d monsters detected close to the character, targeting closest one: %d", len(targetedNormalEnemies)+len(targetedElites), closestMonster.Name)) } - path, _, mPathFound := pather.GetPath(d, closestMonster.Position) + path, _, mPathFound := ctx.PathFinder.GetPath(closestMonster.Position) if mPathFound { doorIsBlocking := false - for _, o := range d.Objects { - if o.IsDoor() && o.Selectable && path.Intersects(d, o.Position, 4) { - b.logger.Debug("Door is blocking the path to the monster, skipping attack sequence") + for _, o := range ctx.Data.Objects { + if o.IsDoor() && o.Selectable && path.Intersects(*ctx.Data, o.Position, 4) { + ctx.Logger.Debug("Door is blocking the path to the monster, skipping attack sequence") doorIsBlocking = true } } if !doorIsBlocking { - pickupBeforeMoving = true - return []Action{b.ch.KillMonsterSequence(func(d data.Data) (data.UnitID, bool) { + ctx.Char.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { return closestMonster.UnitID, true - }, nil)} + }, nil) } } } - if pickupBeforeMoving { - pickupBeforeMoving = false - return []Action{b.ItemPickup(false, 30)} + // Continue moving + WaitForAllMembersWhenLeveling() + previousIterationPosition = ctx.Data.PlayerUnit.Position + + if lastMovement { + return nil } - // Continue moving - return []Action{NewStepChain(func(d data.Data) []step.Step { - newOpts := append(opts, step.ClosestWalkable(), step.WithTimeout(time.Millisecond*1000)) - previousIterationPosition = d.PlayerUnit.Position - if currentStep == nil { - currentStep = step.MoveTo( - to, - newOpts..., - ) - } else { - currentStep.Reset() - } + // TODO: refactor this to use the same approach as ClearThroughPath + if _, distance, _ := ctx.PathFinder.GetPathFrom(ctx.Data.PlayerUnit.Position, to); distance <= step.DistanceToFinishMoving { + lastMovement = true + } - return []step.Step{currentStep} - })} - }, RepeatUntilNoSteps()) + err := step.MoveTo(to) + if err != nil { + return err + } + } } diff --git a/internal/action/pre_run.go b/internal/action/pre_run.go deleted file mode 100644 index 59cd1e327..000000000 --- a/internal/action/pre_run.go +++ /dev/null @@ -1,49 +0,0 @@ -package action - -import "github.com/hectorgimenez/koolo/internal/config" - -func (b *Builder) PreRun(firstRun bool) []Action { - if config.Config.Companion.Enabled && !config.Config.Companion.Leader { - return []Action{ - b.RecoverCorpse(), - b.Heal(), - } - } - - return []Action{ - b.ResetStats(), - b.EnsureStatPoints(), - b.EnsureSkillPoints(), - b.RecoverCorpse(), - b.IdentifyAll(firstRun), - b.Stash(firstRun), - b.VendorRefill(false, true), - b.Gamble(), - b.Stash(false), - b.EnsureSkillBindings(), - b.Heal(), - b.ReviveMerc(), - b.HireMerc(), - b.Repair(), - } -} - -func (b *Builder) InRunReturnTownRoutine() []Action { - return []Action{ - b.ReturnTown(), - b.EnsureStatPoints(), - b.EnsureSkillPoints(), - b.RecoverCorpse(), - b.IdentifyAll(false), - b.Stash(false), - b.VendorRefill(false, true), - b.Gamble(), - b.Stash(false), - b.EnsureSkillBindings(), - b.Heal(), - b.ReviveMerc(), - b.HireMerc(), - b.Repair(), - b.UsePortalInTown(), - } -} diff --git a/internal/action/recover_corpse.go b/internal/action/recover_corpse.go index f4ab1a356..0c1cbb90a 100644 --- a/internal/action/recover_corpse.go +++ b/internal/action/recover_corpse.go @@ -1,43 +1,35 @@ package action import ( - "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" - "github.com/hectorgimenez/koolo/internal/pather" + "errors" + + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/ui" + "github.com/hectorgimenez/koolo/internal/utils" ) -func (b *Builder) RecoverCorpse() *StepChainAction { - return NewStepChain(func(d data.Data) (steps []step.Step) { - b.logger.Debug("Checking for character corpse...") - if d.Corpse.Found { - b.logger.Info("Corpse found, let's recover our stuff...") - steps = append(steps, - step.SyncStepWithCheck(func(d data.Data) error { - x, y := pather.GameCoordsToScreenCords( - d.PlayerUnit.Position.X, - d.PlayerUnit.Position.Y, - d.Corpse.Position.X, - d.Corpse.Position.Y, - ) - hid.MovePointer(x, y) - helper.Sleep(300) - hid.Click(hid.LeftButton) +func RecoverCorpse() error { + ctx := context.Get() + ctx.SetLastAction("RecoverCorpse") - return nil - }, func(d data.Data) step.Status { - if d.Corpse.Found { - return step.StatusInProgress - } + if ctx.Data.Corpse.Found { + ctx.Logger.Info("Corpse found, let's recover our stuff...") - return step.StatusCompleted - }), + attempts := 0 + for ctx.Data.Corpse.Found && attempts < 15 { + utils.Sleep(500) + x, y := ui.GameCoordsToScreenCords( + ctx.Data.Corpse.Position.X, + ctx.Data.Corpse.Position.Y, ) - } else { - b.logger.Debug("Character corpse not found :D") + ctx.HID.Click(game.LeftButton, x, y) + attempts++ + } + if ctx.Data.Corpse.Found { + return errors.New("could not recover corpse") } + } - return - }, Resettable()) + return nil } diff --git a/internal/action/repair.go b/internal/action/repair.go index 534dbe3a5..e5f2671e1 100644 --- a/internal/action/repair.go +++ b/internal/action/repair.go @@ -8,42 +8,141 @@ import ( "github.com/hectorgimenez/d2go/pkg/data/npc" "github.com/hectorgimenez/d2go/pkg/data/stat" "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" "github.com/hectorgimenez/koolo/internal/town" + "github.com/hectorgimenez/koolo/internal/ui" + "github.com/hectorgimenez/koolo/internal/utils" + "github.com/lxn/win" ) -func (b *Builder) Repair() *Chain { - return NewChain(func(d data.Data) (actions []Action) { - for _, i := range d.Items.ByLocation(item.LocationEquipped) { - du, found := i.Stats[stat.Durability] - if _, maxDurabilityFound := i.Stats[stat.MaxDurability]; maxDurabilityFound && !found || (found && du.Value <= 1) { - b.logger.Info(fmt.Sprintf("Repairing %s, durability is: %d", i.Name, du.Value)) - repairNPC := town.GetTownByArea(d.PlayerUnit.Area).RepairNPC() - if repairNPC == npc.Hratli { - actions = append(actions, b.MoveToCoords(data.Position{X: 5224, Y: 5045})) - } - keys := make([]string, 0) - keys = append(keys, "home") - if repairNPC != npc.Halbu { - keys = append(keys, "down") - } - keys = append(keys, "enter") - - return append(actions, b.InteractNPC(town.GetTownByArea(d.PlayerUnit.Area).RepairNPC(), - step.KeySequence(keys...), - step.SyncStep(func(_ data.Data) error { - helper.Sleep(100) - hid.MovePointer(390, 515) - hid.Click(hid.LeftButton) - helper.Sleep(500) - return nil - }), - step.KeySequence("esc"), - )) +func Repair() error { + ctx := context.Get() + ctx.SetLastAction("Repair") + + for _, i := range ctx.Data.Inventory.ByLocation(item.LocationEquipped) { + + _, indestructible := i.FindStat(stat.Indestructible, 0) + + if i.Ethereal || indestructible { + continue + } + + // Get the durability stats + + durability, found := i.FindStat(stat.Durability, 0) + maxDurability, maxDurabilityFound := i.FindStat(stat.MaxDurability, 0) + + // Calculate Durability percent + durabilityPercent := -1 + + if maxDurabilityFound && found { + durabilityPercent = int((float64(durability.Value) / float64(maxDurability.Value)) * 100) + } + + // Restructured conditionals for when to attempt repair + if (maxDurabilityFound && !found) || + (durabilityPercent != -1 && found && durabilityPercent <= 20) || + (found && durabilityPercent == -1 && durability.Value <= 2) { + + ctx.Logger.Info(fmt.Sprintf("Repairing %s, item durability is %d percent", i.Name, durabilityPercent)) + + // Get the repair NPC for the town + repairNPC := town.GetTownByArea(ctx.Data.PlayerUnit.Area).RepairNPC() + + // Act3 repair NPC handling + if repairNPC == npc.Hratli { + MoveToCoords(data.Position{X: 5224, Y: 5045}) + } + + err := InteractNPC(repairNPC) + if err != nil { + return err + } + + if repairNPC != npc.Halbu { + ctx.HID.KeySequence(win.VK_HOME, win.VK_DOWN, win.VK_RETURN) + } else { + ctx.HID.KeySequence(win.VK_HOME, win.VK_RETURN) } + + utils.Sleep(100) + if ctx.Data.LegacyGraphics { + ctx.HID.Click(game.LeftButton, ui.RepairButtonXClassic, ui.RepairButtonYClassic) + } else { + ctx.HID.Click(game.LeftButton, ui.RepairButtonX, ui.RepairButtonY) + } + utils.Sleep(500) + + return step.CloseAllMenus() + } + } + + return nil +} + +func RepairRequired() bool { + ctx := context.Get() + ctx.SetLastAction("RepairRequired") + + for _, i := range ctx.Data.Inventory.ByLocation(item.LocationEquipped) { + // Skip indestructible items + _, indestructible := i.FindStat(stat.Indestructible, 0) + if i.Ethereal || indestructible { + continue + } + + currentDurability, currentDurabilityFound := i.FindStat(stat.Durability, 0) + maxDurability, maxDurabilityFound := i.FindStat(stat.MaxDurability, 0) + + // If we have both stats, check percentage + if currentDurabilityFound && maxDurabilityFound { + durabilityPercent := int((float64(currentDurability.Value) / float64(maxDurability.Value)) * 100) + if durabilityPercent <= 20 { + return true + } + } + + // If we only have current durability, check absolute value + if currentDurabilityFound { + if currentDurability.Value <= 5 { + return true + } + } + + // Handle case where durability stat is missing but max durability exists + // This likely indicates the item needs repair + if maxDurabilityFound && !currentDurabilityFound { + return true + } + } + + return false +} + +func IsEquipmentBroken() bool { + ctx := context.Get() + ctx.SetLastAction("EquipmentBroken") + + for _, i := range ctx.Data.Inventory.ByLocation(item.LocationEquipped) { + + // Check if the item is ethereal + if i.Ethereal { + continue + } + + // Check if the item is indestructible + _, indestructible := i.FindStat(stat.Indestructible, 0) + if indestructible { + continue + } + + // Check if the item is broken + if i.IsBroken { + ctx.Logger.Debug("Equipment is broken, returning to town", "item", i.Name) + return true } + } - return nil - }) + return false } diff --git a/internal/action/revive_merc.go b/internal/action/revive_merc.go index 1fc1eee50..d3917b8d9 100644 --- a/internal/action/revive_merc.go +++ b/internal/action/revive_merc.go @@ -1,40 +1,34 @@ package action import ( - "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/d2go/pkg/data/difficulty" "github.com/hectorgimenez/d2go/pkg/data/npc" - "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" "github.com/hectorgimenez/koolo/internal/town" + "github.com/lxn/win" ) -func (b *Builder) ReviveMerc() *Chain { - return NewChain(func(d data.Data) []Action { - _, isLevelingChar := b.ch.(LevelingCharacter) - if config.Config.Character.UseMerc && d.MercHPPercent() <= 0 { - if isLevelingChar && d.PlayerUnit.Area == area.RogueEncampment && config.Config.Game.Difficulty == difficulty.Normal { - // Ignoring because merc is not hired yet - return nil - } +func ReviveMerc() { + ctx := context.Get() + ctx.SetLastAction("ReviveMerc") - b.logger.Info("Merc is dead, let's revive it!") + _, isLevelingChar := ctx.Char.(context.LevelingCharacter) + if ctx.CharacterCfg.Character.UseMerc && ctx.Data.MercHPPercent() <= 0 { + if isLevelingChar && ctx.Data.PlayerUnit.Area == area.RogueEncampment && ctx.CharacterCfg.Game.Difficulty == difficulty.Normal { + // Ignoring because merc is not hired yet + return + } - mercNPC := town.GetTownByArea(d.PlayerUnit.Area).MercContractorNPC() + ctx.Logger.Info("Merc is dead, let's revive it!") - keySequence := []string{"home", "down", "enter", "esc"} - if mercNPC == npc.Tyrael2 { - keySequence = []string{"end", "up", "enter", "esc"} - } + mercNPC := town.GetTownByArea(ctx.Data.PlayerUnit.Area).MercContractorNPC() + InteractNPC(mercNPC) - return []Action{ - b.InteractNPC(town.GetTownByArea(d.PlayerUnit.Area).MercContractorNPC(), - step.KeySequence(keySequence...), - ), - } + if mercNPC == npc.Tyrael2 { + ctx.HID.KeySequence(win.VK_END, win.VK_UP, win.VK_RETURN, win.VK_ESCAPE) + } else { + ctx.HID.KeySequence(win.VK_HOME, win.VK_DOWN, win.VK_RETURN, win.VK_ESCAPE) } - - return nil - }) + } } diff --git a/internal/action/stash.go b/internal/action/stash.go index c131fbca8..4a1eff294 100644 --- a/internal/action/stash.go +++ b/internal/action/stash.go @@ -1,201 +1,437 @@ package action import ( + "errors" "fmt" + "log/slog" + "slices" + "strconv" + "strings" "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/d2go/pkg/data/item" "github.com/hectorgimenez/d2go/pkg/data/object" - "github.com/hectorgimenez/d2go/pkg/data/stat" - "github.com/hectorgimenez/d2go/pkg/itemfilter" + "github.com/hectorgimenez/d2go/pkg/nip" "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/config" - stat2 "github.com/hectorgimenez/koolo/internal/event/stat" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/event" + "github.com/hectorgimenez/koolo/internal/game" "github.com/hectorgimenez/koolo/internal/ui" + "github.com/hectorgimenez/koolo/internal/utils" + "github.com/lxn/win" ) const ( maxGoldPerStashTab = 2500000 - stashGoldBtnX = 966 - stashGoldBtnY = 526 ) -func (b *Builder) Stash(forceStash bool) *Chain { - return NewChain(func(d data.Data) (actions []Action) { - b.logger.Debug("Checking for items to stash...") - if !b.isStashingRequired(d, forceStash) { - b.logger.Debug("No items to stash...") - return - } +func Stash(forceStash bool) error { + ctx := context.Get() + ctx.SetLastAction("Stash") - b.logger.Info("Stashing items...") + ctx.Logger.Debug("Checking for items to stash...") + if !isStashingRequired(forceStash) { + return nil + } - switch d.PlayerUnit.Area { - case area.KurastDocks: - actions = append(actions, b.MoveToCoords(data.Position{X: 5146, Y: 5067})) - case area.LutGholein: - actions = append(actions, b.MoveToCoords(data.Position{X: 5130, Y: 5086})) - } + ctx.Logger.Info("Stashing items...") + + switch ctx.Data.PlayerUnit.Area { + case area.KurastDocks: + MoveToCoords(data.Position{X: 5146, Y: 5067}) + case area.LutGholein: + MoveToCoords(data.Position{X: 5130, Y: 5086}) + } + + bank, _ := ctx.Data.Objects.FindOne(object.Bank) + InteractObject(bank, + func() bool { + return ctx.Data.OpenMenus.Stash + }, + ) + // Clear messages like TZ change or public game spam. Prevent bot from clicking on messages + ClearMessages() + stashGold() + orderInventoryPotions() + stashInventory(forceStash) + step.CloseAllMenus() - return append(actions, - b.InteractObject(object.Bank, - func(d data.Data) bool { - return d.OpenMenus.Stash - }, - step.SyncStep(func(d data.Data) error { - b.stashGold(d) - b.orderInventoryPotions(d) - b.stashInventory(d, forceStash) - hid.PressKey("esc") - return nil - }), - ), - ) - }) + return nil } -func (b *Builder) orderInventoryPotions(d data.Data) { - for _, i := range d.Items.ByLocation(item.LocationInventory) { +func orderInventoryPotions() { + ctx := context.Get() + ctx.SetLastStep("orderInventoryPotions") + + for _, i := range ctx.Data.Inventory.ByLocation(item.LocationInventory) { if i.IsPotion() { - if config.Config.Inventory.InventoryLock[i.Position.Y][i.Position.X] == 0 { + if ctx.CharacterCfg.Inventory.InventoryLock[i.Position.Y][i.Position.X] == 0 { continue } + screenPos := ui.GetScreenCoordsForItem(i) - hid.MovePointer(screenPos.X, screenPos.Y) - helper.Sleep(100) - hid.Click(hid.RightButton) - helper.Sleep(200) + utils.Sleep(100) + ctx.HID.Click(game.RightButton, screenPos.X, screenPos.Y) + utils.Sleep(200) } } } -func (b *Builder) isStashingRequired(d data.Data, forceStash bool) bool { - for _, i := range d.Items.ByLocation(item.LocationInventory) { - if b.shouldStashIt(i, forceStash) { +func isStashingRequired(firstRun bool) bool { + ctx := context.Get() + ctx.SetLastStep("isStashingRequired") + + for _, i := range ctx.Data.Inventory.ByLocation(item.LocationInventory) { + stashIt, _, _ := shouldStashIt(i, firstRun) + if stashIt { return true } } - if d.PlayerUnit.Stats[stat.Gold] > d.PlayerUnit.MaxGold()/3 { + isStashFull := true + for _, goldInStash := range ctx.Data.Inventory.StashedGold { + if goldInStash < maxGoldPerStashTab { + isStashFull = false + } + } + + if ctx.Data.Inventory.Gold > ctx.Data.PlayerUnit.MaxGold()/3 && !isStashFull { return true } return false } -func (b *Builder) stashGold(d data.Data) { - gold, found := d.PlayerUnit.Stats[stat.Gold] - if !found || gold == 0 { +func stashGold() { + ctx := context.Get() + ctx.SetLastAction("stashGold") + + if ctx.Data.Inventory.Gold == 0 { return } - if d.PlayerUnit.Stats[stat.StashGold] < maxGoldPerStashTab { - switchTab(1) - clickStashGoldBtn() - helper.Sleep(500) - } + ctx.Logger.Info("Stashing gold...", slog.Int("gold", ctx.Data.Inventory.Gold)) - for i := 2; i < 5; i++ { - d = b.gr.GetData(false) - gold, found = d.PlayerUnit.Stats[stat.Gold] - if !found || gold == 0 { + for tab, goldInStash := range ctx.Data.Inventory.StashedGold { + ctx.RefreshGameData() + if ctx.Data.Inventory.Gold == 0 { return } - switchTab(i) - clickStashGoldBtn() + if goldInStash < maxGoldPerStashTab { + SwitchStashTab(tab + 1) + clickStashGoldBtn() + utils.Sleep(500) + } } - b.logger.Info("All stash tabs are full of gold :D") + + ctx.Logger.Info("All stash tabs are full of gold :D") } -func (b *Builder) stashInventory(d data.Data, forceStash bool) { +func stashInventory(firstRun bool) { + ctx := context.Get() + ctx.SetLastAction("stashInventory") + currentTab := 1 - switchTab(currentTab) + if ctx.CharacterCfg.Character.StashToShared { + currentTab = 2 + } + SwitchStashTab(currentTab) + + for _, i := range ctx.Data.Inventory.ByLocation(item.LocationInventory) { + stashIt, matchedRule, ruleFile := shouldStashIt(i, firstRun) - for _, i := range d.Items.ByLocation(item.LocationInventory) { - if !b.shouldStashIt(i, forceStash) { + if !stashIt { continue } + + // Always stash unique charms to the shared stash + if (i.Name == "grandcharm" || i.Name == "smallcharm" || i.Name == "largecharm") && i.Quality == item.QualityUnique { + currentTab = 2 + SwitchStashTab(currentTab) + } + for currentTab < 5 { - if b.stashItemAction(i, forceStash) { - b.logger.Debug(fmt.Sprintf("Item %s [%d] stashed", i.Name, i.Quality)) + if stashItemAction(i, matchedRule, ruleFile, firstRun) { + r, res := ctx.CharacterCfg.Runtime.Rules.EvaluateAll(i) + + if res != nip.RuleResultFullMatch && firstRun { + ctx.Logger.Info( + fmt.Sprintf("Item %s [%s] stashed because it was found in the inventory during the first run.", i.Desc().Name, i.Quality.ToString()), + ) + break + } + + ctx.Logger.Info( + fmt.Sprintf("Item %s [%s] stashed", i.Desc().Name, i.Quality.ToString()), + slog.String("nipFile", fmt.Sprintf("%s:%d", r.Filename, r.LineNumber)), + slog.String("rawRule", r.RawLine), + ) break } if currentTab == 5 { - // TODO: Stop the bot, stash is full + ctx.Logger.Info("Stash is full ...") + //TODO: Stash is full stop the bot } - b.logger.Debug(fmt.Sprintf("Tab %d is full, switching to next one", currentTab)) + ctx.Logger.Debug(fmt.Sprintf("Tab %d is full, switching to next one", currentTab)) currentTab++ - switchTab(currentTab) + SwitchStashTab(currentTab) } } } -func (b *Builder) shouldStashIt(i data.Item, forceStash bool) bool { +func shouldStashIt(i data.Item, firstRun bool) (bool, string, string) { + ctx := context.Get() + ctx.SetLastStep("shouldStashIt") + + // Don't stash items in protected slots + if ctx.CharacterCfg.Inventory.InventoryLock[i.Position.Y][i.Position.X] == 0 { + return false, "", "" + } + // Don't stash items from quests during leveling process, it makes things easier to track - if _, isLevelingChar := b.ch.(LevelingCharacter); isLevelingChar && i.IsFromQuest() { - return false + if _, isLevelingChar := ctx.Char.(context.LevelingCharacter); isLevelingChar && i.IsFromQuest() { + return false, "", "" + } + + if i.IsRuneword { + return true, "Runeword", "" + } + + // Stash items that are part of a recipe which are not covered by the NIP rules + if shouldKeepRecipeItem(i) { + return true, "Item is part of a enabled recipe", "" } // Don't stash the Tomes, keys and WirtsLeg if i.Name == item.TomeOfTownPortal || i.Name == item.TomeOfIdentify || i.Name == item.Key || i.Name == "WirtsLeg" { - return false + return false, "", "" } - if config.Config.Inventory.InventoryLock[i.Position.Y][i.Position.X] == 0 || i.IsPotion() { + if i.Position.Y >= len(ctx.CharacterCfg.Inventory.InventoryLock) || i.Position.X >= len(ctx.CharacterCfg.Inventory.InventoryLock[0]) { + return false, "", "" + } + + if i.Location.LocationType == item.LocationInventory && ctx.CharacterCfg.Inventory.InventoryLock[i.Position.Y][i.Position.X] == 0 || i.IsPotion() { + return false, "", "" + } + + // Let's stash everything during first run, we don't want to sell items from the user + if firstRun { + return true, "FirstRun", "" + } + + rule, res := ctx.CharacterCfg.Runtime.Rules.EvaluateAll(i) + if res == nip.RuleResultFullMatch && doesExceedQuantity(rule) { + return false, "", "" + } + + // Full rule match + if res == nip.RuleResultFullMatch { + return true, rule.RawLine, rule.Filename + ":" + strconv.Itoa(rule.LineNumber) + } + return false, "", "" +} + +func shouldKeepRecipeItem(i data.Item) bool { + ctx := context.Get() + ctx.SetLastStep("shouldKeepRecipeItem") + + // No items with quality higher than magic can be part of a recipe + if i.Quality > item.QualityMagic { return false } - return forceStash || itemfilter.Evaluate(i, config.Config.Runtime.Rules) + itemInStashNotMatchingRule := false + + // Check if we already have the item in our stash and if it doesn't match any of our pickit rules + for _, it := range ctx.Data.Inventory.ByLocation(item.LocationStash, item.LocationSharedStash) { + if it.Name == i.Name { + _, res := ctx.CharacterCfg.Runtime.Rules.EvaluateAll(it) + if res != nip.RuleResultFullMatch { + itemInStashNotMatchingRule = true + } + } + } + + recipeMatch := false + + // Check if the item is part of a recipe and if that recipe is enabled + for _, recipe := range Recipes { + if slices.Contains(recipe.Items, string(i.Name)) && slices.Contains(ctx.CharacterCfg.CubeRecipes.EnabledRecipes, recipe.Name) { + recipeMatch = true + break + } + } + + if recipeMatch && !itemInStashNotMatchingRule { + return true + } + + return false } -func (b *Builder) stashItemAction(i data.Item, forceStash bool) bool { +func stashItemAction(i data.Item, rule string, ruleFile string, skipLogging bool) bool { + ctx := context.Get() + ctx.SetLastAction("stashItemAction") + screenPos := ui.GetScreenCoordsForItem(i) - hid.MovePointer(screenPos.X, screenPos.Y) - helper.Sleep(170) - screenshot := helper.Screenshot() - helper.Sleep(150) - hid.KeyDown("control") - helper.Sleep(700) - hid.Click(hid.LeftButton) - helper.Sleep(700) - hid.KeyUp("control") - helper.Sleep(500) - - d := b.gr.GetData(false) - for _, it := range d.Items.ByLocation(item.LocationInventory) { + ctx.HID.MovePointer(screenPos.X, screenPos.Y) + utils.Sleep(170) + screenshot := ctx.GameReader.Screenshot() + utils.Sleep(150) + ctx.HID.ClickWithModifier(game.LeftButton, screenPos.X, screenPos.Y, game.CtrlKey) + utils.Sleep(500) + + for _, it := range ctx.Data.Inventory.ByLocation(item.LocationInventory) { if it.UnitID == i.UnitID { return false } } - // Don't log items that we already have in inventory during first run - if !forceStash { - stat2.ItemStashed(i, screenshot) + dropLocation := "unknown" + + // log the contents of picked up items + ctx.Logger.Info(fmt.Sprintf("Picked up items: %v", ctx.CurrentGame.PickedUpItems)) + + if _, found := ctx.CurrentGame.PickedUpItems[int(i.UnitID)]; found { + areaId := ctx.CurrentGame.PickedUpItems[int(i.UnitID)] + dropLocation = area.ID(ctx.CurrentGame.PickedUpItems[int(i.UnitID)]).Area().Name + + if slices.Contains(ctx.Data.TerrorZones, area.ID(areaId)) { + dropLocation += " (terrorized)" + } } + + // Don't log items that we already have in inventory during first run or that we don't want to notify about (gems, low runes .. etc) + if !skipLogging && shouldNotifyAboutStashing(i) && ruleFile != "" { + event.Send(event.ItemStashed(event.WithScreenshot(ctx.Name, fmt.Sprintf("Item %s [%d] stashed", i.Name, i.Quality), screenshot), data.Drop{Item: i, Rule: rule, RuleFile: ruleFile, DropLocation: dropLocation})) + } + + return true +} + +func shouldNotifyAboutStashing(i data.Item) bool { + ctx := context.Get() + + ctx.Logger.Debug(fmt.Sprintf("Checking if we should notify about stashing %s %v", i.Name, i.Desc())) + // Don't notify about gems + if strings.Contains(i.Desc().Type, "gem") { + return false + } + + // Skip low runes (below lem) + lowRunes := []string{"elrune", "eldrune", "tirrune", "nefrune", "ethrune", "ithrune", "talrune", "ralrune", "ortrune", "thulrune", "amnrune", "solrune", "shaelrune", "dolrune", "helrune", "iorune", "lumrune", "korune", "falrune"} + if i.Desc().Type == item.TypeRune { + itemName := strings.ToLower(string(i.Name)) + for _, runeName := range lowRunes { + if itemName == runeName { + return false + } + } + } + return true } func clickStashGoldBtn() { - hid.MovePointer(stashGoldBtnX, stashGoldBtnY) - helper.Sleep(170) - hid.Click(hid.LeftButton) - helper.Sleep(500) - hid.PressKey("enter") - helper.Sleep(700) + ctx := context.Get() + ctx.SetLastStep("clickStashGoldBtn") + + utils.Sleep(170) + if ctx.GameReader.LegacyGraphics() { + ctx.HID.Click(game.LeftButton, ui.StashGoldBtnXClassic, ui.StashGoldBtnYClassic) + utils.Sleep(1000) + ctx.HID.Click(game.LeftButton, ui.StashGoldBtnConfirmXClassic, ui.StashGoldBtnConfirmYClassic) + } else { + ctx.HID.Click(game.LeftButton, ui.StashGoldBtnX, ui.StashGoldBtnY) + utils.Sleep(1000) + ctx.HID.Click(game.LeftButton, ui.StashGoldBtnConfirmX, ui.StashGoldBtnConfirmY) + } } -func switchTab(tab int) { - x := 107 - y := 128 - tabSize := 82 - x = x + tabSize*tab - tabSize/2 +func SwitchStashTab(tab int) { + ctx := context.Get() + ctx.SetLastStep("switchTab") + + if ctx.GameReader.LegacyGraphics() { + x := ui.SwitchStashTabBtnXClassic + y := ui.SwitchStashTabBtnYClassic + + tabSize := ui.SwitchStashTabBtnTabSizeClassic + x = x + tabSize*tab - tabSize/2 + ctx.HID.Click(game.LeftButton, x, y) + utils.Sleep(500) + } else { + x := ui.SwitchStashTabBtnX + y := ui.SwitchStashTabBtnY + + tabSize := ui.SwitchStashTabBtnTabSize + x = x + tabSize*tab - tabSize/2 + ctx.HID.Click(game.LeftButton, x, y) + utils.Sleep(500) + } +} + +func OpenStash() error { + ctx := context.Get() + ctx.SetLastAction("OpenStash") + + bank, found := ctx.Data.Objects.FindOne(object.Bank) + if !found { + return errors.New("stash not found") + } + InteractObject(bank, + func() bool { + return ctx.Data.OpenMenus.Stash + }, + ) + + return nil +} + +func CloseStash() error { + ctx := context.Get() + ctx.SetLastAction("CloseStash") + + if ctx.Data.OpenMenus.Stash { + ctx.HID.PressKey(win.VK_ESCAPE) + } else { + return errors.New("stash is not open") + } + + return nil +} + +func TakeItemsFromStash(stashedItems []data.Item) error { + ctx := context.Get() + ctx.SetLastAction("TakeItemsFromStash") + + if ctx.Data.OpenMenus.Stash { + err := OpenStash() + if err != nil { + return err + } + } + + utils.Sleep(250) + + for _, i := range stashedItems { + + if i.Location.LocationType != item.LocationStash && i.Location.LocationType != item.LocationSharedStash { + continue + } + + // Make sure we're on the correct tab + SwitchStashTab(i.Location.Page + 1) + + // Move the item to the inventory + screenPos := ui.GetScreenCoordsForItem(i) + ctx.HID.MovePointer(screenPos.X, screenPos.Y) + ctx.HID.ClickWithModifier(game.LeftButton, screenPos.X, screenPos.Y, game.CtrlKey) + utils.Sleep(500) + } - hid.MovePointer(x, y) - helper.Sleep(400) - hid.Click(hid.LeftButton) - helper.Sleep(500) + return nil } diff --git a/internal/action/step/attack.go b/internal/action/step/attack.go index 2040983c1..6568feb35 100644 --- a/internal/action/step/attack.go +++ b/internal/action/step/attack.go @@ -1,185 +1,398 @@ package step import ( + "errors" + "fmt" + "sync" "time" "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/d2go/pkg/data/skill" "github.com/hectorgimenez/d2go/pkg/data/stat" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" - "github.com/hectorgimenez/koolo/internal/pather" + "github.com/hectorgimenez/d2go/pkg/utils" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" ) -type AttackStep struct { - basicStep - target data.UnitID - standStillBinding string - numOfAttacksRemaining int - primaryAttack bool - keyBinding string - followEnemy bool - minDistance int - maxDistance int - moveToStep *MoveToStep - auraKeyBinding string - forceApplyKeyBinding bool +const attackCycleDuration = 120 * time.Millisecond + +var ( + statesMutex sync.RWMutex + monsterStates = make(map[data.UnitID]*attackState) +) + +// Contains all configuration for an attack sequence +type attackSettings struct { + primaryAttack bool // Whether this is a primary (left click) attack + skill skill.ID // Skill ID for secondary attacks + followEnemy bool // Whether to follow the enemy while attacking + minDistance int // Minimum attack range + maxDistance int // Maximum attack range + aura skill.ID // Aura to maintain during attack + target data.UnitID // Specific target's unit ID (0 for AOE) + shouldStandStill bool // Whether to stand still while attacking + numOfAttacks int // Number of attacks to perform + timeout time.Duration // Timeout for the attack sequence + isBurstCastSkill bool // Whether this is a channeled/burst skill like Nova } -type AttackOption func(step *AttackStep) +// AttackOption defines a function type for configuring attack settings +type AttackOption func(step *attackSettings) +type attackState struct { + lastHealth int + lastHealthCheckTime time.Time + failedAttemptStartTime time.Time + position data.Position +} + +// Distance configures attack to follow enemy within specified range func Distance(minimum, maximum int) AttackOption { - return func(step *AttackStep) { + return func(step *attackSettings) { step.followEnemy = true step.minDistance = minimum step.maxDistance = maximum } } -func EnsureAura(keyBinding string) AttackOption { - return func(step *AttackStep) { - step.auraKeyBinding = keyBinding +// RangedDistance configures attack for ranged combat without following +func RangedDistance(minimum, maximum int) AttackOption { + return func(step *attackSettings) { + step.followEnemy = false // Don't follow enemies for ranged attacks + step.minDistance = minimum + step.maxDistance = maximum } } -func PrimaryAttack(target data.UnitID, numOfAttacks int, opts ...AttackOption) *AttackStep { - s := &AttackStep{ - primaryAttack: true, - basicStep: newBasicStep(), - target: target, - standStillBinding: config.Config.Bindings.StandStill, - numOfAttacksRemaining: numOfAttacks, +// StationaryDistance configures attack to remain stationary (like FoH) +func StationaryDistance(minimum, maximum int) AttackOption { + return func(step *attackSettings) { + step.followEnemy = false + step.minDistance = minimum + step.maxDistance = maximum + step.shouldStandStill = true } +} - for _, o := range opts { - o(s) +// EnsureAura ensures specified aura is active during attack +func EnsureAura(aura skill.ID) AttackOption { + return func(step *attackSettings) { + step.aura = aura } - return s } -func SecondaryAttack(keyBinding string, id data.UnitID, numOfAttacks int, opts ...AttackOption) *AttackStep { - s := &AttackStep{ - primaryAttack: false, - basicStep: newBasicStep(), - target: id, - standStillBinding: config.Config.Bindings.StandStill, - numOfAttacksRemaining: numOfAttacks, - keyBinding: keyBinding, +// PrimaryAttack initiates a primary (left-click) attack sequence +func PrimaryAttack(target data.UnitID, numOfAttacks int, standStill bool, opts ...AttackOption) error { + ctx := context.Get() + + // Special handling for Berserker characters + if berserker, ok := ctx.Char.(interface{ PerformBerserkAttack(data.UnitID) }); ok { + for i := 0; i < numOfAttacks; i++ { + berserker.PerformBerserkAttack(target) + } + return nil + } + + settings := attackSettings{ + target: target, + numOfAttacks: numOfAttacks, + shouldStandStill: standStill, + primaryAttack: true, } for _, o := range opts { - o(s) + o(&settings) } - return s + + return attack(settings) } -func (p *AttackStep) Status(_ data.Data) Status { - if p.status == StatusCompleted { - return StatusCompleted +// SecondaryAttack initiates a secondary (right-click) attack sequence with a specific skill +func SecondaryAttack(skill skill.ID, target data.UnitID, numOfAttacks int, opts ...AttackOption) error { + settings := attackSettings{ + target: target, + numOfAttacks: numOfAttacks, + skill: skill, + primaryAttack: false, + isBurstCastSkill: skill == 48, // nova can define any other burst skill here + } + for _, o := range opts { + o(&settings) } - if p.numOfAttacksRemaining <= 0 && time.Since(p.lastRun) > config.Config.Runtime.CastDuration { - return p.tryTransitionStatus(StatusCompleted) + if settings.isBurstCastSkill { + settings.timeout = 30 * time.Second + return burstAttack(settings) } - return p.status + return attack(settings) } -func (p *AttackStep) Run(d data.Data) error { - monster, found := d.Monsters.FindByID(p.target) - if !found || monster.Stats[stat.Life] <= 0 { - // Monster is dead, let's skip the attack sequence - p.tryTransitionStatus(StatusCompleted) - return nil +// Helper function to validate if a monster should be targetable +func isValidEnemy(monster data.Monster, ctx *context.Status) bool { + // Special case: Always allow Vizier seal boss even if off grid + isVizier := monster.Type == data.MonsterTypeSuperUnique && monster.Name == npc.StormCaster + if isVizier { + return monster.Stats[stat.Life] > 0 + } + + // Skip monsters in invalid positions + if !ctx.Data.AreaData.IsWalkable(monster.Position) { + return false + } + + // Skip dead monsters + if monster.Stats[stat.Life] <= 0 { + return false } - // Move into the attack distance range before starting - if p.followEnemy { - if !p.ensureEnemyIsInRange(monster, d) { + return true +} + +// Cleanup function to ensure proper state on exit +func keyCleanup(ctx *context.Status) { + ctx.HID.KeyUp(ctx.Data.KeyBindings.StandStill) +} + +func attack(settings attackSettings) error { + ctx := context.Get() + ctx.SetLastStep("Attack") + defer keyCleanup(ctx) // cleanup possible pressed keys/buttons + + numOfAttacksRemaining := settings.numOfAttacks + lastRunAt := time.Time{} + + for { + ctx.PauseIfNotPriority() + + if numOfAttacksRemaining <= 0 { return nil } - } else { - // Since we are not following the enemy, and it's not in range, we can't attack it - _, distance, found := pather.GetPath(d, monster.Position) - if !found || distance > p.maxDistance { - p.tryTransitionStatus(StatusCompleted) - return nil + + monster, found := ctx.Data.Monsters.FindByID(settings.target) + if !found || !isValidEnemy(monster, ctx) { + return nil // Target is not valid, we don't have anything to attack } - } - if p.status == StatusNotStarted || p.forceApplyKeyBinding { - if p.keyBinding != "" { - hid.PressKey(p.keyBinding) - helper.Sleep(100) + distance := ctx.PathFinder.DistanceFromMe(monster.Position) + if !lastRunAt.IsZero() && !settings.followEnemy && distance > settings.maxDistance { + return nil // Enemy is out of range and followEnemy is disabled, we cannot attack } - if p.auraKeyBinding != "" { - hid.PressKey(p.auraKeyBinding) + // Check if we need to reposition if we aren't doing any damage (prevent attacking through doors etc.) + _, state := checkMonsterDamage(monster) + needsRepositioning := !state.failedAttemptStartTime.IsZero() && + time.Since(state.failedAttemptStartTime) > 3*time.Second + + // Be sure we stay in range of the enemy + err := ensureEnemyIsInRange(monster, settings.maxDistance, settings.minDistance, needsRepositioning) + if err != nil { + return fmt.Errorf("enemy is out of range and cannot be reached: %w", err) } - p.forceApplyKeyBinding = false + + // Handle aura activation + if settings.aura != 0 && lastRunAt.IsZero() { + ctx.HID.PressKeyBinding(ctx.Data.KeyBindings.MustKBForSkill(settings.aura)) + } + + // Attack timing check + if time.Since(lastRunAt) <= ctx.Data.PlayerCastDuration()-attackCycleDuration { + continue + } + + performAttack(ctx, settings, monster.Position.X, monster.Position.Y) + + lastRunAt = time.Now() + numOfAttacksRemaining-- + } +} + +func burstAttack(settings attackSettings) error { + ctx := context.Get() + ctx.SetLastStep("BurstAttack") + defer keyCleanup(ctx) // cleanup possible pressed keys/buttons + + monster, found := ctx.Data.Monsters.FindByID(settings.target) + if !found || !isValidEnemy(monster, ctx) { + return nil // Target is not valid, we don't have anything to attack + } + + // Initially we try to move to the enemy, later we will check for closer enemies to keep attacking + err := ensureEnemyIsInRange(monster, settings.maxDistance, settings.minDistance, false) + if err != nil { + return fmt.Errorf("enemy is out of range and cannot be reached: %w", err) } - p.tryTransitionStatus(StatusInProgress) - if time.Since(p.lastRun) > config.Config.Runtime.CastDuration && p.numOfAttacksRemaining > 0 { - hid.KeyDown(p.standStillBinding) - x, y := pather.GameCoordsToScreenCords(d.PlayerUnit.Position.X, d.PlayerUnit.Position.Y, monster.Position.X, monster.Position.Y) - hid.MovePointer(x, y) + startedAt := time.Now() + for { + ctx.PauseIfNotPriority() - if p.primaryAttack { - hid.Click(hid.LeftButton) - } else { - hid.Click(hid.RightButton) + if !startedAt.IsZero() && time.Since(startedAt) > settings.timeout { + return nil // Timeout reached, finish attack sequence } - helper.Sleep(20) - hid.KeyUp(p.standStillBinding) - p.lastRun = time.Now() - p.numOfAttacksRemaining-- + + target := data.Monster{} + for _, monster = range ctx.Data.Monsters.Enemies() { + distance := ctx.PathFinder.DistanceFromMe(monster.Position) + if isValidEnemy(monster, ctx) && distance <= settings.maxDistance { + target = monster + break + } + } + + if target.UnitID == 0 { + return nil // We have no valid targets in range, finish attack sequence + } + + // Check if we need to reposition if we aren't doing any damage (prevent attacking through doors etc. + _, state := checkMonsterDamage(target) + needsRepositioning := !state.failedAttemptStartTime.IsZero() && + time.Since(state.failedAttemptStartTime) > 3*time.Second + + // If we don't have LoS we will need to interrupt and move :( + if !ctx.PathFinder.LineOfSight(ctx.Data.PlayerUnit.Position, target.Position) || needsRepositioning { + err = ensureEnemyIsInRange(target, settings.maxDistance, settings.minDistance, needsRepositioning) + if err != nil { + return fmt.Errorf("enemy is out of range and cannot be reached: %w", err) + } + continue + } + + performAttack(ctx, settings, target.Position.X, target.Position.Y) } +} - return nil +func performAttack(ctx *context.Status, settings attackSettings, x, y int) { + monsterPos := data.Position{X: x, Y: y} + if !ctx.PathFinder.LineOfSight(ctx.Data.PlayerUnit.Position, monsterPos) { + return // Skip attack if no line of sight + } + + // Ensure we have the skill selected + if settings.skill != 0 && ctx.Data.PlayerUnit.RightSkill != settings.skill { + ctx.HID.PressKeyBinding(ctx.Data.KeyBindings.MustKBForSkill(settings.skill)) + time.Sleep(time.Millisecond * 10) + } + + if settings.shouldStandStill { + ctx.HID.KeyDown(ctx.Data.KeyBindings.StandStill) + } + + x, y = ctx.PathFinder.GameCoordsToScreenCords(x, y) + if settings.primaryAttack { + ctx.HID.Click(game.LeftButton, x, y) + } else { + ctx.HID.Click(game.RightButton, x, y) + } + + if settings.shouldStandStill { + ctx.HID.KeyUp(ctx.Data.KeyBindings.StandStill) + } } -func (p *AttackStep) ensureEnemyIsInRange(monster data.Monster, d data.Data) bool { - if !p.followEnemy { - return true +func ensureEnemyIsInRange(monster data.Monster, maxDistance, minDistance int, needsRepositioning bool) error { + ctx := context.Get() + ctx.SetLastStep("ensureEnemyIsInRange") + + // TODO: Add an option for telestomp based on the char configuration and kite + currentPos := ctx.Data.PlayerUnit.Position + distanceToMonster := ctx.PathFinder.DistanceFromMe(monster.Position) + hasLoS := ctx.PathFinder.LineOfSight(currentPos, monster.Position) + + // We have line of sight, and we are inside the attack range, we can skip + if hasLoS && distanceToMonster <= maxDistance && !needsRepositioning { + return nil + } + // Handle repositioning if needed + if needsRepositioning { + ctx.Logger.Info(fmt.Sprintf( + "No damage taken by target monster [%d] in area [%s] for more than 3 seconds. Trying to re-position", + monster.Name, // No mapped string value for npc names in d2go, only id + ctx.Data.PlayerUnit.Area.Area().Name, + )) + dest := ctx.PathFinder.BeyondPosition(currentPos, monster.Position, 4) + return MoveTo(dest) } - path, distance, found := pather.GetPath(d, monster.Position) + // Any close-range combat (mosaic,barb...) should move directly to target + if maxDistance <= 3 { + return MoveTo(monster.Position) + } - // We can not reach the enemy, let's skip the attack sequence + // Get path to monster + path, _, found := ctx.PathFinder.GetPath(monster.Position) + // We cannot reach the enemy, let's skip the attack sequence if !found { - return false + return errors.New("path could not be calculated") } - if distance > p.maxDistance { - if p.moveToStep == nil { - if found && p.minDistance > 0 { - // Try to move to the minimum distance - if distance > p.minDistance { - moveTo := p.minDistance - 1 - if len(path.AstarPather) < p.minDistance { - moveTo = 0 - } - - pos := path.AstarPather[moveTo].(*pather.Tile) - p.moveToStep = MoveTo(data.Position{ - X: pos.X + d.AreaOrigin.X, - Y: pos.Y + d.AreaOrigin.Y, - }) - } - } + // Look for suitable position along path + for _, pos := range path { + monsterDistance := utils.DistanceFromPoint(ctx.Data.AreaData.RelativePosition(monster.Position), pos) + if monsterDistance > maxDistance || monsterDistance < minDistance { + continue + } - if p.moveToStep == nil { - p.moveToStep = MoveTo(data.Position{X: monster.Position.X, Y: monster.Position.Y}) - } + dest := data.Position{ + X: pos.X + ctx.Data.AreaData.OffsetX, + Y: pos.Y + ctx.Data.AreaData.OffsetY, } - if p.moveToStep.Status(d) != StatusCompleted { - p.moveToStep.Run(d) - p.forceApplyKeyBinding = true - return false + // Handle overshooting for short distances (Nova distances) + distanceToMove := ctx.PathFinder.DistanceFromMe(dest) + if distanceToMove <= DistanceToFinishMoving { + dest = ctx.PathFinder.BeyondPosition(currentPos, dest, 9) + } + + if ctx.PathFinder.LineOfSight(dest, monster.Position) { + return MoveTo(dest) } - p.moveToStep = nil } - return true + return nil +} + +func checkMonsterDamage(monster data.Monster) (bool, *attackState) { + statesMutex.Lock() + defer statesMutex.Unlock() + + state, exists := monsterStates[monster.UnitID] + if !exists { + state = &attackState{ + lastHealth: monster.Stats[stat.Life], + lastHealthCheckTime: time.Now(), + position: monster.Position, + } + monsterStates[monster.UnitID] = state + } + + didDamage := false + currentHealth := monster.Stats[stat.Life] + + // Only update health check if some time has passed + if time.Since(state.lastHealthCheckTime) > 100*time.Millisecond { + if currentHealth < state.lastHealth { + didDamage = true + state.failedAttemptStartTime = time.Time{} + } else if state.failedAttemptStartTime.IsZero() && + monster.Position == state.position { // only start failing if monster hasn't moved + state.failedAttemptStartTime = time.Now() + } + + state.lastHealth = currentHealth + state.lastHealthCheckTime = time.Now() + state.position = monster.Position + + // Clean up old entries periodically + if len(monsterStates) > 100 { + now := time.Now() + for id, s := range monsterStates { + if now.Sub(s.lastHealthCheckTime) > 5*time.Minute { + delete(monsterStates, id) + } + } + } + } + + return didDamage, state } diff --git a/internal/action/step/close_all_menus.go b/internal/action/step/close_all_menus.go new file mode 100644 index 000000000..f12e9e940 --- /dev/null +++ b/internal/action/step/close_all_menus.go @@ -0,0 +1,30 @@ +package step + +import ( + "errors" + + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/utils" + "github.com/lxn/win" +) + +func CloseAllMenus() error { + ctx := context.Get() + ctx.SetLastStep("CloseAllMenus") + + attempts := 0 + for ctx.Data.OpenMenus.IsMenuOpen() { + // Pause the execution if the priority is not the same as the execution priority + ctx.PauseIfNotPriority() + + ctx.RefreshGameData() + if attempts > 10 { + return errors.New("failed closing game menu") + } + ctx.HID.PressKey(win.VK_ESCAPE) + utils.Sleep(200) + attempts++ + } + + return nil +} diff --git a/internal/action/step/interact_entrance.go b/internal/action/step/interact_entrance.go index 422b7b4f6..6935a12dc 100644 --- a/internal/action/step/interact_entrance.go +++ b/internal/action/step/interact_entrance.go @@ -1,79 +1,95 @@ package step import ( - "errors" "fmt" - "time" - "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" - "github.com/hectorgimenez/koolo/internal/pather" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/utils" + "time" ) -type InteractEntranceStep struct { - basicStep - area area.Area - waitingForInteraction bool - mouseOverAttempts int -} +const ( + maxEntranceDistance = 6 + maxMoveRetries = 3 +) -func InteractEntrance(area area.Area) *InteractEntranceStep { - return &InteractEntranceStep{ - basicStep: newBasicStep(), - area: area, - } -} +func InteractEntrance(area area.ID) error { + maxInteractionAttempts := 5 + interactionAttempts := 0 + waitingForInteraction := false + currentMouseCoords := data.Position{} + lastRun := time.Time{} -func (m *InteractEntranceStep) Status(d data.Data) Status { - if m.status == StatusCompleted { - return StatusCompleted - } + ctx := context.Get() + ctx.SetLastStep("InteractEntrance") - // Give some extra time to render the UI - if d.PlayerUnit.Area == m.area && time.Since(m.lastRun) > time.Second*1 { - return m.tryTransitionStatus(StatusCompleted) - } + for { + ctx.PauseIfNotPriority() - return m.status -} + if ctx.Data.AreaData.Area == area && time.Since(lastRun) > time.Millisecond*500 && ctx.Data.AreaData.IsInside(ctx.Data.PlayerUnit.Position) { + return nil + } -func (m *InteractEntranceStep) Run(d data.Data) error { - m.tryTransitionStatus(StatusInProgress) + if interactionAttempts > maxInteractionAttempts { + return fmt.Errorf("area %s [%d] could not be interacted", area.Area().Name, area) + } - if m.mouseOverAttempts > maxInteractions { - return fmt.Errorf("area %d could not be interacted", m.area) - } + if waitingForInteraction && time.Since(lastRun) < time.Millisecond*500 { + continue + } - if (m.waitingForInteraction && time.Since(m.lastRun) < time.Second*1) || d.PlayerUnit.Area == m.area { - return nil - } + lastRun = time.Now() + for _, l := range ctx.Data.AdjacentLevels { + if l.Area == area { + distance := ctx.PathFinder.DistanceFromMe(l.Position) + if distance > maxEntranceDistance { + // Try to move closer with retries + for retry := 0; retry < maxMoveRetries; retry++ { + if err := MoveTo(l.Position); err != nil { + // If MoveTo fails, try direct movement + screenX, screenY := ctx.PathFinder.GameCoordsToScreenCords( + l.Position.X-2, + l.Position.Y-2, + ) + ctx.HID.Click(game.LeftButton, screenX, screenY) + utils.Sleep(800) + ctx.RefreshGameData() + } - m.lastRun = time.Now() - for _, l := range d.AdjacentLevels { - if l.Area == m.area { - distance := pather.DistanceFromMe(d, l.Position) - if distance > 10 { - return errors.New("entrance too far away") - } + // Check if we're close enough now + newDistance := ctx.PathFinder.DistanceFromMe(l.Position) + if newDistance <= maxEntranceDistance { + break + } - if l.IsEntrance { - if d.HoverData.UnitType == 5 || d.HoverData.UnitType == 2 && d.HoverData.IsHovered { - hid.Click(hid.LeftButton) - m.waitingForInteraction = true + if retry == maxMoveRetries-1 { + return fmt.Errorf("entrance too far away (distance: %d)", distance) + } + } } - lx, ly := pather.GameCoordsToScreenCords(d.PlayerUnit.Position.X, d.PlayerUnit.Position.Y, l.Position.X-2, l.Position.Y-2) - x, y := helper.Spiral(m.mouseOverAttempts) - hid.MovePointer(lx+x, ly+y) - m.mouseOverAttempts++ - return nil - } + if l.IsEntrance { + lx, ly := ctx.PathFinder.GameCoordsToScreenCords(l.Position.X-1, l.Position.Y-1) + if ctx.Data.HoverData.UnitType == 5 || ctx.Data.HoverData.UnitType == 2 && ctx.Data.HoverData.IsHovered { + ctx.HID.Click(game.LeftButton, currentMouseCoords.X, currentMouseCoords.Y) + waitingForInteraction = true + utils.Sleep(200) + } + + x, y := utils.Spiral(interactionAttempts) + x = x / 3 + y = y / 3 + currentMouseCoords = data.Position{X: lx + x, Y: ly + y} + ctx.HID.MovePointer(lx+x, ly+y) + interactionAttempts++ + utils.Sleep(100) + continue + } - return fmt.Errorf("area %d is not an entrance", m.area) + return fmt.Errorf("area %s [%d] is not an entrance", area.Area().Name, area) + } } } - - return fmt.Errorf("area %d not found", m.area) } diff --git a/internal/action/step/interact_npc.go b/internal/action/step/interact_npc.go index 754e58ae4..f044e9f82 100644 --- a/internal/action/step/interact_npc.go +++ b/internal/action/step/interact_npc.go @@ -6,82 +6,83 @@ import ( "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/npc" - "github.com/hectorgimenez/koolo/internal/hid" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" "github.com/hectorgimenez/koolo/internal/pather" + "github.com/hectorgimenez/koolo/internal/ui" ) -type InteractNPCStep struct { - basicStep - NPC npc.ID - waitingForInteraction bool - isCompletedFn func(d data.Data) bool -} - -func InteractNPC(npc npc.ID) *InteractNPCStep { - return &InteractNPCStep{ - basicStep: newBasicStep(), - NPC: npc, - } -} - -func InteractNPCWithCheck(npc npc.ID, isCompletedFn func(d data.Data) bool) *InteractNPCStep { - return &InteractNPCStep{ - basicStep: newBasicStep(), - NPC: npc, - isCompletedFn: isCompletedFn, - } -} - -func (i *InteractNPCStep) Status(d data.Data) Status { - if i.status == StatusCompleted { - return StatusCompleted - } - - if i.isCompletedFn != nil && i.isCompletedFn(d) { - return i.tryTransitionStatus(StatusCompleted) - } - - // Give some extra time to render the UI - if d.OpenMenus.NPCInteract && time.Since(i.lastRun) > time.Second*1 { - return i.tryTransitionStatus(StatusCompleted) - } - - return i.status -} - -func (i *InteractNPCStep) Run(d data.Data) error { - i.tryTransitionStatus(StatusInProgress) - - // Give some time before retrying the interaction - if i.waitingForInteraction && time.Since(i.lastRun) < time.Second { - return nil - } - - i.lastRun = time.Now() - m, found := d.Monsters.FindOne(i.NPC, data.MonsterTypeNone) - if found { - if m.IsHovered { - hid.Click(hid.LeftButton) - i.waitingForInteraction = true - return nil +func InteractNPC(npcID npc.ID) error { + ctx := context.Get() + ctx.SetLastStep("InteractNPC") + + const ( + maxAttempts = 8 + minMenuOpenWait = 300 * time.Millisecond + maxDistance = 15 + hoverWait = 800 * time.Millisecond + ) + + var targetNPCID data.UnitID + + for attempts := 0; attempts < maxAttempts; attempts++ { + // Pause the execution if the priority is not the same as the execution priority + ctx.PauseIfNotPriority() + + // Check if interaction succeeded and menu is open + if ctx.Data.OpenMenus.NPCInteract || ctx.Data.OpenMenus.NPCShop { + // Find current NPC position + if targetNPCID != 0 { + if currentNPC, found := ctx.Data.Monsters.FindByID(targetNPCID); found { + currentDistance := pather.DistanceFromPoint(currentNPC.Position, ctx.Data.PlayerUnit.Position) + if currentDistance <= maxDistance { + time.Sleep(minMenuOpenWait) + return nil + } + } + } + + // Wrong NPC, too far, or NPC moved - close menu and retry + CloseAllMenus() + time.Sleep(200 * time.Millisecond) + targetNPCID = 0 + continue } - distance := pather.DistanceFromMe(d, m.Position) - if distance > 15 { - return fmt.Errorf("NPC is too far away: %d. Current distance: %d", i.NPC, distance) + townNPC, found := ctx.Data.Monsters.FindOne(npcID, data.MonsterTypeNone) + if !found { + if attempts == maxAttempts-1 { + return fmt.Errorf("NPC %d not found after %d attempts", npcID, maxAttempts) + } + time.Sleep(200 * time.Millisecond) + continue } - x, y := pather.GameCoordsToScreenCords(d.PlayerUnit.Position.X, d.PlayerUnit.Position.Y, m.Position.X, m.Position.Y) + distance := ctx.PathFinder.DistanceFromMe(townNPC.Position) + if distance > maxDistance { + return fmt.Errorf("NPC %d is too far away (distance: %d)", npcID, distance) + } - // Act 4 Tyrael has a super weird hitbox - if i.NPC == npc.Tyrael2 { - hid.MovePointer(x, y-20) - } else { - hid.MovePointer(x, y) + // Calculate click position + x, y := ui.GameCoordsToScreenCords(townNPC.Position.X, townNPC.Position.Y) + if npcID == npc.Tyrael2 { + y = y - 40 // Act 4 Tyrael has a super weird hitbox } - return nil + // Move mouse and wait for hover + ctx.HID.MovePointer(x, y) + hoverStart := time.Now() + + for time.Since(hoverStart) < hoverWait { + if currentNPC, found := ctx.Data.Monsters.FindOne(npcID, data.MonsterTypeNone); found && currentNPC.IsHovered { + targetNPCID = currentNPC.UnitID + ctx.HID.Click(game.LeftButton, x, y) + time.Sleep(minMenuOpenWait) + break + } + time.Sleep(50 * time.Millisecond) + } } - return fmt.Errorf("npc %d not found", i.NPC) + return fmt.Errorf("failed to interact with NPC after %d attempts", maxAttempts) } diff --git a/internal/action/step/interact_object.go b/internal/action/step/interact_object.go index 034d7d39b..33762390f 100644 --- a/internal/action/step/interact_object.go +++ b/internal/action/step/interact_object.go @@ -5,86 +5,165 @@ import ( "time" "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" + "github.com/hectorgimenez/d2go/pkg/data/mode" "github.com/hectorgimenez/d2go/pkg/data/object" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" - "github.com/hectorgimenez/koolo/internal/pather" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/town" + "github.com/hectorgimenez/koolo/internal/ui" + "github.com/hectorgimenez/koolo/internal/utils" ) -type InteractObjectStep struct { - basicStep - objectName object.Name - waitingForInteraction bool - isCompleted func(data.Data) bool - mouseOverAttempts int -} +const ( + maxInteractionAttempts = 5 + portalSyncDelay = 200 + maxPortalSyncAttempts = 15 +) -func InteractObject(name object.Name, isCompleted func(data.Data) bool) *InteractObjectStep { - return &InteractObjectStep{ - basicStep: newBasicStep(), - objectName: name, - isCompleted: isCompleted, - } -} +func InteractObject(obj data.Object, isCompletedFn func() bool) error { + interactionAttempts := 0 + mouseOverAttempts := 0 + waitingForInteraction := false + currentMouseCoords := data.Position{} + lastRun := time.Time{} -func (i *InteractObjectStep) Status(d data.Data) Status { - if i.status == StatusCompleted { - return StatusCompleted - } + ctx := context.Get() + ctx.SetLastStep("InteractObject") - // If isCompleted is nil, we run it at least once, sometimes there is no good way to check interaction - if time.Since(i.lastRun) > time.Second*1 && i.isCompleted == nil && i.waitingForInteraction { - i.tryTransitionStatus(StatusCompleted) + // If there is no completion check, just assume the interaction is completed after clicking + if isCompletedFn == nil { + isCompletedFn = func() bool { + return waitingForInteraction + } } - // Give some extra time to render the UI - if time.Since(i.lastRun) > time.Second*1 && i.isCompleted != nil && i.isCompleted(d) { - return i.tryTransitionStatus(StatusCompleted) + // For portals, we need to ensure proper area sync + expectedArea := area.ID(0) + if obj.IsRedPortal() { + // For red portals, we need to determine the expected destination + switch { + case obj.Name == object.PermanentTownPortal && ctx.Data.PlayerUnit.Area == area.StonyField: + expectedArea = area.Tristram + case obj.Name == object.PermanentTownPortal && ctx.Data.PlayerUnit.Area == area.RogueEncampment: + expectedArea = area.MooMooFarm + case obj.Name == object.PermanentTownPortal && ctx.Data.PlayerUnit.Area == area.Harrogath: + expectedArea = area.NihlathaksTemple + case obj.Name == object.PermanentTownPortal && ctx.Data.PlayerUnit.Area == area.ArcaneSanctuary: + expectedArea = area.CanyonOfTheMagi + case obj.Name == object.BaalsPortal && ctx.Data.PlayerUnit.Area == area.ThroneOfDestruction: + expectedArea = area.TheWorldstoneChamber + case obj.Name == object.DurielsLairPortal && (ctx.Data.PlayerUnit.Area >= area.TalRashasTomb1 && ctx.Data.PlayerUnit.Area <= area.TalRashasTomb7): + expectedArea = area.DurielsLair + } + } else if obj.IsPortal() { + // For blue town portals, determine the town area based on current area + fromArea := ctx.Data.PlayerUnit.Area + if !fromArea.IsTown() { + expectedArea = town.GetTownByArea(fromArea).TownArea() + } else { + // When using portal from town, we need to wait for any non-town area + isCompletedFn = func() bool { + return !ctx.Data.PlayerUnit.Area.IsTown() && + ctx.Data.AreaData.IsInside(ctx.Data.PlayerUnit.Position) && + len(ctx.Data.Objects) > 0 + } + } } - return i.status -} + for !isCompletedFn() { + ctx.PauseIfNotPriority() -func (i *InteractObjectStep) Run(d data.Data) error { - i.tryTransitionStatus(StatusInProgress) + if interactionAttempts >= maxInteractionAttempts || mouseOverAttempts >= 20 { + return fmt.Errorf("[%s] failed interacting with object [%v] in Area: [%s]", ctx.Name, obj.Name, ctx.Data.PlayerUnit.Area.Area().Name) + } - if i.mouseOverAttempts > maxInteractions { - return fmt.Errorf("object %d could not be interacted", i.objectName) - } + ctx.RefreshGameData() - // Give some time before retrying the interaction - if i.waitingForInteraction && time.Since(i.lastRun) < time.Second*1 { - return nil - } + // Give some time before retrying the interaction + if waitingForInteraction && time.Since(lastRun) < time.Millisecond*200 { + continue + } - i.lastRun = time.Now() - if o, found := d.Objects.FindOne(i.objectName); found { - if o.IsHovered { - hid.Click(hid.LeftButton) - i.waitingForInteraction = true - return nil + var o data.Object + var found bool + if obj.ID != 0 { + o, found = ctx.Data.Objects.FindByID(obj.ID) + if !found { + return fmt.Errorf("object %v not found", obj) + } } else { - distance := pather.DistanceFromMe(d, o.Position) - if distance > 15 { - return fmt.Errorf("object is too far away: %d. Current distance: %d", o.Name, distance) + o, found = ctx.Data.Objects.FindOne(obj.Name) + if !found { + return fmt.Errorf("object %v not found", obj) + } + } + + lastRun = time.Now() + + // Check portal states + if o.IsPortal() || o.IsRedPortal() { + // If portal is still being created, wait + if o.Mode == mode.ObjectModeOperating { + utils.Sleep(100) + continue + } + + // Only interact when portal is fully opened + if o.Mode != mode.ObjectModeOpened { + utils.Sleep(100) + continue } + } + if o.IsHovered { + ctx.HID.Click(game.LeftButton, currentMouseCoords.X, currentMouseCoords.Y) + waitingForInteraction = true + interactionAttempts++ + + // For portals with expected area, we need to wait for proper area sync + if expectedArea != 0 { + utils.Sleep(500) // Initial delay for area transition + for attempts := 0; attempts < maxPortalSyncAttempts; attempts++ { + ctx.RefreshGameData() + if ctx.Data.PlayerUnit.Area == expectedArea { + if areaData, ok := ctx.Data.Areas[expectedArea]; ok { + if areaData.IsInside(ctx.Data.PlayerUnit.Position) { + if expectedArea.IsTown() { + return nil // For town areas, we can return immediately + } + // For special areas, ensure we have proper object data loaded + if len(ctx.Data.Objects) > 0 { + return nil + } + } + } + } + utils.Sleep(portalSyncDelay) + } + return fmt.Errorf("portal sync timeout - expected area: %v, current: %v", expectedArea, ctx.Data.PlayerUnit.Area) + } + continue + } else { objectX := o.Position.X - 2 objectY := o.Position.Y - 2 - mX, mY := pather.GameCoordsToScreenCords(d.PlayerUnit.Position.X, d.PlayerUnit.Position.Y, objectX, objectY) + distance := ctx.PathFinder.DistanceFromMe(o.Position) + if distance > 15 { + return fmt.Errorf("object is too far away: %d. Current distance: %d", o.Name, distance) + } + mX, mY := ui.GameCoordsToScreenCords(objectX, objectY) // In order to avoid the spiral (super slow and shitty) let's try to point the mouse to the top of the portal directly - if i.mouseOverAttempts == 2 && i.objectName == object.TownPortal { - mX, mY = pather.GameCoordsToScreenCords(d.PlayerUnit.Position.X, d.PlayerUnit.Position.Y, objectX-4, objectY-4) + if mouseOverAttempts == 2 && o.IsPortal() { + mX, mY = ui.GameCoordsToScreenCords(objectX-4, objectY-4) } - x, y := helper.Spiral(i.mouseOverAttempts) - hid.MovePointer(mX+x, mY+y) - i.mouseOverAttempts++ - - return nil + x, y := utils.Spiral(mouseOverAttempts) + currentMouseCoords = data.Position{X: mX + x, Y: mY + y} + ctx.HID.MovePointer(mX+x, mY+y) + mouseOverAttempts++ } } - return fmt.Errorf("object %d not found", i.objectName) + return nil } diff --git a/internal/action/step/key_sequence.go b/internal/action/step/key_sequence.go deleted file mode 100644 index bf2759210..000000000 --- a/internal/action/step/key_sequence.go +++ /dev/null @@ -1,46 +0,0 @@ -package step - -import ( - "time" - - "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/koolo/internal/hid" -) - -type KeySequenceStep struct { - basicStep - keysToPress []string -} - -func KeySequence(keysToPress ...string) *KeySequenceStep { - return &KeySequenceStep{ - basicStep: newBasicStep(), - keysToPress: keysToPress, - } -} - -func (o *KeySequenceStep) Status(_ data.Data) Status { - if o.status == StatusCompleted { - return StatusCompleted - } - - if len(o.keysToPress) > 0 { - return o.status - } - o.tryTransitionStatus(StatusCompleted) - - return o.status -} - -func (o *KeySequenceStep) Run(_ data.Data) error { - if time.Since(o.lastRun) < time.Millisecond*200 { - return nil - } - - var k string - k, o.keysToPress = o.keysToPress[0], o.keysToPress[1:] - hid.PressKey(k) - - o.lastRun = time.Now() - return nil -} diff --git a/internal/action/step/move.go b/internal/action/step/move.go new file mode 100644 index 000000000..538e1c65d --- /dev/null +++ b/internal/action/step/move.go @@ -0,0 +1,148 @@ +package step + +import ( + "errors" + "fmt" + "math" + "time" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/mode" + "github.com/hectorgimenez/d2go/pkg/data/skill" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/utils" +) + +const DistanceToFinishMoving = 4 + +type MoveOpts struct { + distanceOverride *int +} + +type MoveOption func(*MoveOpts) + +// WithDistanceToFinish overrides the default DistanceToFinishMoving +func WithDistanceToFinish(distance int) MoveOption { + return func(opts *MoveOpts) { + opts.distanceOverride = &distance + } +} + +func MoveTo(dest data.Position, options ...MoveOption) error { + // Initialize options + opts := &MoveOpts{} + + // Apply any provided options + for _, o := range options { + o(opts) + } + + minDistanceToFinishMoving := DistanceToFinishMoving + if opts.distanceOverride != nil { + minDistanceToFinishMoving = *opts.distanceOverride + } + + ctx := context.Get() + ctx.SetLastStep("MoveTo") + + defer func() { + for { + switch ctx.Data.PlayerUnit.Mode { + case mode.Walking, mode.WalkingInTown, mode.Running, mode.CastingSkill: + utils.Sleep(100) + ctx.RefreshGameData() + continue + default: + return + } + } + }() + + timeout := time.Second * 30 + idleThreshold := time.Second * 3 + idleStartTime := time.Time{} + + startedAt := time.Now() + lastRun := time.Time{} + previousPosition := data.Position{} + previousDistance := 0 + + for { + // Pause the execution if the priority is not the same as the execution priority + ctx.PauseIfNotPriority() + // is needed to prevent bot teleporting in circle when it reached destination (lower end cpu) cost is minimal. + ctx.RefreshGameData() + + // Add some delay between clicks to let the character move to destination + walkDuration := utils.RandomDurationMs(600, 1200) + if !ctx.Data.CanTeleport() && time.Since(lastRun) < walkDuration { + time.Sleep(walkDuration - time.Since(lastRun)) + continue + } + + // We skip the movement if we can teleport and the last movement time was less than the player cast duration + if ctx.Data.CanTeleport() && time.Since(lastRun) < ctx.Data.PlayerCastDuration() { + time.Sleep(ctx.Data.PlayerCastDuration() - time.Since(lastRun)) + continue + } + + // Check for idle state + if ctx.Data.PlayerUnit.Position == previousPosition { + if idleStartTime.IsZero() { + idleStartTime = time.Now() + } else if time.Since(idleStartTime) > idleThreshold { + // Perform anti-idle action + ctx.Logger.Debug("Anti-idle triggered") + ctx.PathFinder.RandomMovement() + idleStartTime = time.Time{} // Reset idle timer + continue + } + } else { + idleStartTime = time.Time{} // Reset idle timer if position changed + previousPosition = ctx.Data.PlayerUnit.Position + } + + // Press the Teleport keybinding if it's available, otherwise use vigor (if available) + if ctx.Data.CanTeleport() { + if ctx.Data.PlayerUnit.RightSkill != skill.Teleport { + ctx.HID.PressKeyBinding(ctx.Data.KeyBindings.MustKBForSkill(skill.Teleport)) + } + } else if kb, found := ctx.Data.KeyBindings.KeyBindingForSkill(skill.Vigor); found { + if ctx.Data.PlayerUnit.RightSkill != skill.Vigor { + ctx.HID.PressKeyBinding(kb) + } + } + + path, distance, found := ctx.PathFinder.GetPath(dest) + if !found { + if ctx.PathFinder.DistanceFromMe(dest) < minDistanceToFinishMoving+5 { + return nil + } + + return errors.New("path could not be calculated. Current area: [" + ctx.Data.PlayerUnit.Area.Area().Name + "]. Trying to path to Destination: [" + fmt.Sprintf("%d,%d", dest.X, dest.Y) + "]") + } + if distance <= minDistanceToFinishMoving || len(path) <= minDistanceToFinishMoving || len(path) == 0 { + return nil + } + + // Exit on timeout + if timeout > 0 && time.Since(startedAt) > timeout { + return nil + } + + lastRun = time.Now() + + // This is a workaround to avoid the character to get stuck in the same position when the hitbox of the destination is too big + if distance < 20 && math.Abs(float64(previousDistance-distance)) < DistanceToFinishMoving { + minDistanceToFinishMoving += DistanceToFinishMoving + } else if opts.distanceOverride != nil { + minDistanceToFinishMoving = *opts.distanceOverride + } else { + minDistanceToFinishMoving = DistanceToFinishMoving + } + + previousPosition = ctx.Data.PlayerUnit.Position + previousDistance = distance + ctx.PathFinder.MoveThroughPath(path, walkDuration) + } +} diff --git a/internal/action/step/move_to.go b/internal/action/step/move_to.go deleted file mode 100644 index 0009489e4..000000000 --- a/internal/action/step/move_to.go +++ /dev/null @@ -1,162 +0,0 @@ -package step - -import ( - "errors" - "time" - - "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/d2go/pkg/data/skill" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" - "github.com/hectorgimenez/koolo/internal/pather" -) - -type MoveToStep struct { - pathingStep - destination data.Position - stopAtDistance int - nearestWalkable bool - timeout time.Duration - startedAt time.Time -} - -type MoveToStepOption func(step *MoveToStep) - -func MoveTo(destination data.Position, opts ...MoveToStepOption) *MoveToStep { - step := &MoveToStep{ - pathingStep: newPathingStep(), - destination: destination, - timeout: time.Second * 30, - } - - for _, o := range opts { - o(step) - } - - return step -} - -func StopAtDistance(distance int) MoveToStepOption { - return func(step *MoveToStep) { - step.stopAtDistance = distance - } -} - -func ClosestWalkable() MoveToStepOption { - return func(step *MoveToStep) { - step.nearestWalkable = true - } -} - -func WithTimeout(timeout time.Duration) MoveToStepOption { - return func(step *MoveToStep) { - step.timeout = timeout - } -} - -func (m *MoveToStep) Status(d data.Data) Status { - if m.status == StatusCompleted { - return StatusCompleted - } - - distance := pather.DistanceFromMe(d, m.destination) - if distance < 5 || distance < m.stopAtDistance { - return m.tryTransitionStatus(StatusCompleted) - } - - return m.status -} - -func (m *MoveToStep) Run(d data.Data) error { - // Press the Teleport keybinding if it's available, otherwise use vigor (if available) - if helper.CanTeleport(d) { - if d.PlayerUnit.RightSkill != skill.Teleport { - hid.PressKey(config.Config.Bindings.Teleport) - } - } else if d.PlayerUnit.Skills[skill.Vigor] > 0 && config.Config.Bindings.Paladin.Vigor != "" { - if d.PlayerUnit.RightSkill != skill.Vigor { - hid.PressKey(config.Config.Bindings.Paladin.Vigor) - } - } - - if m.startedAt.IsZero() { - m.startedAt = time.Now() - } - - m.tryTransitionStatus(StatusInProgress) - - if m.timeout > 0 && time.Since(m.startedAt) > m.timeout { - m.tryTransitionStatus(StatusCompleted) - return nil - } - - // Add some delay between clicks to let the character move to destination - walkDuration := helper.RandomDurationMs(600, 1200) - if !helper.CanTeleport(d) && time.Since(m.lastRun) < walkDuration { - return nil - } - - if helper.CanTeleport(d) && time.Since(m.lastRun) < config.Config.Runtime.CastDuration { - return nil - } - - stuck := m.isPlayerStuck(d) - - if m.path == nil || !m.cachePath(d) || stuck { - if stuck { - if len(m.path.AstarPather) == 0 { - pather.RandomMovement() - m.lastRun = time.Now() - - return nil - } else { - tile := m.path.AstarPather[len(m.path.AstarPather)-1].(*pather.Tile) - m.blacklistedPositions = append(m.blacklistedPositions, [2]int{tile.X, tile.Y}) - } - } - - path, _, found := pather.GetPath(d, m.destination, m.blacklistedPositions...) - if !found { - // Try to find the nearest walkable place - if m.nearestWalkable { - path, _, found = pather.GetClosestWalkablePath(d, m.destination, m.blacklistedPositions...) - if !found { - return errors.New("path could not be calculated, maybe there is an obstacle or a flying platform (arcane sanctuary)") - } - m.destination = path.Destination - } else { - return errors.New("path could not be calculated, maybe there is an obstacle or a flying platform (arcane sanctuary)") - } - } - m.path = path - } - - m.lastRun = time.Now() - m.previousArea = d.PlayerUnit.Area - if len(m.path.AstarPather) == 0 { - return nil - } - pather.MoveThroughPath(m.path, calculateMaxDistance(d, walkDuration), helper.CanTeleport(d)) - - return nil -} - -func (m *MoveToStep) Reset() { - m.status = StatusNotStarted - m.lastRun = time.Time{} - m.startedAt = time.Time{} -} - -func calculateMaxDistance(d data.Data, duration time.Duration) int { - // We don't care too much if teleport is available, we can ignore corners, 90 degrees turns, etc - if helper.CanTeleport(d) { - return 25 - } - - // Calculate the distance we can walk in the given duration, based on the randomized time - proposedDistance := int(float64(25) * duration.Seconds()) - realDistance := proposedDistance - - return realDistance -} diff --git a/internal/action/step/open_portal.go b/internal/action/step/open_portal.go index 7a884670e..13d375e42 100644 --- a/internal/action/step/open_portal.go +++ b/internal/action/step/open_portal.go @@ -3,48 +3,35 @@ package step import ( "time" - "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" + "github.com/hectorgimenez/d2go/pkg/data/object" + "github.com/hectorgimenez/d2go/pkg/data/skill" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/utils" ) -type OpenPortalStep struct { - basicStep -} +func OpenPortal() error { + ctx := context.Get() + ctx.SetLastStep("OpenPortal") -func OpenPortal() *OpenPortalStep { - return &OpenPortalStep{basicStep: newBasicStep()} -} + lastRun := time.Time{} + for { + // Pause the execution if the priority is not the same as the execution priority + ctx.PauseIfNotPriority() -func (s *OpenPortalStep) Status(d data.Data) Status { - if s.status == StatusCompleted { - return StatusCompleted - } - - // Give some extra time, sometimes if we move the mouse over the portal before is shown - // and there is an intractable entity behind it, will keep it focused - if time.Since(s.LastRun()) > time.Second*1 { - for _, o := range d.Objects { - if o.IsPortal() { - return s.tryTransitionStatus(StatusCompleted) - } + _, found := ctx.Data.Objects.FindOne(object.TownPortal) + if found { + return nil } - } - return StatusInProgress -} + // Give some time to portal to popup before retrying... + if time.Since(lastRun) < time.Second*2 { + continue + } -func (s *OpenPortalStep) Run(_ data.Data) error { - // Give some time to portal to popup before retrying... - if time.Since(s.LastRun()) < time.Second*2 { - return nil + ctx.HID.PressKeyBinding(ctx.Data.KeyBindings.MustKBForSkill(skill.TomeOfTownPortal)) + utils.Sleep(250) + ctx.HID.Click(game.RightButton, 300, 300) + lastRun = time.Now() } - - hid.PressKey(config.Config.Bindings.TP) - helper.Sleep(250) - hid.Click(hid.RightButton) - s.lastRun = time.Now() - - return nil } diff --git a/internal/action/step/pathing.go b/internal/action/step/pathing.go deleted file mode 100644 index 8c1756242..000000000 --- a/internal/action/step/pathing.go +++ /dev/null @@ -1,82 +0,0 @@ -package step - -import ( - "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/d2go/pkg/data/area" - "github.com/hectorgimenez/koolo/internal/pather" -) - -type pathingStep struct { - basicStep - consecutivePathNotFound int - path *pather.Pather - lastRunPositions [][2]int - blacklistedPositions [][2]int - previousArea area.Area -} - -func newPathingStep() pathingStep { - return pathingStep{ - basicStep: basicStep{ - status: StatusNotStarted, - }, - } -} - -func (s *pathingStep) cachePath(d data.Data) bool { - nearestKey := 0 - nearestDistance := 99999999 - - if s.previousArea != d.PlayerUnit.Area { - return false - } - - for k, pos := range s.path.AstarPather { - tile := pos.(*pather.Tile) - expandedGrid := 0 - if len(tile.W) == 3000 { - expandedGrid = 1500 - } - - destination := data.Position{ - X: tile.X + (d.AreaOrigin.X - expandedGrid), - Y: tile.Y + (d.AreaOrigin.Y - expandedGrid), - } - - distance := pather.DistanceFromMe(d, destination) - if distance < nearestDistance { - nearestDistance = distance - nearestKey = k - } - } - - if nearestDistance < 5 && len(s.path.AstarPather) > nearestKey { - //fmt.Println(fmt.Sprintf("Max deviation: %d, using Path Key: %d [%d]", nearestDistance, nearestKey, len(s.path)-1)) - s.path.AstarPather = s.path.AstarPather[:nearestKey] - - return true - } - - return false -} - -func (s *pathingStep) isPlayerStuck(d data.Data) bool { - if s.lastRun.IsZero() { - return false - } - - s.lastRunPositions = append(s.lastRunPositions, [2]int{d.PlayerUnit.Position.X, d.PlayerUnit.Position.Y}) - if len(s.lastRunPositions) > 20 { - s.lastRunPositions = s.lastRunPositions[1:] - } else { - return false - } - - for _, pos := range s.lastRunPositions { - if pos[0] != d.PlayerUnit.Position.X || pos[1] != d.PlayerUnit.Position.Y { - return false - } - } - - return true -} diff --git a/internal/action/step/pickup_item.go b/internal/action/step/pickup_item.go index cd6bd0107..8471d83a6 100644 --- a/internal/action/step/pickup_item.go +++ b/internal/action/step/pickup_item.go @@ -1,120 +1,177 @@ package step import ( + "errors" "fmt" "time" "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/item" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" + "github.com/hectorgimenez/d2go/pkg/data/mode" + "github.com/hectorgimenez/d2go/pkg/data/stat" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" "github.com/hectorgimenez/koolo/internal/pather" - "go.uber.org/zap" + "github.com/hectorgimenez/koolo/internal/utils" ) -const maxInteractions = 45 +const ( + clickDelay = 25 * time.Millisecond + spiralDelay = 25 * time.Millisecond + pickupTimeout = 3 * time.Second +) -type PickupItemStep struct { - basicStep - item data.Item - waitingForInteraction time.Time - mouseOverAttempts int - logger *zap.Logger - startedAt time.Time -} +var ( + maxInteractions = 24 // 25 attempts since we start at 0 + ErrItemTooFar = errors.New("item is too far away") + ErrNoLOSToItem = errors.New("no line of sight to item") + ErrMonsterAroundItem = errors.New("monsters detected around item") + ErrCastingMoving = errors.New("char casting or moving") +) -func PickupItem(logger *zap.Logger, item data.Item) *PickupItemStep { - return &PickupItemStep{ - basicStep: newBasicStep(), - item: item, - logger: logger, +func PickupItem(it data.Item, itemPickupAttempt int) error { + ctx := context.Get() + ctx.SetLastStep("PickupItem") + + // Casting skill/moving return back + for ctx.Data.PlayerUnit.Mode == mode.CastingSkill || ctx.Data.PlayerUnit.Mode == mode.Running || ctx.Data.PlayerUnit.Mode == mode.Walking || ctx.Data.PlayerUnit.Mode == mode.WalkingInTown { + time.Sleep(25 * time.Millisecond) + return ErrCastingMoving } -} -func (p *PickupItemStep) Status(d data.Data) Status { - if p.status == StatusCompleted { - return p.status + // Calculate base screen position for item + baseX := it.Position.X - 1 + baseY := it.Position.Y - 1 + switch itemPickupAttempt { + case 3: + baseX = baseX + 1 + case 4: + maxInteractions = 44 + baseY = baseY + 1 + case 5: + maxInteractions = 44 + baseX = baseX - 1 + baseY = baseY - 1 + default: + maxInteractions = 24 } + baseScreenX, baseScreenY := ctx.PathFinder.GameCoordsToScreenCords(baseX, baseY) - for _, i := range d.Items.ByLocation(item.LocationGround) { - if i.UnitID == p.item.UnitID { - return p.status - } + // Check for monsters first + if hasHostileMonstersNearby(it.Position) { + return ErrMonsterAroundItem } - p.logger.Info(fmt.Sprintf("Item picked up: %s [%s]", p.item.Name, p.item.Quality.ToString())) + // Validate line of sight + if !ctx.PathFinder.LineOfSight(ctx.Data.PlayerUnit.Position, it.Position) { + return ErrNoLOSToItem + } - return p.tryTransitionStatus(StatusCompleted) -} + // Check distance + distance := ctx.PathFinder.DistanceFromMe(it.Position) + if distance >= 7 { + return fmt.Errorf("%w (%d): %s", ErrItemTooFar, distance, it.Desc().Name) + } + + ctx.Logger.Debug(fmt.Sprintf("Picking up: %s [%s]", it.Desc().Name, it.Quality.ToString())) -func (p *PickupItemStep) Run(d data.Data) error { - for _, m := range d.Monsters.Enemies() { - if dist := pather.DistanceFromMe(d, m.Position); dist < 7 && p.mouseOverAttempts > 1 { - return fmt.Errorf("monster %d [%s] is too close to item %s [%s]", m.Name, m.Type, p.item.Name, p.item.Quality.ToString()) + // Track interaction state + waitingForInteraction := time.Time{} + spiralAttempt := 0 + targetItem := it + lastMonsterCheck := time.Now() + const monsterCheckInterval = 150 * time.Millisecond + + startTime := time.Now() + + for { + ctx.PauseIfNotPriority() + ctx.RefreshGameData() + + // Periodic monster check + if time.Since(lastMonsterCheck) > monsterCheckInterval { + if hasHostileMonstersNearby(it.Position) { + return ErrMonsterAroundItem + } + lastMonsterCheck = time.Now() } - } - if p.mouseOverAttempts > maxInteractions || !p.waitingForInteraction.IsZero() && time.Since(p.waitingForInteraction) > time.Second*3 { - return fmt.Errorf("item %s [%s] could not be picked up", p.item.Name, p.item.Quality.ToString()) - } + // Check if item still exists + currentItem, exists := findItemOnGround(targetItem.UnitID) + if !exists { - if p.status == StatusNotStarted { - p.logger.Debug(fmt.Sprintf("Picking up: %s [%s]", p.item.Name, p.item.Quality.ToString())) - p.startedAt = time.Now() - } + ctx.Logger.Info(fmt.Sprintf("Picked up: %s [%s] | Item Pickup Attempt:%d | Spiral Attempt:%d", targetItem.Desc().Name, targetItem.Quality.ToString(), itemPickupAttempt, spiralAttempt)) - p.tryTransitionStatus(StatusInProgress) - if time.Since(p.lastRun) < helper.RandomDurationMs(120, 320) { - return nil - } + ctx.CurrentGame.PickedUpItems[int(targetItem.UnitID)] = int(ctx.Data.PlayerUnit.Area.Area().ID) - if !p.waitingForInteraction.IsZero() && time.Since(p.lastRun) < time.Second { - return nil - } + return nil // Success! + } - p.lastRun = time.Now() - for _, i := range d.Items.ByLocation(item.LocationGround) { - if i.UnitID == p.item.UnitID { - if i.IsHovered { - hid.Click(hid.LeftButton) - if p.waitingForInteraction.IsZero() { - p.waitingForInteraction = time.Now() - } - return nil - } else { - // Sometimes we got stuck because mouse is hovering a chest and item is in behind, it usually happens a lot - // on Andariel, so we open it - if p.isChestHovered(d) { - hid.Click(hid.LeftButton) - } - - distance := pather.DistanceFromMe(d, i.Position) - if distance > 7 { - p.logger.Info("item is too far away", zap.String("item", string(p.item.Name))) - return fmt.Errorf("item is too far away: %s", p.item.Name) - } - - objectX := i.Position.X - 1 - objectY := i.Position.Y - 1 - mX, mY := pather.GameCoordsToScreenCords(d.PlayerUnit.Position.X, d.PlayerUnit.Position.Y, objectX, objectY) - x, y := helper.Spiral(p.mouseOverAttempts) - hid.MovePointer(mX+x, mY+y) - p.mouseOverAttempts++ - - return nil + // Check timeout conditions + if spiralAttempt > maxInteractions || + (!waitingForInteraction.IsZero() && time.Since(waitingForInteraction) > pickupTimeout) || + time.Since(startTime) > pickupTimeout { + return fmt.Errorf("failed to pick up %s after %d attempts", it.Desc().Name, spiralAttempt) + } + + offsetX, offsetY := utils.ItemSpiral(spiralAttempt) + cursorX := baseScreenX + offsetX + cursorY := baseScreenY + offsetY + + // Move cursor directly to target position + ctx.HID.MovePointer(cursorX, cursorY) + time.Sleep(spiralDelay) + + // Click on item if mouse is hovering over + if currentItem.UnitID == ctx.GameReader.GameReader.GetData().HoverData.UnitID { + ctx.HID.Click(game.LeftButton, cursorX, cursorY) + time.Sleep(clickDelay) + + if waitingForInteraction.IsZero() { + waitingForInteraction = time.Now() } + continue + } + + // Sometimes we got stuck because mouse is hovering a chest and item is in behind, it usually happens a lot + // on Andariel, so we open it + if isChestorShrineHovered() { + ctx.HID.Click(game.LeftButton, cursorX, cursorY) + time.Sleep(50 * time.Millisecond) } - } - return fmt.Errorf("item %s not found", p.item.Name) + spiralAttempt++ + } } +func hasHostileMonstersNearby(pos data.Position) bool { + ctx := context.Get() -func (p *PickupItemStep) isChestHovered(d data.Data) bool { - for _, o := range d.Objects { - if o.IsChest() && o.IsHovered { + for _, monster := range ctx.Data.Monsters.Enemies() { + if monster.Stats[stat.Life] > 0 && pather.DistanceFromPoint(pos, monster.Position) <= 4 { return true } } + return false +} + +func findItemOnGround(targetID data.UnitID) (data.Item, bool) { + ctx := context.Get() + + for _, i := range ctx.Data.Inventory.ByLocation(item.LocationGround) { + if i.UnitID == targetID { + return i, true + } + } + return data.Item{}, false +} + +func isChestorShrineHovered() bool { + ctx := context.Get() + for _, o := range ctx.Data.Objects { + if (o.IsChest() || o.IsShrine()) && o.IsHovered { + return true + } + } return false } diff --git a/internal/action/step/set_skill.go b/internal/action/step/set_skill.go new file mode 100644 index 000000000..ad3a04f98 --- /dev/null +++ b/internal/action/step/set_skill.go @@ -0,0 +1,17 @@ +package step + +import ( + "github.com/hectorgimenez/d2go/pkg/data/skill" + "github.com/hectorgimenez/koolo/internal/context" +) + +func SetSkill(id skill.ID) { + ctx := context.Get() + ctx.SetLastStep("SetSkill") + + if kb, found := ctx.Data.KeyBindings.KeyBindingForSkill(id); found { + if ctx.Data.PlayerUnit.RightSkill != id { + ctx.HID.PressKeyBinding(kb) + } + } +} diff --git a/internal/action/step/step.go b/internal/action/step/step.go deleted file mode 100644 index 8908f58b6..000000000 --- a/internal/action/step/step.go +++ /dev/null @@ -1,53 +0,0 @@ -package step - -import ( - "time" - - "github.com/hectorgimenez/d2go/pkg/data" -) - -const ( - StatusNotStarted Status = "NotStarted" - StatusInProgress Status = "InProgress" - StatusCompleted Status = "Completed" -) - -type Status string -type Step interface { - Status(data.Data) Status - Run(data.Data) error - Reset() - LastRun() time.Time -} - -type basicStep struct { - status Status - lastRun time.Time -} - -func newBasicStep() basicStep { - return basicStep{ - status: StatusNotStarted, - } -} - -func (bs *basicStep) LastRun() time.Time { - return bs.lastRun -} - -func (bs *basicStep) tryTransitionStatus(to Status) Status { - if bs.status == StatusCompleted { - return StatusCompleted - } - if bs.status == StatusInProgress && to != StatusCompleted { - return StatusInProgress - } - - bs.status = to - return to -} - -func (bs *basicStep) Reset() { - bs.status = StatusNotStarted - bs.lastRun = time.Time{} -} diff --git a/internal/action/step/swap_weapon.go b/internal/action/step/swap_weapon.go index 6b00e3e17..5cff93761 100644 --- a/internal/action/step/swap_weapon.go +++ b/internal/action/step/swap_weapon.go @@ -3,59 +3,39 @@ package step import ( "time" - "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/skill" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/hid" + "github.com/hectorgimenez/koolo/internal/context" ) -type SwapWeaponStep struct { - basicStep - binding string - wantCTA bool +func SwapToMainWeapon() error { + return swapWeapon(false) } -func SwapToMainWeapon() *SwapWeaponStep { - return &SwapWeaponStep{ - basicStep: newBasicStep(), - binding: config.Config.Bindings.SwapWeapon, - } +func SwapToCTA() error { + return swapWeapon(true) } -func SwapToCTA() *SwapWeaponStep { - return &SwapWeaponStep{ - basicStep: newBasicStep(), - binding: config.Config.Bindings.SwapWeapon, - wantCTA: true, - } -} +func swapWeapon(toCTA bool) error { + lastRun := time.Time{} -func (s *SwapWeaponStep) Status(d data.Data) Status { - _, found := d.PlayerUnit.Skills[skill.BattleOrders] - if (s.wantCTA && found) || (!s.wantCTA && !found) { - return s.tryTransitionStatus(StatusCompleted) - } + ctx := context.Get() + ctx.SetLastStep("SwapToCTA") - return s.status -} + for { + // Pause the execution if the priority is not the same as the execution priority + ctx.PauseIfNotPriority() -func (s *SwapWeaponStep) Run(d data.Data) error { - s.tryTransitionStatus(StatusInProgress) + if time.Since(lastRun) < time.Millisecond*500 { + continue + } - if time.Since(s.lastRun) < time.Second { - return nil - } + _, found := ctx.Data.PlayerUnit.Skills[skill.BattleOrders] + if (toCTA && found) || (!toCTA && !found) { + return nil + } - _, found := d.PlayerUnit.Skills[skill.BattleOrders] - if (s.wantCTA && found) || (!s.wantCTA && !found) { - s.tryTransitionStatus(StatusCompleted) + ctx.HID.PressKeyBinding(ctx.Data.KeyBindings.SwapWeapons) - return nil + lastRun = time.Now() } - - hid.PressKey(s.binding) - - s.lastRun = time.Now() - - return nil } diff --git a/internal/action/step/sync_action.go b/internal/action/step/sync_action.go deleted file mode 100644 index cc622e24f..000000000 --- a/internal/action/step/sync_action.go +++ /dev/null @@ -1,45 +0,0 @@ -package step - -import "github.com/hectorgimenez/d2go/pkg/data" - -type SyncActionStep struct { - basicStep - action func(data.Data) error - statusChecker func(data.Data) Status -} - -func SyncStep(action func(data.Data) error) *SyncActionStep { - return &SyncActionStep{ - basicStep: newBasicStep(), - action: action, - } -} - -func SyncStepWithCheck(action func(data.Data) error, statusChecker func(data.Data) Status) *SyncActionStep { - return &SyncActionStep{ - basicStep: newBasicStep(), - action: action, - statusChecker: statusChecker, - } -} - -func (s *SyncActionStep) Status(d data.Data) Status { - if s.status == StatusCompleted { - return StatusCompleted - } - - if s.statusChecker != nil { - s.tryTransitionStatus(s.statusChecker(d)) - } - - return s.status -} - -func (s *SyncActionStep) Run(d data.Data) error { - if s.statusChecker == nil { - s.tryTransitionStatus(StatusCompleted) - } - - err := s.action(d) - return err -} diff --git a/internal/action/step/wait.go b/internal/action/step/wait.go deleted file mode 100644 index ae85cc93e..000000000 --- a/internal/action/step/wait.go +++ /dev/null @@ -1,41 +0,0 @@ -package step - -import ( - "time" - - "github.com/hectorgimenez/d2go/pkg/data" -) - -type WaitStep struct { - basicStep - waitTime time.Duration - firstRun time.Time -} - -func Wait(duration time.Duration) *WaitStep { - return &WaitStep{ - basicStep: newBasicStep(), - waitTime: duration, - } -} - -func (o *WaitStep) Status(_ data.Data) Status { - if o.status == StatusCompleted { - return StatusCompleted - } - - return o.status -} - -func (o *WaitStep) Run(_ data.Data) error { - if o.firstRun.IsZero() { - o.firstRun = time.Now() - o.tryTransitionStatus(StatusInProgress) - } - - if time.Since(o.firstRun) >= o.waitTime { - o.tryTransitionStatus(StatusCompleted) - } - - return nil -} diff --git a/internal/action/tools.go b/internal/action/tools.go new file mode 100644 index 000000000..07fe37849 --- /dev/null +++ b/internal/action/tools.go @@ -0,0 +1,78 @@ +package action + +import ( + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/koolo/internal/action/step" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/utils" +) + +func OpenTPIfLeader() error { + ctx := context.Get() + ctx.SetLastAction("OpenTPIfLeader") + + isLeader := ctx.CharacterCfg.Companion.Leader + + if isLeader { + return step.OpenPortal() + } + + return nil +} + +func IsMonsterSealElite(monster data.Monster) bool { + return monster.Type == data.MonsterTypeSuperUnique && (monster.Name == npc.OblivionKnight || monster.Name == npc.VenomLord || monster.Name == npc.StormCaster) +} + +func PostRun(isLastRun bool) error { + ctx := context.Get() + ctx.SetLastAction("PostRun") + + // Allow some time for items drop to the ground, otherwise we might miss some + utils.Sleep(200) + ClearAreaAroundPlayer(5, data.MonsterAnyFilter()) + ItemPickup(-1) + + // Don't return town on last run + if !isLastRun { + return ReturnTown() + } + + return nil +} +func AreaCorrection() error { + ctx := context.Get() + currentArea := ctx.Data.PlayerUnit.Area + expectedArea := ctx.CurrentGame.AreaCorrection.ExpectedArea + + // Skip correction if in town, if we're in the expected area, or if expected area is not set + if currentArea.IsTown() || currentArea == expectedArea || expectedArea == 0 { + return nil + } + + if ctx.CurrentGame.AreaCorrection.Enabled && ctx.CurrentGame.AreaCorrection.ExpectedArea != ctx.Data.AreaData.Area { + ctx.Logger.Info("Accidentally went to adjacent area, returning to expected area", + "current", ctx.Data.AreaData.Area.Area().Name, + "expected", ctx.CurrentGame.AreaCorrection.ExpectedArea.Area().Name) + return MoveToArea(ctx.CurrentGame.AreaCorrection.ExpectedArea) + } + + return nil +} +func HidePortraits() error { + ctx := context.Get() + ctx.SetLastAction("HidePortraits") + + // Hide portraits if configured + if ctx.CharacterCfg.HidePortraits && ctx.Data.OpenMenus.PortraitsShown { + ctx.HID.PressKey(ctx.Data.KeyBindings.ShowPortraits.Key1[0]) + } + return nil +} +func ClearMessages() error { + ctx := context.Get() + ctx.SetLastAction("ClearMessages") + ctx.HID.PressKey(ctx.Data.KeyBindings.ClearMessages.Key1[0]) + return nil +} diff --git a/internal/action/town.go b/internal/action/town.go new file mode 100644 index 000000000..c6f9cb2bb --- /dev/null +++ b/internal/action/town.go @@ -0,0 +1,118 @@ +package action + +import ( + "fmt" + + "github.com/hectorgimenez/d2go/pkg/data/skill" + "github.com/hectorgimenez/koolo/internal/action/step" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/utils" +) + +func PreRun(firstRun bool) error { + ctx := context.Get() + + DropMouseItem() + step.SetSkill(skill.Vigor) + RecoverCorpse() + ManageBelt() + // Just to make sure messages like TZ change or public game spam arent on the way + ClearMessages() + + if firstRun { + Stash(false) + } + + UpdateQuestLog() + + // Store items that need to be left unidentified + if HaveItemsToStashUnidentified() { + Stash(false) + } + + // Identify - either via Cain or Tome + IdentifyAll(false) + + // Stash before vendor + Stash(false) + + // Refill pots, sell, buy etc + VendorRefill(false, true) + + // Gamble + Gamble() + + // Stash again if needed + Stash(false) + + CubeRecipes() + + // Leveling related checks + if ctx.CharacterCfg.Game.Leveling.EnsurePointsAllocation { + ResetStats() + EnsureStatPoints() + EnsureSkillPoints() + } + + if ctx.CharacterCfg.Game.Leveling.EnsureKeyBinding { + EnsureSkillBindings() + } + + HealAtNPC() + ReviveMerc() + HireMerc() + + return Repair() +} + +func InRunReturnTownRoutine() error { + ctx := context.Get() + + if err := ReturnTown(); err != nil { + return fmt.Errorf("failed to return to town: %w", err) + } + + // Validate we're actually in town before proceeding + if !ctx.Data.PlayerUnit.Area.IsTown() { + return fmt.Errorf("failed to verify town location after portal") + } + + step.SetSkill(skill.Vigor) + RecoverCorpse() + ManageBelt() + + // Let's stash items that need to be left unidentified + if ctx.CharacterCfg.Game.UseCainIdentify && HaveItemsToStashUnidentified() { + Stash(false) + } + + IdentifyAll(false) + + VendorRefill(false, true) + Stash(false) + Gamble() + Stash(false) + CubeRecipes() + + if ctx.CharacterCfg.Game.Leveling.EnsurePointsAllocation { + EnsureStatPoints() + EnsureSkillPoints() + } + + if ctx.CharacterCfg.Game.Leveling.EnsureKeyBinding { + EnsureSkillBindings() + } + + HealAtNPC() + ReviveMerc() + HireMerc() + Repair() + + if (ctx.CharacterCfg.Companion.Leader) { + UsePortalInTown() + utils.Sleep(500) + return OpenTPIfLeader() + } + + return UsePortalInTown() +} diff --git a/internal/action/tp_actions.go b/internal/action/tp_actions.go index eb9d2ee72..754867827 100644 --- a/internal/action/tp_actions.go +++ b/internal/action/tp_actions.go @@ -1,45 +1,128 @@ package action import ( + "errors" + "fmt" + "time" + "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/object" "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/helper" + "github.com/hectorgimenez/koolo/internal/context" "github.com/hectorgimenez/koolo/internal/town" + "github.com/hectorgimenez/koolo/internal/utils" ) -func (b *Builder) ReturnTown() *StepChainAction { - return NewStepChain(func(d data.Data) (steps []step.Step) { - if d.PlayerUnit.Area.IsTown() { - return - } +func ReturnTown() error { + ctx := context.Get() + ctx.SetLastAction("ReturnTown") + ctx.PauseIfNotPriority() + + if ctx.Data.PlayerUnit.Area.IsTown() { + return nil + } + + err := step.OpenPortal() + if err != nil { + return err + } + portal, found := ctx.Data.Objects.FindOne(object.TownPortal) + if !found { + return errors.New("portal not found") + } + + if err = ClearAreaAroundPosition(portal.Position, 8, data.MonsterAnyFilter()); err != nil { + ctx.Logger.Warn("Error clearing area around portal", "error", err) + } + + // Now that it is safe, interact with portal + err = InteractObject(portal, func() bool { + return ctx.Data.PlayerUnit.Area.IsTown() + }) + if err != nil { + return err + } + + // Wait for area transition and data sync + utils.Sleep(1000) + ctx.RefreshGameData() - return []step.Step{ - step.OpenPortal(), - step.InteractObject(object.TownPortal, func(d data.Data) bool { - return d.PlayerUnit.Area.IsTown() - }), + // Wait for town area data to be fully loaded + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if ctx.Data.PlayerUnit.Area.IsTown() { + // Verify area data exists and is loaded + if townData, ok := ctx.Data.Areas[ctx.Data.PlayerUnit.Area]; ok { + if townData.IsInside(ctx.Data.PlayerUnit.Position) { + return nil + } + } } - }, Resettable()) + utils.Sleep(100) + ctx.RefreshGameData() + } + + return fmt.Errorf("failed to verify town area data after portal transition") } -func (b *Builder) UsePortalInTown() *Chain { - return NewChain(func(d data.Data) []Action { - if !d.PlayerUnit.Area.IsTown() { - return nil - } +func UsePortalInTown() error { + ctx := context.Get() + ctx.SetLastAction("UsePortalInTown") + + tpArea := town.GetTownByArea(ctx.Data.PlayerUnit.Area).TPWaitingArea(*ctx.Data) + _ = MoveToCoords(tpArea) - tpArea := town.GetTownByArea(d.PlayerUnit.Area).TPWaitingArea(d) - return []Action{ - b.MoveToCoords(tpArea), - b.InteractObject(object.TownPortal, func(d data.Data) bool { - if !d.PlayerUnit.Area.IsTown() { - helper.Sleep(500) + err := UsePortalFrom(ctx.Data.PlayerUnit.Name) + if err != nil { + return err + } + + // Wait for area sync before attempting any movement + utils.Sleep(500) + ctx.RefreshGameData() + if err := ensureAreaSync(ctx, ctx.Data.PlayerUnit.Area); err != nil { + return err + } + + // Ensure we're not in town + if ctx.Data.PlayerUnit.Area.IsTown() { + return fmt.Errorf("failed to leave town area") + } + + // Perform item pickup after re-entering the portal + err = ItemPickup(40) + if err != nil { + ctx.Logger.Warn("Error during item pickup after portal use", "error", err) + } + + return nil +} + +func UsePortalFrom(owner string) error { + ctx := context.Get() + ctx.SetLastAction("UsePortalFrom") + + if !ctx.Data.PlayerUnit.Area.IsTown() { + return nil + } + + for _, obj := range ctx.Data.Objects { + if obj.IsPortal() && obj.Owner == owner { + return InteractObjectByID(obj.ID, func() bool { + if !ctx.Data.PlayerUnit.Area.IsTown() { + // Ensure area data is synced after portal transition + utils.Sleep(500) + ctx.RefreshGameData() + + if err := ensureAreaSync(ctx, ctx.Data.PlayerUnit.Area); err != nil { + return false + } return true } - return false - }), + }) } - }) + } + + return errors.New("portal not found") } diff --git a/internal/action/vendor.go b/internal/action/vendor.go index 3df51721a..784db81dc 100644 --- a/internal/action/vendor.go +++ b/internal/action/vendor.go @@ -1,87 +1,84 @@ package action import ( - "time" + "log/slog" - "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/d2go/pkg/data/item" - "github.com/hectorgimenez/d2go/pkg/data/npc" "github.com/hectorgimenez/koolo/internal/action/step" + "github.com/hectorgimenez/koolo/internal/context" "github.com/hectorgimenez/koolo/internal/town" - "go.uber.org/zap" + "github.com/lxn/win" + + "github.com/hectorgimenez/d2go/pkg/data/item" + "github.com/hectorgimenez/d2go/pkg/data/npc" ) -func (b *Builder) VendorRefill(forceRefill, sellJunk bool) *Chain { - return NewChain(func(d data.Data) []Action { - if !forceRefill && !b.shouldVisitVendor(d) { - return nil - } +func VendorRefill(forceRefill, sellJunk bool) error { + ctx := context.Get() + ctx.SetLastAction("VendorRefill") - b.logger.Info("Visiting vendor...", zap.Bool("forceRefill", forceRefill)) + if !forceRefill && !shouldVisitVendor() { + return nil + } - openShopStep := step.KeySequence("home", "down", "enter") - vendorNPC := town.GetTownByArea(d.PlayerUnit.Area).RefillNPC() + ctx.Logger.Info("Visiting vendor...", slog.Bool("forceRefill", forceRefill)) - // Jamella trade button is the first one - if vendorNPC == npc.Jamella { - openShopStep = step.KeySequence("home", "enter") + vendorNPC := town.GetTownByArea(ctx.Data.PlayerUnit.Area).RefillNPC() + if vendorNPC == npc.Drognan { + _, needsBuy := town.ShouldBuyKeys() + if needsBuy { + vendorNPC = npc.Lysander } + } + err := InteractNPC(vendorNPC) + if err != nil { + return err + } - if vendorNPC == npc.Drognan { - if b.sm.ShouldBuyKeys(d) { - vendorNPC = npc.Lysander - } - } + // Jamella trade button is the first one + if vendorNPC == npc.Jamella { + ctx.HID.KeySequence(win.VK_HOME, win.VK_RETURN) + } else { + ctx.HID.KeySequence(win.VK_HOME, win.VK_DOWN, win.VK_RETURN) + } + + SwitchStashTab(4) + ctx.RefreshGameData() + town.BuyConsumables(forceRefill) - return []Action{b.InteractNPC(vendorNPC, - openShopStep, - step.Wait(time.Second), - step.SyncStep(func(d data.Data) error { - switchTab(4) - b.sm.BuyConsumables(d, forceRefill) - return nil - }), - step.SyncStep(func(d data.Data) error { - if sellJunk { - b.sm.SellJunk(d) - } - return nil - }), - step.Wait(time.Second), - step.KeySequence("esc"), - )} - }) + if sellJunk { + town.SellJunk() + } + + return step.CloseAllMenus() } -func (b *Builder) BuyAtVendor(vendor npc.ID, items ...VendorItemRequest) *Chain { - return NewChain(func(d data.Data) []Action { - openShopStep := step.KeySequence("home", "down", "enter") +func BuyAtVendor(vendor npc.ID, items ...VendorItemRequest) error { + ctx := context.Get() + ctx.SetLastAction("BuyAtVendor") + + err := InteractNPC(vendor) + if err != nil { + return err + } + + // Jamella trade button is the first one + if vendor == npc.Jamella { + ctx.HID.KeySequence(win.VK_HOME, win.VK_DOWN, win.VK_RETURN) + } else { + ctx.HID.KeySequence(win.VK_HOME, win.VK_DOWN, win.VK_RETURN) + } - // Jamella trade button is the first one - if vendor == npc.Jamella { - openShopStep = step.KeySequence("home", "enter") + for _, i := range items { + SwitchStashTab(i.Tab) + itm, found := ctx.Data.Inventory.Find(i.Item, item.LocationVendor) + if found { + town.BuyItem(itm, i.Quantity) + } else { + ctx.Logger.Warn("Item not found in vendor", slog.String("Item", string(i.Item))) } + } - return []Action{b.InteractNPC(vendor, - openShopStep, - step.Wait(time.Second), - step.SyncStep(func(d data.Data) error { - for _, i := range items { - switchTab(i.Tab) - itm, found := d.Items.Find(i.Item, item.LocationVendor) - if found { - b.sm.BuyItem(itm, i.Quantity) - } else { - b.logger.Warn("Item not found in vendor", zap.String("Item", string(i.Item))) - } - } - - return nil - }), - step.Wait(time.Second), - step.KeySequence("esc"), - )} - }) + return step.CloseAllMenus() } type VendorItemRequest struct { @@ -90,17 +87,20 @@ type VendorItemRequest struct { Tab int // At this point I have no idea how to detect the Tab the Item is in the vendor (1-4) } -func (b *Builder) shouldVisitVendor(d data.Data) bool { +func shouldVisitVendor() bool { + ctx := context.Get() + ctx.SetLastStep("shouldVisitVendor") + // Check if we should sell junk - if len(town.ItemsToBeSold(d)) > 0 { + if len(town.ItemsToBeSold()) > 0 { return true } // Skip the vendor if we don't have enough gold to do anything... this is not the optimal scenario, // but I have no idea how to check vendor Item prices. - if d.PlayerUnit.TotalGold() < 1000 { + if ctx.Data.PlayerUnit.TotalPlayerGold() < 1000 { return false } - return b.bm.ShouldBuyPotions(d) || b.sm.ShouldBuyTPs(d) || b.sm.ShouldBuyIDs(d) + return ctx.BeltManager.ShouldBuyPotions() || town.ShouldBuyTPs() || town.ShouldBuyIDs() } diff --git a/internal/action/wait.go b/internal/action/wait.go deleted file mode 100644 index e6071c08a..000000000 --- a/internal/action/wait.go +++ /dev/null @@ -1,16 +0,0 @@ -package action - -import ( - "time" - - "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/koolo/internal/action/step" -) - -func (b *Builder) Wait(duration time.Duration) *StepChainAction { - return NewStepChain(func(d data.Data) (steps []step.Step) { - return []step.Step{ - step.Wait(duration), - } - }) -} diff --git a/internal/action/waypoint.go b/internal/action/waypoint.go index 69ad76bfb..95d198f09 100644 --- a/internal/action/waypoint.go +++ b/internal/action/waypoint.go @@ -1,118 +1,133 @@ package action import ( - "github.com/hectorgimenez/d2go/pkg/data" + "fmt" + "log/slog" + "slices" + "github.com/hectorgimenez/d2go/pkg/data/area" - "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" - "go.uber.org/zap" - "gocv.io/x/gocv" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/ui" + "github.com/hectorgimenez/koolo/internal/utils" ) -const ( - wpTabStartX = 131 - wpTabStartY = 148 - wpTabSizeX = 57 - wpListPositionX = 200 - wpListStartY = 158 - wpAreaBtnHeight = 41 -) +func WayPoint(dest area.ID) error { + ctx := context.Get() + ctx.SetLastAction("WayPoint") -func (b *Builder) WayPoint(a area.Area) *Chain { - return NewChain(func(d data.Data) (actions []Action) { - // We don't need to move, we are already at destination area - if d.PlayerUnit.Area == a { - return nil + if !ctx.Data.PlayerUnit.Area.IsTown() { + if err := ReturnTown(); err != nil { + return err } + } - return []Action{ - b.openWPAndSelectTab(a, d), - b.useWP(a), - } - }) -} + if ctx.Data.PlayerUnit.Area == dest { + ctx.WaitForGameToLoad() + return nil + } -func (b *Builder) openWPAndSelectTab(a area.Area, d data.Data) Action { - wpCoords, found := area.WPAddresses[a] + wpCoords, found := area.WPAddresses[dest] if !found { - panic("Area destination is not mapped on WayPoint Action (waypoint.go)") + return fmt.Errorf("area destination %s is not mapped to a WayPoint (waypoint.go)", area.Areas[dest].Name) } - for _, o := range d.Objects { + for _, o := range ctx.Data.Objects { if o.IsWaypoint() { - return b.InteractObject(o.Name, func(d data.Data) bool { - return d.OpenMenus.Waypoint - }, - step.SyncStep(func(d data.Data) error { - actTabX := wpTabStartX + (wpCoords.Tab-1)*wpTabSizeX + (wpTabSizeX / 2) - - hid.MovePointer(actTabX, wpTabStartY) - helper.Sleep(200) - hid.Click(hid.LeftButton) - helper.Sleep(200) - - return nil - }), - ) + + err := InteractObject(o, func() bool { + return ctx.Data.OpenMenus.Waypoint + }) + if err != nil { + return err + } + if ctx.Data.LegacyGraphics { + actTabX := ui.WpTabStartXClassic + (wpCoords.Tab-1)*ui.WpTabSizeXClassic + (ui.WpTabSizeXClassic / 2) + ctx.HID.Click(game.LeftButton, actTabX, ui.WpTabStartYClassic) + } else { + actTabX := ui.WpTabStartX + (wpCoords.Tab-1)*ui.WpTabSizeX + (ui.WpTabSizeX / 2) + ctx.HID.Click(game.LeftButton, actTabX, ui.WpTabStartY) + } + utils.Sleep(200) + // Just to make sure no message like TZ change or public game spam prevent bot from clicking on waypoint + ClearMessages() } } + err := useWP(dest) + if err != nil { + return err + } + + // Wait for the game to load after using the waypoint + ctx.WaitForGameToLoad() + + // Verify that we've reached the destination + ctx.RefreshGameData() + if ctx.Data.PlayerUnit.Area != dest { + return fmt.Errorf("failed to reach destination area %s using waypoint", area.Areas[dest].Name) + } + return nil } +func useWP(dest area.ID) error { + ctx := context.Get() + ctx.SetLastAction("useWP") + + finalDestination := dest + traverseAreas := make([]area.ID, 0) + currentWP := area.WPAddresses[dest] + if !slices.Contains(ctx.Data.PlayerUnit.AvailableWaypoints, dest) { + for { + traverseAreas = append(currentWP.LinkedFrom, traverseAreas...) -func (b *Builder) useWP(a area.Area) *Chain { - return NewChain(func(d data.Data) (actions []Action) { - sc := helper.Screenshot() + if currentWP.LinkedFrom != nil { + dest = currentWP.LinkedFrom[0] + } - nextAvailableWP := area.WPAddresses[a] - traverseAreas := make([]area.Area, 0) - for { - //tm := b.tf.FindInArea("ui_discovered_wp", sc, wpTabStartX, wpListStartY+(wpAreaBtnHeight*(nextAvailableWP.Row-1)), wpTabStartX+60, wpListStartY+(wpAreaBtnHeight*nextAvailableWP.Row)) - tm := b.tf.MatchColorInArea(sc, gocv.NewScalar(3, 230, 230, 0), gocv.NewScalar(140, 255, 255, 0), wpTabStartX, wpListStartY+(wpAreaBtnHeight*(nextAvailableWP.Row-1)), wpTabStartX+9, wpListStartY+(wpAreaBtnHeight*nextAvailableWP.Row)) - if !tm.Found && nextAvailableWP.Row != 1 { - traverseAreas = append(nextAvailableWP.LinkedFrom, traverseAreas...) - nextAvailableWP = area.WPAddresses[nextAvailableWP.LinkedFrom[0]] - continue + currentWP = area.WPAddresses[currentWP.LinkedFrom[0]] + + if slices.Contains(ctx.Data.PlayerUnit.AvailableWaypoints, dest) { + break } - break } + } - // First use the previous available waypoint that we have discovered - actions = append(actions, NewStepChain(func(d data.Data) []step.Step { - return []step.Step{ - step.SyncStep(func(d data.Data) error { - areaBtnY := wpListStartY + (nextAvailableWP.Row-1)*wpAreaBtnHeight + (wpAreaBtnHeight / 2) - hid.MovePointer(wpListPositionX, areaBtnY) - helper.Sleep(200) - hid.Click(hid.LeftButton) - helper.Sleep(1000) - - return nil - }), - } - })) + currentWP = area.WPAddresses[dest] - traverseAreas = append(traverseAreas, a) + // First use the previous available waypoint that we have discovered + if ctx.Data.LegacyGraphics { + areaBtnY := ui.WpListStartYClassic + (currentWP.Row-1)*ui.WpAreaBtnHeightClassic + (ui.WpAreaBtnHeightClassic / 2) + ctx.HID.Click(game.LeftButton, ui.WpListPositionXClassic, areaBtnY) + } else { + areaBtnY := ui.WpListStartY + (currentWP.Row-1)*ui.WpAreaBtnHeight + (ui.WpAreaBtnHeight / 2) + ctx.HID.Click(game.LeftButton, ui.WpListPositionX, areaBtnY) + } + utils.Sleep(1000) + + // We have the WP discovered, just use it + if len(traverseAreas) == 0 { + return nil + } - // Next keep traversing all the areas from the previous available waypoint until we reach the destination, trying to discover WPs during the way - b.logger.Info("Traversing areas to reach destination", zap.Any("areas", traverseAreas)) + traverseAreas = append(traverseAreas, finalDestination) - for i, dst := range traverseAreas { - if !dst.IsTown() { - actions = append(actions, - b.Buff(), - ) + // Next keep traversing all the areas from the previous available waypoint until we reach the destination, trying to discover WPs during the way + ctx.Logger.Info("Traversing areas to reach destination", slog.Any("areas", traverseAreas)) + + for i, dst := range traverseAreas { + if i > 0 { + err := MoveToArea(dst) + if err != nil { + return err } - if i > 0 { - actions = append(actions, - b.MoveToArea(dst), - b.DiscoverWaypoint(), - ) + err = DiscoverWaypoint() + if err != nil { + return err } } + } - return actions - }) + return nil } diff --git a/internal/action/waypoint_discover.go b/internal/action/waypoint_discover.go index 1909ef2ec..b8ad33e6f 100644 --- a/internal/action/waypoint_discover.go +++ b/internal/action/waypoint_discover.go @@ -1,33 +1,31 @@ package action import ( - "github.com/hectorgimenez/d2go/pkg/data" + "log/slog" + "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" - "go.uber.org/zap" + "github.com/hectorgimenez/koolo/internal/context" ) -func (b *Builder) DiscoverWaypoint() *Chain { - return NewChain(func(d data.Data) []Action { - b.logger.Info("Trying to autodiscover Waypoint for current area", zap.Any("area", d.PlayerUnit.Area)) - for _, o := range d.Objects { - if o.IsWaypoint() { - return []Action{b.InteractObject(o.Name, - func(d data.Data) bool { - return d.OpenMenus.Waypoint - }, - step.SyncStep(func(d data.Data) error { - b.logger.Info("Waypoint discovered", zap.Any("area", d.PlayerUnit.Area)) - helper.Sleep(500) - hid.PressKey("esc") - return nil - }), - )} +func DiscoverWaypoint() error { + ctx := context.Get() + ctx.SetLastAction("DiscoverWaypoint") + + ctx.Logger.Info("Trying to autodiscover Waypoint for current area", slog.String("area", ctx.Data.PlayerUnit.Area.Area().Name)) + for _, o := range ctx.Data.Objects { + if o.IsWaypoint() { + err := InteractObject(o, func() bool { + return ctx.Data.OpenMenus.Waypoint + }) + if err != nil { + return err } + + ctx.Logger.Info("Waypoint discovered", slog.String("area", ctx.Data.PlayerUnit.Area.Area().Name)) + step.CloseAllMenus() } + } - b.logger.Info("Waypoint not found :(", zap.Any("area", d.PlayerUnit.Area)) - return nil - }) + ctx.Logger.Info("Waypoint not found :(", slog.String("area", ctx.Data.PlayerUnit.Area.Area().Name)) + return nil } diff --git a/internal/bot.go b/internal/bot.go deleted file mode 100644 index d086ab8f7..000000000 --- a/internal/bot.go +++ /dev/null @@ -1,189 +0,0 @@ -package koolo - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/hectorgimenez/koolo/internal/action" - "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/event" - "github.com/hectorgimenez/koolo/internal/event/stat" - "github.com/hectorgimenez/koolo/internal/health" - "github.com/hectorgimenez/koolo/internal/reader" - "github.com/hectorgimenez/koolo/internal/run" - "go.uber.org/zap" -) - -// Bot will be in charge of running the run loop: create games, traveling, killing bosses, repairing, picking... -type Bot struct { - logger *zap.Logger - hm health.Manager - ab *action.Builder - gr *reader.GameReader - paused bool -} - -func NewBot( - logger *zap.Logger, - hm health.Manager, - ab *action.Builder, - gr *reader.GameReader, -) *Bot { - return &Bot{ - logger: logger, - hm: hm, - ab: ab, - gr: gr, - } -} - -func (b *Bot) Run(ctx context.Context, firstRun bool, runs []run.Run) (err error) { - defer func() { - if r := recover(); r != nil { - err = fmt.Errorf("fatal error detected, Koolo will try to exit game and create a new one: %v", r) - } - }() - - gameStartedAt := time.Now() - loadingScreensDetected := 0 - - for k, r := range runs { - stat.StartRun(r.Name()) - runStart := time.Now() - b.logger.Info(fmt.Sprintf("Running: %s", r.Name())) - - actions := b.ab.PreRun(firstRun) - actions = append(actions, r.BuildActions()...) - actions = append(actions, b.postRunActions(k, runs)...) - - firstRun = false - running := true - loopTime := time.Now() - var buffAct *action.StepChainAction - for running { - select { - case <-ctx.Done(): - return context.Canceled - default: - if b.paused { - time.Sleep(time.Second) - continue - } - - // Throttle loop a bit, don't need to waste CPU - if time.Since(loopTime) < time.Millisecond*10 { - time.Sleep(time.Millisecond*10 - time.Since(loopTime)) - } - - d := b.gr.GetData(false) - - // Skip running stuff if loading screen is present - if d.OpenMenus.LoadingScreen { - if loadingScreensDetected == 15 { - b.logger.Debug("Loading screen detected, waiting until loading screen is gone") - } - loadingScreensDetected++ - continue - } - - if loadingScreensDetected >= 15 { - b.logger.Debug("Load completed, continuing execution") - } - loadingScreensDetected = 0 - - if err := b.hm.HandleHealthAndMana(d); err != nil { - return err - } - if err := b.maxGameLengthExceeded(gameStartedAt); err != nil { - return err - } - - // TODO: Maybe add some kind of "on every iteration action", something that can be executed/skipped on every iteration - if b.ab.IsRebuffRequired(d) && (buffAct == nil || buffAct.Steps == nil || buffAct.Steps[len(buffAct.Steps)-1].Status(d) == step.StatusCompleted) { - buffAct = b.ab.BuffIfRequired(d) - actions = append([]action.Action{buffAct}, actions...) - } - - for k, act := range actions { - err := act.NextStep(b.logger, d) - loopTime = time.Now() - if errors.Is(err, action.ErrNoMoreSteps) { - if len(actions)-1 == k { - b.logger.Info(fmt.Sprintf("Run %s finished, length: %0.2fs", r.Name(), time.Since(runStart).Seconds())) - stat.FinishCurrentRun(event.Kill) - running = false - } - continue - } - if errors.Is(err, action.ErrWillBeRetried) { - b.logger.Warn("error occurred, will be retried", zap.Error(err)) - break - } - if errors.Is(err, action.ErrCanBeSkipped) { - event.Events <- event.WithScreenshot(fmt.Sprintf("error occurred on action that can be skipped, game will continue: %s", err.Error())) - b.logger.Warn("error occurred on action that can be skipped, game will continue", zap.Error(err)) - act.Skip() - break - } - if errors.Is(err, action.ErrLogAndContinue) { - b.logger.Warn(err.Error()) - break - } - if err != nil { - stat.FinishCurrentRun(event.Error) - return err - } - break - } - } - } - } - - return nil -} - -func (b *Bot) maxGameLengthExceeded(startedAt time.Time) error { - if time.Since(startedAt).Seconds() > float64(config.Config.MaxGameLength) { - return fmt.Errorf( - "max game length reached, try to exit game: %0.2f", - time.Since(startedAt).Seconds(), - ) - } - - return nil -} - -func (b *Bot) postRunActions(currentRun int, runs []run.Run) []action.Action { - if config.Config.Companion.Enabled && !config.Config.Companion.Leader { - return []action.Action{} - } - - actions := []action.Action{ - b.ab.ClearAreaAroundPlayer(5), - b.ab.ItemPickup(true, -1), - } - - // Don't return town on last run - if currentRun != len(runs)-1 { - if config.Config.Game.ClearTPArea { - actions = append(actions, b.ab.ClearAreaAroundPlayer(5)) - actions = append(actions, b.ab.ItemPickup(false, -1)) - } - actions = append(actions, b.ab.ReturnTown()) - } - - return actions -} - -func (b *Bot) TogglePause() { - if b.paused { - b.logger.Info("Resuming...") - b.paused = false - } else { - b.logger.Info("Pausing...") - b.paused = true - } -} diff --git a/internal/bot/bot.go b/internal/bot/bot.go new file mode 100644 index 000000000..d6547f159 --- /dev/null +++ b/internal/bot/bot.go @@ -0,0 +1,250 @@ +package bot + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/koolo/internal/action" + botCtx "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/event" + "github.com/hectorgimenez/koolo/internal/health" + "github.com/hectorgimenez/koolo/internal/run" + "golang.org/x/sync/errgroup" +) + +type Bot struct { + ctx *botCtx.Context +} + +func NewBot(ctx *botCtx.Context) *Bot { + return &Bot{ + ctx: ctx, + } +} +func (b *Bot) Run(ctx context.Context, firstRun bool, runs []run.Run) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + g, ctx := errgroup.WithContext(ctx) + + gameStartedAt := time.Now() + b.ctx.SwitchPriority(botCtx.PriorityNormal) // Restore priority to normal, in case it was stopped in previous game + b.ctx.CurrentGame = botCtx.NewGameHelper() // Reset current game helper structure + + err := b.ctx.GameReader.FetchMapData() + if err != nil { + return err + } + + // Let's make sure we have updated game data also fully loaded before performing anything + b.ctx.WaitForGameToLoad() + + // Cleanup the current game helper structure + b.ctx.Cleanup() + + // Switch to legacy mode if configured + action.SwitchToLegacyMode() + b.ctx.RefreshGameData() + + // This routine is in charge of refreshing the game data and handling cancellation, will work in parallel with any other execution + g.Go(func() error { + b.ctx.AttachRoutine(botCtx.PriorityBackground) + ticker := time.NewTicker(100 * time.Millisecond) + for { + select { + case <-ctx.Done(): + cancel() + b.Stop() + return nil + case <-ticker.C: + if b.ctx.ExecutionPriority == botCtx.PriorityPause { + continue + } + b.ctx.RefreshGameData() + } + } + }) + + // This routine is in charge of handling the health/chicken of the bot, will work in parallel with any other execution + g.Go(func() error { + b.ctx.AttachRoutine(botCtx.PriorityBackground) + ticker := time.NewTicker(100 * time.Millisecond) + for { + select { + case <-ctx.Done(): + b.Stop() + return nil + case <-ticker.C: + if b.ctx.ExecutionPriority == botCtx.PriorityPause { + continue + } + err = b.ctx.HealthManager.HandleHealthAndMana() + if err != nil { + cancel() + b.Stop() + return err + } + if time.Since(gameStartedAt).Seconds() > float64(b.ctx.CharacterCfg.MaxGameLength) { + cancel() + b.Stop() + return fmt.Errorf( + "max game length reached, try to exit game: %0.2f", + time.Since(gameStartedAt).Seconds(), + ) + } + } + } + }) + // High priority loop, this will interrupt (pause) low priority loop + g.Go(func() error { + defer func() { + cancel() + b.Stop() + recover() + }() + + b.ctx.AttachRoutine(botCtx.PriorityHigh) + ticker := time.NewTicker(time.Millisecond * 100) + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + if b.ctx.ExecutionPriority == botCtx.PriorityPause { + continue + } + + // Sometimes when we switch areas, monsters are not loaded yet, and we don't properly detect the Merc + // let's add some small delay (just few ms) when this happens, and recheck the merc status + if b.ctx.CharacterCfg.BackToTown.MercDied && b.ctx.Data.MercHPPercent() <= 0 && b.ctx.CharacterCfg.Character.UseMerc { + time.Sleep(200 * time.Millisecond) + } + + // extra RefreshGameData not needed for Legacygraphics/Portraits since Background loop will automatically refresh after 100ms + if b.ctx.CharacterCfg.ClassicMode && !b.ctx.Data.LegacyGraphics { + // Toggle Legacy if enabled + action.SwitchToLegacyMode() + time.Sleep(150 * time.Millisecond) + } + // Hide merc/other players portraits if enabled + if b.ctx.CharacterCfg.HidePortraits && b.ctx.Data.OpenMenus.PortraitsShown { + action.HidePortraits() + time.Sleep(150 * time.Millisecond) + } + // Close chat if somehow was opened (prevention) + if b.ctx.Data.OpenMenus.ChatOpen { + b.ctx.HID.PressKey(b.ctx.Data.KeyBindings.Chat.Key1[0]) + time.Sleep(150 * time.Millisecond) + } + b.ctx.SwitchPriority(botCtx.PriorityHigh) + + // Area correction (only check if enabled) + if b.ctx.CurrentGame.AreaCorrection.Enabled { + if err = action.AreaCorrection(); err != nil { + b.ctx.Logger.Warn("Area correction failed", "error", err) + } + } + + // Perform item pickup if enabled + if b.ctx.CurrentGame.PickupItems { + action.ItemPickup(30) + } + action.BuffIfRequired() + + _, healingPotsFound := b.ctx.Data.Inventory.Belt.GetFirstPotion(data.HealingPotion) + _, manaPotsFound := b.ctx.Data.Inventory.Belt.GetFirstPotion(data.ManaPotion) + + // Check if we need to go back to town (no pots or merc died) + if (b.ctx.CharacterCfg.BackToTown.NoHpPotions && !healingPotsFound || + b.ctx.CharacterCfg.BackToTown.EquipmentBroken && action.IsEquipmentBroken() || + b.ctx.CharacterCfg.BackToTown.NoMpPotions && !manaPotsFound || + b.ctx.CharacterCfg.BackToTown.MercDied && b.ctx.Data.MercHPPercent() <= 0 && b.ctx.CharacterCfg.Character.UseMerc) && + !b.ctx.Data.PlayerUnit.Area.IsTown() { + + // Log the exact reason for going back to town + var reason string + if b.ctx.CharacterCfg.BackToTown.NoHpPotions && !healingPotsFound { + reason = "No healing potions found" + } else if b.ctx.CharacterCfg.BackToTown.EquipmentBroken && action.RepairRequired() { + reason = "Equipment broken" + } else if b.ctx.CharacterCfg.BackToTown.NoMpPotions && !manaPotsFound { + reason = "No mana potions found" + } else if b.ctx.CharacterCfg.BackToTown.MercDied && b.ctx.Data.MercHPPercent() <= 0 && b.ctx.CharacterCfg.Character.UseMerc { + reason = "Mercenary is dead" + } + + b.ctx.Logger.Info("Going back to town", "reason", reason) + + if err = action.InRunReturnTownRoutine(); err != nil { + b.ctx.Logger.Warn("Failed returning town.. will try again shortly", "error", err) + time.Sleep(500 * time.Millisecond) + } + } + b.ctx.SwitchPriority(botCtx.PriorityNormal) + } + } + }) + + // Low priority loop, this will keep executing main run scripts + g.Go(func() error { + defer func() { + cancel() + b.Stop() + recover() + }() + + b.ctx.AttachRoutine(botCtx.PriorityNormal) + for _, r := range runs { + select { + case <-ctx.Done(): + return nil + default: + event.Send(event.RunStarted(event.Text(b.ctx.Name, fmt.Sprintf("Starting run: %s", r.Name())), r.Name())) + err = action.PreRun(firstRun) + if err != nil { + return err + } + + firstRun = false + err = r.Run() + + var runFinishReason event.FinishReason + if err != nil { + switch { + case errors.Is(err, health.ErrChicken): + runFinishReason = event.FinishedChicken + case errors.Is(err, health.ErrMercChicken): + runFinishReason = event.FinishedMercChicken + case errors.Is(err, health.ErrDied): + runFinishReason = event.FinishedDied + default: + runFinishReason = event.FinishedError + } + } else { + runFinishReason = event.FinishedOK + } + + event.Send(event.RunFinished(event.Text(b.ctx.Name, fmt.Sprintf("Finished run: %s", r.Name())), r.Name(), runFinishReason)) + + if err != nil { + return err + } + + err = action.PostRun(r == runs[len(runs)-1]) + if err != nil { + return err + } + } + } + return nil + }) + + return g.Wait() +} + +func (b *Bot) Stop() { + b.ctx.SwitchPriority(botCtx.PriorityStop) + b.ctx.Detach() +} diff --git a/internal/bot/companion_supervisor.go b/internal/bot/companion_supervisor.go new file mode 100644 index 000000000..4726b0e2d --- /dev/null +++ b/internal/bot/companion_supervisor.go @@ -0,0 +1,122 @@ +package bot + +// TODO companion sueprvisor +// +//import ( +// "context" +// "errors" +// "fmt" +// "log/slog" +// "time" +// +// "github.com/hectorgimenez/koolo/internal/action" +// +// "github.com/hectorgimenez/koolo/internal/container" +// +// "github.com/hectorgimenez/koolo/internal/config" +// "github.com/hectorgimenez/koolo/internal/event" +// "github.com/hectorgimenez/koolo/internal/helper" +// "github.com/hectorgimenez/koolo/internal/run" +//) +// +//type CompanionSupervisor struct { +// *baseSupervisor +//} +// +//func NewCompanionSupervisor(name string, bot *Bot, runFactory *run.Factory, statsHandler *StatsHandler, c container.Container, pid uint32, hwnd uintptr) (*CompanionSupervisor, error) { +// bs, err := newBaseSupervisor(bot, runFactory, name, statsHandler, c) +// if err != nil { +// return nil, err +// } +// +// return &CompanionSupervisor{ +// baseSupervisor: bs, +// }, nil +//} +// +//// Start will return error if it can not be started, otherwise will always return nil +//func (s *CompanionSupervisor) Start() error { +// ctx, cancel := context.WithCancel(context.Background()) +// s.cancelFn = cancel +// +// err := s.ensureProcessIsRunningAndPrepare(ctx) +// if err != nil { +// return fmt.Errorf("error preparing game: %w", err) +// } +// +// gameCounter := 0 +// firstRun := true +// err = s.waitUntilCharacterSelectionScreen() +// if err != nil { +// return fmt.Errorf("error waiting for character selection screen: %w", err) +// } +// +// for { +// select { +// case <-ctx.Done(): +// return nil +// default: +// if s.c.CharacterCfg.Companion.Leader { +// time.Sleep(time.Second * 5) +// gameName, err := s.c.Manager.CreateOnlineGame(gameCounter) +// gameCounter++ // Sometimes game is created but error during join, so game name will be in use +// if err != nil { +// s.c.Logger.Error(fmt.Sprintf("Error creating new game: %s", err.Error())) +// continue +// } +// +// if config.Koolo.Discord.EnableGameCreatedMessages { +// event.Send(event.GameCreated(event.Text(s.name, "New game created: %s"), gameName, config.Characters[s.name].Companion.GamePassword)) +// } else { +// event.Send(event.GameCreated(event.Text(s.name, ""), gameName, config.Characters[s.name].Companion.GamePassword)) +// } +// err = s.startBot(ctx, s.runFactory.BuildRuns(), firstRun) +// if err != nil { +// return err +// } +// action.ResetBuffTime(s.Name()) +// firstRun = false +// } else { +// s.c.Logger.Debug("Waiting for new game to be created...") +// evt := s.c.EventListener.WaitForEvent(ctx) +// if gcEvent, ok := evt.(event.GameCreatedEvent); ok && gcEvent.Name != "" { +// err = s.c.Manager.JoinOnlineGame(gcEvent.Name, gcEvent.Password) +// if err != nil { +// s.c.Logger.Error(err.Error()) +// continue +// } +// +// runs := s.runFactory.BuildRuns() +// err = s.startBot(ctx, runs, firstRun) +// firstRun = false +// if err != nil { +// return err +// } +// action.ResetBuffTime(s.Name()) +// } +// } +// } +// } +//} +// +//func (s *CompanionSupervisor) startBot(ctx context.Context, runs []run.Run, firstRun bool) error { +// gameStart := time.Now() +// s.logGameStart(runs) +// err := s.bot.Run(ctx, firstRun, runs) +// if err != nil { +// if errors.Is(context.Canceled, ctx.Err()) { +// return nil +// } +// errorMsg := fmt.Sprintf("Game finished with errors, reason: %s. Game total time: %0.2fs", err.Error(), time.Since(gameStart).Seconds()) +// event.Send(event.GameFinished(event.WithScreenshot(s.name, errorMsg, s.c.Reader.Screenshot()), event.FinishedError)) +// s.c.Logger.Warn(errorMsg, slog.String("supervisor", s.name)) +// } +// if exitErr := s.c.Manager.ExitGame(); exitErr != nil { +// return fmt.Errorf("error exiting game: %s", exitErr) +// } +// firstRun = false +// +// helper.Sleep(5000) +// +// return nil +//} diff --git a/internal/bot/manager.go b/internal/bot/manager.go new file mode 100644 index 000000000..fd2230b8e --- /dev/null +++ b/internal/bot/manager.go @@ -0,0 +1,389 @@ +package bot + +import ( + "fmt" + "log/slog" + "strconv" + "syscall" + "time" + "unsafe" + + "github.com/hectorgimenez/koolo/cmd/koolo/log" + "github.com/hectorgimenez/koolo/internal/character" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/event" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/health" + "github.com/hectorgimenez/koolo/internal/pather" + "github.com/hectorgimenez/koolo/internal/utils" + "github.com/hectorgimenez/koolo/internal/utils/winproc" + "github.com/lxn/win" +) + +type SupervisorManager struct { + logger *slog.Logger + supervisors map[string]Supervisor + crashDetectors map[string]*game.CrashDetector + eventListener *event.Listener +} + +func NewSupervisorManager(logger *slog.Logger, eventListener *event.Listener) *SupervisorManager { + return &SupervisorManager{ + logger: logger, + supervisors: make(map[string]Supervisor), + crashDetectors: make(map[string]*game.CrashDetector), + eventListener: eventListener, + } +} + +func (mng *SupervisorManager) AvailableSupervisors() []string { + availableSupervisors := make([]string, 0) + for name := range config.Characters { + if name != "template" { + availableSupervisors = append(availableSupervisors, name) + } + } + + return availableSupervisors +} + +func (mng *SupervisorManager) Start(supervisorName string, attachToExisting bool, pidHwnd ...uint32) error { + // Avoid multiple instances of the supervisor - shitstorm prevention + if _, exists := mng.supervisors[supervisorName]; exists { + return fmt.Errorf("supervisor %s is already running", supervisorName) + } + + // Reload config to get the latest local changes before starting the supervisor + err := config.Load() + if err != nil { + return fmt.Errorf("error loading config: %w", err) + } + + supervisorLogger, err := log.NewLogger(config.Koolo.Debug.Log, config.Koolo.LogSaveDirectory, supervisorName) + if err != nil { + return err + } + + var optionalPID uint32 + var optionalHWND win.HWND + + if attachToExisting { + if len(pidHwnd) == 2 { + mng.logger.Info("Attaching to existing game", "pid", pidHwnd[0], "hwnd", pidHwnd[1]) + optionalPID = pidHwnd[0] + optionalHWND = win.HWND(pidHwnd[1]) + } else { + return fmt.Errorf("pid and hwnd are required when attaching to an existing game") + } + } + + supervisor, crashDetector, err := mng.buildSupervisor(supervisorName, supervisorLogger, attachToExisting, optionalPID, optionalHWND) + if err != nil { + return err + } + + if oldCrashDetector, exists := mng.crashDetectors[supervisorName]; exists { + oldCrashDetector.Stop() // Stop the old crash detector if it exists + } + + mng.supervisors[supervisorName] = supervisor + mng.crashDetectors[supervisorName] = crashDetector + + if config.Koolo.GameWindowArrangement { + go func() { + // When the game starts, its doing some weird stuff like repositioning and resizing window automatically + // we need to wait until this is done in order to reposition, or it will be overridden + time.Sleep(time.Second * 5) + mng.rearrangeWindows() + }() + } + + // Start the Crash Detector in a thread to avoid blocking and speed up start + go crashDetector.Start() + + err = supervisor.Start() + if err != nil { + mng.logger.Error(fmt.Sprintf("error running supervisor %s: %s", supervisorName, err.Error())) + } + + return nil +} + +func (mng *SupervisorManager) ReloadConfig() error { + + // Load fresh configs + if err := config.Load(); err != nil { + return err + } + + // Apply new configs to running supervisors + for name, sup := range mng.supervisors { + newCfg, exists := config.Characters[name] + if !exists { + continue + } + + ctx := sup.GetContext() + if ctx == nil { + continue + } + + // Preserve runtime data + //oldRuntimeData := ctx.CharacterCfg.Runtime + + // Update the config + *ctx.CharacterCfg = *newCfg + //ctx.CharacterCfg.Runtime = oldRuntimeData + } + + return nil +} + +func (mng *SupervisorManager) StopAll() { + for _, s := range mng.supervisors { + s.Stop() + } +} + +func (mng *SupervisorManager) Stop(supervisor string) { + + s, found := mng.supervisors[supervisor] + if found { + + // Stop the Supervisor + s.Stop() + + // Delete him from the list of Supervisors + delete(mng.supervisors, supervisor) + + if cd, ok := mng.crashDetectors[supervisor]; ok { + cd.Stop() + delete(mng.crashDetectors, supervisor) + } + } +} + +func (mng *SupervisorManager) TogglePause(supervisor string) { + s, found := mng.supervisors[supervisor] + if found { + s.TogglePause() + } +} + +func (mng *SupervisorManager) Status(characterName string) Stats { + for name, supervisor := range mng.supervisors { + if name == characterName { + return supervisor.Stats() + } + } + + return Stats{} +} + +func (mng *SupervisorManager) GetData(characterName string) *game.Data { + for name, supervisor := range mng.supervisors { + if name == characterName { + return supervisor.GetData() + } + } + + return nil +} + +func (mng *SupervisorManager) GetContext(characterName string) *context.Context { + for name, supervisor := range mng.supervisors { + if name == characterName { + return supervisor.GetContext() + } + } + + return nil +} + +func (mng *SupervisorManager) buildSupervisor(supervisorName string, logger *slog.Logger, attach bool, optionalPID uint32, optionalHWND win.HWND) (Supervisor, *game.CrashDetector, error) { + cfg, found := config.Characters[supervisorName] + if !found { + return nil, nil, fmt.Errorf("character %s not found", supervisorName) + } + + var pid uint32 + var hwnd win.HWND + + if attach { + if optionalPID != 0 && optionalHWND != 0 { + pid = optionalPID + hwnd = optionalHWND + } else { + return nil, nil, fmt.Errorf("pid and hwnd are required when attaching to an existing game") + } + } else { + var err error + pid, hwnd, err = game.StartGame(cfg.Username, cfg.Password, cfg.AuthMethod, cfg.AuthToken, cfg.Realm, cfg.CommandLineArgs, config.Koolo.UseCustomSettings) + if err != nil { + return nil, nil, fmt.Errorf("error starting game: %w", err) + } + } + + gr, err := game.NewGameReader(cfg, supervisorName, pid, hwnd, logger) + if err != nil { + return nil, nil, fmt.Errorf("error creating game reader: %w", err) + } + + gi, err := game.InjectorInit(logger, gr.GetPID()) + if err != nil { + return nil, nil, fmt.Errorf("error creating game injector: %w", err) + } + + ctx := context.NewContext(supervisorName) + + hidM := game.NewHID(gr, gi) + pf := pather.NewPathFinder(gr, ctx.Data, hidM, cfg) + + bm := health.NewBeltManager(ctx.Data, hidM, logger, supervisorName) + hm := health.NewHealthManager(bm, ctx.Data) + + ctx.CharacterCfg = cfg + ctx.EventListener = mng.eventListener + ctx.HID = hidM + ctx.Logger = logger + ctx.Manager = game.NewGameManager(gr, hidM, supervisorName) + ctx.GameReader = gr + ctx.MemoryInjector = gi + ctx.PathFinder = pf + ctx.BeltManager = bm + ctx.HealthManager = hm + char, err := character.BuildCharacter(ctx.Context) + if err != nil { + return nil, nil, fmt.Errorf("error creating character: %w", err) + } + ctx.Char = char + + bot := NewBot(ctx.Context) + + statsHandler := NewStatsHandler(supervisorName, logger) + mng.eventListener.Register(statsHandler.Handle) + + var supervisor Supervisor + + supervisor, err = NewSinglePlayerSupervisor(supervisorName, bot, statsHandler) + + if err != nil { + return nil, nil, err + + } + + // This function will be used to restart the client - passed to the crashDetector + restartFunc := func() { + mng.logger.Info("Restarting supervisor after crash", slog.String("supervisor", supervisorName)) + mng.Stop(supervisorName) + time.Sleep(5 * time.Second) // Wait a bit before restarting + + // Get a list of all available Supervisors + supervisorList := mng.AvailableSupervisors() + + for { + + // Set the default state + tokenAuthStarting := false + + // Get the current supervisor's config + supCfg := config.Characters[supervisorName] + + for _, sup := range supervisorList { + + // If the current don't check against the one we're trying to launch + if sup == supervisorName { + continue + } + + if mng.GetSupervisorStats(sup).SupervisorStatus == Starting { + if supCfg.AuthMethod == "TokenAuth" { + tokenAuthStarting = true + mng.logger.Info("Waiting before restart as another client is already starting and we're using token auth", slog.String("supervisor", sup)) + break + } + + sCfg, found := config.Characters[sup] + if found { + if sCfg.AuthMethod == "TokenAuth" { + // A client that uses token auth is currently starting, hold off restart + tokenAuthStarting = true + mng.logger.Info("Waiting before restart as a client that's using token auth is already starting", slog.String("supervisor", sup)) + break + } + } + } + } + + if !tokenAuthStarting { + break + } + + // Wait 5 seconds before checking again + utils.Sleep(5000) + } + + gameTitle := "D2R - [" + strconv.FormatInt(int64(pid), 10) + "] - " + supervisorName + " - " + cfg.Realm + winproc.SetWindowText.Call(uintptr(hwnd), uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(gameTitle)))) + + err := mng.Start(supervisorName, false) + if err != nil { + mng.logger.Error("Failed to restart supervisor", slog.String("supervisor", supervisorName), slog.String("Error: ", err.Error())) + } + } + + gameTitle := "D2R - [" + strconv.FormatInt(int64(pid), 10) + "] - " + supervisorName + " - " + cfg.Realm + winproc.SetWindowText.Call(uintptr(hwnd), uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(gameTitle)))) + crashDetector := game.NewCrashDetector(supervisorName, int32(pid), uintptr(hwnd), mng.logger, restartFunc) + + return supervisor, crashDetector, nil +} + +func (mng *SupervisorManager) GetSupervisorStats(supervisor string) Stats { + if mng.supervisors[supervisor] == nil { + return Stats{} + } + return mng.supervisors[supervisor].Stats() +} + +func (mng *SupervisorManager) rearrangeWindows() { + width := win.GetSystemMetrics(0) + height := win.GetSystemMetrics(1) + var windowBorderX int32 = 2 // left + right window border is 2px + var windowBorderY int32 = 40 // upper window border is usually 40px + var windowOffsetX int32 = -10 // offset horizontal window placement by -10 pixel + maxColumns := width / (1280 + windowBorderX) + maxRows := height / (720 + windowBorderY) + + mng.logger.Debug( + "Arranging windows", + slog.String("displaywidth", strconv.FormatInt(int64(width), 10)), + slog.String("displayheight", strconv.FormatInt(int64(height), 10)), + slog.String("max columns", strconv.FormatInt(int64(maxColumns+1), 10)), // +1 as we are counting from 0 + slog.String("max rows", strconv.FormatInt(int64(maxRows+1), 10)), + ) + + var column, row int32 + for _, sp := range mng.supervisors { + // reminder that columns are vertical (they go up and down) and rows are horizontal (they go left and right) + if column > maxColumns { + column = 0 + row++ + } + + if row <= maxRows { + sp.SetWindowPosition(int(column*(1280+windowBorderX)+windowOffsetX), int(row*(720+windowBorderY))) + mng.logger.Debug( + "Window Positions", + slog.String("supervisor", sp.Name()), + slog.String("column", strconv.FormatInt(int64(column), 10)), + slog.String("row", strconv.FormatInt(int64(row), 10)), + slog.String("position", strconv.FormatInt(int64(column*(1280+windowBorderX)+windowOffsetX), 10)+"x"+strconv.FormatInt(int64(row*(720+windowBorderY)), 10)), + ) + column++ + } else { + mng.logger.Debug("Window position of supervisor " + sp.Name() + " was not changed, no free space for it") + } + } +} diff --git a/internal/bot/scheduler.go b/internal/bot/scheduler.go new file mode 100644 index 000000000..6d906d815 --- /dev/null +++ b/internal/bot/scheduler.go @@ -0,0 +1,105 @@ +package bot + +import ( + "log/slog" + "time" + + "github.com/hectorgimenez/koolo/internal/config" +) + +type Scheduler struct { + manager *SupervisorManager + logger *slog.Logger + stop chan struct{} +} + +func NewScheduler(manager *SupervisorManager, logger *slog.Logger) *Scheduler { + return &Scheduler{ + manager: manager, + logger: logger, + stop: make(chan struct{}), + } +} + +func (s *Scheduler) Start() { + s.logger.Info("Scheduler started") + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + s.checkSchedules() + case <-s.stop: + s.logger.Info("Scheduler stopped") + return + } + } +} + +func (s *Scheduler) Stop() { + close(s.stop) +} + +func (s *Scheduler) checkSchedules() { + now := time.Now() + currentDay := int(now.Weekday()) + + for supervisorName, cfg := range config.Characters { + if !cfg.Scheduler.Enabled { + continue + } + + for _, day := range cfg.Scheduler.Days { + if day.DayOfWeek != currentDay { + continue + } + + actionTaken := false + + // Check if any time range is active + for _, timeRange := range day.TimeRanges { + start := time.Date(now.Year(), now.Month(), now.Day(), timeRange.Start.Hour(), timeRange.Start.Minute(), 0, 0, now.Location()) + end := time.Date(now.Year(), now.Month(), now.Day(), timeRange.End.Hour(), timeRange.End.Minute(), 0, 0, now.Location()) + + if now.After(start) && now.Before(end) && s.supervisorNotStarted(supervisorName) { + s.logger.Info("Starting supervisor based on schedule. Time range: "+start.Format("15:04")+" - "+end.Format("15:04"), "supervisor", supervisorName) + go s.startSupervisor(supervisorName) + actionTaken = true + break + } else if now.After(end) || now == end || now.Before(start) && !s.supervisorNotStarted(supervisorName) { + s.logger.Info("Stopping supervisor based on schedule. Time range: "+start.Format("15:04")+" - "+end.Format("15:04"), "supervisor", supervisorName) + s.stopSupervisor(supervisorName) + actionTaken = true + break + } + } + + // To prevent unnecessary checks since it can be only one day. + // Also avoid starting/stoping a supervisor in the same minute ... this is rediculous + if actionTaken { + break + } + } + } +} + +func (s *Scheduler) supervisorNotStarted(name string) bool { + stats := s.manager.GetSupervisorStats(name) + return stats.SupervisorStatus == NotStarted || stats.SupervisorStatus == Crashed || stats.SupervisorStatus == "" +} + +func (s *Scheduler) startSupervisor(name string) { + if s.supervisorNotStarted(name) { + err := s.manager.Start(name, false) + if err != nil { + s.logger.Error("Failed to start supervisor", "supervisor", name, "error", err) + } + } +} + +func (s *Scheduler) stopSupervisor(name string) { + if !s.supervisorNotStarted(name) { + s.manager.Stop(name) + } +} diff --git a/internal/bot/single_supervisor.go b/internal/bot/single_supervisor.go new file mode 100644 index 000000000..b76030cda --- /dev/null +++ b/internal/bot/single_supervisor.go @@ -0,0 +1,289 @@ +package bot + +import ( + "context" + "errors" + "fmt" + "log/slog" + "math/rand" + "time" + + "github.com/hectorgimenez/d2go/pkg/data/skill" + "github.com/hectorgimenez/koolo/internal/config" + ct "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/event" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/health" + "github.com/hectorgimenez/koolo/internal/run" + "github.com/hectorgimenez/koolo/internal/utils" +) + +type SinglePlayerSupervisor struct { + *baseSupervisor +} + +func (s *SinglePlayerSupervisor) GetData() *game.Data { + return s.bot.ctx.Data +} + +func (s *SinglePlayerSupervisor) GetContext() *ct.Context { + return s.bot.ctx +} + +func NewSinglePlayerSupervisor(name string, bot *Bot, statsHandler *StatsHandler) (*SinglePlayerSupervisor, error) { + bs, err := newBaseSupervisor(bot, name, statsHandler) + if err != nil { + return nil, err + } + + return &SinglePlayerSupervisor{ + baseSupervisor: bs, + }, nil +} + +// Start will return error if it can not be started, otherwise will always return nil +func (s *SinglePlayerSupervisor) Start() error { + ctx, cancel := context.WithCancel(context.Background()) + s.cancelFn = cancel + + err := s.ensureProcessIsRunningAndPrepare() + if err != nil { + return fmt.Errorf("error preparing game: %w", err) + } + + firstRun := true + for { + select { + case <-ctx.Done(): + return nil + default: + if firstRun { + err = s.waitUntilCharacterSelectionScreen() + if err != nil { + return fmt.Errorf("error waiting for character selection screen: %w", err) + } + } + + // By this point, we should be in the character selection screen. + if !s.bot.ctx.Manager.InGame() { + // Create the game + if err = s.HandleOutOfGameFlow(); err != nil { + // Ignore loading screen errors or unhandled errors (for now) and try again + if err.Error() == "loading screen" || err.Error() == "" { + utils.Sleep(100) + continue + } + + s.bot.ctx.Logger.Error(fmt.Sprintf("Error creating new game: %s", err.Error())) + continue + } + } + + runs := run.BuildRuns(s.bot.ctx.CharacterCfg) + gameStart := time.Now() + if config.Characters[s.name].Game.RandomizeRuns { + rand.Shuffle(len(runs), func(i, j int) { runs[i], runs[j] = runs[j], runs[i] }) + } + event.Send(event.GameCreated(event.Text(s.name, "New game created"), s.bot.ctx.GameReader.LastGameName(), s.bot.ctx.GameReader.LastGamePass())) + s.bot.ctx.LastBuffAt = time.Time{} + s.logGameStart(runs) + + // Refresh game data to make sure we have the latest information + s.bot.ctx.RefreshGameData() + + // Perform keybindings check on the first run only + if firstRun { + missingKeybindings := s.bot.ctx.Char.CheckKeyBindings() + if len(missingKeybindings) > 0 { + var missingKeybindingsText = "Missing key binding for skill(s):" + for _, v := range missingKeybindings { + missingKeybindingsText += fmt.Sprintf("\n%s", skill.SkillNames[v]) + } + missingKeybindingsText += "\nPlease bind the skills. Pausing bot..." + + utils.ShowDialog("Missing keybindings for "+s.bot.ctx.Name, missingKeybindingsText) + s.TogglePause() + } + } + + err = s.bot.Run(ctx, firstRun, runs) + firstRun = false + + var gameFinishReason event.FinishReason + if err != nil { + switch { + case errors.Is(err, health.ErrChicken): + gameFinishReason = event.FinishedChicken + case errors.Is(err, health.ErrMercChicken): + gameFinishReason = event.FinishedMercChicken + case errors.Is(err, health.ErrDied): + gameFinishReason = event.FinishedDied + default: + gameFinishReason = event.FinishedError + } + event.Send(event.GameFinished(event.WithScreenshot(s.name, err.Error(), s.bot.ctx.GameReader.Screenshot()), gameFinishReason)) + s.bot.ctx.Logger.Warn( + fmt.Sprintf("Game finished with errors, reason: %s. Game total time: %0.2fs", err.Error(), time.Since(gameStart).Seconds()), + slog.String("supervisor", s.name), + slog.Uint64("mapSeed", uint64(s.bot.ctx.GameReader.MapSeed())), + ) + } else { + gameFinishReason = event.FinishedOK + event.Send(event.GameFinished(event.Text(s.name, "Game finished successfully"), gameFinishReason)) + } + + if exitErr := s.bot.ctx.Manager.ExitGame(); exitErr != nil { + errMsg := fmt.Sprintf("Error exiting game %s", exitErr.Error()) + event.Send(event.GameFinished(event.WithScreenshot(s.name, errMsg, s.bot.ctx.GameReader.Screenshot()), event.FinishedError)) + return errors.New(errMsg) + } + } + } +} + +// This function is responsible for handling all interactions with joining/creating games +func (s *SinglePlayerSupervisor) HandleOutOfGameFlow() error { + // Refresh the data + s.bot.ctx.RefreshGameData() + + // Catch-all + if s.bot.ctx.CharacterCfg.AuthMethod != "None" && s.bot.ctx.GameReader.IsInCharacterSelectionScreen() && !s.bot.ctx.GameReader.IsOnline() { + + // Try and click the online tab to re-connect to bnet + s.bot.ctx.HID.Click(game.LeftButton, 1090, 32) // click the online button + + // Wait a bit + utils.Sleep(4000) + + // Check if we're online again, if not, kill the client + if !s.bot.ctx.GameReader.IsOnline() { + + // Kill the client so the crash detector will restart it + if err := s.KillClient(); err != nil { + return err + } + + return fmt.Errorf("we've lost connection to bnet or client glitched. The d2r process will be killed") + } + } + + // We're either in the in the Lobby or Character selection screen. Let's check + if s.bot.ctx.GameReader.IsInCharacterSelectionScreen() { + // TODO: Add Joining Games + + if s.bot.ctx.CharacterCfg.Game.CreateLobbyGames { + retryCount := 0 + for !s.bot.ctx.GameReader.IsInLobby() { + + // Prevent an infinite loop + if retryCount >= 5 && !s.bot.ctx.GameReader.IsInLobby() { + return fmt.Errorf("failed to enter bnet lobby after 5 retries") + } + + // Try to enter bnet lobby + s.bot.ctx.HID.Click(game.LeftButton, 744, 650) + utils.Sleep(1000) + } + + if _, err := s.bot.ctx.Manager.CreateOnlineGame(s.bot.ctx.CharacterCfg.Game.PublicGameCounter); err != nil { + s.bot.ctx.CharacterCfg.Game.PublicGameCounter++ + return fmt.Errorf("failed to create an online game") + + } else { + // We created the game successfully! + s.bot.ctx.CharacterCfg.Game.PublicGameCounter++ + return nil + } + } else { + // TODO: Add logic to check if we're on the online or offline tab and handle it accordingly. + if !s.bot.ctx.GameReader.IsOnline() && s.bot.ctx.CharacterCfg.AuthMethod != "None" { + + // Try and click the online tab to re-connect to bnet + s.bot.ctx.HID.Click(game.LeftButton, 1090, 32) // click the online button + + // Wait a bit + utils.Sleep(4000) + + // Check again + if !s.bot.ctx.GameReader.IsOnline() { + // We failed to re-connect. Kill the client so it will get re-started automatically. + if err := s.KillClient(); err != nil { + return err + } + return fmt.Errorf("lost connection to bnet, killing client") + } + } + + // Create the game + if err := s.bot.ctx.Manager.NewGame(); err != nil { + return fmt.Errorf("failed to create game") + } + + return nil + } + } else if s.bot.ctx.GameReader.IsInLobby() { + // TODO: Add Joining Games + + // Check if we are suppose to create lobby games and enter lobby. + if s.bot.ctx.CharacterCfg.Game.CreateLobbyGames { + + retryCount := 0 + for !s.bot.ctx.GameReader.IsInLobby() { + + // Prevent an infinite loop + if retryCount >= 5 && !s.bot.ctx.GameReader.IsInLobby() { + return fmt.Errorf("failed to enter bnet lobby after 5 retries") + } + + // Try to enter bnet lobby + s.bot.ctx.HID.Click(game.LeftButton, 744, 650) + utils.Sleep(1000) + } + + if _, err := s.bot.ctx.Manager.CreateOnlineGame(s.bot.ctx.CharacterCfg.Game.PublicGameCounter); err != nil { + s.bot.ctx.CharacterCfg.Game.PublicGameCounter++ + return fmt.Errorf("failed to create an online game") + + } else { + // We created the game successfully! + s.bot.ctx.CharacterCfg.Game.PublicGameCounter++ + return nil + } + } else { + // Press escape to exit the lobby + s.bot.ctx.HID.PressKey(0x1B) // ESC - to avoid importing win here as well + utils.Sleep(1000) + + for range 5 { + if s.bot.ctx.GameReader.IsInCharacterSelectionScreen() && s.bot.ctx.GameReader.IsOnline() { + break + } + + if s.bot.ctx.GameReader.IsInLobby() { + // Mission failed + s.bot.ctx.HID.PressKey(0x1B) // ESC - to avoid importing win here as well + utils.Sleep(1000) + } + } + + if !s.bot.ctx.GameReader.IsInCharacterSelectionScreen() { + return fmt.Errorf("failed to leave lobby or an unknown case occurred") + } + + // Create the game + if err := s.bot.ctx.Manager.NewGame(); err != nil { + return fmt.Errorf("failed to create game") + } + } + } else if s.bot.ctx.Data.OpenMenus.LoadingScreen { + // We're in a loading screen, wait a bit + utils.Sleep(250) + return fmt.Errorf("loading screen") + } else { + return fmt.Errorf("") + } + + // TODO: Maybe expand this with functionality to create new characters if the currently configured char isn't found? :) + + return nil +} diff --git a/internal/bot/stats.go b/internal/bot/stats.go new file mode 100644 index 000000000..2815f1a41 --- /dev/null +++ b/internal/bot/stats.go @@ -0,0 +1,149 @@ +package bot + +import ( + "context" + "log/slog" + "strings" + "time" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/koolo/internal/event" +) + +const ( + NotStarted SupervisorStatus = "Not Started" + Starting SupervisorStatus = "Starting" + InGame SupervisorStatus = "In game" + Paused SupervisorStatus = "Paused" + Crashed SupervisorStatus = "Crashed" +) + +type SupervisorStatus string + +type StatsHandler struct { + stats *Stats + name string + logger *slog.Logger +} + +func NewStatsHandler(name string, logger *slog.Logger) *StatsHandler { + return &StatsHandler{ + name: name, + logger: logger, + stats: &Stats{ + SupervisorStatus: Starting, + StartedAt: time.Now(), + }, + } +} + +func (h *StatsHandler) Handle(_ context.Context, e event.Event) error { + // Only handle events from the supervisor + if !strings.EqualFold(e.Supervisor(), h.name) { + return nil + } + + switch evt := e.(type) { + case event.GameCreatedEvent: + h.stats.Games = append(h.stats.Games, GameStats{ + StartedAt: evt.OccurredAt(), + }) + h.stats.SupervisorStatus = InGame + + case event.GameFinishedEvent: + if len(h.stats.Games) > 0 { + h.stats.Games[len(h.stats.Games)-1].FinishedAt = evt.OccurredAt() + h.stats.Games[len(h.stats.Games)-1].Reason = evt.Reason + } + + case event.RunStartedEvent: + if len(h.stats.Games) > 0 { + h.stats.Games[len(h.stats.Games)-1].Runs = append(h.stats.Games[len(h.stats.Games)-1].Runs, RunStats{ + Name: evt.RunName, + StartedAt: evt.OccurredAt(), + }) + } + + case event.RunFinishedEvent: + if len(h.stats.Games) > 0 && len(h.stats.Games[len(h.stats.Games)-1].Runs) > 0 { + lastRun := &h.stats.Games[len(h.stats.Games)-1].Runs[len(h.stats.Games[len(h.stats.Games)-1].Runs)-1] + lastRun.FinishedAt = evt.OccurredAt() + lastRun.Reason = evt.Reason + } + + case event.GamePausedEvent: + if evt.Paused { + h.stats.SupervisorStatus = Paused + } else { + h.stats.SupervisorStatus = InGame + } + + case event.ItemStashedEvent: + h.stats.Drops = append(h.stats.Drops, evt.Item) + + case event.UsedPotionEvent: + if len(h.stats.Games) > 0 && len(h.stats.Games[len(h.stats.Games)-1].Runs) > 0 { + lastRun := &h.stats.Games[len(h.stats.Games)-1].Runs[len(h.stats.Games[len(h.stats.Games)-1].Runs)-1] + lastRun.UsedPotions = append(lastRun.UsedPotions, evt) + } + } + + return nil +} + +func (h *StatsHandler) Stats() Stats { + return *h.stats +} + +type Stats struct { + StartedAt time.Time + SupervisorStatus SupervisorStatus + Details string + Drops []data.Drop + Games []GameStats +} + +type GameStats struct { + StartedAt time.Time + FinishedAt time.Time + Reason event.FinishReason + Runs []RunStats +} + +type RunStats struct { + Name string + Reason event.FinishReason + StartedAt time.Time + Items []data.Item + FinishedAt time.Time + UsedPotions []event.UsedPotionEvent +} + +func (s Stats) TotalGames() int { + return len(s.Games) +} + +func (s Stats) TotalDeaths() int { + return s.totalRunsByReason(event.FinishedDied) +} + +func (s Stats) TotalChickens() int { + return s.totalRunsByReason(event.FinishedChicken) + s.totalRunsByReason(event.FinishedMercChicken) +} + +func (s Stats) TotalErrors() int { + return s.totalRunsByReason(event.FinishedError) +} + +func (s Stats) totalRunsByReason(reason event.FinishReason) int { + total := 0 + for _, g := range s.Games { + for _, r := range g.Runs { + if r.Reason == reason { + total++ + } + } + } + + return total +} diff --git a/internal/bot/supervisor.go b/internal/bot/supervisor.go new file mode 100644 index 000000000..4552ccb81 --- /dev/null +++ b/internal/bot/supervisor.go @@ -0,0 +1,155 @@ +package bot + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + "time" + + ct "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/event" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/run" + "github.com/hectorgimenez/koolo/internal/utils/winproc" + "github.com/lxn/win" +) + +type Supervisor interface { + Start() error + Name() string + Stop() + Stats() Stats + TogglePause() + SetWindowPosition(x, y int) + GetData() *game.Data + GetContext() *ct.Context +} + +type baseSupervisor struct { + bot *Bot + name string + statsHandler *StatsHandler + cancelFn context.CancelFunc +} + +func newBaseSupervisor( + bot *Bot, + name string, + statsHandler *StatsHandler, +) (*baseSupervisor, error) { + return &baseSupervisor{ + bot: bot, + name: name, + statsHandler: statsHandler, + }, nil +} + +func (s *baseSupervisor) Name() string { + return s.name +} + +func (s *baseSupervisor) Stats() Stats { + return s.statsHandler.Stats() +} + +func (s *baseSupervisor) TogglePause() { + if s.bot.ctx.ExecutionPriority == ct.PriorityPause { + s.bot.ctx.MemoryInjector.Load() + s.bot.ctx.SwitchPriority(ct.PriorityNormal) + s.bot.ctx.Logger.Info("Resuming...", slog.String("configuration", s.name)) + event.Send(event.GamePaused(event.Text(s.name, "Game resumed"), false)) + } else { + s.bot.ctx.SwitchPriority(ct.PriorityPause) + s.bot.ctx.MemoryInjector.RestoreMemory() + s.bot.ctx.Logger.Info("Pausing...", slog.String("configuration", s.name)) + event.Send(event.GamePaused(event.Text(s.name, "Game paused"), true)) + } +} + +func (s *baseSupervisor) Stop() { + s.bot.ctx.Logger.Info("Stopping...", slog.String("configuration", s.name)) + if s.cancelFn != nil { + s.cancelFn() + } + + s.bot.ctx.SwitchPriority(ct.PriorityStop) + + s.bot.ctx.MemoryInjector.Unload() + s.bot.ctx.GameReader.Close() + + if s.bot.ctx.CharacterCfg.KillD2OnStop || s.bot.ctx.CharacterCfg.Scheduler.Enabled { + s.KillClient() + } + + s.bot.ctx.Logger.Info("Finished stopping", slog.String("configuration", s.name)) +} + +func (s *baseSupervisor) KillClient() error { + + process, err := os.FindProcess(int(s.bot.ctx.GameReader.Process.GetPID())) + if err != nil { + s.bot.ctx.Logger.Info("Failed to find process", slog.String("configuration", s.name)) + return err + } + err = process.Kill() + if err != nil { + s.bot.ctx.Logger.Info("Failed to kill process", slog.String("configuration", s.name)) + return err + } + return nil +} + +func (s *baseSupervisor) ensureProcessIsRunningAndPrepare() error { + // Prevent screen from turning off + winproc.SetThreadExecutionState.Call(winproc.EXECUTION_STATE_ES_DISPLAY_REQUIRED | winproc.EXECUTION_STATE_ES_CONTINUOUS) + + return s.bot.ctx.MemoryInjector.Load() +} + +func (s *baseSupervisor) logGameStart(runs []run.Run) { + runNames := "" + for _, r := range runs { + runNames += r.Name() + ", " + } + s.bot.ctx.Logger.Info(fmt.Sprintf("Starting Game #%d. Run list: %s", s.statsHandler.Stats().TotalGames(), runNames[:len(runNames)-2])) +} + +func (s *baseSupervisor) waitUntilCharacterSelectionScreen() error { + s.bot.ctx.Logger.Info("Waiting for character selection screen...") + + for !s.bot.ctx.GameReader.IsInCharacterSelectionScreen() { + // Spam left click to skip to the char select screen + s.bot.ctx.HID.Click(game.LeftButton, 100, 100) + time.Sleep(250 * time.Millisecond) + } + + s.bot.ctx.Logger.Info("Character selection screen found") + + if s.bot.ctx.CharacterCfg.CharacterName != "" { + s.bot.ctx.Logger.Info("Selecting character...") + previousSelection := "" + for { + characterName := s.bot.ctx.GameReader.GameReader.GetSelectedCharacterName() + if strings.EqualFold(previousSelection, characterName) { + return fmt.Errorf("character %s not found", s.bot.ctx.CharacterCfg.CharacterName) + } + if strings.EqualFold(characterName, s.bot.ctx.CharacterCfg.CharacterName) { + s.bot.ctx.Logger.Info("Character found") + return nil + } + + s.bot.ctx.HID.PressKey(win.VK_DOWN) + time.Sleep(time.Millisecond * 150) + previousSelection = characterName + } + } + + return nil +} + +func (s *baseSupervisor) SetWindowPosition(x, y int) { + uFlags := win.SWP_NOZORDER | win.SWP_NOSIZE | win.SWP_NOACTIVATE + win.SetWindowPos(s.bot.ctx.GameReader.HWND, 0, int32(x), int32(y), 0, 0, uint32(uFlags)) +} diff --git a/internal/character/berserk_barb.go b/internal/character/berserk_barb.go new file mode 100644 index 000000000..8fb7cd219 --- /dev/null +++ b/internal/character/berserk_barb.go @@ -0,0 +1,374 @@ +package character + +import ( + "log/slog" + "sort" + "sync/atomic" + "time" + + "github.com/hectorgimenez/koolo/internal/action" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/d2go/pkg/data/skill" + "github.com/hectorgimenez/d2go/pkg/data/stat" + "github.com/hectorgimenez/d2go/pkg/data/state" + "github.com/hectorgimenez/koolo/internal/action/step" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" +) + +type Berserker struct { + BaseCharacter + isKillingCouncil atomic.Bool +} + +const ( + maxHorkRange = 40 + meleeRange = 5 + maxAttackAttempts = 20 +) + +func (s *Berserker) CheckKeyBindings() []skill.ID { + requireKeybindings := []skill.ID{skill.BattleCommand, skill.BattleOrders, skill.Shout, skill.FindItem, skill.Berserk} + missingKeybindings := []skill.ID{} + + for _, cskill := range requireKeybindings { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(cskill); !found { + missingKeybindings = append(missingKeybindings, cskill) + } + } + + if len(missingKeybindings) > 0 { + s.Logger.Debug("There are missing required key bindings.", slog.Any("Bindings", missingKeybindings)) + } + + return missingKeybindings +} + +func (s *Berserker) IsKillingCouncil() bool { + return s.isKillingCouncil.Load() +} + +func (s *Berserker) KillMonsterSequence( + monsterSelector func(d game.Data) (data.UnitID, bool), + skipOnImmunities []stat.Resist, +) error { + + for attackAttempts := 0; attackAttempts < maxAttackAttempts; attackAttempts++ { + id, found := monsterSelector(*s.Data) + if !found { + if !s.isKillingCouncil.Load() { + s.FindItemOnNearbyCorpses(maxHorkRange) + } + return nil + } + + if !s.preBattleChecks(id, skipOnImmunities) { + return nil + } + + monster, monsterFound := s.Data.Monsters.FindByID(id) + if !monsterFound || monster.Stats[stat.Life] <= 0 { + continue + } + + distance := s.PathFinder.DistanceFromMe(monster.Position) + if distance > meleeRange { + err := step.MoveTo(monster.Position) + if err != nil { + s.Logger.Warn("Failed to move to monster", slog.String("error", err.Error())) + continue + } + } + + s.PerformBerserkAttack(monster.UnitID) + time.Sleep(50 * time.Millisecond) + } + + return nil +} + +func (s *Berserker) PerformBerserkAttack(monsterID data.UnitID) { + ctx := context.Get() + ctx.PauseIfNotPriority() + monster, found := s.Data.Monsters.FindByID(monsterID) + if !found { + return + } + + // Ensure Berserk skill is active + berserkKey, found := s.Data.KeyBindings.KeyBindingForSkill(skill.Berserk) + if found && s.Data.PlayerUnit.RightSkill != skill.Berserk { + ctx.HID.PressKeyBinding(berserkKey) + time.Sleep(50 * time.Millisecond) + } + + screenX, screenY := ctx.PathFinder.GameCoordsToScreenCords(monster.Position.X, monster.Position.Y) + ctx.HID.Click(game.LeftButton, screenX, screenY) +} + +func (s *Berserker) FindItemOnNearbyCorpses(maxRange int) { + ctx := context.Get() + ctx.PauseIfNotPriority() + s.SwapToSlot(1) + + findItemKey, found := s.Data.KeyBindings.KeyBindingForSkill(skill.FindItem) + if !found { + s.Logger.Debug("Find Item skill not found in key bindings") + return + } + + corpses := s.getSortedHorkableCorpses(s.Data.Corpses, maxRange) + s.Logger.Debug("Horkable corpses found", slog.Int("count", len(corpses))) + + for _, corpse := range corpses { + err := step.MoveTo(corpse.Position) + if err != nil { + s.Logger.Warn("Failed to move to corpse", slog.String("error", err.Error())) + continue + } + + if s.Data.PlayerUnit.RightSkill != skill.FindItem { + ctx.HID.PressKeyBinding(findItemKey) + time.Sleep(time.Millisecond * 50) + } + + clickPos := s.getOptimalClickPosition(corpse) + screenX, screenY := ctx.PathFinder.GameCoordsToScreenCords(clickPos.X, clickPos.Y) + ctx.HID.Click(game.RightButton, screenX, screenY) + s.Logger.Debug("Find Item used on corpse", slog.Any("corpse_id", corpse.UnitID)) + + time.Sleep(time.Millisecond * 300) + } + +} + +func (s *Berserker) getSortedHorkableCorpses(corpses data.Monsters, maxRange int) []data.Monster { + var horkableCorpses []data.Monster + for _, corpse := range corpses { + if s.isCorpseHorkable(corpse) && s.PathFinder.DistanceFromMe(corpse.Position) <= maxRange { + horkableCorpses = append(horkableCorpses, corpse) + } + } + + sort.Slice(horkableCorpses, func(i, j int) bool { + distI := s.PathFinder.DistanceFromMe(horkableCorpses[i].Position) + distJ := s.PathFinder.DistanceFromMe(horkableCorpses[j].Position) + return distI < distJ + }) + + return horkableCorpses +} + +func (s *Berserker) isCorpseHorkable(corpse data.Monster) bool { + unhorkableStates := []state.State{ + state.CorpseNoselect, + state.CorpseNodraw, + state.Revive, + state.Redeemed, + state.Shatter, + state.Freeze, + state.Restinpeace, + } + + for _, st := range unhorkableStates { + if corpse.States.HasState(st) { + return false + } + } + + return corpse.Type == data.MonsterTypeChampion || + corpse.Type == data.MonsterTypeMinion || + corpse.Type == data.MonsterTypeUnique || + corpse.Type == data.MonsterTypeSuperUnique +} + +func (s *Berserker) getOptimalClickPosition(corpse data.Monster) data.Position { + return data.Position{X: corpse.Position.X, Y: corpse.Position.Y + 1} +} + +// slot 0 means lowest Gold Find, slot 1 means highest Gold Find +// Presuming attack items will be on slot 0 and Goldfind items on slot 1 +// TODO find a way to get active inventory slot from memory. +func (s *Berserker) SwapToSlot(slot int) { + ctx := context.Get() + if !ctx.CharacterCfg.Character.BerserkerBarb.FindItemSwitch { + return // Do nothing if FindItemSwitch is disabled + } + + initialGF, _ := s.Data.PlayerUnit.FindStat(stat.GoldFind, 0) + ctx.HID.PressKey('W') + time.Sleep(100 * time.Millisecond) + ctx.RefreshGameData() + swappedGF, _ := s.Data.PlayerUnit.FindStat(stat.GoldFind, 0) + + if (slot == 0 && swappedGF.Value > initialGF.Value) || + (slot == 1 && swappedGF.Value < initialGF.Value) { + ctx.HID.PressKey('W') // Swap back if not in desired slot + } +} + +func (s *Berserker) BuffSkills() []skill.ID { + + skillsList := make([]skill.ID, 0) + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.BattleCommand); found { + skillsList = append(skillsList, skill.BattleCommand) + } + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.Shout); found { + skillsList = append(skillsList, skill.Shout) + } + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.BattleOrders); found { + skillsList = append(skillsList, skill.BattleOrders) + } + return skillsList +} + +func (s *Berserker) PreCTABuffSkills() []skill.ID { + return []skill.ID{} +} + +func (s *Berserker) killMonster(npc npc.ID, t data.MonsterType) error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + m, found := d.Monsters.FindOne(npc, t) + if !found { + return 0, false + } + return m.UnitID, true + }, nil) +} + +func (s *Berserker) KillCountess() error { + return s.killMonster(npc.DarkStalker, data.MonsterTypeSuperUnique) +} + +func (s *Berserker) KillAndariel() error { + return s.killMonster(npc.Andariel, data.MonsterTypeUnique) +} + +func (s *Berserker) KillSummoner() error { + return s.killMonster(npc.Summoner, data.MonsterTypeUnique) +} + +func (s *Berserker) KillDuriel() error { + return s.killMonster(npc.Duriel, data.MonsterTypeUnique) +} + +func (s *Berserker) KillMephisto() error { + return s.killMonster(npc.Mephisto, data.MonsterTypeUnique) +} +func (s *Berserker) KillDiablo() error { + timeout := time.Second * 20 + startTime := time.Now() + diabloFound := false + + for { + if time.Since(startTime) > timeout && !diabloFound { + s.Logger.Error("Diablo was not found, timeout reached") + return nil + } + + diablo, found := s.Data.Monsters.FindOne(npc.Diablo, data.MonsterTypeUnique) + if !found || diablo.Stats[stat.Life] <= 0 { + if diabloFound { + return nil + } + time.Sleep(200 * time.Millisecond) + continue + } + + diabloFound = true + s.Logger.Info("Diablo detected, attacking") + + return s.killMonster(npc.Diablo, data.MonsterTypeUnique) + } +} + +func (s *Berserker) KillCouncil() error { + s.isKillingCouncil.Store(true) + defer s.isKillingCouncil.Store(false) + + err := s.killAllCouncilMembers() + if err != nil { + return err + } + + context.Get().EnableItemPickup() + + // Wait for corpses to settle + time.Sleep(500 * time.Millisecond) + + // Perform horking in two passes + for i := 0; i < 2; i++ { + s.FindItemOnNearbyCorpses(maxHorkRange) + + // Wait between passes + time.Sleep(300 * time.Millisecond) + + // Refresh game data to catch any new corpses + context.Get().RefreshGameData() + } + + // Final wait for items to drop + time.Sleep(500 * time.Millisecond) + + // Final item pickup + err = action.ItemPickup(maxHorkRange) + if err != nil { + s.Logger.Warn("Error during final item pickup after horking", "error", err) + return err + } + + // Wait a moment to ensure all items are picked up + time.Sleep(300 * time.Millisecond) + + return nil +} + +func (s *Berserker) killAllCouncilMembers() error { + context.Get().DisableItemPickup() + for { + if !s.anyCouncilMemberAlive() { + return nil + } + + err := s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + for _, m := range d.Monsters.Enemies() { + if (m.Name == npc.CouncilMember || m.Name == npc.CouncilMember2 || m.Name == npc.CouncilMember3) && m.Stats[stat.Life] > 0 { + return m.UnitID, true + } + } + return 0, false + }, nil) + + if err != nil { + return err + } + } +} + +func (s *Berserker) anyCouncilMemberAlive() bool { + for _, m := range s.Data.Monsters.Enemies() { + if (m.Name == npc.CouncilMember || m.Name == npc.CouncilMember2 || m.Name == npc.CouncilMember3) && m.Stats[stat.Life] > 0 { + return true + } + + } + return false +} + +func (s *Berserker) KillIzual() error { + return s.killMonster(npc.Izual, data.MonsterTypeUnique) +} + +func (s *Berserker) KillPindle() error { + return s.killMonster(npc.DefiledWarrior, data.MonsterTypeSuperUnique) +} + +func (s *Berserker) KillNihlathak() error { + return s.killMonster(npc.Nihlathak, data.MonsterTypeSuperUnique) +} + +func (s *Berserker) KillBaal() error { + return s.killMonster(npc.BaalCrab, data.MonsterTypeUnique) +} diff --git a/internal/character/blizzard_sorceress.go b/internal/character/blizzard_sorceress.go index d749e4f57..cc475a250 100644 --- a/internal/character/blizzard_sorceress.go +++ b/internal/character/blizzard_sorceress.go @@ -1,209 +1,197 @@ package character import ( + "fmt" + "log/slog" "time" "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/npc" "github.com/hectorgimenez/d2go/pkg/data/skill" "github.com/hectorgimenez/d2go/pkg/data/stat" - "github.com/hectorgimenez/koolo/internal/action" + "github.com/hectorgimenez/d2go/pkg/data/state" "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/pather" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" ) const ( - sorceressMaxAttacksLoop = 10 - sorceressMinDistance = 25 - sorceressMaxDistance = 30 + sorceressMaxAttacksLoop = 40 + blizzMinDistance = 8 + blizzMaxDistance = 20 + LSMinDistance = 6 + LSMaxDistance = 15 // Left skill ) type BlizzardSorceress struct { BaseCharacter } +func (s BlizzardSorceress) CheckKeyBindings() []skill.ID { + requireKeybindings := []skill.ID{skill.Blizzard, skill.Teleport, skill.TomeOfTownPortal, skill.ShiverArmor, skill.StaticField} + missingKeybindings := []skill.ID{} + + for _, cskill := range requireKeybindings { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(cskill); !found { + switch cskill { + // Since we can have one of 3 armors: + case skill.ShiverArmor: + _, found1 := s.Data.KeyBindings.KeyBindingForSkill(skill.FrozenArmor) + _, found2 := s.Data.KeyBindings.KeyBindingForSkill(skill.ChillingArmor) + if !found1 && !found2 { + missingKeybindings = append(missingKeybindings, skill.ShiverArmor) + } + default: + missingKeybindings = append(missingKeybindings, cskill) + } + } + } + + if len(missingKeybindings) > 0 { + s.Logger.Debug("There are missing required key bindings.", slog.Any("Bindings", missingKeybindings)) + } + + return missingKeybindings +} + func (s BlizzardSorceress) KillMonsterSequence( - monsterSelector func(d data.Data) (data.UnitID, bool), + monsterSelector func(d game.Data) (data.UnitID, bool), skipOnImmunities []stat.Resist, - opts ...step.AttackOption, -) action.Action { +) error { + ctx := context.Get() completedAttackLoops := 0 previousUnitID := 0 + previousSelfBlizzard := time.Time{} + + blizzOpts := step.StationaryDistance(blizzMinDistance, blizzMaxDistance) + lsOpts := step.Distance(LSMinDistance, LSMaxDistance) - return action.NewStepChain(func(d data.Data) []step.Step { - id, found := monsterSelector(d) + for { + // Pause if not priority + ctx.PauseIfNotPriority() + + id, found := monsterSelector(*s.Data) if !found { - return []step.Step{} + return nil } if previousUnitID != int(id) { completedAttackLoops = 0 } - if !s.preBattleChecks(d, id, skipOnImmunities) { - return []step.Step{} + if !s.preBattleChecks(id, skipOnImmunities) { + return nil } - if len(opts) == 0 { - opts = append(opts, step.Distance(sorceressMinDistance, sorceressMaxDistance)) - } - //if useStaticField { - // steps = append(steps, - // step.SecondaryAttack(config.Config.Bindings.Sorceress.Blizzard, id, 1, time.Millisecond*100, step.Distance(sorceressMinDistance, maxDistance)), - // step.SecondaryAttack(config.Config.Bindings.Sorceress.StaticField, id, 5, config.Config.Runtime.CastDuration, step.Distance(sorceressMinDistance, 15)), - // ) - //} if completedAttackLoops >= sorceressMaxAttacksLoop { - return []step.Step{} + return nil + } + + monster, found := s.Data.Monsters.FindByID(id) + if !found { + s.Logger.Info("Monster not found", slog.String("monster", fmt.Sprintf("%v", monster))) + return nil } - steps := make([]step.Step, 0) // Cast a Blizzard on very close mobs, in order to clear possible trash close the player, every two attack rotations - if completedAttackLoops%2 == 0 { - for _, m := range d.Monsters.Enemies() { - if d := pather.DistanceFromMe(d, m.Position); d < 4 { - s.logger.Debug("Monster detected close to the player, casting Blizzard over it") - steps = append(steps, step.SecondaryAttack(config.Config.Bindings.Sorceress.Blizzard, m.UnitID, 1, opts...)) - break + if time.Since(previousSelfBlizzard) > time.Second*4 && !s.Data.PlayerUnit.States.HasState(state.Cooldown) { + for _, m := range s.Data.Monsters.Enemies() { + if dist := s.PathFinder.DistanceFromMe(m.Position); dist < 4 { + previousSelfBlizzard = time.Now() + step.SecondaryAttack(skill.Blizzard, m.UnitID, 1, blizzOpts) } } } - // In case monster is stuck behind a wall or character is not able to reach it we will short the distance - if completedAttackLoops > 5 { - if completedAttackLoops == 6 { - s.logger.Debug("Looks like monster is not reachable, reducing max attack distance") - } - opts = []step.AttackOption{step.Distance(1, 5)} + if s.Data.PlayerUnit.States.HasState(state.Cooldown) { + step.PrimaryAttack(id, 2, true, lsOpts) } - steps = append(steps, - step.SecondaryAttack(config.Config.Bindings.Sorceress.Blizzard, id, 1, opts...), - step.PrimaryAttack(id, 4, opts...), - ) + step.SecondaryAttack(skill.Blizzard, id, 1, blizzOpts) + completedAttackLoops++ previousUnitID = int(id) - - return steps - }, action.RepeatUntilNoSteps()) -} - -func (s BlizzardSorceress) BuffSkills() map[skill.Skill]string { - return map[skill.Skill]string{ - skill.FrozenArmor: config.Config.Bindings.Sorceress.FrozenArmor, } } -func (s BlizzardSorceress) KillCountess() action.Action { - return s.killMonsterByName(npc.DarkStalker, data.MonsterTypeSuperUnique, sorceressMaxDistance, false, nil) -} - -func (s BlizzardSorceress) KillAndariel() action.Action { - return s.killMonsterByName(npc.Andariel, data.MonsterTypeNone, sorceressMaxDistance, false, nil) -} +func (s BlizzardSorceress) killMonster(npc npc.ID, t data.MonsterType) error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + m, found := d.Monsters.FindOne(npc, t) + if !found { + return 0, false + } -func (s BlizzardSorceress) KillSummoner() action.Action { - return s.killMonsterByName(npc.Summoner, data.MonsterTypeNone, sorceressMaxDistance, false, nil) + return m.UnitID, true + }, nil) } -func (s BlizzardSorceress) KillDuriel() action.Action { - return s.killMonsterByName(npc.Duriel, data.MonsterTypeNone, sorceressMaxDistance, true, nil) -} +func (s BlizzardSorceress) killMonsterByName(id npc.ID, monsterType data.MonsterType, skipOnImmunities []stat.Resist) error { + // while the monster is alive, keep attacking it + for { + if m, found := s.Data.Monsters.FindOne(id, monsterType); found { + if m.Stats[stat.Life] <= 0 { + break + } -func (s BlizzardSorceress) KillPindle(skipOnImmunities []stat.Resist) action.Action { - return s.killMonsterByName(npc.DefiledWarrior, data.MonsterTypeSuperUnique, sorceressMaxDistance, false, skipOnImmunities) -} + // Check if monster is immune to any of the skipOnImmunities + for _, immunity := range skipOnImmunities { + if m.IsImmune(immunity) { + return nil + } + } -func (s BlizzardSorceress) KillMephisto() action.Action { - return s.killMonsterByName(npc.Mephisto, data.MonsterTypeNone, sorceressMaxDistance, true, nil) -} + s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + if m, found := d.Monsters.FindOne(id, monsterType); found { + return m.UnitID, true + } -func (s BlizzardSorceress) KillNihlathak() action.Action { - return s.killMonsterByName(npc.Nihlathak, data.MonsterTypeSuperUnique, sorceressMaxDistance, false, nil) + return 0, false + }, skipOnImmunities) + } else { + break + } + } + return nil } -func (s BlizzardSorceress) KillDiablo() action.Action { - timeout := time.Second * 20 - startTime := time.Time{} - diabloFound := false - return action.NewChain(func(d data.Data) []action.Action { - if startTime.IsZero() { - startTime = time.Now() - } +func (s BlizzardSorceress) BuffSkills() []skill.ID { + skillsList := make([]skill.ID, 0) + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.EnergyShield); found { + skillsList = append(skillsList, skill.EnergyShield) + } - if time.Since(startTime) > timeout && !diabloFound { - s.logger.Error("Diablo was not found, timeout reached") - return nil + armors := []skill.ID{skill.ChillingArmor, skill.ShiverArmor, skill.FrozenArmor} + for _, armor := range armors { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(armor); found { + skillsList = append(skillsList, armor) + return skillsList } + } - diablo, found := d.Monsters.FindOne(npc.Diablo, data.MonsterTypeNone) - if !found || diablo.Stats[stat.Life] <= 0 { - // Already dead - if diabloFound { - return nil - } + return skillsList +} - // Keep waiting... - return []action.Action{action.NewStepChain(func(d data.Data) []step.Step { - return []step.Step{step.Wait(time.Millisecond * 100)} - })} - } +func (s BlizzardSorceress) PreCTABuffSkills() []skill.ID { + return []skill.ID{} +} - diabloFound = true - s.logger.Info("Diablo detected, attacking") +func (s BlizzardSorceress) KillCountess() error { + return s.killMonsterByName(npc.DarkStalker, data.MonsterTypeSuperUnique, nil) +} - return []action.Action{ - action.NewStepChain(func(d data.Data) []step.Step { - return []step.Step{ - step.SecondaryAttack(config.Config.Bindings.Sorceress.StaticField, diablo.UnitID, 5, step.Distance(3, 8)), - } - }), - s.killMonster(npc.Diablo, data.MonsterTypeNone), - } - }, action.RepeatUntilNoSteps()) +func (s BlizzardSorceress) KillAndariel() error { + return s.killMonsterByName(npc.Andariel, data.MonsterTypeUnique, nil) } -func (s BlizzardSorceress) KillIzual() action.Action { - return action.NewChain(func(d data.Data) []action.Action { - return []action.Action{ - action.NewStepChain(func(d data.Data) []step.Step { - m, _ := d.Monsters.FindOne(npc.Izual, data.MonsterTypeNone) - return []step.Step{ - step.SecondaryAttack(config.Config.Bindings.Sorceress.StaticField, m.UnitID, 7, step.Distance(5, 8)), - } - }), - // We will need a lot of cycles to kill him probably - s.killMonster(npc.Izual, data.MonsterTypeNone), - s.killMonster(npc.Izual, data.MonsterTypeNone), - s.killMonster(npc.Izual, data.MonsterTypeNone), - s.killMonster(npc.Izual, data.MonsterTypeNone), - s.killMonster(npc.Izual, data.MonsterTypeNone), - s.killMonster(npc.Izual, data.MonsterTypeNone), - s.killMonster(npc.Izual, data.MonsterTypeNone), - } - }) +func (s BlizzardSorceress) KillSummoner() error { + return s.killMonsterByName(npc.Summoner, data.MonsterTypeUnique, nil) } -func (s BlizzardSorceress) KillBaal() action.Action { - return action.NewChain(func(d data.Data) []action.Action { - return []action.Action{ - action.NewStepChain(func(d data.Data) []step.Step { - m, _ := d.Monsters.FindOne(npc.BaalCrab, data.MonsterTypeNone) - return []step.Step{ - step.SecondaryAttack(config.Config.Bindings.Sorceress.StaticField, m.UnitID, 5, step.Distance(5, 8)), - } - }), - // We will need a lot of cycles to kill him probably - s.killMonster(npc.BaalCrab, data.MonsterTypeNone), - s.killMonster(npc.BaalCrab, data.MonsterTypeNone), - s.killMonster(npc.BaalCrab, data.MonsterTypeNone), - s.killMonster(npc.BaalCrab, data.MonsterTypeNone), - } - }) +func (s BlizzardSorceress) KillDuriel() error { + return s.killMonsterByName(npc.Duriel, data.MonsterTypeUnique, nil) } -func (s BlizzardSorceress) KillCouncil() action.Action { - return s.KillMonsterSequence(func(d data.Data) (data.UnitID, bool) { +func (s BlizzardSorceress) KillCouncil() error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { // Exclude monsters that are not council members var councilMembers []data.Monster var coldImmunes []data.Monster @@ -224,26 +212,63 @@ func (s BlizzardSorceress) KillCouncil() action.Action { } return 0, false - }, nil, step.Distance(8, sorceressMaxDistance)) + }, nil) } -func (s BlizzardSorceress) killMonsterByName(id npc.ID, monsterType data.MonsterType, maxDistance int, useStaticField bool, skipOnImmunities []stat.Resist) action.Action { - return s.KillMonsterSequence(func(d data.Data) (data.UnitID, bool) { - if m, found := d.Monsters.FindOne(id, monsterType); found { - return m.UnitID, true - } +func (s BlizzardSorceress) KillMephisto() error { + return s.killMonsterByName(npc.Mephisto, data.MonsterTypeUnique, nil) +} - return 0, false - }, skipOnImmunities, step.Distance(sorceressMinDistance, maxDistance)) +func (s BlizzardSorceress) KillIzual() error { + m, _ := s.Data.Monsters.FindOne(npc.Izual, data.MonsterTypeUnique) + _ = step.SecondaryAttack(skill.StaticField, m.UnitID, 4, step.Distance(5, 8)) + + return s.killMonsterByName(npc.Izual, data.MonsterTypeUnique, nil) } -func (s BlizzardSorceress) killMonster(npc npc.ID, t data.MonsterType) action.Action { - return s.KillMonsterSequence(func(d data.Data) (data.UnitID, bool) { - m, found := d.Monsters.FindOne(npc, t) - if !found { - return 0, false +func (s BlizzardSorceress) KillDiablo() error { + timeout := time.Second * 20 + startTime := time.Now() + diabloFound := false + + for { + if time.Since(startTime) > timeout && !diabloFound { + s.Logger.Error("Diablo was not found, timeout reached") + return nil } - return m.UnitID, true - }, nil) + diablo, found := s.Data.Monsters.FindOne(npc.Diablo, data.MonsterTypeUnique) + if !found || diablo.Stats[stat.Life] <= 0 { + // Already dead + if diabloFound { + return nil + } + + // Keep waiting... + time.Sleep(200) + continue + } + + diabloFound = true + s.Logger.Info("Diablo detected, attacking") + + _ = step.SecondaryAttack(skill.StaticField, diablo.UnitID, 5, step.Distance(3, 8)) + + return s.killMonsterByName(npc.Diablo, data.MonsterTypeUnique, nil) + } +} + +func (s BlizzardSorceress) KillPindle() error { + return s.killMonsterByName(npc.DefiledWarrior, data.MonsterTypeSuperUnique, s.CharacterCfg.Game.Pindleskin.SkipOnImmunities) +} + +func (s BlizzardSorceress) KillNihlathak() error { + return s.killMonsterByName(npc.Nihlathak, data.MonsterTypeSuperUnique, nil) +} + +func (s BlizzardSorceress) KillBaal() error { + m, _ := s.Data.Monsters.FindOne(npc.BaalCrab, data.MonsterTypeUnique) + step.SecondaryAttack(skill.StaticField, m.UnitID, 4, step.Distance(5, 8)) + + return s.killMonsterByName(npc.BaalCrab, data.MonsterTypeUnique, nil) } diff --git a/internal/character/character.go b/internal/character/character.go index 72160fb6c..4f62b02f6 100644 --- a/internal/character/character.go +++ b/internal/character/character.go @@ -2,23 +2,24 @@ package character import ( "fmt" + "log/slog" "strings" "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/stat" - "github.com/hectorgimenez/koolo/internal/action" - "github.com/hectorgimenez/koolo/internal/config" - "go.uber.org/zap" + "github.com/hectorgimenez/koolo/internal/context" ) -func BuildCharacter(logger *zap.Logger) (action.Character, error) { +func BuildCharacter(ctx *context.Context) (context.Character, error) { bc := BaseCharacter{ - logger: logger, + Context: ctx, } - if config.Config.Game.Runs[0] == "leveling" { - switch strings.ToLower(config.Config.Character.Class) { - case "sorceress": + if len(ctx.CharacterCfg.Game.Runs) > 0 && ctx.CharacterCfg.Game.Runs[0] == "leveling" { + switch strings.ToLower(ctx.CharacterCfg.Character.Class) { + case "sorceress_leveling_lightning": + return SorceressLevelingLightning{BaseCharacter: bc}, nil + case "sorceress_leveling": return SorceressLeveling{BaseCharacter: bc}, nil case "paladin": return PaladinLeveling{BaseCharacter: bc}, nil @@ -27,30 +28,48 @@ func BuildCharacter(logger *zap.Logger) (action.Character, error) { return nil, fmt.Errorf("leveling only available for sorceress and paladin") } - switch strings.ToLower(config.Config.Character.Class) { + switch strings.ToLower(ctx.CharacterCfg.Character.Class) { case "sorceress": return BlizzardSorceress{BaseCharacter: bc}, nil - case "lightning": + case "fireballsorc": + return FireballSorceress{BaseCharacter: bc}, nil + case "nova": + return NovaSorceress{BaseCharacter: bc}, nil + case "hydraorb": + return HydraOrbSorceress{BaseCharacter: bc}, nil + case "lightsorc": return LightningSorceress{BaseCharacter: bc}, nil case "hammerdin": return Hammerdin{BaseCharacter: bc}, nil + case "foh": + return Foh{BaseCharacter: bc}, nil + case "trapsin": + return Trapsin{BaseCharacter: bc}, nil + case "mosaic": + return MosaicSin{BaseCharacter: bc}, nil + case "winddruid": + return WindDruid{BaseCharacter: bc}, nil + case "javazon": + return Javazon{BaseCharacter: bc}, nil + case "berserker": + return &Berserker{BaseCharacter: bc}, nil // Return a pointer to Berserker } - return nil, fmt.Errorf("class %s not implemented", config.Config.Character.Class) + return nil, fmt.Errorf("class %s not implemented", ctx.CharacterCfg.Character.Class) } type BaseCharacter struct { - logger *zap.Logger + *context.Context } -func (bc BaseCharacter) preBattleChecks(d data.Data, id data.UnitID, skipOnImmunities []stat.Resist) bool { - monster, found := d.Monsters.FindByID(id) +func (bc BaseCharacter) preBattleChecks(id data.UnitID, skipOnImmunities []stat.Resist) bool { + monster, found := bc.Data.Monsters.FindByID(id) if !found { return false } for _, i := range skipOnImmunities { if monster.IsImmune(i) { - bc.logger.Info("Monster is immune! skipping", zap.String("immuneTo", string(i))) + bc.Logger.Info("Monster is immune! skipping", slog.String("immuneTo", string(i))) return false } } diff --git a/internal/character/fireball_sorc.go b/internal/character/fireball_sorc.go new file mode 100644 index 000000000..c523ab0f4 --- /dev/null +++ b/internal/character/fireball_sorc.go @@ -0,0 +1,251 @@ +package character + +import ( + "fmt" + "log/slog" + "time" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/d2go/pkg/data/skill" + "github.com/hectorgimenez/d2go/pkg/data/stat" + "github.com/hectorgimenez/d2go/pkg/data/state" + "github.com/hectorgimenez/koolo/internal/action/step" + "github.com/hectorgimenez/koolo/internal/game" +) + +const ( + fireballSorceressMaxAttacksLoop = 40 + fireballSorceressMinDistance = 8 + fireballSorceressMaxDistance = 16 + fireballSorceressLSMinDistance = 6 + fireballSorceressLSMaxDistance = 15 // Left skill +) + +type FireballSorceress struct { + BaseCharacter +} + +func (f FireballSorceress) CheckKeyBindings() []skill.ID { + requireKeybindings := []skill.ID{skill.Meteor, skill.Teleport, skill.TomeOfTownPortal, skill.FrozenArmor, skill.StaticField} + missingKeybindings := []skill.ID{} + + for _, cskill := range requireKeybindings { + if _, found := f.Data.KeyBindings.KeyBindingForSkill(cskill); !found { + switch cskill { + // Since we can have one of 3 armors: + case skill.FrozenArmor: + + _, found1 := f.Data.KeyBindings.KeyBindingForSkill(skill.ChillingArmor) + _, found2 := f.Data.KeyBindings.KeyBindingForSkill(skill.ShiverArmor) + if !found1 && !found2 { + missingKeybindings = append(missingKeybindings, skill.FrozenArmor) + } + + default: + missingKeybindings = append(missingKeybindings, cskill) + } + } + } + + if len(missingKeybindings) > 0 { + f.Logger.Debug("There are missing required key bindings.", slog.Any("Bindings", missingKeybindings)) + } + + return missingKeybindings +} + +func (f FireballSorceress) KillMonsterSequence( + monsterSelector func(d game.Data) (data.UnitID, bool), + + skipOnImmunities []stat.Resist, +) error { + + completedAttackLoops := 0 + previousUnitID := 0 + + lsOpts := step.Distance(fireballSorceressLSMinDistance, fireballSorceressLSMaxDistance) + + for { + id, found := monsterSelector(*f.Data) + if !found { + return nil + + } + if previousUnitID != int(id) { + completedAttackLoops = 0 + } + + if !f.preBattleChecks(id, skipOnImmunities) { + return nil + } + + if completedAttackLoops >= fireballSorceressMaxAttacksLoop { + return nil + } + + monster, found := f.Data.Monsters.FindByID(id) + if !found { + f.Logger.Info("Monster not found", slog.String("monster", fmt.Sprintf("%v", monster))) + return nil + } + + if f.Data.PlayerUnit.States.HasState(state.Cooldown) { + step.PrimaryAttack(id, 2, true, lsOpts) + } + + step.SecondaryAttack(skill.Meteor, id, 1, lsOpts) + + completedAttackLoops++ + previousUnitID = int(id) + } +} + +func (f FireballSorceress) killMonster(npc npc.ID, t data.MonsterType) error { + return f.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + m, found := d.Monsters.FindOne(npc, t) + if !found { + + return 0, false + } + + return m.UnitID, true + }, nil) +} + +func (f FireballSorceress) killMonsterByName(id npc.ID, monsterType data.MonsterType, skipOnImmunities []stat.Resist) error { + return f.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + if m, found := d.Monsters.FindOne(id, monsterType); found { + + return m.UnitID, true + } + + return 0, false + }, skipOnImmunities) +} + +func (f FireballSorceress) BuffSkills() []skill.ID { + skillsList := make([]skill.ID, 0) + if _, found := f.Data.KeyBindings.KeyBindingForSkill(skill.EnergyShield); found { + skillsList = append(skillsList, skill.EnergyShield) + + } + + armors := []skill.ID{skill.ChillingArmor, skill.ShiverArmor, skill.FrozenArmor} + for _, armor := range armors { + if _, found := f.Data.KeyBindings.KeyBindingForSkill(armor); found { + skillsList = append(skillsList, armor) + return skillsList + } + } + + return skillsList +} + +func (f FireballSorceress) PreCTABuffSkills() []skill.ID { + return []skill.ID{} +} + +func (f FireballSorceress) KillCountess() error { + return f.killMonsterByName(npc.DarkStalker, data.MonsterTypeSuperUnique, nil) +} + +func (f FireballSorceress) KillAndariel() error { + return f.killMonsterByName(npc.Andariel, data.MonsterTypeUnique, nil) +} + +func (f FireballSorceress) KillSummoner() error { + return f.killMonsterByName(npc.Summoner, data.MonsterTypeUnique, nil) +} + +func (f FireballSorceress) KillDuriel() error { + return f.killMonsterByName(npc.Duriel, data.MonsterTypeUnique, nil) +} + +func (f FireballSorceress) KillCouncil() error { + return f.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + // Exclude monsters that are not council members + var councilMembers []data.Monster + var fireImmunes []data.Monster + for _, m := range d.Monsters.Enemies() { + + if m.Name == npc.CouncilMember || m.Name == npc.CouncilMember2 || m.Name == npc.CouncilMember3 { + if m.IsImmune(stat.FireImmune) { + fireImmunes = append(fireImmunes, m) + } else { + councilMembers = append(councilMembers, m) + } + } + + } + + councilMembers = append(councilMembers, fireImmunes...) + + for _, m := range councilMembers { + return m.UnitID, true + } + + return 0, false + }, nil) +} + +func (f FireballSorceress) KillMephisto() error { + return f.killMonsterByName(npc.Mephisto, data.MonsterTypeUnique, nil) +} + +func (f FireballSorceress) KillIzual() error { + m, _ := f.Data.Monsters.FindOne(npc.Izual, data.MonsterTypeUnique) + _ = step.SecondaryAttack(skill.StaticField, m.UnitID, 4, step.Distance(5, 8)) + + return f.killMonster(npc.Izual, data.MonsterTypeUnique) +} + +func (f FireballSorceress) KillDiablo() error { + timeout := time.Second * 20 + startTime := time.Now() + + diabloFound := false + + for { + if time.Since(startTime) > timeout && !diabloFound { + f.Logger.Error("Diablo was not found, timeout reached") + return nil + } + + diablo, found := f.Data.Monsters.FindOne(npc.Diablo, data.MonsterTypeUnique) + + if !found || diablo.Stats[stat.Life] <= 0 { + // Already dead + if diabloFound { + return nil + } + + // Keep waiting... + time.Sleep(200) + continue + } + + diabloFound = true + f.Logger.Info("Diablo detected, attacking") + + _ = step.SecondaryAttack(skill.StaticField, diablo.UnitID, 5, step.Distance(3, 8)) + + return f.killMonster(npc.Diablo, data.MonsterTypeUnique) + + } +} + +func (f FireballSorceress) KillPindle() error { + return f.killMonsterByName(npc.DefiledWarrior, data.MonsterTypeSuperUnique, f.CharacterCfg.Game.Pindleskin.SkipOnImmunities) +} + +func (f FireballSorceress) KillNihlathak() error { + return f.killMonsterByName(npc.Nihlathak, data.MonsterTypeSuperUnique, nil) +} + +func (f FireballSorceress) KillBaal() error { + m, _ := f.Data.Monsters.FindOne(npc.BaalCrab, data.MonsterTypeUnique) + step.SecondaryAttack(skill.StaticField, m.UnitID, 4, step.Distance(5, 8)) + + return f.killMonster(npc.BaalCrab, data.MonsterTypeUnique) +} diff --git a/internal/character/foh.go b/internal/character/foh.go new file mode 100644 index 000000000..4bc391af0 --- /dev/null +++ b/internal/character/foh.go @@ -0,0 +1,413 @@ +package character + +import ( + "fmt" + "log/slog" + "time" + + "github.com/hectorgimenez/d2go/pkg/data/mode" + "github.com/hectorgimenez/koolo/internal/action" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/d2go/pkg/data/skill" + "github.com/hectorgimenez/d2go/pkg/data/stat" + "github.com/hectorgimenez/d2go/pkg/data/state" + "github.com/hectorgimenez/koolo/internal/action/step" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" +) + +const ( + fohMinDistance = 8 + fohMaxDistance = 15 + hbMinDistance = 6 + hbMaxDistance = 12 + fohMaxAttacksLoop = 35 // Maximum attack attempts before resetting + castingTimeout = 3 * time.Second // Maximum time to wait for a cast to complete +) + +type Foh struct { + BaseCharacter + lastCastTime time.Time +} + +func (f Foh) CheckKeyBindings() []skill.ID { + requireKeybindings := []skill.ID{skill.Conviction, skill.HolyShield, skill.TomeOfTownPortal, skill.FistOfTheHeavens, skill.HolyBolt} + missingKeybindings := make([]skill.ID, 0) + + for _, cskill := range requireKeybindings { + if _, found := f.Data.KeyBindings.KeyBindingForSkill(cskill); !found { + missingKeybindings = append(missingKeybindings, cskill) + } + } + + if len(missingKeybindings) > 0 { + f.Logger.Debug("There are missing required key bindings.", slog.Any("Bindings", missingKeybindings)) + } + + return missingKeybindings +} + +// waitForCastComplete waits until the character is no longer in casting animation +func (f Foh) waitForCastComplete() bool { + ctx := context.Get() + startTime := time.Now() + + for time.Since(startTime) < castingTimeout { + ctx.RefreshGameData() + + // Check if we're no longer casting and enough time has passed since last cast + if ctx.Data.PlayerUnit.Mode != mode.CastingSkill && + time.Since(f.lastCastTime) > 150*time.Millisecond { //150 for Foh but if we make that generic it would need tuning maybe from skill desc + return true + } + + time.Sleep(16 * time.Millisecond) // Small sleep to avoid hammering CPU + } + + return false +} +func (f Foh) KillMonsterSequence(monsterSelector func(d game.Data) (data.UnitID, bool), skipOnImmunities []stat.Resist) error { + ctx := context.Get() + lastRefresh := time.Now() + completedAttackLoops := 0 + var currentTargetID data.UnitID + useHolyBolt := false + + // Ensure we always return to FoH when done + defer func() { + if kb, found := ctx.Data.KeyBindings.KeyBindingForSkill(skill.FistOfTheHeavens); found { + ctx.HID.PressKeyBinding(kb) + } + }() + + fohOpts := []step.AttackOption{ + step.StationaryDistance(fohMinDistance, fohMaxDistance), + step.EnsureAura(skill.Conviction), + } + hbOpts := []step.AttackOption{ + step.StationaryDistance(hbMinDistance, hbMaxDistance), + step.EnsureAura(skill.Conviction), + } + + // Initial target selection and analysis + initialTargetAnalysis := func() (data.UnitID, bool, bool) { + id, found := monsterSelector(*f.Data) + if !found { + return 0, false, false + } + + // Count initial valid targets + validTargets := 0 + //monstersInRange := make([]data.Monster, 0) + monster, found := f.Data.Monsters.FindByID(id) + if !found { + return 0, false, false + } + + for _, m := range ctx.Data.Monsters.Enemies() { + if ctx.Data.AreaData.IsInside(m.Position) { + dist := ctx.PathFinder.DistanceFromMe(m.Position) + if dist <= fohMaxDistance && dist >= fohMinDistance && m.Stats[stat.Life] > 0 { + validTargets++ + //monstersInRange = append(monstersInRange, m) + } + } + } + + // Determine if we should use Holy Bolt + // Only use Holy Bolt if it's a single target and it's immune to lightning + shouldUseHB := validTargets == 1 && monster.IsImmune(stat.LightImmune) + + return id, true, shouldUseHB + } + + for { + // Refresh game data periodically + if time.Since(lastRefresh) > time.Millisecond*100 { + ctx.RefreshGameData() + lastRefresh = time.Now() + } + + ctx.PauseIfNotPriority() + + if completedAttackLoops >= fohMaxAttacksLoop { + return nil + } + + // If we don't have a current target, get one and analyze the situation + if currentTargetID == 0 { + var found bool + currentTargetID, found, useHolyBolt = initialTargetAnalysis() + if !found { + return nil + } + } + + // Verify our target still exists and is alive + monster, found := f.Data.Monsters.FindByID(currentTargetID) + if !found || monster.Stats[stat.Life] <= 0 { + currentTargetID = 0 // Reset target + continue + } + + if !f.preBattleChecks(currentTargetID, skipOnImmunities) { + return nil + } + + // Ensure Conviction is active + if !f.Data.PlayerUnit.States.HasState(state.Conviction) { + if kb, found := ctx.Data.KeyBindings.KeyBindingForSkill(skill.Conviction); found { + ctx.HID.PressKeyBinding(kb) + } + } + + // Cast appropriate skill + if useHolyBolt { + if kb, found := ctx.Data.KeyBindings.KeyBindingForSkill(skill.HolyBolt); found { + ctx.HID.PressKeyBinding(kb) + if err := step.PrimaryAttack(currentTargetID, 1, true, hbOpts...); err == nil { + if !f.waitForCastComplete() { + continue + } + f.lastCastTime = time.Now() + completedAttackLoops++ + } + } + } else { + if kb, found := ctx.Data.KeyBindings.KeyBindingForSkill(skill.FistOfTheHeavens); found { + ctx.HID.PressKeyBinding(kb) + if err := step.PrimaryAttack(currentTargetID, 1, true, fohOpts...); err == nil { + if !f.waitForCastComplete() { + continue + } + f.lastCastTime = time.Now() + completedAttackLoops++ + } + } + } + } +} +func (f Foh) handleBoss(bossID data.UnitID, fohOpts, hbOpts []step.AttackOption, completedAttackLoops *int) error { + ctx := context.Get() + + // Cast FoH + if kb, found := ctx.Data.KeyBindings.KeyBindingForSkill(skill.FistOfTheHeavens); found { + ctx.HID.PressKeyBinding(kb) + + if err := step.PrimaryAttack(bossID, 1, true, fohOpts...); err == nil { + // Wait for FoH cast to complete + if !f.waitForCastComplete() { + return fmt.Errorf("foh cast timed out") + } + f.lastCastTime = time.Now() + + // Switch to Holy Bolt + if kb, found := ctx.Data.KeyBindings.KeyBindingForSkill(skill.HolyBolt); found { + ctx.HID.PressKeyBinding(kb) + + // Cast 3 Holy Bolts + for i := 0; i < 3; i++ { + if err := step.PrimaryAttack(bossID, 1, true, hbOpts...); err == nil { + if !f.waitForCastComplete() { + return fmt.Errorf("holy Bolt cast timed out") + } + f.lastCastTime = time.Now() + } + } + + (*completedAttackLoops)++ + } + } + } + return nil +} +func (f Foh) KillBossSequence(monsterSelector func(d game.Data) (data.UnitID, bool), skipOnImmunities []stat.Resist) error { + ctx := context.Get() + lastRefresh := time.Now() + completedAttackLoops := 0 + + // Ensure we always return to FoH when done + defer func() { + if kb, found := ctx.Data.KeyBindings.KeyBindingForSkill(skill.FistOfTheHeavens); found { + ctx.HID.PressKeyBinding(kb) + } + }() + + fohOpts := []step.AttackOption{ + step.StationaryDistance(fohMinDistance, fohMaxDistance), + step.EnsureAura(skill.Conviction), + } + hbOpts := []step.AttackOption{ + step.StationaryDistance(hbMinDistance, hbMaxDistance), + step.EnsureAura(skill.Conviction), + } + + for { + if time.Since(lastRefresh) > time.Millisecond*100 { + ctx.RefreshGameData() + lastRefresh = time.Now() + } + ctx.PauseIfNotPriority() + if completedAttackLoops >= fohMaxAttacksLoop { + return nil + } + id, found := monsterSelector(*f.Data) + if !found { + return nil + } + if !f.preBattleChecks(id, skipOnImmunities) { + return nil + } + monster, found := f.Data.Monsters.FindByID(id) + if !found || monster.Stats[stat.Life] <= 0 { + return nil + } + if !f.Data.PlayerUnit.States.HasState(state.Conviction) { + if kb, found := ctx.Data.KeyBindings.KeyBindingForSkill(skill.Conviction); found { + ctx.HID.PressKeyBinding(kb) + } + } + + if err := f.handleBoss(monster.UnitID, fohOpts, hbOpts, &completedAttackLoops); err == nil { + continue + } + } +} + +func (f Foh) BuffSkills() []skill.ID { + if _, found := f.Data.KeyBindings.KeyBindingForSkill(skill.HolyShield); found { + return []skill.ID{skill.HolyShield} + } + return make([]skill.ID, 0) +} + +func (f Foh) PreCTABuffSkills() []skill.ID { + return make([]skill.ID, 0) +} + +func (f Foh) killBoss(npc npc.ID, t data.MonsterType) error { + return f.KillBossSequence(func(d game.Data) (data.UnitID, bool) { + m, found := d.Monsters.FindOne(npc, t) + if !found || m.Stats[stat.Life] <= 0 { + return 0, false + } + return m.UnitID, true + }, nil) +} + +func (f Foh) KillCountess() error { + return f.killBoss(npc.DarkStalker, data.MonsterTypeSuperUnique) +} + +func (f Foh) KillAndariel() error { + return f.killBoss(npc.Andariel, data.MonsterTypeUnique) +} + +func (f Foh) KillSummoner() error { + return f.killBoss(npc.Summoner, data.MonsterTypeUnique) +} + +func (f Foh) KillDuriel() error { + return f.killBoss(npc.Duriel, data.MonsterTypeUnique) +} + +func (f Foh) KillCouncil() error { + // Disable item pickup while killing council members + context.Get().DisableItemPickup() + defer context.Get().EnableItemPickup() + + err := f.killAllCouncilMembers() + if err != nil { + return err + } + + // Wait a moment for items to settle + time.Sleep(300 * time.Millisecond) + + // Re-enable item pickup and do a final pickup pass + err = action.ItemPickup(40) + if err != nil { + f.Logger.Warn("Error during final item pickup after council", "error", err) + } + + return nil +} +func (f Foh) killAllCouncilMembers() error { + for { + if !f.anyCouncilMemberAlive() { + return nil + } + + err := f.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + for _, m := range d.Monsters.Enemies() { + if (m.Name == npc.CouncilMember || m.Name == npc.CouncilMember2 || m.Name == npc.CouncilMember3) && m.Stats[stat.Life] > 0 { + return m.UnitID, true + } + } + return 0, false + }, nil) + + if err != nil { + return err + } + } +} + +func (f Foh) anyCouncilMemberAlive() bool { + for _, m := range f.Data.Monsters.Enemies() { + if (m.Name == npc.CouncilMember || m.Name == npc.CouncilMember2 || m.Name == npc.CouncilMember3) && m.Stats[stat.Life] > 0 { + return true + } + + } + return false +} + +func (f Foh) KillMephisto() error { + return f.killBoss(npc.Mephisto, data.MonsterTypeUnique) +} + +func (f Foh) KillIzual() error { + return f.killBoss(npc.Izual, data.MonsterTypeUnique) +} + +func (f Foh) KillDiablo() error { + timeout := time.Second * 20 + startTime := time.Now() + diabloFound := false + + for { + if time.Since(startTime) > timeout && !diabloFound { + f.Logger.Error("Diablo was not found, timeout reached") + return nil + } + + diablo, found := f.Data.Monsters.FindOne(npc.Diablo, data.MonsterTypeUnique) + if !found || diablo.Stats[stat.Life] <= 0 { + if diabloFound { + return nil + } + time.Sleep(200 * time.Millisecond) + continue + } + + diabloFound = true + f.Logger.Info("Diablo detected, attacking") + + return f.killBoss(npc.Diablo, data.MonsterTypeUnique) + } +} + +func (f Foh) KillPindle() error { + return f.killBoss(npc.DefiledWarrior, data.MonsterTypeSuperUnique) +} + +func (f Foh) KillNihlathak() error { + return f.killBoss(npc.Nihlathak, data.MonsterTypeSuperUnique) +} + +func (f Foh) KillBaal() error { + return f.killBoss(npc.BaalCrab, data.MonsterTypeUnique) +} diff --git a/internal/character/hammerdin.go b/internal/character/hammerdin.go index 21a34f006..7bec14d0b 100644 --- a/internal/character/hammerdin.go +++ b/internal/character/hammerdin.go @@ -1,159 +1,145 @@ package character import ( + "fmt" + "log/slog" "sort" "time" + "github.com/hectorgimenez/koolo/internal/action/step" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/npc" "github.com/hectorgimenez/d2go/pkg/data/skill" "github.com/hectorgimenez/d2go/pkg/data/stat" - "github.com/hectorgimenez/koolo/internal/action" - "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/pather" ) const ( - hammerdinMaxAttacksLoop = 20 + hammerdinMaxAttacksLoop = 20 // Adjust from 5-20 depending on DMG and rotation, lower attack loops would cause higher attack rotation whereas bigger would perform multiple(longer) attacks on one spot. ) type Hammerdin struct { BaseCharacter } +func (s Hammerdin) CheckKeyBindings() []skill.ID { + requireKeybindings := []skill.ID{skill.Concentration, skill.HolyShield, skill.TomeOfTownPortal} + missingKeybindings := []skill.ID{} + + for _, cskill := range requireKeybindings { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(cskill); !found { + missingKeybindings = append(missingKeybindings, cskill) + } + } + + if len(missingKeybindings) > 0 { + s.Logger.Debug("There are missing required key bindings.", slog.Any("Bindings", missingKeybindings)) + } + + return missingKeybindings +} + func (s Hammerdin) KillMonsterSequence( - monsterSelector func(d data.Data) (data.UnitID, bool), + monsterSelector func(d game.Data) (data.UnitID, bool), skipOnImmunities []stat.Resist, - opts ...step.AttackOption, -) action.Action { +) error { completedAttackLoops := 0 previousUnitID := 0 - return action.NewStepChain(func(d data.Data) []step.Step { - id, found := monsterSelector(d) + for { + id, found := monsterSelector(*s.Data) if !found { - return []step.Step{} + return nil } if previousUnitID != int(id) { completedAttackLoops = 0 } - if !s.preBattleChecks(d, id, skipOnImmunities) { - return []step.Step{} + if !s.preBattleChecks(id, skipOnImmunities) { + return nil } if completedAttackLoops >= hammerdinMaxAttacksLoop { - return []step.Step{} + return nil + } + + monster, found := s.Data.Monsters.FindByID(id) + if !found { + s.Logger.Info("Monster not found", slog.String("monster", fmt.Sprintf("%v", monster))) + return nil } - steps := make([]step.Step, 0) // Add a random movement, maybe hammer is not hitting the target if previousUnitID == int(id) { - steps = append(steps, - step.SyncStep(func(d data.Data) error { - monster, f := d.Monsters.FindByID(id) - if f && monster.Stats[stat.Life] > 0 { - pather.RandomMovement() - } - return nil - }), - ) + if monster.Stats[stat.Life] > 0 { + s.PathFinder.RandomMovement() + } + return nil } - steps = append(steps, - step.PrimaryAttack( - id, - 8, - step.Distance(2, 8), - step.EnsureAura(config.Config.Bindings.Paladin.Concentration), - ), + + step.PrimaryAttack( + id, + 3, + true, + step.Distance(2, 2), // X,Y coords of 2,2 is the perfect hammer angle attack for NPC targeting/attacking, you can adjust accordingly anything between 1,1 - 3,3 is acceptable, where the higher the number, the bigger the distance from the player (usually used for De Seis) + step.EnsureAura(skill.Concentration), ) + completedAttackLoops++ previousUnitID = int(id) - - return steps - }, action.RepeatUntilNoSteps()) -} - -func (s Hammerdin) BuffSkills() map[skill.Skill]string { - return map[skill.Skill]string{ - skill.HolyShield: config.Config.Bindings.Paladin.HolyShield, } } -func (s Hammerdin) KillCountess() action.Action { - return s.killMonster(npc.DarkStalker, data.MonsterTypeSuperUnique) -} +func (s Hammerdin) killMonster(npc npc.ID, t data.MonsterType) error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + m, found := d.Monsters.FindOne(npc, t) + if !found { + return 0, false + } -func (s Hammerdin) KillAndariel() action.Action { - return s.killMonster(npc.Andariel, data.MonsterTypeNone) + return m.UnitID, true + }, nil) } -func (s Hammerdin) KillSummoner() action.Action { - return s.killMonster(npc.Summoner, data.MonsterTypeNone) -} +func (s Hammerdin) killMonsterByName(id npc.ID, monsterType data.MonsterType, _ bool) error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + if m, found := d.Monsters.FindOne(id, monsterType); found { + return m.UnitID, true + } -func (s Hammerdin) KillDuriel() action.Action { - return s.killMonster(npc.Duriel, data.MonsterTypeNone) + return 0, false + }, nil) } -func (s Hammerdin) KillPindle(_ []stat.Resist) action.Action { - return s.killMonster(npc.DefiledWarrior, data.MonsterTypeSuperUnique) +func (s Hammerdin) BuffSkills() []skill.ID { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.HolyShield); found { + return []skill.ID{skill.HolyShield} + } + return []skill.ID{} } -func (s Hammerdin) KillMephisto() action.Action { - return s.killMonster(npc.Mephisto, data.MonsterTypeNone) +func (s Hammerdin) PreCTABuffSkills() []skill.ID { + return []skill.ID{} } -func (s Hammerdin) KillNihlathak() action.Action { - return s.killMonster(npc.Nihlathak, data.MonsterTypeSuperUnique) +func (s Hammerdin) KillCountess() error { + return s.killMonsterByName(npc.DarkStalker, data.MonsterTypeSuperUnique, false) } -func (s Hammerdin) KillDiablo() action.Action { - timeout := time.Second * 20 - startTime := time.Time{} - diabloFound := false - return action.NewChain(func(d data.Data) []action.Action { - if startTime.IsZero() { - startTime = time.Now() - } - - if time.Since(startTime) > timeout && !diabloFound { - s.logger.Error("Diablo was not found, timeout reached") - return nil - } - - diablo, found := d.Monsters.FindOne(npc.Diablo, data.MonsterTypeNone) - if !found || diablo.Stats[stat.Life] <= 0 { - // Already dead - if diabloFound { - return nil - } - - // Keep waiting... - return []action.Action{action.NewStepChain(func(d data.Data) []step.Step { - return []step.Step{step.Wait(time.Millisecond * 100)} - })} - } - - diabloFound = true - s.logger.Info("Diablo detected, attacking") - - return []action.Action{ - s.killMonster(npc.Diablo, data.MonsterTypeNone), - s.killMonster(npc.Diablo, data.MonsterTypeNone), - s.killMonster(npc.Diablo, data.MonsterTypeNone), - } - }, action.RepeatUntilNoSteps()) +func (s Hammerdin) KillAndariel() error { + return s.killMonsterByName(npc.Andariel, data.MonsterTypeUnique, false) +} +func (s Hammerdin) KillSummoner() error { + return s.killMonsterByName(npc.Summoner, data.MonsterTypeUnique, false) } -func (s Hammerdin) KillIzual() action.Action { - return s.killMonster(npc.Izual, data.MonsterTypeNone) +func (s Hammerdin) KillDuriel() error { + return s.killMonsterByName(npc.Duriel, data.MonsterTypeUnique, false) } -func (s Hammerdin) KillCouncil() action.Action { - return action.NewStepChain(func(d data.Data) (steps []step.Step) { +func (s Hammerdin) KillCouncil() error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { // Exclude monsters that are not council members var councilMembers []data.Monster for _, m := range d.Monsters { @@ -164,51 +150,65 @@ func (s Hammerdin) KillCouncil() action.Action { // Order council members by distance sort.Slice(councilMembers, func(i, j int) bool { - distanceI := pather.DistanceFromMe(d, councilMembers[i].Position) - distanceJ := pather.DistanceFromMe(d, councilMembers[j].Position) + distanceI := s.PathFinder.DistanceFromMe(councilMembers[i].Position) + distanceJ := s.PathFinder.DistanceFromMe(councilMembers[j].Position) return distanceI < distanceJ }) for _, m := range councilMembers { - for i := 0; i < hammerdinMaxAttacksLoop; i++ { - steps = append(steps, - step.PrimaryAttack( - m.UnitID, - 8, - step.Distance(2, 8), - step.EnsureAura(config.Config.Bindings.Paladin.Concentration), - ), - ) - } + return m.UnitID, true } - return - }, action.CanBeSkipped()) + + return 0, false + }, nil) } -func (s Hammerdin) KillBaal() action.Action { - return s.killMonster(npc.BaalCrab, data.MonsterTypeNone) +func (s Hammerdin) KillMephisto() error { + return s.killMonsterByName(npc.Mephisto, data.MonsterTypeUnique, false) +} +func (s Hammerdin) KillIzual() error { + return s.killMonster(npc.Izual, data.MonsterTypeUnique) } -func (s Hammerdin) killMonster(npc npc.ID, t data.MonsterType) action.Action { - return action.NewStepChain(func(d data.Data) (steps []step.Step) { - m, found := d.Monsters.FindOne(npc, t) - if !found { +func (s Hammerdin) KillDiablo() error { + timeout := time.Second * 20 + startTime := time.Now() + diabloFound := false + + for { + if time.Since(startTime) > timeout && !diabloFound { + s.Logger.Error("Diablo was not found, timeout reached") return nil } - helper.Sleep(100) - for i := 0; i < hammerdinMaxAttacksLoop; i++ { - steps = append(steps, - step.PrimaryAttack( - m.UnitID, - 8, - step.Distance(2, 8), - step.EnsureAura(config.Config.Bindings.Paladin.Concentration), - ), - ) + diablo, found := s.Data.Monsters.FindOne(npc.Diablo, data.MonsterTypeUnique) + if !found || diablo.Stats[stat.Life] <= 0 { + // Already dead + if diabloFound { + return nil + } + + // Keep waiting... + time.Sleep(200) + continue } - return - }, action.CanBeSkipped()) + diabloFound = true + s.Logger.Info("Diablo detected, attacking") + + return s.killMonster(npc.Diablo, data.MonsterTypeUnique) + } +} + +func (s Hammerdin) KillPindle() error { + return s.killMonsterByName(npc.DefiledWarrior, data.MonsterTypeSuperUnique, false) +} + +func (s Hammerdin) KillNihlathak() error { + return s.killMonsterByName(npc.Nihlathak, data.MonsterTypeSuperUnique, false) +} + +func (s Hammerdin) KillBaal() error { + return s.killMonster(npc.BaalCrab, data.MonsterTypeUnique) } diff --git a/internal/character/hydraorb_sorceress.go b/internal/character/hydraorb_sorceress.go new file mode 100644 index 000000000..c06ba3a72 --- /dev/null +++ b/internal/character/hydraorb_sorceress.go @@ -0,0 +1,247 @@ +package character + +import ( + "fmt" + "log/slog" + "time" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/d2go/pkg/data/skill" + "github.com/hectorgimenez/d2go/pkg/data/stat" + "github.com/hectorgimenez/d2go/pkg/data/state" + "github.com/hectorgimenez/koolo/internal/action/step" + "github.com/hectorgimenez/koolo/internal/game" +) + +const ( + ho_sorceressMaxAttacksLoop = 40 + ho_sorceressMinDistance = 0 + ho_sorceressMaxDistance = 30 +) + +type HydraOrbSorceress struct { + BaseCharacter +} + +func (s HydraOrbSorceress) CheckKeyBindings() []skill.ID { + requireKeybindings := []skill.ID{skill.FrozenOrb, skill.Hydra, skill.Teleport, skill.TomeOfTownPortal, skill.ShiverArmor, skill.StaticField} + missingKeybindings := []skill.ID{} + + for _, cskill := range requireKeybindings { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(cskill); !found { + switch cskill { + // Since we can have one of 3 armors: + case skill.ShiverArmor: + _, found1 := s.Data.KeyBindings.KeyBindingForSkill(skill.FrozenArmor) + _, found2 := s.Data.KeyBindings.KeyBindingForSkill(skill.ChillingArmor) + if !found1 && !found2 { + missingKeybindings = append(missingKeybindings, skill.ShiverArmor) + } + default: + missingKeybindings = append(missingKeybindings, cskill) + } + } + } + + if len(missingKeybindings) > 0 { + s.Logger.Debug("There are missing required key bindings.", slog.Any("Bindings", missingKeybindings)) + } + + return missingKeybindings +} + +func (s HydraOrbSorceress) KillMonsterSequence( + monsterSelector func(d game.Data) (data.UnitID, bool), + skipOnImmunities []stat.Resist, +) error { + completedAttackLoops := 0 + previousUnitID := 0 + // previousSelfHydra := time.Time{} + + for { + id, found := monsterSelector(*s.Data) + if !found { + return nil + } + if previousUnitID != int(id) { + completedAttackLoops = 0 + } + + if !s.preBattleChecks(id, skipOnImmunities) { + return nil + } + + if completedAttackLoops >= sorceressMaxAttacksLoop { + return nil + } + + monster, found := s.Data.Monsters.FindByID(id) + if !found { + s.Logger.Info("Monster not found", slog.String("monster", fmt.Sprintf("%v", monster))) + return nil + } + + opts := step.Distance(ho_sorceressMinDistance, ho_sorceressMaxDistance) + + // Cast a Blizzard on very close mobs, in order to clear possible trash close the player, every two attack rotations + // if time.Since(previousSelfHydra) > time.Second*4 && !s.Data.PlayerUnit.States.HasState(state.Cooldown) { + // for _, m := range s.Data.Monsters.Enemies() { + // if dist := s.pf.DistanceFromMe(m.Position); dist < 4 { + // s.Logger.Debug("Monster detected close to the player, casting Hydra on myself") + // previousSelfHydra = time.Now() + // step.SecondaryAttack(skill.Hydra, m.UnitID, 1, opts) + // } + // } + //} + + if s.Data.PlayerUnit.States.HasState(state.Cooldown) { + step.SecondaryAttack(skill.Hydra, id, 1, opts) + } + + step.SecondaryAttack(skill.FrozenOrb, id, 1, opts) + + completedAttackLoops++ + previousUnitID = int(id) + } +} + +func (s HydraOrbSorceress) killMonster(npc npc.ID, t data.MonsterType) error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + m, found := d.Monsters.FindOne(npc, t) + if !found { + return 0, false + } + + return m.UnitID, true + }, nil) +} + +func (s HydraOrbSorceress) killMonsterByName(id npc.ID, monsterType data.MonsterType, _ int, _ bool, skipOnImmunities []stat.Resist) error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + if m, found := d.Monsters.FindOne(id, monsterType); found { + return m.UnitID, true + } + + return 0, false + }, skipOnImmunities) +} + +func (s HydraOrbSorceress) BuffSkills() []skill.ID { + skillsList := make([]skill.ID, 0) + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.EnergyShield); found { + skillsList = append(skillsList, skill.EnergyShield) + } + + armors := []skill.ID{skill.ChillingArmor, skill.ShiverArmor, skill.FrozenArmor} + for _, armor := range armors { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(armor); found { + skillsList = append(skillsList, armor) + return skillsList + } + } + + return skillsList +} + +func (s HydraOrbSorceress) PreCTABuffSkills() []skill.ID { + return []skill.ID{} +} + +func (s HydraOrbSorceress) KillCountess() error { + return s.killMonsterByName(npc.DarkStalker, data.MonsterTypeSuperUnique, ho_sorceressMaxDistance, false, nil) +} + +func (s HydraOrbSorceress) KillAndariel() error { + return s.killMonsterByName(npc.Andariel, data.MonsterTypeUnique, ho_sorceressMaxDistance, false, nil) +} +func (s HydraOrbSorceress) KillSummoner() error { + return s.killMonsterByName(npc.Summoner, data.MonsterTypeUnique, ho_sorceressMaxDistance, false, nil) +} + +func (s HydraOrbSorceress) KillDuriel() error { + return s.killMonsterByName(npc.Duriel, data.MonsterTypeUnique, ho_sorceressMaxDistance, true, nil) +} + +func (s HydraOrbSorceress) KillCouncil() error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + // Exclude monsters that are not council members + var councilMembers []data.Monster + var veryImmunes []data.Monster + for _, m := range d.Monsters.Enemies() { + if m.Name == npc.CouncilMember || m.Name == npc.CouncilMember2 || m.Name == npc.CouncilMember3 { + if m.IsImmune(stat.ColdImmune) && m.IsImmune(stat.FireImmune) { + veryImmunes = append(veryImmunes, m) + } else { + councilMembers = append(councilMembers, m) + } + } + } + + councilMembers = append(councilMembers, veryImmunes...) + + for _, m := range councilMembers { + return m.UnitID, true + } + + return 0, false + }, nil) +} + +func (s HydraOrbSorceress) KillMephisto() error { + return s.killMonsterByName(npc.Mephisto, data.MonsterTypeUnique, blizzMaxDistance, true, nil) +} + +func (s HydraOrbSorceress) KillIzual() error { + m, _ := s.Data.Monsters.FindOne(npc.Izual, data.MonsterTypeUnique) + _ = step.SecondaryAttack(skill.StaticField, m.UnitID, 4, step.Distance(5, 8)) + + return s.killMonster(npc.Izual, data.MonsterTypeUnique) +} + +func (s HydraOrbSorceress) KillDiablo() error { + timeout := time.Second * 20 + startTime := time.Now() + diabloFound := false + + for { + if time.Since(startTime) > timeout && !diabloFound { + s.Logger.Error("Diablo was not found, timeout reached") + return nil + } + + diablo, found := s.Data.Monsters.FindOne(npc.Diablo, data.MonsterTypeUnique) + if !found || diablo.Stats[stat.Life] <= 0 { + // Already dead + if diabloFound { + return nil + } + + // Keep waiting... + time.Sleep(200) + continue + } + + diabloFound = true + s.Logger.Info("Diablo detected, attacking") + + _ = step.SecondaryAttack(skill.StaticField, diablo.UnitID, 5, step.Distance(3, 8)) + + return s.killMonster(npc.Diablo, data.MonsterTypeUnique) + } +} + +func (s HydraOrbSorceress) KillPindle() error { + return s.killMonsterByName(npc.DefiledWarrior, data.MonsterTypeSuperUnique, ho_sorceressMaxDistance, false, s.CharacterCfg.Game.Pindleskin.SkipOnImmunities) +} + +func (s HydraOrbSorceress) KillNihlathak() error { + return s.killMonsterByName(npc.Nihlathak, data.MonsterTypeSuperUnique, ho_sorceressMaxDistance, false, nil) +} + +func (s HydraOrbSorceress) KillBaal() error { + m, _ := s.Data.Monsters.FindOne(npc.BaalCrab, data.MonsterTypeUnique) + step.SecondaryAttack(skill.StaticField, m.UnitID, 5, step.Distance(5, 8)) + + return s.killMonster(npc.BaalCrab, data.MonsterTypeUnique) +} diff --git a/internal/character/javazon.go b/internal/character/javazon.go new file mode 100644 index 000000000..a5efc8789 --- /dev/null +++ b/internal/character/javazon.go @@ -0,0 +1,256 @@ +package character + +import ( + "fmt" + "log/slog" + "sort" + "time" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/d2go/pkg/data/skill" + "github.com/hectorgimenez/d2go/pkg/data/stat" + "github.com/hectorgimenez/koolo/internal/action/step" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/pather" +) + +const ( + maxJavazonAttackLoops = 10 + minJavazonDistance = 10 + maxJavazonDistance = 30 +) + +type Javazon struct { + BaseCharacter +} + +func (s Javazon) CheckKeyBindings() []skill.ID { + requireKeybindings := []skill.ID{skill.LightningFury, skill.TomeOfTownPortal} + missingKeybindings := []skill.ID{} + + for _, cskill := range requireKeybindings { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(cskill); !found { + missingKeybindings = append(missingKeybindings, cskill) + } + } + + if len(missingKeybindings) > 0 { + s.Logger.Debug("There are missing required key bindings.", slog.Any("Bindings", missingKeybindings)) + } + + return missingKeybindings +} + +func (s Javazon) KillMonsterSequence( + monsterSelector func(d game.Data) (data.UnitID, bool), + skipOnImmunities []stat.Resist, +) error { + completedAttackLoops := 0 + previousUnitID := 0 + const numOfAttacks = 5 + + for { + id, found := monsterSelector(*s.Data) + if !found { + return nil + } + if previousUnitID != int(id) { + completedAttackLoops = 0 + } + + if !s.preBattleChecks(id, skipOnImmunities) { + return nil + } + + if completedAttackLoops >= maxJavazonAttackLoops { + return nil + } + + monster, found := s.Data.Monsters.FindByID(id) + if !found { + s.Logger.Info("Monster not found", slog.String("monster", fmt.Sprintf("%v", monster))) + return nil + } + + closeMonsters := 0 + for _, mob := range s.Data.Monsters { + if mob.IsPet() || mob.IsMerc() || mob.IsGoodNPC() || mob.IsSkip() || monster.Stats[stat.Life] <= 0 && mob.UnitID != monster.UnitID { + continue + } + if pather.DistanceFromPoint(mob.Position, monster.Position) <= 15 { + closeMonsters++ + } + if closeMonsters >= 3 { + break + } + } + + if closeMonsters >= 3 { + step.SecondaryAttack(skill.LightningFury, id, numOfAttacks, step.Distance(minJavazonDistance, maxJavazonDistance)) + } else { + step.PrimaryAttack(id, numOfAttacks, false, step.Distance(1, 1)) + } + + completedAttackLoops++ + previousUnitID = int(id) + } +} + +func (s Javazon) KillBossSequence( + monsterSelector func(d game.Data) (data.UnitID, bool), + skipOnImmunities []stat.Resist, +) error { + completedAttackLoops := 0 + previousUnitID := 0 + const numOfAttacks = 5 + + for { + id, found := monsterSelector(*s.Data) + if !found { + return nil + } + if previousUnitID != int(id) { + completedAttackLoops = 0 + } + + if !s.preBattleChecks(id, skipOnImmunities) { + return nil + } + + if completedAttackLoops >= maxJavazonAttackLoops { + return nil + } + + completedAttackLoops++ + previousUnitID = int(id) + + step.PrimaryAttack(id, numOfAttacks, false, step.Distance(1, 1)) + } +} + +func (s Javazon) killMonster(npc npc.ID, t data.MonsterType) error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + m, found := d.Monsters.FindOne(npc, t) + if !found { + return 0, false + } + + return m.UnitID, true + }, nil) +} + +func (s Javazon) killBoss(npc npc.ID, t data.MonsterType) error { + return s.KillBossSequence(func(d game.Data) (data.UnitID, bool) { + m, found := d.Monsters.FindOne(npc, t) + if !found { + return 0, false + } + + return m.UnitID, true + }, nil) +} + +func (s Javazon) PreCTABuffSkills() []skill.ID { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.Valkyrie); found { + return []skill.ID{skill.Valkyrie} + } else { + return []skill.ID{} + } +} + +func (s Javazon) BuffSkills() []skill.ID { + return []skill.ID{} +} + +func (s Javazon) KillCountess() error { + return s.killMonster(npc.DarkStalker, data.MonsterTypeSuperUnique) +} + +func (s Javazon) KillAndariel() error { + return s.killBoss(npc.Andariel, data.MonsterTypeUnique) +} + +func (s Javazon) KillSummoner() error { + return s.killMonster(npc.Summoner, data.MonsterTypeUnique) +} + +func (s Javazon) KillDuriel() error { + return s.killBoss(npc.Duriel, data.MonsterTypeUnique) +} + +func (s Javazon) KillCouncil() error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + // Exclude monsters that are not council members + var councilMembers []data.Monster + for _, m := range d.Monsters { + if m.Name == npc.CouncilMember || m.Name == npc.CouncilMember2 || m.Name == npc.CouncilMember3 { + councilMembers = append(councilMembers, m) + } + } + + // Order council members by distance + sort.Slice(councilMembers, func(i, j int) bool { + distanceI := s.PathFinder.DistanceFromMe(councilMembers[i].Position) + distanceJ := s.PathFinder.DistanceFromMe(councilMembers[j].Position) + + return distanceI < distanceJ + }) + + for _, m := range councilMembers { + return m.UnitID, true + } + + return 0, false + }, nil) +} + +func (s Javazon) KillMephisto() error { + return s.killBoss(npc.Mephisto, data.MonsterTypeUnique) +} + +func (s Javazon) KillIzual() error { + return s.killBoss(npc.Izual, data.MonsterTypeUnique) +} + +func (s Javazon) KillDiablo() error { + timeout := time.Second * 20 + startTime := time.Now() + diabloFound := false + + for { + if time.Since(startTime) > timeout && !diabloFound { + s.Logger.Error("Diablo was not found, timeout reached") + return nil + } + + diablo, found := s.Data.Monsters.FindOne(npc.Diablo, data.MonsterTypeUnique) + if !found || diablo.Stats[stat.Life] <= 0 { + // Already dead + if diabloFound { + return nil + } + + // Keep waiting... + time.Sleep(200) + continue + } + + diabloFound = true + s.Logger.Info("Diablo detected, attacking") + + return s.killMonster(npc.Diablo, data.MonsterTypeUnique) + } +} + +func (s Javazon) KillPindle() error { + return s.killBoss(npc.DefiledWarrior, data.MonsterTypeSuperUnique) +} + +func (s Javazon) KillNihlathak() error { + return s.killBoss(npc.Nihlathak, data.MonsterTypeSuperUnique) +} + +func (s Javazon) KillBaal() error { + return s.killBoss(npc.BaalCrab, data.MonsterTypeUnique) +} diff --git a/internal/character/lightning_sorceress.go b/internal/character/lightning_sorceress.go index e641ac8fd..9ab74fea6 100644 --- a/internal/character/lightning_sorceress.go +++ b/internal/character/lightning_sorceress.go @@ -1,251 +1,272 @@ package character import ( + "log/slog" "time" "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/npc" "github.com/hectorgimenez/d2go/pkg/data/skill" "github.com/hectorgimenez/d2go/pkg/data/stat" - "github.com/hectorgimenez/koolo/internal/action" "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/pather" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" ) const ( - lightningSorceressMaxAttacksLoop = 10 - lightningSorceressMinDistance = 1 - lightningSorceressMaxDistance = 10 + LightningMinDistance = 10 + LightningMaxDistance = 20 + LightningStaticMinDistance = 1 + LightningStaticMaxDistance = 3 + LightningMaxAttacksLoop = 40 + LightningStaticFieldThreshold = 67 // Cast Static Field if monster HP is above this percentage ) type LightningSorceress struct { BaseCharacter } +func (s LightningSorceress) CheckKeyBindings() []skill.ID { + requiredKeybindings := []skill.ID{skill.ChainLightning, skill.Teleport, skill.TomeOfTownPortal, skill.StaticField, skill.ShiverArmor} + missingKeybindings := []skill.ID{} + + for _, cskill := range requiredKeybindings { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(cskill); !found { + missingKeybindings = append(missingKeybindings, cskill) + } + } + + // Check for one of the armor skills + armorSkills := []skill.ID{skill.FrozenArmor, skill.ShiverArmor, skill.ChillingArmor} + hasArmor := false + for _, armor := range armorSkills { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(armor); found { + hasArmor = true + break + } + } + if !hasArmor { + missingKeybindings = append(missingKeybindings, skill.FrozenArmor) + } + + if len(missingKeybindings) > 0 { + s.Logger.Debug("There are missing required key bindings.", slog.Any("Bindings", missingKeybindings)) + } + + return missingKeybindings +} + func (s LightningSorceress) KillMonsterSequence( - monsterSelector func(d data.Data) (data.UnitID, bool), + monsterSelector func(d game.Data) (data.UnitID, bool), skipOnImmunities []stat.Resist, - opts ...step.AttackOption, -) action.Action { +) error { + ctx := context.Get() completedAttackLoops := 0 - previousUnitID := 0 + staticFieldCast := false + ldOpts := step.Distance(LightningMinDistance, LightningMaxDistance) + lightningOpts := []step.AttackOption{ + step.RangedDistance(LightningMinDistance, LightningMaxDistance), + } - return action.NewStepChain(func(d data.Data) []step.Step { - id, found := monsterSelector(d) + for { + ctx.PauseIfNotPriority() + + id, found := monsterSelector(*s.Data) if !found { - return []step.Step{} - } - if previousUnitID != int(id) { - completedAttackLoops = 0 + return nil } - if !s.preBattleChecks(d, id, skipOnImmunities) { - return []step.Step{} + if !s.preBattleChecks(id, skipOnImmunities) { + return nil } - if len(opts) == 0 { - opts = append(opts, step.Distance(lightningSorceressMinDistance, lightningSorceressMaxDistance)) - } - //if useStaticField { - // steps = append(steps, - // step.SecondaryAttack(config.Config.Bindings.Sorceress.Blizzard, id, 1, time.Millisecond*100, step.Distance(lightningSorceressMinDistance, maxDistance)), - // step.SecondaryAttack(config.Config.Bindings.Sorceress.StaticField, id, 5, config.Config.Runtime.CastDuration, step.Distance(lightningSorceressMinDistance, 15)), - // ) - //} - if completedAttackLoops >= lightningSorceressMaxAttacksLoop { - return []step.Step{} + monster, found := s.Data.Monsters.FindByID(id) + if !found || monster.Stats[stat.Life] <= 0 { + return nil } - steps := make([]step.Step, 0) - // Cast a Blizzard on very close mobs, in order to clear possible trash close the player, every two attack rotations - if completedAttackLoops%2 == 0 { - for _, m := range d.Monsters.Enemies() { - if d := pather.DistanceFromMe(d, m.Position); d < 4 { - s.logger.Debug("Monster detected close to the player, casting Nova over it") - steps = append(steps, step.SecondaryAttack(config.Config.Bindings.Sorceress.Nova, m.UnitID, 5, opts...), step.SecondaryAttack(config.Config.Bindings.Sorceress.Nova, m.UnitID, 5, opts...)) - break - } + // Cast Static Field first if needed + if !staticFieldCast && s.shouldCastStaticField(monster) { + staticOpts := []step.AttackOption{ + step.RangedDistance(LightningStaticMinDistance, LightningStaticMaxDistance), } - } - // In case monster is stuck behind a wall or character is not able to reach it we will short the distance - if completedAttackLoops > 5 { - if completedAttackLoops == 6 { - s.logger.Debug("Looks like monster is not reachable, reducing max attack distance") + if err := step.SecondaryAttack(skill.StaticField, monster.UnitID, 1, staticOpts...); err == nil { + staticFieldCast = true + continue } - opts = []step.AttackOption{step.Distance(1, 5)} } - steps = append(steps, - step.SecondaryAttack(config.Config.Bindings.Sorceress.Nova, id, 3, opts...), - step.SecondaryAttack(config.Config.Bindings.Sorceress.Nova, id, 3, opts...), - // step.PrimaryAttack(id, 4, opts...), - ) - completedAttackLoops++ - previousUnitID = int(id) + if monster.Name == npc.Andariel || + monster.Name == npc.Duriel || + monster.Name == npc.Mephisto || + monster.Name == npc.Diablo || + monster.Name == npc.BaalCrab || + monster.Name == npc.Izual { + if err := step.PrimaryAttack(monster.UnitID, 1, true, ldOpts); err == nil { + completedAttackLoops++ + } + } else { + if err := step.SecondaryAttack(skill.ChainLightning, monster.UnitID, 1, lightningOpts...); err == nil { + completedAttackLoops++ + } + } - return steps - }, action.RepeatUntilNoSteps()) + if completedAttackLoops >= LightningMaxAttacksLoop { + completedAttackLoops = 0 + staticFieldCast = false + } + } } -func (s LightningSorceress) BuffSkills() map[skill.Skill]string { - return map[skill.Skill]string{ - skill.FrozenArmor: config.Config.Bindings.Sorceress.FrozenArmor, - skill.EnergyShield: config.Config.Bindings.Sorceress.EnergyShield, +func (s LightningSorceress) shouldCastStaticField(monster data.Monster) bool { + // Only cast Static Field if monster HP is above threshold + maxLife := float64(monster.Stats[stat.MaxLife]) + if maxLife == 0 { + return false } + + hpPercentage := (float64(monster.Stats[stat.Life]) / maxLife) * 100 + return hpPercentage > LightningStaticFieldThreshold } -func (s LightningSorceress) KillCountess() action.Action { - return s.killMonsterByName(npc.DarkStalker, data.MonsterTypeSuperUnique, lightningSorceressMaxDistance, false, nil) +func (s LightningSorceress) killBossWithStatic(bossID npc.ID, monsterType data.MonsterType) error { + ctx := context.Get() + + for { + ctx.PauseIfNotPriority() + + boss, found := s.Data.Monsters.FindOne(bossID, monsterType) + if !found || boss.Stats[stat.Life] <= 0 { + return nil + } + + bossHPPercent := (float64(boss.Stats[stat.Life]) / float64(boss.Stats[stat.MaxLife])) * 100 + thresholdFloat := float64(ctx.CharacterCfg.Character.NovaSorceress.BossStaticThreshold) + + // Cast Static Field until boss HP is below threshold + if bossHPPercent > thresholdFloat { + staticOpts := []step.AttackOption{ + step.Distance(LightningStaticMinDistance, LightningStaticMaxDistance), + } + err := step.SecondaryAttack(skill.StaticField, boss.UnitID, 1, staticOpts...) + if err != nil { + s.Logger.Warn("Failed to cast Static Field", slog.String("error", err.Error())) + } + continue + } + + // Switch to Lightning once boss HP is low enough + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + return boss.UnitID, true + }, nil) + } } -func (s LightningSorceress) KillAndariel() action.Action { - return s.killMonsterByName(npc.Andariel, data.MonsterTypeNone, lightningSorceressMaxDistance, false, nil) +func (s LightningSorceress) killMonsterByName(id npc.ID, monsterType data.MonsterType, skipOnImmunities []stat.Resist) error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + if m, found := d.Monsters.FindOne(id, monsterType); found { + return m.UnitID, true + } + + return 0, false + }, skipOnImmunities) } -func (s LightningSorceress) KillSummoner() action.Action { - return s.killMonsterByName(npc.Summoner, data.MonsterTypeNone, lightningSorceressMaxDistance, false, nil) +func (s LightningSorceress) BuffSkills() []skill.ID { + skillsList := make([]skill.ID, 0) + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.EnergyShield); found { + skillsList = append(skillsList, skill.EnergyShield) + } + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.ThunderStorm); found { + skillsList = append(skillsList, skill.ThunderStorm) + } + + // Add one of the armor skills + for _, armor := range []skill.ID{skill.ChillingArmor, skill.ShiverArmor, skill.FrozenArmor} { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(armor); found { + skillsList = append(skillsList, armor) + break + } + } + + return skillsList } -func (s LightningSorceress) KillDuriel() action.Action { - return s.killMonsterByName(npc.Duriel, data.MonsterTypeNone, lightningSorceressMaxDistance, true, nil) +func (s LightningSorceress) PreCTABuffSkills() []skill.ID { + return []skill.ID{} } -func (s LightningSorceress) KillPindle(skipOnImmunities []stat.Resist) action.Action { - return s.killMonsterByName(npc.DefiledWarrior, data.MonsterTypeSuperUnique, lightningSorceressMaxDistance, false, skipOnImmunities) +func (s LightningSorceress) KillAndariel() error { + return s.killBossWithStatic(npc.Andariel, data.MonsterTypeUnique) } -func (s LightningSorceress) KillMephisto() action.Action { - return s.killMonsterByName(npc.Mephisto, data.MonsterTypeNone, lightningSorceressMaxDistance, true, nil) +func (s LightningSorceress) KillDuriel() error { + return s.killBossWithStatic(npc.Duriel, data.MonsterTypeUnique) } -func (s LightningSorceress) KillNihlathak() action.Action { - return s.killMonsterByName(npc.Nihlathak, data.MonsterTypeSuperUnique, lightningSorceressMaxDistance, false, nil) +func (s LightningSorceress) KillMephisto() error { + return s.killBossWithStatic(npc.Mephisto, data.MonsterTypeUnique) } -func (s LightningSorceress) KillDiablo() action.Action { +func (s LightningSorceress) KillDiablo() error { timeout := time.Second * 20 - startTime := time.Time{} + startTime := time.Now() diabloFound := false - return action.NewChain(func(d data.Data) []action.Action { - if startTime.IsZero() { - startTime = time.Now() - } + for { if time.Since(startTime) > timeout && !diabloFound { - s.logger.Error("Diablo was not found, timeout reached") + s.Logger.Error("Diablo was not found, timeout reached") return nil } - diablo, found := d.Monsters.FindOne(npc.Diablo, data.MonsterTypeNone) + diablo, found := s.Data.Monsters.FindOne(npc.Diablo, data.MonsterTypeUnique) if !found || diablo.Stats[stat.Life] <= 0 { - // Already dead if diabloFound { return nil } - - // Keep waiting... - return []action.Action{action.NewStepChain(func(d data.Data) []step.Step { - return []step.Step{step.Wait(time.Millisecond * 100)} - })} + time.Sleep(200 * time.Millisecond) + continue } diabloFound = true - s.logger.Info("Diablo detected, attacking") - - return []action.Action{ - action.NewStepChain(func(d data.Data) []step.Step { - return []step.Step{ - step.SecondaryAttack(config.Config.Bindings.Sorceress.StaticField, diablo.UnitID, 5, step.Distance(3, 8)), - } - }), - s.killMonster(npc.Diablo, data.MonsterTypeNone), - } - }, action.RepeatUntilNoSteps()) -} - -func (s LightningSorceress) KillIzual() action.Action { - return action.NewChain(func(d data.Data) []action.Action { - return []action.Action{ - action.NewStepChain(func(d data.Data) []step.Step { - m, _ := d.Monsters.FindOne(npc.Izual, data.MonsterTypeNone) - return []step.Step{ - step.SecondaryAttack(config.Config.Bindings.Sorceress.StaticField, m.UnitID, 7, step.Distance(5, 8)), - } - }), - // We will need a lot of cycles to kill him probably - s.killMonster(npc.Izual, data.MonsterTypeNone), - s.killMonster(npc.Izual, data.MonsterTypeNone), - s.killMonster(npc.Izual, data.MonsterTypeNone), - s.killMonster(npc.Izual, data.MonsterTypeNone), - s.killMonster(npc.Izual, data.MonsterTypeNone), - s.killMonster(npc.Izual, data.MonsterTypeNone), - s.killMonster(npc.Izual, data.MonsterTypeNone), - } - }) -} - -func (s LightningSorceress) KillBaal() action.Action { - return action.NewChain(func(d data.Data) []action.Action { - return []action.Action{ - action.NewStepChain(func(d data.Data) []step.Step { - m, _ := d.Monsters.FindOne(npc.BaalCrab, data.MonsterTypeNone) - return []step.Step{ - step.SecondaryAttack(config.Config.Bindings.Sorceress.StaticField, m.UnitID, 5, step.Distance(5, 8)), - } - }), - // We will need a lot of cycles to kill him probably - s.killMonster(npc.BaalCrab, data.MonsterTypeNone), - s.killMonster(npc.BaalCrab, data.MonsterTypeNone), - s.killMonster(npc.BaalCrab, data.MonsterTypeNone), - s.killMonster(npc.BaalCrab, data.MonsterTypeNone), - } - }) + s.Logger.Info("Diablo detected, attacking") + + return s.killBossWithStatic(npc.Diablo, data.MonsterTypeUnique) + } } -func (s LightningSorceress) KillCouncil() action.Action { - return s.KillMonsterSequence(func(d data.Data) (data.UnitID, bool) { - // Exclude monsters that are not council members - var councilMembers []data.Monster - var coldImmunes []data.Monster - for _, m := range d.Monsters.Enemies() { - if m.Name == npc.CouncilMember || m.Name == npc.CouncilMember2 || m.Name == npc.CouncilMember3 { - if m.IsImmune(stat.ColdImmune) { - coldImmunes = append(coldImmunes, m) - } else { - councilMembers = append(councilMembers, m) - } - } - } +func (s LightningSorceress) KillBaal() error { + return s.killBossWithStatic(npc.BaalCrab, data.MonsterTypeUnique) +} - councilMembers = append(councilMembers, coldImmunes...) +func (s LightningSorceress) KillCountess() error { + return s.killMonsterByName(npc.DarkStalker, data.MonsterTypeSuperUnique, nil) +} - for _, m := range councilMembers { - return m.UnitID, true - } +func (s LightningSorceress) KillSummoner() error { + return s.killMonsterByName(npc.Summoner, data.MonsterTypeUnique, nil) +} - return 0, false - }, nil, step.Distance(8, lightningSorceressMaxDistance)) +func (s LightningSorceress) KillIzual() error { + return s.killBossWithStatic(npc.Izual, data.MonsterTypeUnique) } -func (s LightningSorceress) killMonsterByName(id npc.ID, monsterType data.MonsterType, maxDistance int, useStaticField bool, skipOnImmunities []stat.Resist) action.Action { - return s.KillMonsterSequence(func(d data.Data) (data.UnitID, bool) { - if m, found := d.Monsters.FindOne(id, monsterType); found { - return m.UnitID, true +func (s LightningSorceress) KillCouncil() error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + for _, m := range d.Monsters.Enemies() { + if m.Name == npc.CouncilMember || m.Name == npc.CouncilMember2 || m.Name == npc.CouncilMember3 { + return m.UnitID, true + } } - return 0, false - }, skipOnImmunities, step.Distance(lightningSorceressMinDistance, maxDistance)) + }, nil) } -func (s LightningSorceress) killMonster(npc npc.ID, t data.MonsterType) action.Action { - return s.KillMonsterSequence(func(d data.Data) (data.UnitID, bool) { - m, found := d.Monsters.FindOne(npc, t) - if !found { - return 0, false - } +func (s LightningSorceress) KillPindle() error { + return s.killMonsterByName(npc.DefiledWarrior, data.MonsterTypeSuperUnique, s.CharacterCfg.Game.Pindleskin.SkipOnImmunities) +} - return m.UnitID, true - }, nil) +func (s LightningSorceress) KillNihlathak() error { + return s.killMonsterByName(npc.Nihlathak, data.MonsterTypeSuperUnique, nil) } diff --git a/internal/character/mosaic.go b/internal/character/mosaic.go new file mode 100644 index 000000000..da4109112 --- /dev/null +++ b/internal/character/mosaic.go @@ -0,0 +1,284 @@ +package character + +import ( + "fmt" + "log/slog" + "sort" + "time" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/d2go/pkg/data/skill" + "github.com/hectorgimenez/d2go/pkg/data/stat" + "github.com/hectorgimenez/d2go/pkg/data/state" + "github.com/hectorgimenez/koolo/internal/action/step" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" +) + +type MosaicSin struct { + BaseCharacter +} + +func (s MosaicSin) CheckKeyBindings() []skill.ID { + requireKeybindings := []skill.ID{skill.TigerStrike, skill.CobraStrike, skill.PhoenixStrike, skill.ClawsOfThunder, skill.BladesOfIce, skill.TomeOfTownPortal} + missingKeybindings := []skill.ID{} + + for _, cskill := range requireKeybindings { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(cskill); !found { + missingKeybindings = append(missingKeybindings, cskill) + } + } + + if len(missingKeybindings) > 0 { + s.Logger.Debug("There are missing required key bindings.", slog.Any("Bindings", missingKeybindings)) + } + + return missingKeybindings +} + +func (s MosaicSin) KillMonsterSequence( + monsterSelector func(d game.Data) (data.UnitID, bool), + skipOnImmunities []stat.Resist, +) error { + ctx := context.Get() + ctx.RefreshGameData() + lastRefresh := time.Now() + + for { + // Limit refresh rate to 10 times per second to avoid excessive CPU usage + if time.Since(lastRefresh) > time.Millisecond*100 { + ctx.RefreshGameData() + lastRefresh = time.Now() + } + + // Get the charges for each skill we're using + tigerCharges, foundTiger := ctx.Data.PlayerUnit.Stats.FindStat(stat.ProgressiveDamage, 0) + cobraCharges, foundCobra := ctx.Data.PlayerUnit.Stats.FindStat(stat.ProgressiveSteal, 0) + phoenixCharges, foundPhoenix := ctx.Data.PlayerUnit.Stats.FindStat(stat.ProgressiveOther, 0) + clawsCharges, foundClaws := ctx.Data.PlayerUnit.Stats.FindStat(stat.ProgressiveLightning, 0) + bladesCharges, foundBlades := ctx.Data.PlayerUnit.Stats.FindStat(stat.ProgressiveCold, 0) + firstCharges, foundFirst := ctx.Data.PlayerUnit.Stats.FindStat(stat.ProgressiveFire, 0) + + id, found := monsterSelector(*s.Data) + if !found { + return nil + } + + monster, found := s.Data.Monsters.FindByID(id) + if !found { + s.Logger.Info("Monster not found", slog.String("monster", fmt.Sprintf("%v", monster))) + return nil + } + + if !s.preBattleChecks(id, skipOnImmunities) { + return nil + } + + // Initial move to monster if we're too far + if ctx.PathFinder.DistanceFromMe(monster.Position) > 3 { + if err := step.MoveTo(monster.Position); err != nil { + s.Logger.Debug("Failed to move to monster position", slog.String("error", err.Error())) + continue + } + } + + if !s.MobAlive(id, *s.Data) { + return nil + } + + // Tiger Strike - 3 charges + if ctx.CharacterCfg.Character.MosaicSin.UseTigerStrike { + if !s.Data.PlayerUnit.States.HasState(state.Tigerstrike) || (foundTiger && tigerCharges.Value < 3) { + step.SecondaryAttack(skill.TigerStrike, id, 1) + continue + } + } + + if !s.MobAlive(id, *s.Data) { + return nil + } + + // Cobra Strike - 3 charges + if ctx.CharacterCfg.Character.MosaicSin.UseCobraStrike { + if !s.Data.PlayerUnit.States.HasState(state.Cobrastrike) || (foundCobra && cobraCharges.Value < 3) { + step.SecondaryAttack(skill.CobraStrike, id, 1) + continue + } + } + + if !s.MobAlive(id, *s.Data) { + return nil + } + + // Phoenix Strike - 2 charges + if !s.Data.PlayerUnit.States.HasState(state.Phoenixstrike) || (foundPhoenix && phoenixCharges.Value < 2) { + step.SecondaryAttack(skill.PhoenixStrike, id, 1) + continue + } + + if !s.MobAlive(id, *s.Data) { + return nil + } + + // Claws of Thunder - 3 charges + if ctx.CharacterCfg.Character.MosaicSin.UseClawsOfThunder { + if !s.Data.PlayerUnit.States.HasState(state.Clawsofthunder) || (foundClaws && clawsCharges.Value < 3) { + step.SecondaryAttack(skill.ClawsOfThunder, id, 1) + continue + } + } + + if !s.MobAlive(id, *s.Data) { + return nil + } + + // Blades of Ice - 3 charges + if ctx.CharacterCfg.Character.MosaicSin.UseBladesOfIce { + if !s.Data.PlayerUnit.States.HasState(state.Bladesofice) || (foundBlades && bladesCharges.Value < 3) { + step.SecondaryAttack(skill.BladesOfIce, id, 1) + continue + } + } + + // First of Fire - 3 charges + if ctx.CharacterCfg.Character.MosaicSin.UseFistsOfFire { + if !s.Data.PlayerUnit.States.HasState(state.Fistsoffire) || (foundFirst && firstCharges.Value < 3) { + step.SecondaryAttack(skill.FistsOfFire, id, 1) + continue + } + } + + if !s.MobAlive(id, *s.Data) { + return nil + } + + opts := step.Distance(1, 2) + // Finish it off with primary attack + step.PrimaryAttack(id, 1, false, opts) + } +} + +func (s MosaicSin) MobAlive(mob data.UnitID, d game.Data) bool { + monster, found := s.Data.Monsters.FindByID(mob) + return found && monster.Stats[stat.Life] > 0 +} + +func (s MosaicSin) BuffSkills() []skill.ID { + skillsList := make([]skill.ID, 0) + + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.Fade); found { + skillsList = append(skillsList, skill.Fade) + } else { + // If we don't use fade but we use Burst of Speed + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.BurstOfSpeed); found { + skillsList = append(skillsList, skill.BurstOfSpeed) + } + } + + return skillsList +} + +func (s MosaicSin) PreCTABuffSkills() []skill.ID { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.ShadowMaster); found { + return []skill.ID{skill.ShadowMaster} + } else if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.ShadowWarrior); found { + return []skill.ID{skill.ShadowWarrior} + } + return []skill.ID{} +} + +func (s MosaicSin) killMonster(npc npc.ID, t data.MonsterType) error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + m, found := d.Monsters.FindOne(npc, t) + if !found { + return 0, false + } + return m.UnitID, true + }, nil) +} + +func (s MosaicSin) KillCountess() error { + return s.killMonster(npc.DarkStalker, data.MonsterTypeSuperUnique) +} + +func (s MosaicSin) KillAndariel() error { + return s.killMonster(npc.Andariel, data.MonsterTypeUnique) +} + +func (s MosaicSin) KillSummoner() error { + return s.killMonster(npc.Summoner, data.MonsterTypeUnique) +} + +func (s MosaicSin) KillDuriel() error { + return s.killMonster(npc.Duriel, data.MonsterTypeUnique) +} + +func (s MosaicSin) KillCouncil() error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + var councilMembers []data.Monster + for _, m := range d.Monsters { + if m.Name == npc.CouncilMember || m.Name == npc.CouncilMember2 || m.Name == npc.CouncilMember3 { + councilMembers = append(councilMembers, m) + } + } + + sort.Slice(councilMembers, func(i, j int) bool { + distanceI := s.PathFinder.DistanceFromMe(councilMembers[i].Position) + distanceJ := s.PathFinder.DistanceFromMe(councilMembers[j].Position) + return distanceI < distanceJ + }) + + if len(councilMembers) > 0 { + return councilMembers[0].UnitID, true + } + + return 0, false + }, nil) +} + +func (s MosaicSin) KillMephisto() error { + return s.killMonster(npc.Mephisto, data.MonsterTypeUnique) +} + +func (s MosaicSin) KillIzual() error { + return s.killMonster(npc.Izual, data.MonsterTypeUnique) +} + +func (s MosaicSin) KillDiablo() error { + timeout := time.Second * 20 + startTime := time.Now() + diabloFound := false + + for { + if time.Since(startTime) > timeout && !diabloFound { + s.Logger.Error("Diablo was not found, timeout reached") + return nil + } + + diablo, found := s.Data.Monsters.FindOne(npc.Diablo, data.MonsterTypeUnique) + if !found || diablo.Stats[stat.Life] <= 0 { + if diabloFound { + return nil + } + time.Sleep(200 * time.Millisecond) + continue + } + + diabloFound = true + s.Logger.Info("Diablo detected, attacking") + return s.killMonster(npc.Diablo, data.MonsterTypeUnique) + } +} + +func (s MosaicSin) KillPindle() error { + return s.killMonster(npc.DefiledWarrior, data.MonsterTypeSuperUnique) +} + +func (s MosaicSin) KillNihlathak() error { + return s.killMonster(npc.Nihlathak, data.MonsterTypeSuperUnique) +} + +func (s MosaicSin) KillBaal() error { + return s.killMonster(npc.BaalCrab, data.MonsterTypeUnique) +} diff --git a/internal/character/nova_sorceress.go b/internal/character/nova_sorceress.go new file mode 100644 index 000000000..179262ea0 --- /dev/null +++ b/internal/character/nova_sorceress.go @@ -0,0 +1,260 @@ +package character + +import ( + "log/slog" + "time" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/d2go/pkg/data/skill" + "github.com/hectorgimenez/d2go/pkg/data/stat" + "github.com/hectorgimenez/koolo/internal/action/step" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" +) + +const ( + NovaMinDistance = 6 + NovaMaxDistance = 9 + StaticMinDistance = 13 + StaticMaxDistance = 22 + NovaMaxAttacksLoop = 10 + StaticFieldThreshold = 67 // Cast Static Field if monster HP is above this percentage +) + +type NovaSorceress struct { + BaseCharacter +} + +func (s NovaSorceress) CheckKeyBindings() []skill.ID { + requiredKeybindings := []skill.ID{skill.Nova, skill.Teleport, skill.TomeOfTownPortal, skill.StaticField} + missingKeybindings := []skill.ID{} + + for _, cskill := range requiredKeybindings { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(cskill); !found { + missingKeybindings = append(missingKeybindings, cskill) + } + } + + // Check for one of the armor skills + armorSkills := []skill.ID{skill.FrozenArmor, skill.ShiverArmor, skill.ChillingArmor} + hasArmor := false + for _, armor := range armorSkills { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(armor); found { + hasArmor = true + break + } + } + if !hasArmor { + missingKeybindings = append(missingKeybindings, skill.FrozenArmor) + } + + if len(missingKeybindings) > 0 { + s.Logger.Debug("There are missing required key bindings.", slog.Any("Bindings", missingKeybindings)) + } + + return missingKeybindings +} + +func (s NovaSorceress) KillMonsterSequence( + monsterSelector func(d game.Data) (data.UnitID, bool), + skipOnImmunities []stat.Resist, +) error { + ctx := context.Get() + completedAttackLoops := 0 + staticFieldCast := false + + for { + ctx.PauseIfNotPriority() + + id, found := monsterSelector(*s.Data) + if !found { + return nil + } + + if !s.preBattleChecks(id, skipOnImmunities) { + return nil + } + + monster, found := s.Data.Monsters.FindByID(id) + if !found || monster.Stats[stat.Life] <= 0 { + return nil + } + + // Cast Static Field first if needed + if !staticFieldCast && s.shouldCastStaticField(monster) { + staticOpts := []step.AttackOption{ + step.RangedDistance(StaticMinDistance, StaticMaxDistance), + } + + if err := step.SecondaryAttack(skill.StaticField, monster.UnitID, 1, staticOpts...); err == nil { + staticFieldCast = true + continue + } + } + + novaOpts := []step.AttackOption{ + step.RangedDistance(NovaMinDistance, NovaMaxDistance), + } + + if err := step.SecondaryAttack(skill.Nova, monster.UnitID, 1, novaOpts...); err == nil { + completedAttackLoops++ + } + + if completedAttackLoops >= NovaMaxAttacksLoop { + completedAttackLoops = 0 + staticFieldCast = false + } + } +} +func (s NovaSorceress) shouldCastStaticField(monster data.Monster) bool { + // Only cast Static Field if monster HP is above threshold + maxLife := float64(monster.Stats[stat.MaxLife]) + if maxLife == 0 { + return false + } + + hpPercentage := (float64(monster.Stats[stat.Life]) / maxLife) * 100 + return hpPercentage > StaticFieldThreshold +} + +func (s NovaSorceress) killBossWithStatic(bossID npc.ID, monsterType data.MonsterType) error { + ctx := context.Get() + + for { + ctx.PauseIfNotPriority() + + boss, found := s.Data.Monsters.FindOne(bossID, monsterType) + if !found || boss.Stats[stat.Life] <= 0 { + return nil + } + + bossHPPercent := (float64(boss.Stats[stat.Life]) / float64(boss.Stats[stat.MaxLife])) * 100 + thresholdFloat := float64(ctx.CharacterCfg.Character.NovaSorceress.BossStaticThreshold) + + // Cast Static Field until boss HP is below threshold + if bossHPPercent > thresholdFloat { + staticOpts := []step.AttackOption{ + step.Distance(StaticMinDistance, StaticMaxDistance), + } + err := step.SecondaryAttack(skill.StaticField, boss.UnitID, 1, staticOpts...) + if err != nil { + s.Logger.Warn("Failed to cast Static Field", slog.String("error", err.Error())) + } + continue + } + + // Switch to Nova once boss HP is low enough + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + return boss.UnitID, true + }, nil) + } +} + +func (s NovaSorceress) killMonsterByName(id npc.ID, monsterType data.MonsterType, skipOnImmunities []stat.Resist) error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + if m, found := d.Monsters.FindOne(id, monsterType); found { + return m.UnitID, true + } + + return 0, false + }, skipOnImmunities) +} + +func (s NovaSorceress) BuffSkills() []skill.ID { + skillsList := make([]skill.ID, 0) + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.EnergyShield); found { + skillsList = append(skillsList, skill.EnergyShield) + } + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.ThunderStorm); found { + skillsList = append(skillsList, skill.ThunderStorm) + } + + // Add one of the armor skills + for _, armor := range []skill.ID{skill.ChillingArmor, skill.ShiverArmor, skill.FrozenArmor} { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(armor); found { + skillsList = append(skillsList, armor) + break + } + } + + return skillsList +} + +func (s NovaSorceress) PreCTABuffSkills() []skill.ID { + return []skill.ID{} +} + +func (s NovaSorceress) KillAndariel() error { + return s.killBossWithStatic(npc.Andariel, data.MonsterTypeUnique) +} + +func (s NovaSorceress) KillDuriel() error { + return s.killBossWithStatic(npc.Duriel, data.MonsterTypeUnique) +} + +func (s NovaSorceress) KillMephisto() error { + return s.killBossWithStatic(npc.Mephisto, data.MonsterTypeUnique) +} + +func (s NovaSorceress) KillDiablo() error { + timeout := time.Second * 20 + startTime := time.Now() + diabloFound := false + + for { + if time.Since(startTime) > timeout && !diabloFound { + s.Logger.Error("Diablo was not found, timeout reached") + return nil + } + + diablo, found := s.Data.Monsters.FindOne(npc.Diablo, data.MonsterTypeUnique) + if !found || diablo.Stats[stat.Life] <= 0 { + if diabloFound { + return nil + } + time.Sleep(200 * time.Millisecond) + continue + } + + diabloFound = true + s.Logger.Info("Diablo detected, attacking") + + return s.killBossWithStatic(npc.Diablo, data.MonsterTypeUnique) + } +} + +func (s NovaSorceress) KillBaal() error { + return s.killBossWithStatic(npc.BaalCrab, data.MonsterTypeUnique) +} + +func (s NovaSorceress) KillCountess() error { + return s.killMonsterByName(npc.DarkStalker, data.MonsterTypeSuperUnique, nil) +} + +func (s NovaSorceress) KillSummoner() error { + return s.killMonsterByName(npc.Summoner, data.MonsterTypeUnique, nil) +} + +func (s NovaSorceress) KillIzual() error { + return s.killBossWithStatic(npc.Izual, data.MonsterTypeUnique) +} + +func (s NovaSorceress) KillCouncil() error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + for _, m := range d.Monsters.Enemies() { + if m.Name == npc.CouncilMember || m.Name == npc.CouncilMember2 || m.Name == npc.CouncilMember3 { + return m.UnitID, true + } + } + return 0, false + }, nil) +} + +func (s NovaSorceress) KillPindle() error { + return s.killMonsterByName(npc.DefiledWarrior, data.MonsterTypeSuperUnique, s.CharacterCfg.Game.Pindleskin.SkipOnImmunities) +} + +func (s NovaSorceress) KillNihlathak() error { + return s.killMonsterByName(npc.Nihlathak, data.MonsterTypeSuperUnique, nil) +} diff --git a/internal/character/paladin_leveling.go b/internal/character/paladin_leveling.go index da079ce0a..0b554ed2f 100644 --- a/internal/character/paladin_leveling.go +++ b/internal/character/paladin_leveling.go @@ -1,238 +1,204 @@ package character import ( + "fmt" + "log/slog" "sort" "time" + "github.com/hectorgimenez/koolo/internal/action/step" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/npc" "github.com/hectorgimenez/d2go/pkg/data/skill" "github.com/hectorgimenez/d2go/pkg/data/stat" - "github.com/hectorgimenez/koolo/internal/action" - "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/pather" - "go.uber.org/zap" +) + +const ( + paladinLevelingMaxAttacksLoop = 10 ) type PaladinLeveling struct { BaseCharacter } -func (p PaladinLeveling) GetSkillTree() skill.Tree { - return skill.PaladinTree -} +func (s PaladinLeveling) CheckKeyBindings() []skill.ID { + requireKeybindings := []skill.ID{skill.TomeOfTownPortal} + missingKeybindings := []skill.ID{} -func (p PaladinLeveling) BuffSkills() map[skill.Skill]string { - return map[skill.Skill]string{ - skill.HolyShield: config.Config.Bindings.Paladin.HolyShield, - } -} - -func (p PaladinLeveling) killMonster(npc npc.ID, t data.MonsterType) action.Action { - return p.KillMonsterSequence(func(d data.Data) (data.UnitID, bool) { - m, found := d.Monsters.FindOne(npc, t) - if !found { - return 0, false + for _, cskill := range requireKeybindings { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(cskill); !found { + missingKeybindings = append(missingKeybindings, cskill) } + } - return m.UnitID, true - }, nil) -} - -func (p PaladinLeveling) KillCountess() action.Action { - return p.killMonster(npc.DarkStalker, data.MonsterTypeSuperUnique) -} - -func (p PaladinLeveling) KillAndariel() action.Action { - return p.killMonster(npc.Andariel, data.MonsterTypeNone) -} - -func (p PaladinLeveling) KillSummoner() action.Action { - return p.killMonster(npc.Summoner, data.MonsterTypeNone) -} - -func (p PaladinLeveling) KillDuriel() action.Action { - return p.killMonster(npc.Duriel, data.MonsterTypeNone) -} - -func (p PaladinLeveling) KillMephisto() action.Action { - return p.killMonster(npc.Mephisto, data.MonsterTypeNone) -} + if len(missingKeybindings) > 0 { + s.Logger.Debug("There are missing required key bindings.", slog.Any("Bindings", missingKeybindings)) + } -func (p PaladinLeveling) KillPindle(_ []stat.Resist) action.Action { - return p.killMonster(npc.DefiledWarrior, data.MonsterTypeSuperUnique) + return missingKeybindings } -func (p PaladinLeveling) KillNihlathak() action.Action { - return p.killMonster(npc.Nihlathak, data.MonsterTypeSuperUnique) -} +func (s PaladinLeveling) KillMonsterSequence( + monsterSelector func(d game.Data) (data.UnitID, bool), + skipOnImmunities []stat.Resist, +) error { + completedAttackLoops := 0 + previousUnitID := 0 -func (p PaladinLeveling) KillCouncil() action.Action { - return p.KillMonsterSequence(func(d data.Data) (data.UnitID, bool) { - var councilMembers []data.Monster - for _, m := range d.Monsters { - if m.Name == npc.CouncilMember || m.Name == npc.CouncilMember2 || m.Name == npc.CouncilMember3 { - councilMembers = append(councilMembers, m) - } + for { + id, found := monsterSelector(*s.Data) + if !found { + return nil } - - // Order council members by distance - sort.Slice(councilMembers, func(i, j int) bool { - distanceI := pather.DistanceFromMe(d, councilMembers[i].Position) - distanceJ := pather.DistanceFromMe(d, councilMembers[j].Position) - - return distanceI < distanceJ - }) - - if len(councilMembers) > 0 { - return councilMembers[0].UnitID, true + if previousUnitID != int(id) { + completedAttackLoops = 0 } - return 0, false - }, nil) -} + if !s.preBattleChecks(id, skipOnImmunities) { + return nil + } -func (p PaladinLeveling) KillDiablo() action.Action { - timeout := time.Second * 20 - startTime := time.Time{} - diabloFound := false - return action.NewChain(func(d data.Data) []action.Action { - if startTime.IsZero() { - startTime = time.Now() + if completedAttackLoops >= paladinLevelingMaxAttacksLoop { + return nil } - if time.Since(startTime) > timeout && !diabloFound { - p.logger.Error("Diablo was not found, timeout reached") + monster, found := s.Data.Monsters.FindByID(id) + if !found { + s.Logger.Info("Monster not found", slog.String("monster", fmt.Sprintf("%v", monster))) return nil } - diablo, found := d.Monsters.FindOne(npc.Diablo, data.MonsterTypeNone) - if !found || diablo.Stats[stat.Life] <= 0 { - // Already dead - if diabloFound { + numOfAttacks := 5 + + if s.Data.PlayerUnit.Skills[skill.BlessedHammer].Level > 0 { + s.Logger.Debug("Using Blessed Hammer") + // Add a random movement, maybe hammer is not hitting the target + if previousUnitID == int(id) { + if monster.Stats[stat.Life] > 0 { + s.PathFinder.RandomMovement() + } return nil } + step.PrimaryAttack(id, numOfAttacks, false, step.Distance(2, 7), step.EnsureAura(skill.Concentration)) - // Keep waiting... - return []action.Action{action.NewStepChain(func(d data.Data) []step.Step { - return []step.Step{step.Wait(time.Millisecond * 100)} - })} - } - - diabloFound = true - p.logger.Info("Diablo detected, attacking") - - return []action.Action{ - p.killMonster(npc.Diablo, data.MonsterTypeNone), - p.killMonster(npc.Diablo, data.MonsterTypeNone), - p.killMonster(npc.Diablo, data.MonsterTypeNone), + } else { + if s.Data.PlayerUnit.Skills[skill.Zeal].Level > 0 { + s.Logger.Debug("Using Zeal") + numOfAttacks = 1 + } + s.Logger.Debug("Using primary attack with Holy Fire aura") + step.PrimaryAttack(id, numOfAttacks, false, step.Distance(1, 3), step.EnsureAura(skill.HolyFire)) } - }, action.RepeatUntilNoSteps()) -} -func (p PaladinLeveling) KillIzual() action.Action { - return p.killMonster(npc.Izual, data.MonsterTypeNone) -} - -func (p PaladinLeveling) KillBaal() action.Action { - return p.killMonster(npc.BaalCrab, data.MonsterTypeNone) + completedAttackLoops++ + previousUnitID = int(id) + } } -func (p PaladinLeveling) KillMonsterSequence(monsterSelector func(d data.Data) (data.UnitID, bool), skipOnImmunities []stat.Resist, opts ...step.AttackOption) action.Action { - completedAttackLoops := 0 - var previousUnitID data.UnitID = 0 - - return action.NewStepChain(func(d data.Data) []step.Step { - id, found := monsterSelector(d) +func (s PaladinLeveling) killMonster(npc npc.ID, t data.MonsterType) error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + m, found := d.Monsters.FindOne(npc, t) if !found { - return []step.Step{} - } - if previousUnitID != id { - completedAttackLoops = 0 + return 0, false } - if !p.preBattleChecks(d, id, skipOnImmunities) { - return []step.Step{} - } + return m.UnitID, true + }, nil) +} - if completedAttackLoops >= 10 { - return []step.Step{} - } +func (s PaladinLeveling) BuffSkills() []skill.ID { + skillsList := make([]skill.ID, 0) + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.HolyShield); found { + skillsList = append(skillsList, skill.HolyShield) + } + s.Logger.Info("Buff skills", "skills", skillsList) + return skillsList +} - steps := make([]step.Step, 0) +func (s PaladinLeveling) PreCTABuffSkills() []skill.ID { + return []skill.ID{} +} - numOfAttacks := 5 +func (s PaladinLeveling) ShouldResetSkills() bool { + lvl, _ := s.Data.PlayerUnit.FindStat(stat.Level, 0) + if lvl.Value >= 21 && s.Data.PlayerUnit.Skills[skill.HolyFire].Level > 10 { + s.Logger.Info("Resetting skills: Level 21+ and Holy Fire level > 10") + return true + } - m, _ := d.Monsters.FindByID(id) - p.logger.Debug("ATTACKING MONSTER", zap.Any("monster", m.Name)) - if d.PlayerUnit.Skills[skill.BlessedHammer] > 0 { - // Add a random movement, maybe hammer is not hitting the target - if previousUnitID == id { - steps = append(steps, - step.SyncStep(func(_ data.Data) error { - pather.RandomMovement() - return nil - }), - ) - } - steps = append(steps, - step.PrimaryAttack(id, numOfAttacks, step.Distance(2, 7), step.EnsureAura(config.Config.Bindings.Paladin.Concentration)), - ) - } else { - if d.PlayerUnit.Skills[skill.Zeal] > 0 { - numOfAttacks = 1 - } + return false +} - steps = append(steps, - step.PrimaryAttack(id, numOfAttacks, step.Distance(1, 3), step.EnsureAura(config.Config.Bindings.Paladin.Concentration)), - ) - } +func (s PaladinLeveling) SkillsToBind() (skill.ID, []skill.ID) { + lvl, _ := s.Data.PlayerUnit.FindStat(stat.Level, 0) + mainSkill := skill.AttackSkill + skillBindings := []skill.ID{} - completedAttackLoops++ - previousUnitID = id - return steps - }, action.RepeatUntilNoSteps()) -} + if lvl.Value >= 6 { + skillBindings = append(skillBindings, skill.Vigor) + } -func (p PaladinLeveling) StatPoints(d data.Data) map[stat.ID]int { - if d.PlayerUnit.Stats[stat.Level] >= 21 && d.PlayerUnit.Stats[stat.Level] < 30 { - return map[stat.ID]int{ - stat.Strength: 35, - stat.Vitality: 200, - stat.Energy: 0, - } + if lvl.Value >= 24 { + skillBindings = append(skillBindings, skill.HolyShield) } - if d.PlayerUnit.Stats[stat.Level] >= 30 && d.PlayerUnit.Stats[stat.Level] < 45 { - return map[stat.ID]int{ - stat.Strength: 50, - stat.Dexterity: 40, - stat.Vitality: 220, - stat.Energy: 0, - } + if s.Data.PlayerUnit.Skills[skill.BlessedHammer].Level > 0 && lvl.Value >= 18 { + mainSkill = skill.BlessedHammer + } else if s.Data.PlayerUnit.Skills[skill.Zeal].Level > 0 { + mainSkill = skill.Zeal } - if d.PlayerUnit.Stats[stat.Level] >= 45 { - return map[stat.ID]int{ - stat.Strength: 86, - stat.Dexterity: 50, - stat.Vitality: 300, - stat.Energy: 0, + if s.Data.PlayerUnit.Skills[skill.Concentration].Level > 0 && lvl.Value >= 18 { + skillBindings = append(skillBindings, skill.Concentration) + } else { + if _, found := s.Data.PlayerUnit.Skills[skill.HolyFire]; found { + skillBindings = append(skillBindings, skill.HolyFire) + } else if _, found := s.Data.PlayerUnit.Skills[skill.Might]; found { + skillBindings = append(skillBindings, skill.Might) } } - return map[stat.ID]int{ - stat.Strength: 0, - stat.Dexterity: 25, - stat.Vitality: 150, - stat.Energy: 0, + s.Logger.Info("Skills bound", "mainSkill", mainSkill, "skillBindings", skillBindings) + return mainSkill, skillBindings +} + +func (s PaladinLeveling) StatPoints() map[stat.ID]int { + lvl, _ := s.Data.PlayerUnit.FindStat(stat.Level, 0) + statPoints := make(map[stat.ID]int) + + if lvl.Value < 21 { + statPoints[stat.Strength] = 0 + statPoints[stat.Dexterity] = 25 + statPoints[stat.Vitality] = 150 + statPoints[stat.Energy] = 0 + } else if lvl.Value < 30 { + statPoints[stat.Strength] = 35 + statPoints[stat.Vitality] = 200 + statPoints[stat.Energy] = 0 + } else if lvl.Value < 45 { + statPoints[stat.Strength] = 50 + statPoints[stat.Dexterity] = 40 + statPoints[stat.Vitality] = 220 + statPoints[stat.Energy] = 0 + } else { + statPoints[stat.Strength] = 86 + statPoints[stat.Dexterity] = 50 + statPoints[stat.Vitality] = 300 + statPoints[stat.Energy] = 0 } + + s.Logger.Info("Assigning stat points", "level", lvl.Value, "statPoints", statPoints) + return statPoints } -func (p PaladinLeveling) SkillPoints(d data.Data) []skill.Skill { - if d.PlayerUnit.Stats[stat.Level] < 21 { - return []skill.Skill{ +func (s PaladinLeveling) SkillPoints() []skill.ID { + lvl, _ := s.Data.PlayerUnit.FindStat(stat.Level, 0) + var skillPoints []skill.ID + + if lvl.Value < 21 { + skillPoints = []skill.ID{ skill.Might, skill.Sacrifice, skill.ResistFire, @@ -256,140 +222,200 @@ func (p PaladinLeveling) SkillPoints(d data.Data) []skill.Skill { skill.HolyFire, skill.HolyFire, } + } else { + // Hammerdin + skillPoints = []skill.ID{ + skill.HolyBolt, + skill.BlessedHammer, + skill.Prayer, + skill.Defiance, + skill.Cleansing, + skill.Vigor, + skill.Might, + skill.BlessedAim, + skill.Concentration, + skill.BlessedAim, + skill.BlessedAim, + skill.BlessedAim, + skill.BlessedAim, + skill.BlessedAim, + skill.BlessedAim, + // Level 19 + skill.BlessedHammer, + skill.Concentration, + skill.Vigor, + // Level 20 + skill.BlessedHammer, + skill.Vigor, + skill.BlessedHammer, + skill.BlessedHammer, + skill.BlessedHammer, + skill.Vigor, + skill.BlessedHammer, + skill.BlessedHammer, + skill.BlessedHammer, + skill.BlessedHammer, + skill.BlessedHammer, + skill.BlessedHammer, + skill.BlessedHammer, + skill.BlessedHammer, + skill.Smite, + skill.BlessedHammer, + skill.BlessedHammer, + skill.BlessedHammer, + skill.BlessedHammer, + skill.BlessedHammer, + skill.Charge, + skill.BlessedHammer, + skill.Vigor, + skill.Vigor, + skill.Vigor, + skill.HolyShield, + skill.Concentration, + skill.Vigor, + skill.Vigor, + skill.Vigor, + skill.Vigor, + skill.Vigor, + skill.Vigor, + skill.Vigor, + skill.Vigor, + skill.Vigor, + skill.Vigor, + skill.Vigor, + skill.Vigor, + skill.Vigor, + skill.Concentration, + skill.Concentration, + skill.Concentration, + skill.Concentration, + skill.Concentration, + skill.Concentration, + skill.Concentration, + skill.Concentration, + skill.Concentration, + skill.Concentration, + skill.Concentration, + skill.Concentration, + skill.Concentration, + skill.Concentration, + skill.Concentration, + skill.BlessedAim, + skill.BlessedAim, + skill.BlessedAim, + skill.BlessedAim, + skill.BlessedAim, + skill.BlessedAim, + skill.BlessedAim, + skill.BlessedAim, + skill.BlessedAim, + skill.BlessedAim, + skill.BlessedAim, + skill.BlessedAim, + } } - // Hammerdin - return []skill.Skill{ - skill.HolyBolt, - skill.BlessedHammer, - skill.Prayer, - skill.Defiance, - skill.Cleansing, - skill.Vigor, - skill.Might, - skill.BlessedAim, - skill.Concentration, - skill.BlessedAim, - skill.BlessedAim, - skill.BlessedAim, - skill.BlessedAim, - skill.BlessedAim, - skill.BlessedAim, - // Level 19 - skill.BlessedHammer, - skill.Concentration, - skill.Vigor, - // Level 20 - skill.BlessedHammer, - skill.Vigor, - skill.BlessedHammer, - skill.BlessedHammer, - skill.BlessedHammer, - skill.Vigor, - skill.BlessedHammer, - skill.BlessedHammer, - skill.BlessedHammer, - skill.BlessedHammer, - skill.BlessedHammer, - skill.BlessedHammer, - skill.BlessedHammer, - skill.BlessedHammer, - skill.Smite, - skill.BlessedHammer, - skill.BlessedHammer, - skill.BlessedHammer, - skill.BlessedHammer, - skill.BlessedHammer, - skill.Charge, - skill.BlessedHammer, - skill.Vigor, - skill.Vigor, - skill.Vigor, - skill.HolyShield, - skill.Concentration, - skill.Vigor, - skill.Vigor, - skill.Vigor, - skill.Vigor, - skill.Vigor, - skill.Vigor, - skill.Vigor, - skill.Vigor, - skill.Vigor, - skill.Vigor, - skill.Vigor, - skill.Vigor, - skill.Vigor, - skill.Concentration, - skill.Concentration, - skill.Concentration, - skill.Concentration, - skill.Concentration, - skill.Concentration, - skill.Concentration, - skill.Concentration, - skill.Concentration, - skill.Concentration, - skill.Concentration, - skill.Concentration, - skill.Concentration, - skill.Concentration, - skill.Concentration, - skill.BlessedAim, - skill.BlessedAim, - skill.BlessedAim, - skill.BlessedAim, - skill.BlessedAim, - skill.BlessedAim, - skill.BlessedAim, - skill.BlessedAim, - skill.BlessedAim, - skill.BlessedAim, - skill.BlessedAim, - skill.BlessedAim, - } + s.Logger.Info("Assigning skill points", "level", lvl.Value, "skillPoints", skillPoints) + return skillPoints } -func (p PaladinLeveling) GetKeyBindings(d data.Data) map[skill.Skill]string { - skillBindings := map[skill.Skill]string{ - skill.Vigor: config.Config.Bindings.Paladin.Vigor, - skill.HolyShield: config.Config.Bindings.Paladin.HolyShield, - } +func (s PaladinLeveling) KillCountess() error { + return s.killMonster(npc.DarkStalker, data.MonsterTypeSuperUnique) +} - if d.PlayerUnit.Skills[skill.BlessedHammer] > 0 && d.PlayerUnit.Stats[stat.Level] >= 18 { - skillBindings[skill.BlessedHammer] = "" - } else if d.PlayerUnit.Skills[skill.Zeal] > 0 { - skillBindings[skill.Zeal] = "" - } +func (s PaladinLeveling) KillAndariel() error { + return s.killMonster(npc.Andariel, data.MonsterTypeUnique) +} - if d.PlayerUnit.Skills[skill.Concentration] > 0 && d.PlayerUnit.Stats[stat.Level] >= 18 { - skillBindings[skill.Concentration] = config.Config.Bindings.Paladin.Concentration - } else { - if _, found := d.PlayerUnit.Skills[skill.HolyFire]; found { - skillBindings[skill.HolyFire] = config.Config.Bindings.Paladin.Concentration - } else if _, found := d.PlayerUnit.Skills[skill.Might]; found { - skillBindings[skill.Might] = config.Config.Bindings.Paladin.Concentration +func (s PaladinLeveling) KillSummoner() error { + return s.killMonster(npc.Summoner, data.MonsterTypeUnique) +} + +func (s PaladinLeveling) KillDuriel() error { + return s.killMonster(npc.Duriel, data.MonsterTypeUnique) +} + +func (s PaladinLeveling) KillCouncil() error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + var councilMembers []data.Monster + for _, m := range d.Monsters { + if m.Name == npc.CouncilMember || m.Name == npc.CouncilMember2 || m.Name == npc.CouncilMember3 { + councilMembers = append(councilMembers, m) + } } - } - return skillBindings + // Order council members by distance + sort.Slice(councilMembers, func(i, j int) bool { + distanceI := s.PathFinder.DistanceFromMe(councilMembers[i].Position) + distanceJ := s.PathFinder.DistanceFromMe(councilMembers[j].Position) + + return distanceI < distanceJ + }) + + if len(councilMembers) > 0 { + s.Logger.Debug("Targeting Council member", "id", councilMembers[0].UnitID) + return councilMembers[0].UnitID, true + } + + s.Logger.Debug("No Council members found") + return 0, false + }, nil) } -func (p PaladinLeveling) ShouldResetSkills(d data.Data) bool { - if d.PlayerUnit.Stats[stat.Level] >= 21 && d.PlayerUnit.Skills[skill.HolyFire] > 10 { - return true +func (s PaladinLeveling) KillMephisto() error { + return s.killMonster(npc.Mephisto, data.MonsterTypeUnique) +} +func (s PaladinLeveling) KillIzual() error { + return s.killMonster(npc.Izual, data.MonsterTypeUnique) +} + +func (s PaladinLeveling) KillDiablo() error { + timeout := time.Second * 20 + startTime := time.Now() + diabloFound := false + + for { + if time.Since(startTime) > timeout && !diabloFound { + s.Logger.Error("Diablo was not found, timeout reached") + return nil + } + + diablo, found := s.Data.Monsters.FindOne(npc.Diablo, data.MonsterTypeUnique) + if !found || diablo.Stats[stat.Life] <= 0 { + // Already dead + if diabloFound { + return nil + } + + // Keep waiting... + time.Sleep(200) + continue + } + + diabloFound = true + s.Logger.Info("Diablo detected, attacking") + + return s.killMonster(npc.Diablo, data.MonsterTypeUnique) } +} - return false +func (s PaladinLeveling) KillPindle() error { + return s.killMonster(npc.DefiledWarrior, data.MonsterTypeSuperUnique) } -func (p PaladinLeveling) KillAncients() action.Action { - return action.NewChain(func(d data.Data) (actions []action.Action) { - for _, m := range d.Monsters.Enemies(data.MonsterEliteFilter()) { - actions = append(actions, - p.killMonster(m.Name, data.MonsterTypeSuperUnique), - ) - } - return actions - }) +func (s PaladinLeveling) KillNihlathak() error { + return s.killMonster(npc.Nihlathak, data.MonsterTypeSuperUnique) +} + +func (s PaladinLeveling) KillAncients() error { + for _, m := range s.Data.Monsters.Enemies(data.MonsterEliteFilter()) { + m, _ := s.Data.Monsters.FindOne(m.Name, data.MonsterTypeSuperUnique) + + s.killMonster(m.Name, data.MonsterTypeSuperUnique) + } + return nil +} + +func (s PaladinLeveling) KillBaal() error { + return s.killMonster(npc.BaalCrab, data.MonsterTypeUnique) } diff --git a/internal/character/sorceress_leveling.go b/internal/character/sorceress_leveling.go index 2f2aa7e2e..9331526f8 100644 --- a/internal/character/sorceress_leveling.go +++ b/internal/character/sorceress_leveling.go @@ -1,6 +1,8 @@ package character import ( + "fmt" + "log/slog" "sort" "time" @@ -9,91 +11,242 @@ import ( "github.com/hectorgimenez/d2go/pkg/data/npc" "github.com/hectorgimenez/d2go/pkg/data/skill" "github.com/hectorgimenez/d2go/pkg/data/stat" - "github.com/hectorgimenez/koolo/internal/action" "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/pather" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" ) type SorceressLeveling struct { BaseCharacter } -func (s SorceressLeveling) GetSkillTree() skill.Tree { - return skill.SorceressTree +const ( + SorceressLevelingMaxAttacksLoop = 10 + SorceressLevelingMinDistance = 25 + SorceressLevelingMaxDistance = 30 + SorceressLevelingMeleeDistance = 3 +) + +func (s SorceressLeveling) CheckKeyBindings() []skill.ID { + requireKeybindings := []skill.ID{skill.TomeOfTownPortal} + missingKeybindings := []skill.ID{} + + for _, cskill := range requireKeybindings { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(cskill); !found { + missingKeybindings = append(missingKeybindings, cskill) + } + } + + if len(missingKeybindings) > 0 { + s.Logger.Debug("There are missing required key bindings.", slog.Any("Bindings", missingKeybindings)) + } + + return missingKeybindings } -func (s SorceressLeveling) ShouldResetSkills(d data.Data) bool { - if d.PlayerUnit.Stats[stat.Level] >= 25 && d.PlayerUnit.Skills[skill.FireBall] > 10 { - return true +func (s SorceressLeveling) KillMonsterSequence( + monsterSelector func(d game.Data) (data.UnitID, bool), + skipOnImmunities []stat.Resist, +) error { + completedAttackLoops := 0 + previousUnitID := 0 + + for { + id, found := monsterSelector(*s.Data) + if !found { + return nil + } + if previousUnitID != int(id) { + completedAttackLoops = 0 + } + + if !s.preBattleChecks(id, skipOnImmunities) { + return nil + } + + if completedAttackLoops >= SorceressLevelingMaxAttacksLoop { + return nil + } + + monster, found := s.Data.Monsters.FindByID(id) + if !found { + s.Logger.Info("Monster not found", slog.String("monster", fmt.Sprintf("%v", monster))) + return nil + } + + lvl, _ := s.Data.PlayerUnit.FindStat(stat.Level, 0) + if s.Data.PlayerUnit.MPPercent() < 15 && lvl.Value < 15 { + s.Logger.Debug("Low mana, using primary attack") + step.PrimaryAttack(id, 1, false, step.Distance(1, SorceressLevelingMeleeDistance)) + } else { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.Blizzard); found { + s.Logger.Debug("Using Blizzard") + step.SecondaryAttack(skill.Blizzard, id, 1, step.Distance(SorceressLevelingMinDistance, SorceressLevelingMaxDistance)) + } else if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.Meteor); found { + s.Logger.Debug("Using Meteor") + step.SecondaryAttack(skill.Meteor, id, 1, step.Distance(SorceressLevelingMinDistance, SorceressLevelingMaxDistance)) + } else if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.FireBall); found { + s.Logger.Debug("Using FireBall") + step.SecondaryAttack(skill.FireBall, id, 4, step.Distance(SorceressLevelingMinDistance, SorceressLevelingMaxDistance)) + } else if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.IceBolt); found { + s.Logger.Debug("Using IceBolt") + step.SecondaryAttack(skill.IceBolt, id, 4, step.Distance(SorceressLevelingMinDistance, SorceressLevelingMaxDistance)) + } else { + s.Logger.Debug("No secondary skills available, using primary attack") + step.PrimaryAttack(id, 1, false, step.Distance(1, SorceressLevelingMeleeDistance)) + } + } + + completedAttackLoops++ + previousUnitID = int(id) } +} - return false +func (s SorceressLeveling) killMonster(npc npc.ID, t data.MonsterType) error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + m, found := d.Monsters.FindOne(npc, t) + if !found { + return 0, false + } + + return m.UnitID, true + }, nil) } -func (s SorceressLeveling) GetKeyBindings(d data.Data) map[skill.Skill]string { - skillBindings := map[skill.Skill]string{ - skill.FrozenArmor: config.Config.Bindings.Sorceress.FrozenArmor, - skill.StaticField: config.Config.Bindings.Sorceress.StaticField, - skill.Teleport: config.Config.Bindings.Teleport, - skill.TomeOfTownPortal: config.Config.Bindings.TP, +func (s SorceressLeveling) BuffSkills() []skill.ID { + skillsList := make([]skill.ID, 0) + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.FrozenArmor); found { + skillsList = append(skillsList, skill.FrozenArmor) } - if d.PlayerUnit.Skills[skill.Blizzard] > 0 { - skillBindings[skill.Blizzard] = config.Config.Bindings.Sorceress.Blizzard - skillBindings[skill.GlacialSpike] = "" - } else if d.PlayerUnit.Skills[skill.FireBall] > 0 { - skillBindings[skill.FireBall] = config.Config.Bindings.Sorceress.FireBall - } else if d.PlayerUnit.Skills[skill.FireBolt] > 0 { - skillBindings[skill.FireBolt] = config.Config.Bindings.Sorceress.FireBall + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.EnergyShield); found { + skillsList = append(skillsList, skill.EnergyShield) } - return skillBindings + s.Logger.Info("Buff skills", "skills", skillsList) + return skillsList } -func (s SorceressLeveling) StatPoints(d data.Data) map[stat.ID]int { - if d.PlayerUnit.Stats[stat.Level] < 9 { - return map[stat.ID]int{ - stat.Vitality: 9999, - } +func (s SorceressLeveling) PreCTABuffSkills() []skill.ID { + return []skill.ID{} +} + +func (s SorceressLeveling) staticFieldCasts() int { + casts := 6 + ctx := context.Get() + + switch ctx.CharacterCfg.Game.Difficulty { + case difficulty.Normal: + casts = 8 } + s.Logger.Debug("Static Field casts", "count", casts) + return casts +} - if d.PlayerUnit.Stats[stat.Level] < 15 { - return map[stat.ID]int{ - stat.Energy: 45, - stat.Strength: 25, - stat.Vitality: 9999, - } +func (s SorceressLeveling) ShouldResetSkills() bool { + lvl, _ := s.Data.PlayerUnit.FindStat(stat.Level, 0) + if lvl.Value >= 24 && s.Data.PlayerUnit.Skills[skill.FireBall].Level > 1 { + s.Logger.Info("Resetting skills: Level 24+ and FireBall level > 1") + return true + } + return false +} + +func (s SorceressLeveling) SkillsToBind() (skill.ID, []skill.ID) { + level, _ := s.Data.PlayerUnit.FindStat(stat.Level, 0) + skillBindings := []skill.ID{ + skill.TomeOfTownPortal, + } + + if level.Value >= 4 { + skillBindings = append(skillBindings, skill.FrozenArmor) + } + if level.Value >= 6 { + skillBindings = append(skillBindings, skill.StaticField) + } + if level.Value >= 18 { + skillBindings = append(skillBindings, skill.Teleport) + } + + if s.Data.PlayerUnit.Skills[skill.Blizzard].Level > 0 { + skillBindings = append(skillBindings, skill.Blizzard) + } else if s.Data.PlayerUnit.Skills[skill.Meteor].Level > 0 { + skillBindings = append(skillBindings, skill.Meteor) + } else if s.Data.PlayerUnit.Skills[skill.FireBall].Level > 0 { + skillBindings = append(skillBindings, skill.FireBall) + } else if s.Data.PlayerUnit.Skills[skill.IceBolt].Level > 0 { + skillBindings = append(skillBindings, skill.IceBolt) } - return map[stat.ID]int{ - stat.Energy: 60, - stat.Strength: 50, - stat.Vitality: 9999, + mainSkill := skill.AttackSkill + if s.Data.PlayerUnit.Skills[skill.Blizzard].Level > 0 { + mainSkill = skill.Blizzard + } else if s.Data.PlayerUnit.Skills[skill.Meteor].Level > 0 { + mainSkill = skill.Meteor } + + s.Logger.Info("Skills bound", "mainSkill", mainSkill, "skillBindings", skillBindings) + return mainSkill, skillBindings +} + +func (s SorceressLeveling) StatPoints() map[stat.ID]int { + lvl, _ := s.Data.PlayerUnit.FindStat(stat.Level, 0) + statPoints := make(map[stat.ID]int) + + if lvl.Value < 20 { + statPoints[stat.Vitality] = 9999 + } else { + statPoints[stat.Energy] = 80 + statPoints[stat.Strength] = 60 + statPoints[stat.Vitality] = 9999 + } + + s.Logger.Info("Assigning stat points", "level", lvl.Value, "statPoints", statPoints) + return statPoints } -func (s SorceressLeveling) SkillPoints(d data.Data) []skill.Skill { - if d.PlayerUnit.Stats[stat.Level] < 25 { - return []skill.Skill{ +func (s SorceressLeveling) SkillPoints() []skill.ID { + lvl, _ := s.Data.PlayerUnit.FindStat(stat.Level, 0) + var skillPoints []skill.ID + + if lvl.Value < 24 { + skillPoints = []skill.ID{ + skill.FireBolt, + skill.FireBolt, skill.FireBolt, skill.FrozenArmor, skill.FireBolt, + skill.StaticField, skill.FireBolt, skill.Warmth, - skill.StaticField, skill.FireBolt, + skill.Telekinesis, skill.FireBolt, skill.FireBolt, skill.FireBolt, - skill.Telekinesis, + skill.FireBolt, + skill.IceBolt, + skill.IceBolt, + skill.IceBolt, + skill.Teleport, + skill.IceBolt, + skill.IceBolt, + skill.IceBolt, + skill.IceBolt, + skill.IceBolt, + } + } else { + skillPoints = []skill.ID{ + skill.FireBolt, + skill.Warmth, + skill.Inferno, + skill.Blaze, skill.FireBall, skill.FireBall, skill.FireBall, skill.FireBall, skill.FireBall, skill.FireBall, - skill.Teleport, skill.FireBall, skill.FireBall, skill.FireBall, @@ -108,177 +261,70 @@ func (s SorceressLeveling) SkillPoints(d data.Data) []skill.Skill { skill.FireBall, skill.FireBall, skill.FireBall, + skill.Meteor, + skill.FireMastery, + skill.Meteor, + skill.Meteor, + skill.Meteor, + skill.Meteor, + skill.Meteor, + skill.Meteor, + skill.Meteor, + skill.Meteor, + skill.Meteor, + skill.Meteor, + skill.Meteor, + skill.Meteor, + skill.Meteor, + skill.Meteor, + skill.Meteor, + skill.Meteor, + skill.Meteor, + skill.Meteor, + skill.Meteor, + skill.FireMastery, + skill.FireMastery, + skill.FireMastery, + skill.FireMastery, + skill.FireMastery, + skill.FireMastery, + skill.FireMastery, + skill.FireMastery, + skill.FireMastery, + skill.FireMastery, + skill.FireMastery, + skill.FireMastery, + skill.FireMastery, + skill.FireMastery, skill.FireMastery, } } - return []skill.Skill{ - skill.StaticField, - skill.StaticField, - skill.StaticField, - skill.StaticField, - skill.Telekinesis, - skill.Teleport, - skill.FrozenArmor, - skill.IceBolt, - skill.IceBlast, - skill.FrostNova, - skill.GlacialSpike, - skill.Blizzard, - skill.Blizzard, - skill.Warmth, - skill.GlacialSpike, - skill.GlacialSpike, - skill.GlacialSpike, - skill.GlacialSpike, - skill.GlacialSpike, - skill.GlacialSpike, - skill.GlacialSpike, - skill.GlacialSpike, - skill.IceBlast, - skill.IceBlast, - skill.IceBlast, - skill.IceBlast, - skill.IceBlast, - skill.Blizzard, - skill.Blizzard, - skill.Blizzard, - skill.Blizzard, - skill.ColdMastery, - skill.Blizzard, - skill.Blizzard, - skill.Blizzard, - skill.Blizzard, - skill.Blizzard, - skill.Blizzard, - skill.Blizzard, - skill.Blizzard, - skill.Blizzard, - skill.Blizzard, - skill.Blizzard, - skill.Blizzard, - skill.Blizzard, - skill.Blizzard, - skill.ColdMastery, - skill.ColdMastery, - skill.ColdMastery, - skill.ColdMastery, - skill.GlacialSpike, - skill.GlacialSpike, - skill.GlacialSpike, - skill.GlacialSpike, - skill.GlacialSpike, - skill.GlacialSpike, - skill.GlacialSpike, - skill.GlacialSpike, - skill.GlacialSpike, - skill.GlacialSpike, - skill.GlacialSpike, - } -} - -func (s SorceressLeveling) EnsureStatPoints() action.Action { - return action.NewStepChain(func(d data.Data) []step.Step { - _, found := d.PlayerUnit.Stats[stat.StatPoints] - if !found { - return []step.Step{} - } - - return nil - }) -} - -func (s SorceressLeveling) EnsureSkillPoints() action.Action { - return action.NewStepChain(func(d data.Data) []step.Step { - _, found := d.PlayerUnit.Stats[stat.SkillPoints] - if !found { - return []step.Step{} - } - - return nil - }) + s.Logger.Info("Assigning skill points", "level", lvl.Value, "skillPoints", skillPoints) + return skillPoints } -func (s SorceressLeveling) KillCountess() action.Action { +func (s SorceressLeveling) KillCountess() error { return s.killMonster(npc.DarkStalker, data.MonsterTypeSuperUnique) } -func (s SorceressLeveling) KillAndariel() action.Action { - return action.NewChain(func(d data.Data) []action.Action { - return []action.Action{ - action.NewStepChain(func(d data.Data) []step.Step { - m, _ := d.Monsters.FindOne(npc.Andariel, data.MonsterTypeNone) - return []step.Step{ - step.SecondaryAttack(config.Config.Bindings.Sorceress.Blizzard, m.UnitID, 1, step.Distance(25, 30)), - step.SecondaryAttack(config.Config.Bindings.Sorceress.StaticField, m.UnitID, s.staticFieldCasts(), step.Distance(3, 5)), - } - }), - s.killMonster(npc.Andariel, data.MonsterTypeNone), - } - }) +func (s SorceressLeveling) KillAndariel() error { + return s.killMonster(npc.Andariel, data.MonsterTypeUnique) } - -func (s SorceressLeveling) KillSummoner() action.Action { - return s.killMonster(npc.Summoner, data.MonsterTypeNone) +func (s SorceressLeveling) KillSummoner() error { + return s.killMonster(npc.Summoner, data.MonsterTypeUnique) } -func (s SorceressLeveling) KillDuriel() action.Action { - return action.NewChain(func(d data.Data) []action.Action { - return []action.Action{ - action.NewStepChain(func(d data.Data) []step.Step { - m, _ := d.Monsters.FindOne(npc.Duriel, data.MonsterTypeNone) - return []step.Step{ - step.SecondaryAttack(config.Config.Bindings.Sorceress.StaticField, m.UnitID, s.staticFieldCasts(), step.Distance(1, 5)), - } - }), - s.killMonster(npc.Duriel, data.MonsterTypeNone), - } - }) -} +func (s SorceressLeveling) KillDuriel() error { + m, _ := s.Data.Monsters.FindOne(npc.Duriel, data.MonsterTypeUnique) + _ = step.SecondaryAttack(skill.StaticField, m.UnitID, s.staticFieldCasts(), step.Distance(1, 5)) -func (s SorceressLeveling) KillMephisto() action.Action { - return action.NewChain(func(d data.Data) []action.Action { - // Let's try to moat trick if Teleport is available - //if step.CanTeleport(d) { - // moatTrickPosition := data.Position{X: 17611, Y: 8093} - // return []action.Action{ - // action.NewStepChain(func(d data.Data) []step.Step { - // mephisto, _ := d.Monsters.FindOne(npc.Mephisto, data.MonsterTypeNone) - // return []step.Step{ - // step.Wait(time.Second * 2), - // step.MoveTo(data.Position{X: 17580, Y: 8085}), - // step.Wait(time.Second * 3), - // step.MoveTo(moatTrickPosition), - // step.Wait(time.Second * 3), - // step.SecondaryAttack(config.Config.Bindings.Sorceress.Blizzard, mephisto.UnitID, 3), - // } - // }), - // } - //} - - // If teleport is not available, just try to kill him with Static Field and Fire Ball - return []action.Action{ - action.NewStepChain(func(d data.Data) []step.Step { - mephisto, _ := d.Monsters.FindOne(npc.Mephisto, data.MonsterTypeNone) - return []step.Step{ - step.SecondaryAttack(config.Config.Bindings.Sorceress.StaticField, mephisto.UnitID, s.staticFieldCasts(), step.Distance(1, 5)), - } - }), - s.killMonster(npc.Mephisto, data.MonsterTypeNone), - } - }) -} - -func (s SorceressLeveling) KillPindle(skipOnImmunities []stat.Resist) action.Action { - return s.killMonster(npc.DefiledWarrior, data.MonsterTypeSuperUnique) + return s.killMonster(npc.Duriel, data.MonsterTypeUnique) } -func (s SorceressLeveling) KillNihlathak() action.Action { - return s.killMonster(npc.Nihlathak, data.MonsterTypeSuperUnique) -} - -func (s SorceressLeveling) KillCouncil() action.Action { - return s.KillMonsterSequence(func(d data.Data) (data.UnitID, bool) { +func (s SorceressLeveling) KillCouncil() error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + // Exclude monsters that are not council members var councilMembers []data.Monster for _, m := range d.Monsters { if m.Name == npc.CouncilMember || m.Name == npc.CouncilMember2 || m.Name == npc.CouncilMember3 { @@ -288,35 +334,42 @@ func (s SorceressLeveling) KillCouncil() action.Action { // Order council members by distance sort.Slice(councilMembers, func(i, j int) bool { - distanceI := pather.DistanceFromMe(d, councilMembers[i].Position) - distanceJ := pather.DistanceFromMe(d, councilMembers[j].Position) + distanceI := s.PathFinder.DistanceFromMe(councilMembers[i].Position) + distanceJ := s.PathFinder.DistanceFromMe(councilMembers[j].Position) return distanceI < distanceJ }) - if len(councilMembers) > 0 { - return councilMembers[0].UnitID, true + for _, m := range councilMembers { + return m.UnitID, true } return 0, false }, nil) } -func (s SorceressLeveling) KillDiablo() action.Action { +func (s SorceressLeveling) KillMephisto() error { + return s.killMonster(npc.Mephisto, data.MonsterTypeUnique) +} +func (s SorceressLeveling) KillIzual() error { + m, _ := s.Data.Monsters.FindOne(npc.Izual, data.MonsterTypeUnique) + _ = step.SecondaryAttack(skill.StaticField, m.UnitID, s.staticFieldCasts(), step.Distance(1, 5)) + + return s.killMonster(npc.Izual, data.MonsterTypeUnique) +} + +func (s SorceressLeveling) KillDiablo() error { timeout := time.Second * 20 - startTime := time.Time{} + startTime := time.Now() diabloFound := false - return action.NewChain(func(d data.Data) []action.Action { - if startTime.IsZero() { - startTime = time.Now() - } + for { if time.Since(startTime) > timeout && !diabloFound { - s.logger.Error("Diablo was not found, timeout reached") + s.Logger.Error("Diablo was not found, timeout reached") return nil } - diablo, found := d.Monsters.FindOne(npc.Diablo, data.MonsterTypeNone) + diablo, found := s.Data.Monsters.FindOne(npc.Diablo, data.MonsterTypeUnique) if !found || diablo.Stats[stat.Life] <= 0 { // Already dead if diabloFound { @@ -324,165 +377,43 @@ func (s SorceressLeveling) KillDiablo() action.Action { } // Keep waiting... - return []action.Action{action.NewStepChain(func(d data.Data) []step.Step { - return []step.Step{step.Wait(time.Millisecond * 100)} - })} + time.Sleep(200) + continue } diabloFound = true - s.logger.Info("Diablo detected, attacking") - - return []action.Action{ - action.NewStepChain(func(d data.Data) []step.Step { - return []step.Step{ - step.SecondaryAttack(config.Config.Bindings.Sorceress.StaticField, diablo.UnitID, s.staticFieldCasts(), step.Distance(1, 5)), - } - }), - s.killMonster(npc.Diablo, data.MonsterTypeNone), - } - }, action.RepeatUntilNoSteps()) -} + s.Logger.Info("Diablo detected, attacking") -func (s SorceressLeveling) KillIzual() action.Action { - return action.NewChain(func(d data.Data) []action.Action { - return []action.Action{ - action.NewStepChain(func(d data.Data) []step.Step { - monster, _ := d.Monsters.FindOne(npc.Izual, data.MonsterTypeNone) - return []step.Step{ - step.SecondaryAttack(config.Config.Bindings.Sorceress.StaticField, monster.UnitID, s.staticFieldCasts(), step.Distance(1, 4)), - } - }), - // We will need a lot of cycles to kill him probably - s.killMonster(npc.Izual, data.MonsterTypeNone), - s.killMonster(npc.Izual, data.MonsterTypeNone), - s.killMonster(npc.Izual, data.MonsterTypeNone), - s.killMonster(npc.Izual, data.MonsterTypeNone), - s.killMonster(npc.Izual, data.MonsterTypeNone), - s.killMonster(npc.Izual, data.MonsterTypeNone), - s.killMonster(npc.Izual, data.MonsterTypeNone), - } - }) -} + _ = step.SecondaryAttack(skill.StaticField, diablo.UnitID, s.staticFieldCasts(), step.Distance(1, 5)) -func (s SorceressLeveling) KillBaal() action.Action { - return action.NewChain(func(d data.Data) []action.Action { - return []action.Action{ - action.NewStepChain(func(d data.Data) []step.Step { - baal, _ := d.Monsters.FindOne(npc.BaalCrab, data.MonsterTypeNone) - return []step.Step{ - step.SecondaryAttack(config.Config.Bindings.Sorceress.StaticField, baal.UnitID, s.staticFieldCasts(), step.Distance(1, 4)), - } - }), - // We will need a lot of cycles to kill him probably - s.killMonster(npc.BaalCrab, data.MonsterTypeNone), - s.killMonster(npc.BaalCrab, data.MonsterTypeNone), - s.killMonster(npc.BaalCrab, data.MonsterTypeNone), - s.killMonster(npc.BaalCrab, data.MonsterTypeNone), - } - }) + return s.killMonster(npc.Diablo, data.MonsterTypeUnique) + } } -func (s SorceressLeveling) KillAncients() action.Action { - return action.NewChain(func(d data.Data) (actions []action.Action) { - for _, m := range d.Monsters.Enemies(data.MonsterEliteFilter()) { - actions = append(actions, - action.NewStepChain(func(d data.Data) []step.Step { - m, _ := d.Monsters.FindOne(m.Name, data.MonsterTypeSuperUnique) - return []step.Step{ - step.SecondaryAttack(config.Config.Bindings.Sorceress.StaticField, m.UnitID, s.staticFieldCasts(), step.Distance(8, 10)), - step.MoveTo(data.Position{ - X: 10062, - Y: 12639, - }), - } - }), - s.killMonster(m.Name, data.MonsterTypeSuperUnique), - ) - } - return actions - }) +func (s SorceressLeveling) KillPindle() error { + return s.killMonster(npc.DefiledWarrior, data.MonsterTypeSuperUnique) } -func (s SorceressLeveling) KillMonsterSequence(monsterSelector func(d data.Data) (data.UnitID, bool), skipOnImmunities []stat.Resist, opts ...step.AttackOption) action.Action { - completedAttackLoops := 0 - previousUnitID := 0 - - return action.NewStepChain(func(d data.Data) []step.Step { - id, found := monsterSelector(d) - if !found { - return []step.Step{} - } - if previousUnitID != int(id) { - completedAttackLoops = 0 - } - - if !s.preBattleChecks(d, id, skipOnImmunities) { - return []step.Step{} - } - - if len(opts) == 0 { - opts = append(opts, step.Distance(1, 30)) - } - - if completedAttackLoops >= sorceressMaxAttacksLoop { - return []step.Step{} - } - - steps := make([]step.Step, 0) - - // During early game stages amount of mana is ridiculous... - if d.PlayerUnit.MPPercent() < 15 && d.PlayerUnit.Stats[stat.Level] < 15 { - steps = append(steps, step.PrimaryAttack(id, 1, step.Distance(1, 3))) - } else { - if _, found := d.PlayerUnit.Skills[skill.Blizzard]; found { - if completedAttackLoops%2 == 0 { - for _, m := range d.Monsters.Enemies() { - if d := pather.DistanceFromMe(d, m.Position); d < 4 { - s.logger.Debug("Monster detected close to the player, casting Blizzard over it") - steps = append(steps, step.SecondaryAttack(config.Config.Bindings.Sorceress.Blizzard, m.UnitID, 1, step.Distance(25, 30))) - break - } - } - } - - steps = append(steps, - step.SecondaryAttack(config.Config.Bindings.Sorceress.Blizzard, id, 1, step.Distance(25, 30)), - step.PrimaryAttack(id, 3, step.Distance(25, 30)), - ) - } else { - steps = append(steps, step.SecondaryAttack(config.Config.Bindings.Sorceress.FireBall, id, 4, step.Distance(1, 25))) - } - } - - completedAttackLoops++ - previousUnitID = int(id) - - return steps - }, action.RepeatUntilNoSteps()) +func (s SorceressLeveling) KillNihlathak() error { + return s.killMonster(npc.Nihlathak, data.MonsterTypeSuperUnique) } -func (s SorceressLeveling) killMonster(npc npc.ID, t data.MonsterType) action.Action { - return s.KillMonsterSequence(func(d data.Data) (data.UnitID, bool) { - m, found := d.Monsters.FindOne(npc, t) - if !found { - return 0, false - } +func (s SorceressLeveling) KillAncients() error { + for _, m := range s.Data.Monsters.Enemies(data.MonsterEliteFilter()) { + m, _ := s.Data.Monsters.FindOne(m.Name, data.MonsterTypeSuperUnique) - return m.UnitID, true - }, nil) -} + step.SecondaryAttack(skill.StaticField, m.UnitID, s.staticFieldCasts(), step.Distance(8, 10)) + + step.MoveTo(data.Position{X: 10062, Y: 12639}) -func (s SorceressLeveling) BuffSkills() map[skill.Skill]string { - return map[skill.Skill]string{ - skill.FrozenArmor: config.Config.Bindings.Sorceress.FrozenArmor, + s.killMonster(m.Name, data.MonsterTypeSuperUnique) } + return nil } -func (s SorceressLeveling) staticFieldCasts() int { - switch config.Config.Game.Difficulty { - case difficulty.Normal: - return 8 - } +func (s SorceressLeveling) KillBaal() error { + m, _ := s.Data.Monsters.FindOne(npc.BaalCrab, data.MonsterTypeUnique) + step.SecondaryAttack(skill.StaticField, m.UnitID, s.staticFieldCasts(), step.Distance(1, 4)) - return 6 + return s.killMonster(npc.BaalCrab, data.MonsterTypeUnique) } diff --git a/internal/character/sorceress_leveling_lightning.go b/internal/character/sorceress_leveling_lightning.go new file mode 100644 index 000000000..7dc13788e --- /dev/null +++ b/internal/character/sorceress_leveling_lightning.go @@ -0,0 +1,456 @@ +package character + +import ( + "fmt" + "log/slog" + "sort" + "time" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/difficulty" + "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/d2go/pkg/data/skill" + "github.com/hectorgimenez/d2go/pkg/data/stat" + "github.com/hectorgimenez/koolo/internal/action/step" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" +) + +const ( + SorceressLevelingLightningMaxAttacksLoop = 10 +) + +type SorceressLevelingLightning struct { + BaseCharacter +} + +func (s SorceressLevelingLightning) CheckKeyBindings() []skill.ID { + requireKeybindings := []skill.ID{skill.TomeOfTownPortal} + missingKeybindings := []skill.ID{} + + for _, cskill := range requireKeybindings { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(cskill); !found { + missingKeybindings = append(missingKeybindings, cskill) + } + } + + if len(missingKeybindings) > 0 { + s.Logger.Debug("There are missing required key bindings.", slog.Any("Bindings", missingKeybindings)) + } + + return missingKeybindings +} + +func (s SorceressLevelingLightning) KillMonsterSequence( + monsterSelector func(d game.Data) (data.UnitID, bool), + skipOnImmunities []stat.Resist, +) error { + completedAttackLoops := 0 + previousUnitID := 0 + + for { + id, found := monsterSelector(*s.Data) + if !found { + return nil + } + if previousUnitID != int(id) { + completedAttackLoops = 0 + } + + if !s.preBattleChecks(id, skipOnImmunities) { + return nil + } + + if completedAttackLoops >= SorceressLevelingLightningMaxAttacksLoop { + return nil + } + + monster, found := s.Data.Monsters.FindByID(id) + if !found { + s.Logger.Info("Monster not found", slog.String("monster", fmt.Sprintf("%v", monster))) + return nil + } + + lvl, _ := s.Data.PlayerUnit.FindStat(stat.Level, 0) + if s.Data.PlayerUnit.MPPercent() < 15 && lvl.Value < 15 { + s.Logger.Debug("Low mana, using primary attack") + step.PrimaryAttack(id, 1, false, step.Distance(1, 3)) + } else { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.Blizzard); found { + if completedAttackLoops%2 == 0 { + for _, m := range s.Data.Monsters.Enemies() { + if d := s.PathFinder.DistanceFromMe(m.Position); d < 4 { + s.Logger.Debug("Monster close, casting Blizzard") + step.SecondaryAttack(skill.Blizzard, m.UnitID, 1, step.Distance(25, 30)) + break + } + } + } + + s.Logger.Debug("Using Blizzard") + + step.SecondaryAttack(skill.Blizzard, id, 1, step.Distance(25, 30)) + step.PrimaryAttack(id, 3, false, step.Distance(25, 30)) + + } else if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.Nova); found { + s.Logger.Debug("Using Nova") + step.SecondaryAttack(skill.Nova, id, 4, step.Distance(1, 5)) + } else if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.ChargedBolt); found { + s.Logger.Debug("Using ChargedBolt") + step.SecondaryAttack(skill.ChargedBolt, id, 4, step.Distance(1, 5)) + } else if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.FireBolt); found { + s.Logger.Debug("Using FireBolt") + step.SecondaryAttack(skill.FireBolt, id, 4, step.Distance(1, 5)) + } else { + s.Logger.Debug("No secondary skills available, using primary attack") + step.PrimaryAttack(id, 1, false, step.Distance(1, 3)) + } + } + + completedAttackLoops++ + previousUnitID = int(id) + } +} + +func (s SorceressLevelingLightning) killMonster(npc npc.ID, t data.MonsterType) error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + m, found := d.Monsters.FindOne(npc, t) + if !found { + return 0, false + } + + return m.UnitID, true + }, nil) +} + +func (s SorceressLevelingLightning) BuffSkills() []skill.ID { + skillsList := make([]skill.ID, 0) + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.EnergyShield); found { + skillsList = append(skillsList, skill.EnergyShield) + } + + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.ThunderStorm); found { + skillsList = append(skillsList, skill.ThunderStorm) + } + + armors := []skill.ID{skill.ChillingArmor, skill.ShiverArmor, skill.FrozenArmor} + for _, armor := range armors { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(armor); found { + skillsList = append(skillsList, armor) + break + } + } + + return skillsList +} + +func (s SorceressLevelingLightning) PreCTABuffSkills() []skill.ID { + return []skill.ID{} +} + +func (s SorceressLevelingLightning) staticFieldCasts() int { + casts := 6 + ctx := context.Get() + + switch ctx.CharacterCfg.Game.Difficulty { + case difficulty.Normal: + casts = 8 + } + s.Logger.Debug("Static Field casts", "count", casts) + return casts +} + +func (s SorceressLevelingLightning) ShouldResetSkills() bool { + lvl, _ := s.Data.PlayerUnit.FindStat(stat.Level, 0) + if lvl.Value >= 25 && s.Data.PlayerUnit.Skills[skill.Nova].Level > 10 { + s.Logger.Info("Resetting skills: Level 25+ and Nova level > 10") + return true + } + + return false +} + +func (s SorceressLevelingLightning) SkillsToBind() (skill.ID, []skill.ID) { + level, _ := s.Data.PlayerUnit.FindStat(stat.Level, 0) + skillBindings := []skill.ID{ + skill.TomeOfTownPortal, + } + + // Add skills only if the character level is high enough + if level.Value >= 4 { + skillBindings = append(skillBindings, skill.FrozenArmor) + } + if level.Value >= 6 { + skillBindings = append(skillBindings, skill.StaticField) + } + if level.Value >= 18 { + skillBindings = append(skillBindings, skill.Teleport) + } + + if s.Data.PlayerUnit.Skills[skill.Blizzard].Level > 0 { + skillBindings = append(skillBindings, skill.Blizzard) + } else if s.Data.PlayerUnit.Skills[skill.Nova].Level > 1 { + skillBindings = append(skillBindings, skill.Nova) + } else if s.Data.PlayerUnit.Skills[skill.ChargedBolt].Level > 0 { + skillBindings = append(skillBindings, skill.ChargedBolt) + } else if s.Data.PlayerUnit.Skills[skill.FireBolt].Level > 0 { + skillBindings = append(skillBindings, skill.FireBolt) + } + + mainSkill := skill.AttackSkill + if s.Data.PlayerUnit.Skills[skill.GlacialSpike].Level > 0 { + mainSkill = skill.GlacialSpike + } + + s.Logger.Info("Skills bound", "mainSkill", mainSkill, "skillBindings", skillBindings) + return mainSkill, skillBindings +} + +func (s SorceressLevelingLightning) StatPoints() map[stat.ID]int { + lvl, _ := s.Data.PlayerUnit.FindStat(stat.Level, 0) + statPoints := make(map[stat.ID]int) + + if lvl.Value < 9 { + statPoints[stat.Vitality] = 9999 + } else if lvl.Value < 15 { + statPoints[stat.Energy] = 45 + statPoints[stat.Strength] = 25 + statPoints[stat.Vitality] = 9999 + } else { + statPoints[stat.Energy] = 60 + statPoints[stat.Strength] = 50 + statPoints[stat.Vitality] = 9999 + } + + s.Logger.Info("Assigning stat points", "level", lvl.Value, "statPoints", statPoints) + return statPoints +} + +func (s SorceressLevelingLightning) SkillPoints() []skill.ID { + lvl, _ := s.Data.PlayerUnit.FindStat(stat.Level, 0) + var skillPoints []skill.ID + + if lvl.Value < 25 { + skillPoints = []skill.ID{ + skill.ChargedBolt, + skill.ChargedBolt, + skill.ChargedBolt, + skill.FrozenArmor, + skill.ChargedBolt, + skill.StaticField, + skill.StaticField, + skill.StaticField, + skill.StaticField, + skill.Telekinesis, + skill.Warmth, + skill.Nova, + skill.Nova, + skill.Nova, + skill.Nova, + skill.Nova, + skill.Nova, + skill.Teleport, + skill.Nova, + skill.Nova, + skill.Nova, + skill.Nova, + skill.Nova, + skill.Nova, + skill.Nova, + skill.Nova, + skill.Nova, + skill.Nova, + skill.Nova, + skill.Nova, + skill.Nova, + skill.Nova, + } + } else { + skillPoints = []skill.ID{ + skill.StaticField, + skill.StaticField, + skill.StaticField, + skill.StaticField, + skill.Telekinesis, + skill.Teleport, + skill.FrozenArmor, + skill.IceBolt, + skill.IceBlast, + skill.FrostNova, + skill.GlacialSpike, + skill.Blizzard, + skill.Blizzard, + skill.Warmth, + skill.GlacialSpike, + skill.GlacialSpike, + skill.GlacialSpike, + skill.GlacialSpike, + skill.GlacialSpike, + skill.GlacialSpike, + skill.GlacialSpike, + skill.GlacialSpike, + skill.IceBlast, + skill.IceBlast, + skill.IceBlast, + skill.IceBlast, + skill.IceBlast, + skill.Blizzard, + skill.Blizzard, + skill.Blizzard, + skill.Blizzard, + skill.ColdMastery, + skill.Blizzard, + skill.Blizzard, + skill.Blizzard, + skill.Blizzard, + skill.Blizzard, + skill.Blizzard, + skill.Blizzard, + skill.Blizzard, + skill.Blizzard, + skill.Blizzard, + skill.Blizzard, + skill.Blizzard, + skill.Blizzard, + skill.Blizzard, + skill.ColdMastery, + skill.ColdMastery, + skill.ColdMastery, + skill.ColdMastery, + skill.GlacialSpike, + skill.GlacialSpike, + skill.GlacialSpike, + skill.GlacialSpike, + skill.GlacialSpike, + skill.GlacialSpike, + skill.GlacialSpike, + skill.GlacialSpike, + skill.GlacialSpike, + skill.GlacialSpike, + skill.GlacialSpike, + } + } + + s.Logger.Info("Assigning skill points", "level", lvl.Value, "skillPoints", skillPoints) + return skillPoints +} + +func (s SorceressLevelingLightning) KillCountess() error { + return s.killMonster(npc.DarkStalker, data.MonsterTypeSuperUnique) +} + +func (s SorceressLevelingLightning) KillAndariel() error { + m, _ := s.Data.Monsters.FindOne(npc.Andariel, data.MonsterTypeNone) + _ = step.SecondaryAttack(skill.StaticField, m.UnitID, s.staticFieldCasts(), step.Distance(3, 5)) + return s.killMonster(npc.Andariel, data.MonsterTypeNone) +} + +func (s SorceressLevelingLightning) KillSummoner() error { + return s.killMonster(npc.Summoner, data.MonsterTypeNone) +} + +func (s SorceressLevelingLightning) KillDuriel() error { + m, _ := s.Data.Monsters.FindOne(npc.Duriel, data.MonsterTypeUnique) + _ = step.SecondaryAttack(skill.StaticField, m.UnitID, s.staticFieldCasts(), step.Distance(1, 5)) + + return s.killMonster(npc.Duriel, data.MonsterTypeUnique) +} + +func (s SorceressLevelingLightning) KillCouncil() error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + // Exclude monsters that are not council members + var councilMembers []data.Monster + for _, m := range d.Monsters { + if m.Name == npc.CouncilMember || m.Name == npc.CouncilMember2 || m.Name == npc.CouncilMember3 { + councilMembers = append(councilMembers, m) + } + } + + // Order council members by distance + sort.Slice(councilMembers, func(i, j int) bool { + distanceI := s.PathFinder.DistanceFromMe(councilMembers[i].Position) + distanceJ := s.PathFinder.DistanceFromMe(councilMembers[j].Position) + + return distanceI < distanceJ + }) + + for _, m := range councilMembers { + return m.UnitID, true + } + + return 0, false + }, nil) +} + +func (s SorceressLevelingLightning) KillMephisto() error { + m, _ := s.Data.Monsters.FindOne(npc.Mephisto, data.MonsterTypeNone) + _ = step.SecondaryAttack(skill.StaticField, m.UnitID, s.staticFieldCasts(), step.Distance(1, 5)) + return s.killMonster(npc.Mephisto, data.MonsterTypeNone) +} + +func (s SorceressLevelingLightning) KillIzual() error { + m, _ := s.Data.Monsters.FindOne(npc.Izual, data.MonsterTypeUnique) + _ = step.SecondaryAttack(skill.StaticField, m.UnitID, s.staticFieldCasts(), step.Distance(1, 5)) + + return s.killMonster(npc.Izual, data.MonsterTypeUnique) +} + +func (s SorceressLevelingLightning) KillDiablo() error { + timeout := time.Second * 20 + startTime := time.Now() + diabloFound := false + + for { + if time.Since(startTime) > timeout && !diabloFound { + s.Logger.Error("Diablo was not found, timeout reached") + return nil + } + + diablo, found := s.Data.Monsters.FindOne(npc.Diablo, data.MonsterTypeUnique) + if !found || diablo.Stats[stat.Life] <= 0 { + // Already dead + if diabloFound { + return nil + } + + // Keep waiting... + time.Sleep(200) + continue + } + + diabloFound = true + s.Logger.Info("Diablo detected, attacking") + + _ = step.SecondaryAttack(skill.StaticField, diablo.UnitID, s.staticFieldCasts(), step.Distance(1, 5)) + + return s.killMonster(npc.Diablo, data.MonsterTypeUnique) + } +} + +func (s SorceressLevelingLightning) KillPindle() error { + return s.killMonster(npc.DefiledWarrior, data.MonsterTypeSuperUnique) +} + +func (s SorceressLevelingLightning) KillNihlathak() error { + return s.killMonster(npc.Nihlathak, data.MonsterTypeSuperUnique) +} + +func (s SorceressLevelingLightning) KillAncients() error { + for _, m := range s.Data.Monsters.Enemies(data.MonsterEliteFilter()) { + m, _ := s.Data.Monsters.FindOne(m.Name, data.MonsterTypeSuperUnique) + + step.SecondaryAttack(skill.StaticField, m.UnitID, s.staticFieldCasts(), step.Distance(8, 10)) + + step.MoveTo(data.Position{X: 10062, Y: 12639}) + + s.killMonster(m.Name, data.MonsterTypeSuperUnique) + } + return nil +} + +func (s SorceressLevelingLightning) KillBaal() error { + m, _ := s.Data.Monsters.FindOne(npc.BaalCrab, data.MonsterTypeUnique) + step.SecondaryAttack(skill.StaticField, m.UnitID, s.staticFieldCasts(), step.Distance(1, 4)) + + return s.killMonster(npc.BaalCrab, data.MonsterTypeUnique) +} diff --git a/internal/character/trapsin.go b/internal/character/trapsin.go new file mode 100644 index 000000000..f41f001b3 --- /dev/null +++ b/internal/character/trapsin.go @@ -0,0 +1,222 @@ +package character + +import ( + "fmt" + "log/slog" + "sort" + "time" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/d2go/pkg/data/skill" + "github.com/hectorgimenez/d2go/pkg/data/stat" + "github.com/hectorgimenez/koolo/internal/action/step" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/utils" +) + +const ( + maxAttacksLoop = 5 + minDistance = 25 + maxDistance = 30 +) + +type Trapsin struct { + BaseCharacter +} + +func (s Trapsin) CheckKeyBindings() []skill.ID { + requireKeybindings := []skill.ID{skill.DeathSentry, skill.LightningSentry, skill.TomeOfTownPortal} + missingKeybindings := []skill.ID{} + + for _, cskill := range requireKeybindings { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(cskill); !found { + missingKeybindings = append(missingKeybindings, cskill) + } + } + + if len(missingKeybindings) > 0 { + s.Logger.Debug("There are missing required key bindings.", slog.Any("Bindings", missingKeybindings)) + } + + return missingKeybindings +} + +func (s Trapsin) KillMonsterSequence( + monsterSelector func(d game.Data) (data.UnitID, bool), + skipOnImmunities []stat.Resist, +) error { + completedAttackLoops := 0 + previousUnitID := 0 + + for { + id, found := monsterSelector(*s.Data) + if !found { + return nil + } + if previousUnitID != int(id) { + completedAttackLoops = 0 + } + + if !s.preBattleChecks(id, skipOnImmunities) { + return nil + } + + if completedAttackLoops >= maxAttacksLoop { + return nil + } + + monster, found := s.Data.Monsters.FindByID(id) + if !found { + s.Logger.Info("Monster not found", slog.String("monster", fmt.Sprintf("%v", monster))) + return nil + } + + opts := step.Distance(minDistance, maxDistance) + + utils.Sleep(100) + step.SecondaryAttack(skill.LightningSentry, id, 3, opts) + step.SecondaryAttack(skill.DeathSentry, id, 2, opts) + step.PrimaryAttack(id, 2, true, opts) + + completedAttackLoops++ + previousUnitID = int(id) + } +} + +func (s Trapsin) killMonster(npc npc.ID, t data.MonsterType) error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + m, found := d.Monsters.FindOne(npc, t) + if !found { + return 0, false + } + + return m.UnitID, true + }, nil) +} + +func (s Trapsin) BuffSkills() []skill.ID { + armor := skill.Fade + armors := []skill.ID{skill.BurstOfSpeed, skill.Fade} + for _, arm := range armors { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(arm); found { + armor = arm + } + } + + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.BladeShield); found { + return []skill.ID{armor, skill.BladeShield} + } + + return []skill.ID{armor} +} + +func (s Trapsin) PreCTABuffSkills() []skill.ID { + armor := skill.ShadowWarrior + armors := []skill.ID{skill.ShadowWarrior, skill.ShadowMaster} + hasShadow := false + for _, arm := range armors { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(arm); found { + armor = arm + hasShadow = true + } + } + + if hasShadow { + return []skill.ID{armor} + } + + return []skill.ID{} +} + +func (s Trapsin) KillCountess() error { + return s.killMonster(npc.DarkStalker, data.MonsterTypeSuperUnique) +} + +func (s Trapsin) KillAndariel() error { + return s.killMonster(npc.Andariel, data.MonsterTypeUnique) +} + +func (s Trapsin) KillSummoner() error { + return s.killMonster(npc.Summoner, data.MonsterTypeUnique) +} + +func (s Trapsin) KillDuriel() error { + return s.killMonster(npc.Duriel, data.MonsterTypeUnique) +} + +func (s Trapsin) KillCouncil() error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + // Exclude monsters that are not council members + var councilMembers []data.Monster + for _, m := range d.Monsters { + if m.Name == npc.CouncilMember || m.Name == npc.CouncilMember2 || m.Name == npc.CouncilMember3 { + councilMembers = append(councilMembers, m) + } + } + + // Order council members by distance + sort.Slice(councilMembers, func(i, j int) bool { + distanceI := s.PathFinder.DistanceFromMe(councilMembers[i].Position) + distanceJ := s.PathFinder.DistanceFromMe(councilMembers[j].Position) + + return distanceI < distanceJ + }) + + for _, m := range councilMembers { + return m.UnitID, true + } + + return 0, false + }, nil) +} + +func (s Trapsin) KillMephisto() error { + return s.killMonster(npc.Mephisto, data.MonsterTypeUnique) +} + +func (s Trapsin) KillIzual() error { + return s.killMonster(npc.Izual, data.MonsterTypeUnique) +} + +func (s Trapsin) KillDiablo() error { + timeout := time.Second * 20 + startTime := time.Now() + diabloFound := false + + for { + if time.Since(startTime) > timeout && !diabloFound { + s.Logger.Error("Diablo was not found, timeout reached") + return nil + } + + diablo, found := s.Data.Monsters.FindOne(npc.Diablo, data.MonsterTypeUnique) + if !found || diablo.Stats[stat.Life] <= 0 { + // Already dead + if diabloFound { + return nil + } + + // Keep waiting... + time.Sleep(200) + continue + } + + diabloFound = true + s.Logger.Info("Diablo detected, attacking") + + return s.killMonster(npc.Diablo, data.MonsterTypeUnique) + } +} + +func (s Trapsin) KillPindle() error { + return s.killMonster(npc.DefiledWarrior, data.MonsterTypeSuperUnique) +} + +func (s Trapsin) KillNihlathak() error { + return s.killMonster(npc.Nihlathak, data.MonsterTypeSuperUnique) +} + +func (s Trapsin) KillBaal() error { + return s.killMonster(npc.BaalCrab, data.MonsterTypeUnique) +} diff --git a/internal/character/wind_druid.go b/internal/character/wind_druid.go new file mode 100644 index 000000000..f44facce4 --- /dev/null +++ b/internal/character/wind_druid.go @@ -0,0 +1,327 @@ +package character + +import ( + "fmt" + "log/slog" + "sort" + "time" + + "github.com/hectorgimenez/d2go/pkg/data/mode" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/d2go/pkg/data/skill" + "github.com/hectorgimenez/d2go/pkg/data/stat" + "github.com/hectorgimenez/d2go/pkg/data/state" + "github.com/hectorgimenez/koolo/internal/action/step" + "github.com/hectorgimenez/koolo/internal/utils" +) + +const ( + druMaxAttacksLoop = 20 // Max number of attack loops before stopping + druMinDistance = 2 // Min distance to maintain from target + druMaxDistance = 8 // Max distance to maintain from target + druidCastingTimeout = 3 * time.Second // Timeout for casting actions +) + +type WindDruid struct { + BaseCharacter // Inherits common character functionality + lastCastTime time.Time // Tracks the last time a skill was cast +} + +// Verify that required skills are bound to keys +func (s WindDruid) CheckKeyBindings() []skill.ID { + requireKeybindings := []skill.ID{skill.Hurricane, skill.OakSage, skill.CycloneArmor, skill.TomeOfTownPortal, skill.Tornado} + missingKeybindings := make([]skill.ID, 0) + + for _, cskill := range requireKeybindings { + if _, found := s.Data.KeyBindings.KeyBindingForSkill(cskill); !found { + missingKeybindings = append(missingKeybindings, cskill) + } + } + + if len(missingKeybindings) > 0 { + s.Logger.Debug("There are missing required key bindings.", slog.Any("Bindings", missingKeybindings)) + } + + return missingKeybindings // Returns list of unbound required skills +} + +// Ensure casting animation finishes before proceeding +func (s WindDruid) waitForCastComplete() bool { + ctx := context.Get() + startTime := time.Now() + + for time.Since(startTime) < castingTimeout { + ctx.RefreshGameData() + + if ctx.Data.PlayerUnit.Mode != mode.CastingSkill && // Check if not casting + time.Since(s.lastCastTime) > 150*time.Millisecond { // Ensure enough time has passed + return true + } + + time.Sleep(16 * time.Millisecond) // Small delay to avoid busy-waiting + } + + return false // Returns false if timeout is reached +} + +// Handle the main combat loop for attacking monsters +func (s WindDruid) KillMonsterSequence( + monsterSelector func(d game.Data) (data.UnitID, bool), // Function to select target monster + skipOnImmunities []stat.Resist, // Resistances to skip if monster is immune +) error { + ctx := context.Get() + lastRefresh := time.Now() + completedAttackLoops := 0 + var currentTargetID data.UnitID + + defer func() { // Ensures Tornado is set as active skill on exit + if kb, found := ctx.Data.KeyBindings.KeyBindingForSkill(skill.Tornado); found { + ctx.HID.PressKeyBinding(kb) + } + }() + + attackOpts := []step.AttackOption{ + step.StationaryDistance(druMinDistance, druMaxDistance), // Maintains distance range + } + + for { + if time.Since(lastRefresh) > time.Millisecond*100 { + ctx.RefreshGameData() + lastRefresh = time.Now() + } + + ctx.PauseIfNotPriority() // Pause if not the priority task + + if completedAttackLoops >= druMaxAttacksLoop { + return nil // Exit if max attack loops reached + } + + if currentTargetID == 0 { // Select a new target if none exists + id, found := monsterSelector(*s.Data) + if !found { + return nil // Exit if no target found + } + currentTargetID = id + } + + monster, found := s.Data.Monsters.FindByID(currentTargetID) + if !found || monster.Stats[stat.Life] <= 0 { // Check if target is dead or missing + currentTargetID = 0 + continue + } + + if !s.preBattleChecks(currentTargetID, skipOnImmunities) { // Perform pre-combat checks + return nil + } + + s.RecastBuffs() // Refresh buffs before attacking + + if kb, found := ctx.Data.KeyBindings.KeyBindingForSkill(skill.Tornado); found { + ctx.HID.PressKeyBinding(kb) // Set Tornado as active skill + if err := step.PrimaryAttack(currentTargetID, 1, true, attackOpts...); err == nil { + if !s.waitForCastComplete() { // Wait for cast to complete + continue + } + s.lastCastTime = time.Now() // Update last cast time + completedAttackLoops++ + } + } else { + return fmt.Errorf("tornado skill not bound") + } + } +} + +// Helper for killing a specific monster by NPC ID and type +func (s WindDruid) killMonster(npc npc.ID, t data.MonsterType) error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + m, found := d.Monsters.FindOne(npc, t) + if !found { + return 0, false + } + return m.UnitID, true + }, nil) +} + +// Reapplies active buffs if they’ve expired +func (s WindDruid) RecastBuffs() { + ctx := context.Get() + skills := []skill.ID{skill.Hurricane, skill.OakSage, skill.CycloneArmor} + states := []state.State{state.Hurricane, state.Oaksage, state.Cyclonearmor} + + for i, druSkill := range skills { + if kb, found := ctx.Data.KeyBindings.KeyBindingForSkill(druSkill); found { + if !ctx.Data.PlayerUnit.States.HasState(states[i]) { // Check if buff is missing + ctx.HID.PressKeyBinding(kb) // Activate skill + utils.Sleep(180) // Small delay + s.HID.Click(game.RightButton, 640, 340) // Cast skill at center screen + utils.Sleep(100) // Delay to ensure cast completes + } + } + } +} + +// Return a list of available buff skills +func (s WindDruid) BuffSkills() []skill.ID { + buffs := make([]skill.ID, 0) + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.CycloneArmor); found { + buffs = append(buffs, skill.CycloneArmor) + } + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.Raven); found { + buffs = append(buffs, skill.Raven, skill.Raven, skill.Raven, skill.Raven, skill.Raven) // Summon 5 ravens + } + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.Hurricane); found { + buffs = append(buffs, skill.Hurricane) + } + return buffs +} + +// Dynamically determines pre-combat buffs and summons +func (s WindDruid) PreCTABuffSkills() []skill.ID { + needsBear := true + wolves := 5 + direWolves := 3 + needsOak := true + needsCreeper := true + + for _, monster := range s.Data.Monsters { // Check existing pets + if monster.IsPet() { + switch monster.Name { + case npc.DruBear: + needsBear = false + case npc.DruFenris: + direWolves-- + case npc.DruSpiritWolf: + wolves-- + case npc.OakSage: + needsOak = false + case npc.DruCycleOfLife, npc.VineCreature, npc.DruPlaguePoppy: + needsCreeper = false + } + } + } + + skills := make([]skill.ID, 0) + if s.Data.PlayerUnit.States.HasState(state.Oaksage) { + needsOak = false + } + + // Add summoning skills based on need and key bindings + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.SummonSpiritWolf); found { + for i := 0; i < wolves; i++ { + skills = append(skills, skill.SummonSpiritWolf) + } + } + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.SummonDireWolf); found { + for i := 0; i < direWolves; i++ { + skills = append(skills, skill.SummonDireWolf) + } + } + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.SummonGrizzly); found && needsBear { + skills = append(skills, skill.SummonGrizzly) + } + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.OakSage); found && needsOak { + skills = append(skills, skill.OakSage) + } + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.SolarCreeper); found && needsCreeper { + skills = append(skills, skill.SolarCreeper) + } + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.CarrionVine); found && needsCreeper { + skills = append(skills, skill.CarrionVine) + } + if _, found := s.Data.KeyBindings.KeyBindingForSkill(skill.PoisonCreeper); found && needsCreeper { + skills = append(skills, skill.PoisonCreeper) + } + + return skills +} + +func (s WindDruid) KillCountess() error { + return s.killMonster(npc.DarkStalker, data.MonsterTypeSuperUnique) +} + +func (s WindDruid) KillAndariel() error { + return s.killMonster(npc.Andariel, data.MonsterTypeUnique) +} + +func (s WindDruid) KillSummoner() error { + return s.killMonster(npc.Summoner, data.MonsterTypeUnique) +} + +func (s WindDruid) KillDuriel() error { + return s.killMonster(npc.Duriel, data.MonsterTypeUnique) +} + +// Targets multiple council members, sorted by distance +func (s WindDruid) KillCouncil() error { + return s.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + var councilMembers []data.Monster + for _, m := range d.Monsters { + if m.Name == npc.CouncilMember || m.Name == npc.CouncilMember2 || m.Name == npc.CouncilMember3 { + councilMembers = append(councilMembers, m) + } + } + + sort.Slice(councilMembers, func(i, j int) bool { + return s.PathFinder.DistanceFromMe(councilMembers[i].Position) < s.PathFinder.DistanceFromMe(councilMembers[j].Position) + }) + + for _, m := range councilMembers { + return m.UnitID, true + } + + return 0, false + }, nil) +} + +func (s WindDruid) KillMephisto() error { + return s.killMonster(npc.Mephisto, data.MonsterTypeUnique) +} + +func (s WindDruid) KillIzual() error { + return s.killMonster(npc.Izual, data.MonsterTypeUnique) +} + +// KillDiablo includes a timeout and detection logic +func (s WindDruid) KillDiablo() error { + timeout := time.Second * 20 + startTime := time.Now() + diabloFound := false + + for { + if time.Since(startTime) > timeout && !diabloFound { + s.Logger.Error("Diablo was not found, timeout reached") + return nil + } + + diablo, found := s.Data.Monsters.FindOne(npc.Diablo, data.MonsterTypeUnique) + if !found || diablo.Stats[stat.Life] <= 0 { // Check if Diablo is dead or missing + // Diablo is dead + if diabloFound { + return nil + } + // Keep waiting.. + time.Sleep(200 * time.Millisecond) + continue + } + + diabloFound = true + s.Logger.Info("Diablo detected, attacking") + return s.killMonster(npc.Diablo, data.MonsterTypeUnique) + } +} + +func (s WindDruid) KillPindle() error { + return s.killMonster(npc.DefiledWarrior, data.MonsterTypeSuperUnique) +} + +func (s WindDruid) KillNihlathak() error { + return s.killMonster(npc.Nihlathak, data.MonsterTypeSuperUnique) +} + +func (s WindDruid) KillBaal() error { + return s.killMonster(npc.BaalCrab, data.MonsterTypeUnique) +} diff --git a/internal/companion_supervisor.go b/internal/companion_supervisor.go deleted file mode 100644 index f8eb6883a..000000000 --- a/internal/companion_supervisor.go +++ /dev/null @@ -1,121 +0,0 @@ -package koolo - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/event" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/reader" - "github.com/hectorgimenez/koolo/internal/run" - "go.uber.org/zap" -) - -type Companion interface { - JoinGame(gameName, password string) -} - -type CompanionGameData struct { - GameName string - Password string -} - -type CompanionSupervisor struct { - baseSupervisor - companionCh chan CompanionGameData -} - -func (s *CompanionSupervisor) JoinGame(gameName, password string) { - s.companionCh <- CompanionGameData{GameName: gameName, Password: password} -} - -func NewCompanionSupervisor(logger *zap.Logger, bot *Bot, gr *reader.GameReader, gm *helper.GameManager) *CompanionSupervisor { - return &CompanionSupervisor{ - baseSupervisor: baseSupervisor{ - logger: logger, - bot: bot, - gr: gr, - gm: gm, - }, - companionCh: make(chan CompanionGameData), - } -} - -// Start will stay running during the application lifecycle, it will orchestrate all the required bot pieces -func (s *CompanionSupervisor) Start(ctx context.Context, factory *run.Factory) error { - err := s.ensureProcessIsRunningAndPrepare() - if err != nil { - return fmt.Errorf("error preparing game: %w", err) - } - - gameCounter := 0 - firstRun := true - for { - select { - case <-ctx.Done(): - return nil - default: - if config.Config.Companion.Leader { - gameName, err := s.gm.CreateOnlineGame(gameCounter) - gameCounter++ // Sometimes game is created but error during join, so game name will be in use - if err != nil { - s.logger.Error(fmt.Sprintf("Error creating new game: %s", err.Error())) - continue - } - - event.Events <- event.Text(fmt.Sprintf("New game created. GameName: " + gameName + "|||x")) - - err = s.startBot(ctx, factory.BuildRuns(), firstRun) - firstRun = false - if err != nil { - return err - } - } else { - for { - s.logger.Debug("Waiting for new game to be created...") - select { - case gd := <-s.companionCh: - err := s.gm.JoinOnlineGame(gd.GameName, gd.Password) - if err != nil { - s.logger.Error(err.Error()) - continue - } - - runs := factory.BuildRuns() - err = s.startBot(ctx, runs, firstRun) - firstRun = false - if err != nil { - return err - } - } - } - } - } - } -} - -func (s *CompanionSupervisor) startBot(ctx context.Context, runs []run.Run, firstRun bool) error { - gameStart := time.Now() - s.logGameStart(runs) - err := s.bot.Run(ctx, firstRun, runs) - if err != nil { - if errors.Is(context.Canceled, ctx.Err()) { - return nil - } - errorMsg := fmt.Sprintf("Game finished with errors, reason: %s. Game total time: %0.2fs", err.Error(), time.Since(gameStart).Seconds()) - event.Events <- event.WithScreenshot(errorMsg) - s.logger.Warn(errorMsg) - } - if exitErr := s.gm.ExitGame(); exitErr != nil { - return fmt.Errorf("error exiting game: %s", exitErr) - } - firstRun = false - - s.updateGameStats() - helper.Sleep(5000) - - return nil -} diff --git a/internal/config/config.go b/internal/config/config.go index 6d4c0f006..bed53b663 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,42 +1,97 @@ package config import ( + "errors" "fmt" - "os" + "path/filepath" "time" + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/koolo/internal/utils" + + "os" + "strings" + "github.com/hectorgimenez/d2go/pkg/data/area" + "github.com/hectorgimenez/d2go/pkg/data/difficulty" "github.com/hectorgimenez/d2go/pkg/data/item" + "github.com/hectorgimenez/d2go/pkg/data/stat" + cp "github.com/otiai10/copy" + "github.com/hectorgimenez/d2go/pkg/nip" - "github.com/hectorgimenez/d2go/pkg/data/difficulty" - "github.com/hectorgimenez/d2go/pkg/data/stat" "gopkg.in/yaml.v3" ) var ( - Config *StructConfig + Koolo *KooloCfg + Characters map[string]*CharacterCfg + Version = "dev" ) -type StructConfig struct { +type KooloCfg struct { Debug struct { - Log bool `yaml:"log"` - RenderMap bool `yaml:"renderMap"` + Log bool `yaml:"log"` + Screenshots bool `yaml:"screenshots"` + RenderMap bool `yaml:"renderMap"` } `yaml:"debug"` - LogFilePath string `yaml:"logFilePath"` - MaxGameLength int `yaml:"maxGameLength"` - D2LoDPath string `yaml:"D2LoDPath"` - Discord struct { - Enabled bool `yaml:"enabled"` - ChannelID string `yaml:"channelId"` - Token string `yaml:"token"` + FirstRun bool `yaml:"firstRun"` + UseCustomSettings bool `yaml:"useCustomSettings"` + GameWindowArrangement bool `yaml:"gameWindowArrangement"` + LogSaveDirectory string `yaml:"logSaveDirectory"` + D2LoDPath string `yaml:"D2LoDPath"` + D2RPath string `yaml:"D2RPath"` + CentralizedPickitPath string `yaml:"centralizedPickitPath"` + Discord struct { + Enabled bool `yaml:"enabled"` + EnableGameCreatedMessages bool `yaml:"enableGameCreatedMessages"` + EnableNewRunMessages bool `yaml:"enableNewRunMessages"` + EnableRunFinishMessages bool `yaml:"enableRunFinishMessages"` + EnableDiscordChickenMessages bool `yaml:"enableDiscordChickenMessages"` + EnableDiscordErrorMessages bool `yaml:"enableDiscordErrorMessages"` + BotAdmins []string `yaml:"botAdmins"` + ChannelID string `yaml:"channelId"` + Token string `yaml:"token"` } `yaml:"discord"` Telegram struct { Enabled bool `yaml:"enabled"` ChatID int64 `yaml:"chatId"` Token string `yaml:"token"` } - Health struct { +} + +type Day struct { + DayOfWeek int `yaml:"dayOfWeek"` + TimeRanges []TimeRange `yaml:"timeRange"` +} + +type Scheduler struct { + Enabled bool `yaml:"enabled"` + Days []Day `yaml:"days"` +} + +type TimeRange struct { + Start time.Time `yaml:"start"` + End time.Time `yaml:"end"` +} + +type CharacterCfg struct { + MaxGameLength int `yaml:"maxGameLength"` + Username string `yaml:"username"` + Password string `yaml:"password"` + AuthMethod string `yaml:"authMethod"` + AuthToken string `yaml:"authToken"` + Realm string `yaml:"realm"` + CharacterName string `yaml:"characterName"` + CommandLineArgs string `yaml:"commandLineArgs"` + KillD2OnStop bool `yaml:"killD2OnStop"` + ClassicMode bool `yaml:"classicMode"` + CloseMiniPanel bool `yaml:"closeMiniPanel"` + UseCentralizedPickit bool `yaml:"useCentralizedPickit"` + HidePortraits bool `yaml:"hidePortraits"` + + Scheduler Scheduler `yaml:"scheduler"` + Health struct { HealingPotionAt int `yaml:"healingPotionAt"` ManaPotionAt int `yaml:"manaPotionAt"` RejuvPotionAtLife int `yaml:"rejuvPotionAtLife"` @@ -46,64 +101,83 @@ type StructConfig struct { ChickenAt int `yaml:"chickenAt"` MercChickenAt int `yaml:"mercChickenAt"` } `yaml:"health"` - Bindings struct { - OpenInventory string `yaml:"openInventory"` - OpenCharacterScreen string `yaml:"openCharacterScreen"` - OpenSkillTree string `yaml:"openSkillTree"` - OpenQuestLog string `yaml:"openQuestLog"` - Potion1 string `yaml:"potion1"` - Potion2 string `yaml:"potion2"` - Potion3 string `yaml:"potion3"` - Potion4 string `yaml:"potion4"` - ForceMove string `yaml:"forceMove"` - StandStill string `yaml:"standStill"` - SwapWeapon string `yaml:"swapWeapon"` - Teleport string `yaml:"teleport"` - TP string `yaml:"tp"` - CTABattleCommand string `yaml:"CTABattleCommand"` - CTABattleOrders string `yaml:"CTABattleOrders"` - - // Class Specific bindings - Sorceress struct { - Blizzard string `yaml:"blizzard"` - StaticField string `yaml:"staticField"` - FrozenArmor string `yaml:"frozenArmor"` - FireBall string `yaml:"fireBall"` - Nova string `yaml:"nova"` - EnergyShield string `yaml:"energyShield"` - } `yaml:"sorceress"` - Paladin struct { - Concentration string `yaml:"concentration"` - HolyShield string `yaml:"holyShield"` - Vigor string `yaml:"vigor"` - Redemption string `yaml:"redemption"` - Cleansing string `yaml:"cleansing"` - } `yaml:"paladin"` - } `yaml:"bindings"` Inventory struct { - InventoryLock [][]int `yaml:"inventoryLock"` - BeltColumns struct { - Healing int `yaml:"healing"` - Mana int `yaml:"mana"` - Rejuvenation int `yaml:"rejuvenation"` - } `yaml:"beltColumns"` + InventoryLock [][]int `yaml:"inventoryLock"` + BeltColumns BeltColumns `yaml:"beltColumns"` } `yaml:"inventory"` Character struct { Class string `yaml:"class"` - CastingFrames int `yaml:"castingFrames"` UseMerc bool `yaml:"useMerc"` + StashToShared bool `yaml:"stashToShared"` + UseTeleport bool `yaml:"useTeleport"` + BerserkerBarb struct { + FindItemSwitch bool `yaml:"find_item_switch"` + SkipPotionPickupInTravincal bool `yaml:"skip_potion_pickup_in_travincal"` + } `yaml:"berserker_barb"` + NovaSorceress struct { + BossStaticThreshold int `yaml:"boss_static_threshold"` + } `yaml:"nova_sorceress"` + MosaicSin struct { + UseTigerStrike bool `yaml:"useTigerStrike"` + UseCobraStrike bool `yaml:"useCobraStrike"` + UseClawsOfThunder bool `yaml:"useClawsOfThunder"` + UseBladesOfIce bool `yaml:"useBladesOfIce"` + UseFistsOfFire bool `yaml:"useFistsOfFire"` + } `yaml:"mosaic_sin"` } `yaml:"character"` + Game struct { - ClearTPArea bool `yaml:"clearTPArea"` - Difficulty difficulty.Difficulty `yaml:"difficulty"` - RandomizeRuns bool `yaml:"randomizeRuns"` - Runs []string `yaml:"runs"` - Pindleskin struct { + MinGoldPickupThreshold int `yaml:"minGoldPickupThreshold"` + UseCainIdentify bool `yaml:"useCainIdentify"` + ClearTPArea bool `yaml:"clearTPArea"` + Difficulty difficulty.Difficulty `yaml:"difficulty"` + RandomizeRuns bool `yaml:"randomizeRuns"` + Runs []Run `yaml:"runs"` + CreateLobbyGames bool `yaml:"createLobbyGames"` + PublicGameCounter int `yaml:"-"` + Pindleskin struct { SkipOnImmunities []stat.Resist `yaml:"skipOnImmunities"` } `yaml:"pindleskin"` + Cows struct { + OpenChests bool `yaml:"openChests"` + } + Pit struct { + MoveThroughBlackMarsh bool `yaml:"moveThroughBlackMarsh"` + OpenChests bool `yaml:"openChests"` + FocusOnElitePacks bool `yaml:"focusOnElitePacks"` + OnlyClearLevel2 bool `yaml:"onlyClearLevel2"` + } `yaml:"pit"` + Andariel struct { + ClearRoom bool `yaml:"clearRoom"` + } + StonyTomb struct { + OpenChests bool `yaml:"openChests"` + FocusOnElitePacks bool `yaml:"focusOnElitePacks"` + } `yaml:"stony_tomb"` + Mausoleum struct { + OpenChests bool `yaml:"openChests"` + FocusOnElitePacks bool `yaml:"focusOnElitePacks"` + } `yaml:"mausoleum"` + AncientTunnels struct { + OpenChests bool `yaml:"openChests"` + FocusOnElitePacks bool `yaml:"focusOnElitePacks"` + } `yaml:"ancient_tunnels"` + DrifterCavern struct { + OpenChests bool `yaml:"openChests"` + FocusOnElitePacks bool `yaml:"focusOnElitePacks"` + } `yaml:"drifter_cavern"` + SpiderCavern struct { + OpenChests bool `yaml:"openChests"` + FocusOnElitePacks bool `yaml:"focusOnElitePacks"` + } `yaml:"spider_cavern"` + ArachnidLair struct { + OpenChests bool `yaml:"openChests"` + FocusOnElitePacks bool `yaml:"focusOnElitePacks"` + } `yaml:"arachnid_lair"` Mephisto struct { KillCouncilMembers bool `yaml:"killCouncilMembers"` OpenChests bool `yaml:"openChests"` + ExitToA4 bool `yaml:"exitToA4"` } `yaml:"mephisto"` Tristram struct { ClearPortal bool `yaml:"clearPortal"` @@ -112,62 +186,274 @@ type StructConfig struct { Nihlathak struct { ClearArea bool `yaml:"clearArea"` } `yaml:"nihlathak"` + Diablo struct { + KillDiablo bool `yaml:"killDiablo"` + StartFromStar bool `yaml:"startFromStar"` + FocusOnElitePacks bool `yaml:"focusOnElitePacks"` + DisableItemPickupDuringBosses bool `yaml:"disableItemPickupDuringBosses"` + AttackFromDistance int `yaml:"attackFromDistance"` + } `yaml:"diablo"` Baal struct { - KillBaal bool `yaml:"killBaal"` + KillBaal bool `yaml:"killBaal"` + DollQuit bool `yaml:"dollQuit"` + SoulQuit bool `yaml:"soulQuit"` + ClearFloors bool `yaml:"clearFloors"` + OnlyElites bool `yaml:"onlyElites"` } `yaml:"baal"` + Eldritch struct { + KillShenk bool `yaml:"killShenk"` + } `yaml:"eldritch"` + LowerKurastChest struct { + OpenRacks bool `yaml:"openRacks"` + } `yaml:"lowerkurastchests"` TerrorZone struct { FocusOnElitePacks bool `yaml:"focusOnElitePacks"` SkipOnImmunities []stat.Resist `yaml:"skipOnImmunities"` SkipOtherRuns bool `yaml:"skipOtherRuns"` - Areas []area.Area `yaml:"areas"` - } `yaml:"terrorZone"` + Areas []area.ID `yaml:"areas"` + OpenChests bool `yaml:"openChests"` + } `yaml:"terror_zone"` + Leveling struct { + EnsurePointsAllocation bool `yaml:"ensurePointsAllocation"` + EnsureKeyBinding bool `yaml:"ensureKeyBinding"` + } `yaml:"leveling"` + Quests struct { + ClearDen bool `yaml:"clearDen"` + RescueCain bool `yaml:"rescueCain"` + RetrieveHammer bool `yaml:"retrieveHammer"` + GetCube bool `yaml:"getCube"` + KillRadament bool `yaml:"killRadament"` + RetrieveBook bool `yaml:"retrieveBook"` + KillIzual bool `yaml:"killIzual"` + KillShenk bool `yaml:"killShenk"` + RescueAnya bool `yaml:"rescueAnya"` + KillAncients bool `yaml:"killAncients"` + } `yaml:"quests"` } `yaml:"game"` Companion struct { - Enabled bool `yaml:"enabled"` Leader bool `yaml:"leader"` LeaderName string `yaml:"leaderName"` - Remote string `yaml:"remote"` GameNameTemplate string `yaml:"gameNameTemplate"` + GamePassword string `yaml:"gamePassword"` } `yaml:"companion"` Gambling struct { Enabled bool `yaml:"enabled"` Items []item.Name `yaml:"items"` } `yaml:"gambling"` + CubeRecipes struct { + Enabled bool `yaml:"enabled"` + EnabledRecipes []string `yaml:"enabledRecipes"` + SkipPerfectAmethysts bool `yaml:"skipPerfectAmethysts"` + SkipPerfectRubies bool `yaml:"skipPerfectRubies"` + } `yaml:"cubing"` + BackToTown struct { + NoHpPotions bool `yaml:"noHpPotions"` + NoMpPotions bool `yaml:"noMpPotions"` + MercDied bool `yaml:"mercDied"` + EquipmentBroken bool `yaml:"equipmentBroken"` + } `yaml:"backtotown"` Runtime struct { - CastDuration time.Duration - Rules []nip.Rule + Rules nip.Rules `yaml:"-"` + Drops []data.Item `yaml:"-"` + } `yaml:"-"` +} + +type BeltColumns [4]string + +func (bm BeltColumns) Total(potionType data.PotionType) int { + typeString := "" + switch potionType { + case data.HealingPotion: + typeString = "healing" + case data.ManaPotion: + typeString = "mana" + case data.RejuvenationPotion: + typeString = "rejuvenation" } + + total := 0 + for _, v := range bm { + if strings.EqualFold(v, typeString) { + total++ + } + } + + return total } // Load reads the config.ini file and returns a Config struct filled with data from the ini file func Load() error { - r, err := os.Open("config/config.yaml") + Characters = make(map[string]*CharacterCfg) + + // Get the absolute path of the current working directory + cwd, err := os.Getwd() if err != nil { - return fmt.Errorf("error loading config.yaml: %w", err) + return fmt.Errorf("error getting current working directory: %w", err) } + // Function to get absolute path + getAbsPath := func(relPath string) string { + return filepath.Join(cwd, relPath) + } + + kooloPath := getAbsPath("config/koolo.yaml") + r, err := os.Open(kooloPath) + if err != nil { + return fmt.Errorf("error loading koolo.yaml: %w", err) + } + defer r.Close() + d := yaml.NewDecoder(r) - if err = d.Decode(&Config); err != nil { - return fmt.Errorf("error reading config: %w", err) + if err = d.Decode(&Koolo); err != nil { + return fmt.Errorf("error reading config %s: %w", kooloPath, err) } - rules, err := nip.ReadDir("config/pickit/") + configDir := getAbsPath("config") + entries, err := os.ReadDir(configDir) if err != nil { - return err + return fmt.Errorf("error reading config directory %s: %w", configDir, err) } - if Config.Game.Runs[0] == "leveling" { - levelingRules, err := nip.ReadDir("config/pickit_leveling/") + // Read character configs + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + charCfg := CharacterCfg{} + + // Load character config from the current working directory/config/{charName}/config.yaml + charConfigPath := getAbsPath(filepath.Join("config", entry.Name(), "config.yaml")) + r, err = os.Open(charConfigPath) if err != nil { - return err + return fmt.Errorf("error loading config.yaml: %w", err) + } + defer r.Close() + + // Load character config + d := yaml.NewDecoder(r) + if err = d.Decode(&charCfg); err != nil { + return fmt.Errorf("error reading %s character config: %w", charConfigPath, err) } - rules = append(rules, levelingRules...) - } - Config.Runtime.Rules = rules + var pickitPath string - secs := float32(Config.Character.CastingFrames)*0.04 + 0.01 - Config.Runtime.CastDuration = time.Duration(secs*1000) * time.Millisecond + if Koolo.CentralizedPickitPath != "" && charCfg.UseCentralizedPickit { + // Validate centralized pickit path + if _, err := os.Stat(Koolo.CentralizedPickitPath); os.IsNotExist(err) { + utils.ShowDialog("Error loading pickit rules for "+entry.Name(), "The centralized pickit path does not exist: "+Koolo.CentralizedPickitPath+"\nPlease check your Koolo settings.\nFalling back to local pickit.") + + // Set the pickit path to the current dir/config/{charName}/pickit + pickitPath = getAbsPath(filepath.Join("config", entry.Name(), "pickit")) + "\\" + } else { + pickitPath = Koolo.CentralizedPickitPath + "\\" + } + } else { + // Set the pickit path to the current dir/config/{charName}/pickit + pickitPath = getAbsPath(filepath.Join("config", entry.Name(), "pickit")) + "\\" + } + + // Load the pickit rules from the directory + rules, err := nip.ReadDir(pickitPath) + if err != nil { + return fmt.Errorf("error reading pickit directory %s: %w", pickitPath, err) + } + + // Load the leveling pickit rules + if len(charCfg.Game.Runs) > 0 && charCfg.Game.Runs[0] == "leveling" { + levelingPickitPath := getAbsPath(filepath.Join("config", entry.Name(), "pickit_leveling")) + "\\" + levelingRules, err := nip.ReadDir(levelingPickitPath) + if err != nil { + return fmt.Errorf("error reading pickit_leveling directory %s: %w", levelingPickitPath, err) + } + rules = append(rules, levelingRules...) + } + + charCfg.Runtime.Rules = rules + Characters[entry.Name()] = &charCfg + } + + // Validate configs + for _, charCfg := range Characters { + charCfg.Validate() + } return nil } + +func CreateFromTemplate(name string) error { + if name == "" { + return errors.New("name cannot be empty") + } + + if _, err := os.Stat("config/" + name); !os.IsNotExist(err) { + return errors.New("configuration with that name already exists") + } + + err := cp.Copy("config/template", "config/"+name) + if err != nil { + return fmt.Errorf("error copying template: %w", err) + } + + return Load() +} + +func ValidateAndSaveConfig(config KooloCfg) error { + // Trim executable from the path, just in case + config.D2LoDPath = strings.ReplaceAll(strings.ToLower(config.D2LoDPath), "game.exe", "") + config.D2RPath = strings.ReplaceAll(strings.ToLower(config.D2RPath), "d2r.exe", "") + + // Validate paths + if _, err := os.Stat(config.D2LoDPath + "/d2data.mpq"); os.IsNotExist(err) { + return errors.New("D2LoDPath is not valid") + } + + if _, err := os.Stat(config.D2RPath + "/d2r.exe"); os.IsNotExist(err) { + return errors.New("D2RPath is not valid") + } + + text, err := yaml.Marshal(config) + if err != nil { + return fmt.Errorf("error parsing koolo config: %w", err) + } + + err = os.WriteFile("config/koolo.yaml", text, 0644) + if err != nil { + return fmt.Errorf("error writing koolo config: %w", err) + } + + return Load() +} + +func SaveSupervisorConfig(supervisorName string, config *CharacterCfg) error { + filePath := filepath.Join("config", supervisorName, "config.yaml") + d, err := yaml.Marshal(config) + config.Validate() + if err != nil { + return err + } + + err = os.WriteFile(filePath, d, 0644) + if err != nil { + return fmt.Errorf("error writing supervisor config: %w", err) + } + + return Load() +} + +func (c *CharacterCfg) Validate() { + if c.Character.Class == "nova" || c.Character.Class == "lightsorc" { + minThreshold := 65 // Default + switch c.Game.Difficulty { + case difficulty.Normal: + minThreshold = 1 + case difficulty.Nightmare: + minThreshold = 33 + case difficulty.Hell: + minThreshold = 50 + } + if c.Character.NovaSorceress.BossStaticThreshold < minThreshold || c.Character.NovaSorceress.BossStaticThreshold > 100 { + c.Character.NovaSorceress.BossStaticThreshold = minThreshold + } + } +} diff --git a/internal/config/game_settings.go b/internal/config/game_settings.go new file mode 100644 index 000000000..bc3baffdb --- /dev/null +++ b/internal/config/game_settings.go @@ -0,0 +1,64 @@ +package config + +import ( + "fmt" + "os" + + "github.com/lxn/win" + cp "github.com/otiai10/copy" +) + +var userProfile = os.Getenv("USERPROFILE") +var settingsPath = userProfile + "\\Saved Games\\Diablo II Resurrected" + +func ReplaceGameSettings(modName string) error { + modDirPath := settingsPath + "\\mods\\" + modName + modSettingsPath := modDirPath + "\\Settings.json" + + if _, err := os.Stat(settingsPath); os.IsNotExist(err) { + return fmt.Errorf("game settings not found at %s", settingsPath) + } + + if _, err := os.Stat(modDirPath); os.IsNotExist(err) { + err = os.MkdirAll(modDirPath, os.ModePerm) + if err != nil { + return fmt.Errorf("error creating mod folder to store settings: %w", err) + } + } + + if _, err := os.Stat(modSettingsPath + ".bkp"); os.IsExist(err) { + err = os.Rename(modSettingsPath, modSettingsPath+".bkp") + // File does not exist, no need to back up + if err != nil && !os.IsNotExist(err) { + return err + } + } + + return cp.Copy("config/Settings.json", modSettingsPath) +} + +func InstallMod() error { + if _, err := os.Stat(Koolo.D2RPath + "\\d2r.exe"); os.IsNotExist(err) { + return fmt.Errorf("game not found at %s", Koolo.D2RPath) + } + + if _, err := os.Stat(Koolo.D2RPath + "\\mods\\koolo\\koolo.mpq\\modinfo.json"); err == nil { + return nil + } + + if err := os.MkdirAll(Koolo.D2RPath+"\\mods\\koolo\\koolo.mpq", os.ModePerm); err != nil { + return fmt.Errorf("error creating mod folder: %w", err) + } + + modFileContent := []byte(`{"name":"koolo","savepath":"koolo/"}`) + + return os.WriteFile(Koolo.D2RPath+"\\mods\\koolo\\koolo.mpq\\modinfo.json", modFileContent, 0644) +} + +func GetCurrentDisplayScale() float64 { + hDC := win.GetDC(0) + defer win.ReleaseDC(0, hDC) + dpiX := win.GetDeviceCaps(hDC, win.LOGPIXELSX) + + return float64(dpiX) / 96.0 +} diff --git a/internal/config/recipes.go b/internal/config/recipes.go new file mode 100644 index 000000000..f734cadbd --- /dev/null +++ b/internal/config/recipes.go @@ -0,0 +1,66 @@ +package config + +var AvailableRecipes = []string{ + // List out all the recipe names from cube_recipes.go + "Perfect Amethyst", + "Perfect Diamond", + "Perfect Emerald", + "Perfect Ruby", + "Perfect Sapphire", + "Perfect Topaz", + "Perfect Skull", + "Token of Absolution", + "Upgrade El", + "Upgrade Eld", + "Upgrade Tir", + "Upgrade Nef", + "Upgrade Eth", + "Upgrade Ith", + "Upgrade Tal", + "Upgrade Ral", + "Upgrade Ort", + "Upgrade Thul", + "Upgrade Amn", + "Upgrade Sol", + "Upgrade Shael", + "Upgrade Dol", + "Upgrade Hel", + "Upgrade Io", + "Upgrade Lum", + "Upgrade Ko", + "Upgrade Fal", + "Upgrade Lem", + "Upgrade Pul", + "Upgrade Um", + "Upgrade Mal", + "Upgrade Ist", + "Upgrade Gul", + "Upgrade Vex", + "Upgrade Ohm", + "Upgrade Lo", + "Upgrade Sur", + "Upgrade Ber", + "Upgrade Jah", + "Upgrade Cham", + "Reroll GrandCharms", + "Caster Amulet", + "Caster Ring", + "Blood Gloves", + "Blood Boots", + "Blood Belt", + "Blood Helm", + "Blood Armor", + "Blood Weapon", + "Safety Shield", + "Safety Armor", + "Safety Boots", + "Safety Gloves", + "Safety Belt", + "Safety Helm", + "Hitpower Gloves", + "Hitpower Boots", + "Hitpower Belt", + "Hitpower Helm", + "Hitpower Armor", + "Hitpower Shield", +} diff --git a/internal/config/runs.go b/internal/config/runs.go new file mode 100644 index 000000000..575c4f146 --- /dev/null +++ b/internal/config/runs.go @@ -0,0 +1,65 @@ +package config + +type Run string + +const ( + CountessRun Run = "countess" + AndarielRun Run = "andariel" + AncientTunnelsRun Run = "ancient_tunnels" + MausoleumRun Run = "mausoleum" + SummonerRun Run = "summoner" + DurielRun Run = "duriel" + MephistoRun Run = "mephisto" + TravincalRun Run = "travincal" + EldritchRun Run = "eldritch" + PindleskinRun Run = "pindleskin" + NihlathakRun Run = "nihlathak" + TristramRun Run = "tristram" + LowerKurastRun Run = "lower_kurast" + LowerKurastChestRun Run = "lower_kurast_chest" + StonyTombRun Run = "stony_tomb" + PitRun Run = "pit" + ArachnidLairRun Run = "arachnid_lair" + TalRashaTombsRun Run = "tal_rasha_tombs" + BaalRun Run = "baal" + DiabloRun Run = "diablo" + CowsRun Run = "cows" + LevelingRun Run = "leveling" + QuestsRun Run = "quests" + TerrorZoneRun Run = "terror_zone" + ThreshsocketRun Run = "threshsocket" + DrifterCavernRun Run = "drifter_cavern" + SpiderCavernRun Run = "spider_cavern" + EnduguRun Run = "endugu" +) + +var AvailableRuns = map[Run]interface{}{ + CountessRun: nil, + AndarielRun: nil, + AncientTunnelsRun: nil, + MausoleumRun: nil, + SummonerRun: nil, + DurielRun: nil, + MephistoRun: nil, + TravincalRun: nil, + EldritchRun: nil, + PindleskinRun: nil, + NihlathakRun: nil, + TristramRun: nil, + LowerKurastRun: nil, + LowerKurastChestRun: nil, + StonyTombRun: nil, + PitRun: nil, + ArachnidLairRun: nil, + TalRashaTombsRun: nil, + BaalRun: nil, + DiabloRun: nil, + CowsRun: nil, + LevelingRun: nil, + QuestsRun: nil, + TerrorZoneRun: nil, + ThreshsocketRun: nil, + DrifterCavernRun: nil, + SpiderCavernRun: nil, + EnduguRun: nil, +} diff --git a/internal/context/character.go b/internal/context/character.go new file mode 100644 index 000000000..e66b8d9ec --- /dev/null +++ b/internal/context/character.go @@ -0,0 +1,39 @@ +package context + +import ( + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/skill" + "github.com/hectorgimenez/d2go/pkg/data/stat" + "github.com/hectorgimenez/koolo/internal/game" +) + +type Character interface { + CheckKeyBindings() []skill.ID + BuffSkills() []skill.ID + PreCTABuffSkills() []skill.ID + KillCountess() error + KillAndariel() error + KillSummoner() error + KillDuriel() error + KillMephisto() error + KillPindle() error + KillNihlathak() error + KillCouncil() error + KillDiablo() error + KillIzual() error + KillBaal() error + KillMonsterSequence( + monsterSelector func(d game.Data) (data.UnitID, bool), + skipOnImmunities []stat.Resist, + ) error +} + +type LevelingCharacter interface { + Character + // StatPoints Stats will be assigned in the order they are returned by this function. + StatPoints() map[stat.ID]int + SkillPoints() []skill.ID + SkillsToBind() (skill.ID, []skill.ID) + ShouldResetSkills() bool + KillAncients() error +} diff --git a/internal/context/context.go b/internal/context/context.go new file mode 100644 index 000000000..92d5f9b68 --- /dev/null +++ b/internal/context/context.go @@ -0,0 +1,186 @@ +package context + +import ( + "log/slog" + "runtime" + "strconv" + "strings" + "sync" + "time" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/event" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/health" + "github.com/hectorgimenez/koolo/internal/pather" +) + +var mu sync.Mutex +var botContexts = make(map[uint64]*Status) + +type Priority int + +const ( + PriorityHigh = 0 + PriorityNormal = 1 + PriorityBackground = 5 + PriorityPause = 10 + PriorityStop = 100 +) + +type Status struct { + *Context + Priority Priority +} + +type Context struct { + Name string + ExecutionPriority Priority + CharacterCfg *config.CharacterCfg + Data *game.Data + EventListener *event.Listener + HID *game.HID + Logger *slog.Logger + Manager *game.Manager + GameReader *game.MemoryReader + MemoryInjector *game.MemoryInjector + PathFinder *pather.PathFinder + BeltManager *health.BeltManager + HealthManager *health.Manager + Char Character + LastBuffAt time.Time + ContextDebug map[Priority]*Debug + CurrentGame *CurrentGameHelper +} + +type Debug struct { + LastAction string `json:"lastAction"` + LastStep string `json:"lastStep"` +} + +type CurrentGameHelper struct { + BlacklistedItems []data.Item + PickedUpItems map[int]int + AreaCorrection struct { + Enabled bool + ExpectedArea area.ID + } + PickupItems bool +} + +func NewContext(name string) *Status { + ctx := &Context{ + Name: name, + Data: &game.Data{}, + ExecutionPriority: PriorityNormal, + ContextDebug: map[Priority]*Debug{ + PriorityBackground: {}, + PriorityNormal: {}, + PriorityHigh: {}, + PriorityPause: {}, + PriorityStop: {}, + }, + CurrentGame: NewGameHelper(), + } + botContexts[getGoroutineID()] = &Status{Priority: PriorityNormal, Context: ctx} + + return botContexts[getGoroutineID()] +} + +func NewGameHelper() *CurrentGameHelper { + return &CurrentGameHelper{ + PickupItems: true, + PickedUpItems: make(map[int]int), + BlacklistedItems: []data.Item{}, + } +} + +func Get() *Status { + mu.Lock() + defer mu.Unlock() + return botContexts[getGoroutineID()] +} + +func (s *Status) SetLastAction(actionName string) { + s.Context.ContextDebug[s.Priority].LastAction = actionName +} + +func (s *Status) SetLastStep(stepName string) { + s.Context.ContextDebug[s.Priority].LastStep = stepName +} + +func getGoroutineID() uint64 { + var buf [64]byte + n := runtime.Stack(buf[:], false) + stackTrace := string(buf[:n]) + fields := strings.Fields(stackTrace) + id, _ := strconv.ParseUint(fields[1], 10, 64) + + return id +} + +func (ctx *Context) RefreshGameData() { + *ctx.Data = ctx.GameReader.GetData() +} + +func (ctx *Context) Detach() { + mu.Lock() + defer mu.Unlock() + delete(botContexts, getGoroutineID()) +} + +func (ctx *Context) AttachRoutine(priority Priority) { + mu.Lock() + defer mu.Unlock() + botContexts[getGoroutineID()] = &Status{Priority: priority, Context: ctx} +} + +func (ctx *Context) SwitchPriority(priority Priority) { + ctx.ExecutionPriority = priority +} + +func (ctx *Context) DisableItemPickup() { + ctx.CurrentGame.PickupItems = false +} + +func (ctx *Context) EnableItemPickup() { + ctx.CurrentGame.PickupItems = true +} + +func (s *Status) PauseIfNotPriority() { + // This prevents bot from trying to move when loading screen is shown. + if s.Data.OpenMenus.LoadingScreen { + time.Sleep(time.Millisecond * 5) + } + + for s.Priority != s.ExecutionPriority { + if s.ExecutionPriority == PriorityStop { + panic("Bot is stopped") + } + + time.Sleep(time.Millisecond * 10) + } +} +func (ctx *Context) WaitForGameToLoad() { + for ctx.Data.OpenMenus.LoadingScreen { + time.Sleep(100 * time.Millisecond) + ctx.RefreshGameData() + } + // Add a small buffer to ensure everything is fully loaded + time.Sleep(300 * time.Millisecond) +} + +func (ctx *Context) Cleanup() { + ctx.Logger.Debug("Resetting blacklisted items") + + // Remove all items from the blacklisted items list + ctx.CurrentGame.BlacklistedItems = []data.Item{} + + // Remove all items from the picked up items map if it exceeds 200 items + if len(ctx.CurrentGame.PickedUpItems) > 200 { + ctx.Logger.Debug("Resetting picked up items map due to exceeding 200 items") + ctx.CurrentGame.PickedUpItems = make(map[int]int) + } +} diff --git a/internal/event/event.go b/internal/event/event.go index 1ef78ef01..af7853662 100644 --- a/internal/event/event.go +++ b/internal/event/event.go @@ -2,36 +2,55 @@ package event import ( "image" - - "github.com/hectorgimenez/koolo/internal/helper" + "time" ) -const ( - Kill Event = "kill" - Death Event = "death" - Chicken Event = "chicken" - MercChicken Event = "merc chicken" - Error Event = "error" -) +type FinishReason string +type InteractionType string + +type Event interface { + Message() string + Image() image.Image + OccurredAt() time.Time + Supervisor() string +} + +type BaseEvent struct { + message string + image image.Image + occurredAt time.Time + supervisor string +} -type Event string +func (b BaseEvent) Message() string { + return b.message +} -var Events = make(chan Message, 10) +func (b BaseEvent) Image() image.Image { + return b.image +} + +func (b BaseEvent) OccurredAt() time.Time { + return b.occurredAt +} -type Message struct { - Message string - Image image.Image +func (b BaseEvent) Supervisor() string { + return b.supervisor } -func WithScreenshot(message string) Message { - return Message{ - Message: message, - Image: helper.Screenshot(), +func WithScreenshot(supervisor string, message string, img image.Image) BaseEvent { + return BaseEvent{ + message: message, + image: img, + occurredAt: time.Now(), + supervisor: supervisor, } } -func Text(message string) Message { - return Message{ - Message: message, +func Text(supervisor string, message string) BaseEvent { + return BaseEvent{ + message: message, + occurredAt: time.Now(), + supervisor: supervisor, } } diff --git a/internal/event/events.go b/internal/event/events.go new file mode 100644 index 000000000..991e4cc3c --- /dev/null +++ b/internal/event/events.go @@ -0,0 +1,153 @@ +package event + +import ( + "github.com/hectorgimenez/d2go/pkg/data" +) + +const ( + FinishedOK FinishReason = "ok" + FinishedDied FinishReason = "death" + FinishedChicken FinishReason = "chicken" + FinishedMercChicken FinishReason = "merc chicken" + FinishedError FinishReason = "error" + + InteractionTypeEntrance InteractionType = "entrance" + InteractionTypeNPC InteractionType = "npc" + InteractionTypeObject InteractionType = "object" +) + +type UsedPotionEvent struct { + BaseEvent + PotionType data.PotionType + OnMerc bool +} + +func UsedPotion(be BaseEvent, pt data.PotionType, onMerc bool) UsedPotionEvent { + return UsedPotionEvent{ + BaseEvent: be, + PotionType: pt, + OnMerc: onMerc, + } +} + +type GameCreatedEvent struct { + BaseEvent + Name string + Password string +} + +func GameCreated(be BaseEvent, name string, password string) GameCreatedEvent { + return GameCreatedEvent{ + BaseEvent: be, + Name: name, + Password: password, + } +} + +type GameFinishedEvent struct { + BaseEvent + Reason FinishReason +} + +func GameFinished(be BaseEvent, reason FinishReason) GameFinishedEvent { + return GameFinishedEvent{ + BaseEvent: be, + Reason: reason, + } +} + +type RunFinishedEvent struct { + BaseEvent + RunName string + Reason FinishReason +} + +func RunFinished(be BaseEvent, runName string, reason FinishReason) RunFinishedEvent { + return RunFinishedEvent{ + BaseEvent: be, + RunName: runName, + Reason: reason, + } +} + +type ItemStashedEvent struct { + BaseEvent + Item data.Drop +} + +func ItemStashed(be BaseEvent, drop data.Drop) ItemStashedEvent { + return ItemStashedEvent{ + BaseEvent: be, + Item: drop, + } +} + +type RunStartedEvent struct { + BaseEvent + RunName string +} + +type ItemBlackListedEvent struct { + BaseEvent + Item data.Drop +} + +func ItemBlackListed(be BaseEvent, drop data.Drop) ItemBlackListedEvent { + return ItemBlackListedEvent{ + BaseEvent: be, + Item: drop, + } +} + +func RunStarted(be BaseEvent, runName string) RunStartedEvent { + return RunStartedEvent{ + BaseEvent: be, + RunName: runName, + } +} + +type CompanionLeaderAttackEvent struct { + BaseEvent + TargetUnitID data.UnitID +} + +func CompanionLeaderAttack(be BaseEvent, targetUnitID data.UnitID) CompanionLeaderAttackEvent { + return CompanionLeaderAttackEvent{ + BaseEvent: be, + TargetUnitID: targetUnitID, + } +} + +type CompanionRequestedTPEvent struct { + BaseEvent +} + +func CompanionRequestedTP(be BaseEvent) CompanionRequestedTPEvent { + return CompanionRequestedTPEvent{BaseEvent: be} +} + +type InteractedToEvent struct { + BaseEvent + ID int + InteractionType InteractionType +} + +func InteractedTo(be BaseEvent, id int, it InteractionType) InteractedToEvent { + return InteractedToEvent{ + BaseEvent: be, + ID: id, + InteractionType: it, + } +} + +type GamePausedEvent struct { + BaseEvent + Paused bool +} + +func GamePaused(be BaseEvent, paused bool) GamePausedEvent { + return GamePausedEvent{ + BaseEvent: be, + Paused: paused, + } +} diff --git a/internal/event/listener.go b/internal/event/listener.go index a48ac2da6..fbe84d56e 100644 --- a/internal/event/listener.go +++ b/internal/event/listener.go @@ -3,23 +3,30 @@ package event import ( "context" "fmt" + "log/slog" + "math" + "math/rand" "os" "time" - "github.com/go-vgo/robotgo" - "go.uber.org/zap" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/utils" ) +var events = make(chan Event) + type Listener struct { - handlers []Handler - logger *zap.Logger + handlers []Handler + deliveryHandlers map[int]Handler + logger *slog.Logger } -type Handler func(ctx context.Context, m Message) error +type Handler func(ctx context.Context, e Event) error -func NewListener(logger *zap.Logger) Listener { - return Listener{ - logger: logger, +func NewListener(logger *slog.Logger) *Listener { + return &Listener{ + logger: logger, + deliveryHandlers: make(map[int]Handler), } } @@ -30,29 +37,61 @@ func (l *Listener) Register(h Handler) { func (l *Listener) Listen(ctx context.Context) error { for { select { - case e := <-Events: + case e := <-events: if _, err := os.Stat("screenshots"); os.IsNotExist(err) { - err = os.MkdirAll("screenshots", 0700) + err = os.MkdirAll("screenshots", os.ModePerm) if err != nil { - l.logger.Error("error creating screenshots directory", zap.Error(err)) + l.logger.Error("error creating screenshots directory", slog.Any("error", err)) } } - if e.Image != nil { - fileName := fmt.Sprintf("screenshots/error-%s.png", time.Now().Format("2006-01-02 15_04_05")) - err := robotgo.SavePng(e.Image, fileName) + if e.Image() != nil && config.Koolo.Debug.Screenshots { + fileName := fmt.Sprintf("screenshots/error-%s.jpeg", time.Now().Format("2006-01-02 15_04_05")) + err := utils.SaveImageJPEG(e.Image(), fileName) if err != nil { - l.logger.Error("error saving screenshot", zap.Error(err)) + l.logger.Error("error saving screenshot", slog.Any("error", err)) } } for _, h := range l.handlers { + if err := h(ctx, e); err != nil && e.Message() != "" { + l.logger.Error("error running event handler", slog.Any("error", err)) + } + } + for _, h := range l.deliveryHandlers { if err := h(ctx, e); err != nil { - l.logger.Error("error running event handler", zap.Error(err)) + l.logger.Error("error running event delivery handler", slog.Any("error", err)) } } + + case <-ctx.Done(): + return nil + } + } +} + +func (l *Listener) WaitForEvent(ctx context.Context) Event { + evtChan := make(chan Event) + idx := rand.Intn(math.MaxInt64) + l.deliveryHandlers[idx] = func(ctx context.Context, e Event) error { + evtChan <- e + return nil + } + // Clean up the handler when we're done + defer func() { + delete(l.deliveryHandlers, idx) + }() + + for { + select { + case e := <-evtChan: + return e case <-ctx.Done(): return nil } } } + +func Send(e Event) { + events <- e +} diff --git a/internal/event/stat/game_stats.go b/internal/event/stat/game_stats.go deleted file mode 100644 index 8212107bf..000000000 --- a/internal/event/stat/game_stats.go +++ /dev/null @@ -1,102 +0,0 @@ -package stat - -import ( - "fmt" - "image" - "strings" - "time" - - "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/koolo/internal/event" -) - -var Status = GameStatus{} - -func StartRun(runName string) { - if Status.RunStats == nil { - Status.RunStats = map[string]*RunStats{} - } - - rs, found := Status.RunStats[runName] - if !found { - rs = &RunStats{} - Status.RunStats[runName] = rs - } - - Status.CurrentRun = runName - Status.CurrentRunStart = time.Now() -} - -func FinishCurrentRun(evt event.Event) { - rs := Status.RunStats[Status.CurrentRun] - switch evt { - case event.Kill: - rs.Kills++ - case event.Death: - rs.Deaths++ - case event.Chicken: - rs.Chickens++ - case event.MercChicken: - rs.MerChicken++ - case event.Error: - rs.Errors++ - } - - runTime := time.Since(Status.CurrentRunStart) - rs.TotalRunsTime += runTime -} - -func UsedPotion(potionType data.PotionType, onMerc bool) { - switch potionType { - case data.HealingPotion: - if onMerc { - Status.RunStats[Status.CurrentRun].MercHealingPotionsUsed++ - } else { - Status.RunStats[Status.CurrentRun].HealingPotionsUsed++ - } - case data.ManaPotion: - Status.RunStats[Status.CurrentRun].ManaPotionsUsed++ - case data.RejuvenationPotion: - if onMerc { - Status.RunStats[Status.CurrentRun].MercRejuvPotionsUsed++ - } else { - Status.RunStats[Status.CurrentRun].RejuvPotionsUsed++ - } - } -} - -func ItemStashed(item data.Item, screenshot image.Image) { - if item.IsPotion() || strings.EqualFold(string(item.Name), "Gold") { - return - } - - Status.RunStats[Status.CurrentRun].ItemsFound = append(Status.RunStats[Status.CurrentRun].ItemsFound, item) - event.Events <- event.Message{ - Message: fmt.Sprintf("Item stashed! %s", item.Name), - Image: screenshot, - } -} - -type GameStatus struct { - ApplicationStartedAt time.Time - TotalGames int - RunStats map[string]*RunStats - - CurrentRun string - CurrentRunStart time.Time -} - -type RunStats struct { - TotalRunsTime time.Duration - Kills int - Deaths int - Chickens int - MerChicken int - Errors int - ItemsFound []data.Item - HealingPotionsUsed int - ManaPotionsUsed int - RejuvPotionsUsed int - MercHealingPotionsUsed int - MercRejuvPotionsUsed int -} diff --git a/internal/game/area.go b/internal/game/area.go new file mode 100644 index 000000000..2a219e517 --- /dev/null +++ b/internal/game/area.go @@ -0,0 +1,60 @@ +package game + +import ( + "slices" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" +) + +type AreaData struct { + Area area.ID + Name string + NPCs data.NPCs + AdjacentLevels []data.Level + Objects []data.Object + Rooms []data.Room + *Grid +} + +func (ad AreaData) IsInside(pos data.Position) bool { + return pos.X > ad.OffsetX && pos.Y > ad.OffsetY && pos.X < ad.OffsetX+ad.Width && pos.Y < ad.OffsetY+ad.Height +} + +var _85Zones = []area.ID{ + area.Mausoleum, + area.UndergroundPassageLevel2, + area.PitLevel1, + area.PitLevel2, + area.StonyTombLevel1, + area.StonyTombLevel2, + area.MaggotLairLevel3, + area.AncientTunnels, + area.SwampyPitLevel1, + area.SwampyPitLevel2, + area.SwampyPitLevel3, + area.SpiderCave, + area.SewersLevel1Act3, + area.SewersLevel2Act3, + area.DisusedFane, + area.RuinedTemple, + area.ForgottenReliquary, + area.ForgottenTemple, + area.RuinedFane, + area.DisusedReliquary, + area.RiverOfFlame, + area.ChaosSanctuary, + area.Abaddon, + area.PitOfAcheron, + area.InfernalPit, + area.DrifterCavern, + area.IcyCellar, + area.TheWorldStoneKeepLevel1, + area.TheWorldStoneKeepLevel2, + area.TheWorldStoneKeepLevel3, + area.ThroneOfDestruction, +} + +func (ad AreaData) Is85Zone() bool { + return slices.Contains(_85Zones, ad.Area.Area().ID) +} diff --git a/internal/game/crash_detector.go b/internal/game/crash_detector.go new file mode 100644 index 000000000..0e65a53b9 --- /dev/null +++ b/internal/game/crash_detector.go @@ -0,0 +1,76 @@ +package game + +import ( + "log/slog" + "time" + + "golang.org/x/sys/windows" +) + +type CrashDetector struct { + pid int32 + supervisor string + hwnd uintptr + logger *slog.Logger + restartFunc func() + stopChan chan struct{} +} + +func NewCrashDetector(sup string, pid int32, hwnd uintptr, logger *slog.Logger, restartFunc func()) *CrashDetector { + return &CrashDetector{ + supervisor: sup, + pid: pid, + hwnd: hwnd, + logger: logger, + restartFunc: restartFunc, + stopChan: make(chan struct{}), + } +} + +func (cd *CrashDetector) Start() { + cd.logger.Info("Starting Crash Detector ...", slog.Int("PID", int(cd.pid)), slog.String("Supervisor", cd.supervisor)) + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-cd.stopChan: + cd.logger.Info("Crash Detector stopped.", slog.Int("PID", int(cd.pid)), slog.String("Supervisor", cd.supervisor)) + return + case <-ticker.C: + if !cd.isProcessRunning() { + cd.logger.Error("Client crash detected ...", slog.Int("PID", int(cd.pid)), slog.String("Supervisor", cd.supervisor)) + if cd.restartFunc != nil { + cd.logger.Info("Attempting to restart client ...", slog.String("Supervisor", cd.supervisor)) + cd.restartFunc() + } + return + } + } + } +} + +func (cd *CrashDetector) Stop() { + cd.logger.Info("Stopping Crash Detector", slog.Int("PID", int(cd.pid)), slog.String("Supervisor", cd.supervisor)) + close(cd.stopChan) +} + +func (cd *CrashDetector) isProcessRunning() bool { + handle, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION, false, uint32(cd.pid)) + if err != nil { + cd.logger.Debug("Failed to open process", slog.Int("PID", int(cd.pid)), slog.String("err", err.Error())) + return false + } + defer windows.CloseHandle(handle) + + var exitCode uint32 + err = windows.GetExitCodeProcess(handle, &exitCode) + if err != nil { + cd.logger.Debug("Failed to get exit code", slog.Int("PID", int(cd.pid)), slog.String("error", err.Error())) + return false + } + + isRunning := exitCode == 259 // STILL_ACTIVE + + return isRunning +} diff --git a/internal/game/data.go b/internal/game/data.go new file mode 100644 index 000000000..721ce877c --- /dev/null +++ b/internal/game/data.go @@ -0,0 +1,60 @@ +package game + +import ( + "math" + "time" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" + "github.com/hectorgimenez/d2go/pkg/data/skill" + "github.com/hectorgimenez/koolo/internal/config" +) + +type Data struct { + Areas map[area.ID]AreaData `json:"-"` + AreaData AreaData `json:"-"` + data.Data + CharacterCfg config.CharacterCfg +} + +func (d Data) CanTeleport() bool { + if !d.CharacterCfg.Character.UseTeleport { + return false + } + // In Duriel Lair we can teleport only in boss room + // Only enable Teleport in largest room where is Duriel + if d.PlayerUnit.Area == area.DurielsLair { + if len(d.AreaData.Rooms) > 0 { + bossRoom := d.AreaData.Rooms[0] + for _, room := range d.AreaData.Rooms { + if (room.Width * room.Height) > (bossRoom.Width * bossRoom.Height) { + bossRoom = room + } + } + return bossRoom.IsInside(d.PlayerUnit.Position) + } + return false + } + + _, isTpBound := d.KeyBindings.KeyBindingForSkill(skill.Teleport) + return isTpBound && !d.PlayerUnit.Area.IsTown() +} + +func (d Data) PlayerCastDuration() time.Duration { + secs := float64(d.PlayerUnit.CastingFrames())*0.04 + 0.01 + secs = math.Max(0.30, secs) + + return time.Duration(secs*1000) * time.Millisecond +} + +func (d Data) MonsterFilterAnyReachable() data.MonsterFilter { + return func(monsters data.Monsters) (filtered []data.Monster) { + for _, m := range monsters { + if d.AreaData.IsWalkable(m.Position) { + filtered = append(filtered, m) + } + } + + return filtered + } +} diff --git a/internal/game/grid.go b/internal/game/grid.go new file mode 100644 index 000000000..e2d7d9f01 --- /dev/null +++ b/internal/game/grid.go @@ -0,0 +1,82 @@ +package game + +import "github.com/hectorgimenez/d2go/pkg/data" + +const ( + CollisionTypeNonWalkable CollisionType = iota + CollisionTypeWalkable + CollisionTypeLowPriority + CollisionTypeMonster + CollisionTypeObject +) + +type CollisionType uint8 + +type Grid struct { + OffsetX int + OffsetY int + Width int + Height int + CollisionGrid [][]CollisionType +} + +func NewGrid(rawCollisionGrid [][]CollisionType, offsetX, offsetY int) *Grid { + grid := &Grid{ + OffsetX: offsetX, + OffsetY: offsetY, + Width: len(rawCollisionGrid[0]), + Height: len(rawCollisionGrid), + CollisionGrid: rawCollisionGrid, + } + + // Let's lower the priority for the walkable tiles that are close to non-walkable tiles, so we can avoid walking too close to walls and obstacles + for y := 0; y < len(rawCollisionGrid); y++ { + for x := 0; x < len(rawCollisionGrid[y]); x++ { + if rawCollisionGrid[y][x] == CollisionTypeNonWalkable { + for i := -2; i <= 2; i++ { + for j := -2; j <= 2; j++ { + if i == 0 && j == 0 { + continue + } + if y+i < 0 || y+i >= len(rawCollisionGrid) || x+j < 0 || x+j >= len(rawCollisionGrid[y]) { + continue + } + if rawCollisionGrid[y+i][x+j] == CollisionTypeWalkable { + rawCollisionGrid[y+i][x+j] = CollisionTypeLowPriority + } + } + } + } + } + } + + return grid +} + +func (g *Grid) RelativePosition(p data.Position) data.Position { + return data.Position{ + X: p.X - g.OffsetX, + Y: p.Y - g.OffsetY, + } +} + +func (g *Grid) IsWalkable(p data.Position) bool { + p = g.RelativePosition(p) + return p.X >= 0 && p.X < g.Width && p.Y >= 0 && p.Y < g.Height && g.CollisionGrid[p.Y][p.X] != CollisionTypeNonWalkable +} + +func (g *Grid) Copy() *Grid { + cg := make([][]CollisionType, g.Height) + for y := 0; y < g.Height; y++ { + cg[y] = make([]CollisionType, g.Width) + copy(cg[y], g.CollisionGrid[y]) + } + + return &Grid{ + OffsetX: g.OffsetX, + OffsetY: g.OffsetY, + Width: g.Width, + Height: g.Height, + CollisionGrid: cg, + } +} diff --git a/internal/game/handle_killer.go b/internal/game/handle_killer.go new file mode 100644 index 000000000..81da0fbe5 --- /dev/null +++ b/internal/game/handle_killer.go @@ -0,0 +1,37 @@ +package game + +import ( + "fmt" + "os/exec" + "strings" + "syscall" +) + +func KillAllClientHandles() error { + cmd := exec.Command("./tools/handle64.exe", "-accepteula", "-nobanner", "-a", "-v", "-p", "d2r.exe", "Check For Other Instances") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + stdout, err := cmd.Output() + if err != nil && !strings.Contains(string(stdout), "No matching handles found.") { + return fmt.Errorf("error running handle64.exe: %d", stdout) + } + + stdoutLines := strings.Split(string(stdout), "\r\n") + + // Maybe the handle was previously closed, so we don't need to do anything + if len(stdoutLines) > 1 { + for _, line := range stdoutLines { + if strings.Contains(line, "Check For Other Instances") { + cols := strings.Split(line, ",") // 0: process, 1: pid, 2: type, 3: handle, 4: name + + cmd = exec.Command("./tools/handle64.exe", "-accepteula", "-nobanner", "-p", fmt.Sprintf("%s", cols[1]), "-c", cols[3], "-y") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + stdout, err = cmd.Output() + if err != nil { + return fmt.Errorf("error running handle64.exe: %d", stdout) + } + } + } + } + + return nil +} diff --git a/internal/game/hid.go b/internal/game/hid.go new file mode 100644 index 000000000..3f6e6d653 --- /dev/null +++ b/internal/game/hid.go @@ -0,0 +1,13 @@ +package game + +type HID struct { + gr *MemoryReader + gi *MemoryInjector +} + +func NewHID(gr *MemoryReader, gi *MemoryInjector) *HID { + return &HID{ + gr: gr, + gi: gi, + } +} diff --git a/internal/game/keyboard.go b/internal/game/keyboard.go new file mode 100644 index 000000000..74a7b2b50 --- /dev/null +++ b/internal/game/keyboard.go @@ -0,0 +1,127 @@ +package game + +import ( + "math/rand" + "strings" + "time" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/koolo/internal/utils/winproc" + "github.com/lxn/win" +) + +const ( + keyPressMinTime = 40 // ms + keyPressMaxTime = 90 // ms +) + +// PressKey receives an ASCII code and sends a key press event to the game window +func (hid *HID) PressKey(key byte) { + win.PostMessage(hid.gr.HWND, win.WM_KEYDOWN, uintptr(key), hid.calculatelParam(key, true)) + sleepTime := rand.Intn(keyPressMaxTime-keyPressMinTime) + keyPressMinTime + time.Sleep(time.Duration(sleepTime) * time.Millisecond) + win.PostMessage(hid.gr.HWND, win.WM_KEYUP, uintptr(key), hid.calculatelParam(key, false)) +} + +func (hid *HID) KeySequence(keysToPress ...byte) { + for _, key := range keysToPress { + hid.PressKey(key) + time.Sleep(200 * time.Millisecond) + } +} + +// PressKeyWithModifier works the same as PressKey but with a modifier key (shift, ctrl, alt) +func (hid *HID) PressKeyWithModifier(key byte, modifier ModifierKey) { + hid.gi.OverrideGetKeyState(byte(modifier)) + hid.PressKey(key) + hid.gi.RestoreGetKeyState() +} + +func (hid *HID) PressKeyBinding(kb data.KeyBinding) { + keys := getKeysForKB(kb) + if keys[1] == 0 || keys[1] == 255 { + hid.PressKey(keys[0]) + return + } + + hid.PressKeyWithModifier(keys[0], ModifierKey(keys[1])) +} + +// KeyDown sends a key down event to the game window +func (hid *HID) KeyDown(kb data.KeyBinding) { + keys := getKeysForKB(kb) + win.PostMessage(hid.gr.HWND, win.WM_KEYDOWN, uintptr(keys[0]), hid.calculatelParam(keys[0], true)) +} + +// KeyUp sends a key up event to the game window +func (hid *HID) KeyUp(kb data.KeyBinding) { + keys := getKeysForKB(kb) + win.PostMessage(hid.gr.HWND, win.WM_KEYUP, uintptr(keys[0]), hid.calculatelParam(keys[0], false)) +} + +func getKeysForKB(kb data.KeyBinding) [2]byte { + if kb.Key1[0] == 0 || kb.Key1[0] == 255 { + return [2]byte{kb.Key2[0], kb.Key2[1]} + } + + return [2]byte{kb.Key1[0], kb.Key1[1]} +} + +func (hid *HID) GetASCIICode(key string) byte { + char, found := specialChars[strings.ToLower(key)] + if found { + return char + } + + return strings.ToUpper(key)[0] +} + +var specialChars = map[string]byte{ + "esc": win.VK_ESCAPE, + "enter": win.VK_RETURN, + "f1": win.VK_F1, + "f2": win.VK_F2, + "f3": win.VK_F3, + "f4": win.VK_F4, + "f5": win.VK_F5, + "f6": win.VK_F6, + "f7": win.VK_F7, + "f8": win.VK_F8, + "f9": win.VK_F9, + "f10": win.VK_F10, + "f11": win.VK_F11, + "f12": win.VK_F12, + "lctrl": win.VK_LCONTROL, + "home": win.VK_HOME, + "down": win.VK_DOWN, + "up": win.VK_UP, + "left": win.VK_LEFT, + "right": win.VK_RIGHT, + "tab": win.VK_TAB, + "space": win.VK_SPACE, + "alt": win.VK_MENU, + "lalt": win.VK_LMENU, + "ralt": win.VK_RMENU, + "shift": win.VK_LSHIFT, + "backspace": win.VK_BACK, + "lwin": win.VK_LWIN, + "rwin": win.VK_RWIN, + "end": win.VK_END, + "-": win.VK_OEM_MINUS, +} + +func (hid *HID) calculatelParam(keyCode byte, down bool) uintptr { + ret, _, _ := winproc.MapVirtualKey.Call(uintptr(keyCode), 0) + scanCode := int(ret) + repeatCount := 1 + extendedKeyFlag := 0 + contextCode := 0 + previousKeyState := 0 + transitionState := 0 + if !down { + transitionState = 1 + } + + lParam := uintptr((repeatCount & 0xFFFF) | (scanCode << 16) | (extendedKeyFlag << 24) | (contextCode << 29) | (previousKeyState << 30) | (transitionState << 31)) + return lParam +} diff --git a/internal/game/manager.go b/internal/game/manager.go new file mode 100644 index 000000000..630eea4ab --- /dev/null +++ b/internal/game/manager.go @@ -0,0 +1,330 @@ +package game + +import ( + "errors" + "fmt" + "os/exec" + "strings" + "syscall" + "time" + "unsafe" + + "github.com/billgraziano/dpapi" + "github.com/hectorgimenez/d2go/pkg/data/difficulty" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/utils" + "github.com/lxn/win" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +type Manager struct { + gr *MemoryReader + hid *HID + supervisorName string +} + +func NewGameManager(gr *MemoryReader, hid *HID, sueprvisorName string) *Manager { + return &Manager{gr: gr, hid: hid, supervisorName: sueprvisorName} +} + +func (gm *Manager) ExitGame() error { + if !gm.gr.InGame() { + return nil + } + // First try to exit game as fast as possible, without any check, useful when chickening + gm.hid.PressKey(win.VK_ESCAPE) + gm.hid.Click(LeftButton, gm.gr.GameAreaSizeX/2, int(float64(gm.gr.GameAreaSizeY)/2.2)) + + for range 5 { + if !gm.gr.InGame() { + return nil + } + utils.Sleep(1000) + } + + // If we are still in game, probably character is dead, so let's do it nicely. + // Probably closing the socket is more reliable, but was not working properly for me on singleplayer. + for range 10 { + if gm.gr.GetData().OpenMenus.QuitMenu { + gm.hid.Click(LeftButton, gm.gr.GameAreaSizeX/2, int(float64(gm.gr.GameAreaSizeY)/2.2)) + + for range 5 { + if !gm.gr.InGame() { + return nil + } + utils.Sleep(1000) + } + } + gm.hid.PressKey(win.VK_ESCAPE) + utils.Sleep(1000) + } + + return errors.New("error exiting game! Timeout") +} + +func (gm *Manager) NewGame() error { + if gm.gr.InGame() { + return errors.New("character still in a game") + } + + for range 30 { + if gm.gr.IsInCharacterSelectionScreen() { + break + } else { + utils.Sleep(500) + } + } + + difficultyPosition := map[difficulty.Difficulty]struct { + X, Y int + }{ + difficulty.Normal: {X: 640, Y: 311}, + difficulty.Nightmare: {X: 640, Y: 355}, + difficulty.Hell: {X: 640, Y: 403}, + } + + createX := difficultyPosition[config.Characters[gm.supervisorName].Game.Difficulty].X + createY := difficultyPosition[config.Characters[gm.supervisorName].Game.Difficulty].Y + gm.hid.Click(LeftButton, 600, 650) + utils.Sleep(250) + gm.hid.Click(LeftButton, createX, createY) + + for range 12 { + if gm.gr.InGame() { + return nil + } + utils.Sleep(500) + } + + return errors.New("timeout") +} + +func (gm *Manager) clearGameNameOrPasswordField() { + for range 16 { + gm.hid.PressKey(win.VK_BACK) + } +} + +func (gm *Manager) CreateOnlineGame(gameCounter int) (string, error) { + + // Click "Create game" tab + gm.hid.Click(LeftButton, 845, 54) + utils.Sleep(200) + + difficultyPosition := map[difficulty.Difficulty]struct { + X, Y int + }{ + difficulty.Normal: {X: 900, Y: 252}, + difficulty.Nightmare: {X: 980, Y: 252}, + difficulty.Hell: {X: 1065, Y: 252}, + } + + difficultyPos := difficultyPosition[config.Characters[gm.supervisorName].Game.Difficulty] + gm.hid.Click(LeftButton, difficultyPos.X, difficultyPos.Y) + utils.Sleep(200) + + // Click the game name textbox, delete text and type new game name + gm.hid.Click(LeftButton, 1000, 116) + gm.clearGameNameOrPasswordField() + gameName := config.Characters[gm.supervisorName].Companion.GameNameTemplate + fmt.Sprintf("%d", gameCounter) + for _, ch := range gameName { + gm.hid.PressKey(gm.hid.GetASCIICode(fmt.Sprintf("%c", ch))) + } + + // Same for password + gm.hid.Click(LeftButton, 1000, 161) + utils.Sleep(200) + gamePassword := config.Characters[gm.supervisorName].Companion.GamePassword + if gamePassword != "" { + gm.clearGameNameOrPasswordField() + for _, ch := range gamePassword { + gm.hid.PressKey(gm.hid.GetASCIICode(fmt.Sprintf("%c", ch))) + } + } + gm.hid.PressKey(win.VK_RETURN) + + for range 30 { + if gm.gr.InGame() { + return gameName, nil + } + utils.Sleep(1000) + } + + return gameName, errors.New("error creating game! Timeout") +} + +func (gm *Manager) JoinOnlineGame(gameName, password string) error { + + // Click "Join game" tab + gm.hid.Click(LeftButton, 977, 54) + utils.Sleep(200) + + // Click the game name textbox, delete text and type new game name + gm.hid.Click(LeftButton, 950, 100) + utils.Sleep(200) + gm.clearGameNameOrPasswordField() + utils.Sleep(200) + for _, ch := range gameName { + gm.hid.PressKey(gm.hid.GetASCIICode(fmt.Sprintf("%c", ch))) + } + + // Same for password + gm.hid.Click(LeftButton, 1130, 100) + utils.Sleep(200) + gm.clearGameNameOrPasswordField() + utils.Sleep(200) + for _, ch := range password { + gm.hid.PressKey(gm.hid.GetASCIICode(fmt.Sprintf("%c", ch))) + } + gm.hid.PressKey(win.VK_RETURN) + + for range 30 { + if gm.gr.InGame() { + return nil + } + utils.Sleep(1000) + } + + return errors.New("error joining game! Timeout") +} + +func (gm *Manager) InGame() bool { + return gm.gr.InGame() +} + +func StartGame(username string, password string, authmethod string, authToken string, realm string, arguments string, useCustomSettings bool) (uint32, win.HWND, error) { + // First check for other instances of the game and kill the handles, otherwise we will not be able to start the game + err := KillAllClientHandles() + if err != nil { + return 0, 0, err + } + + // Depending on the authentication method set base arguments + var baseArgs []string + + if authmethod == "TokenAuth" { + baseArgs = []string{"-uid", "osi"} + } else if authmethod == "UsernamePassword" { + baseArgs = []string{"-username", username, "-password", password, "-address", realm} + } else if authmethod == "None" { + baseArgs = []string{} + } else { + // Default to no auth method + baseArgs = []string{} + } + + // Parse the provided additional arguments + additionalArguments := strings.Fields(arguments) + + // Let's use the mod directory for storing the settings, so we stop overwriting the default config + if useCustomSettings { + modName := "koolo" + found := false + for i, arg := range additionalArguments { + if arg == "-mod" { + modName = additionalArguments[i+1] + found = true + break + } + } + if !found { + additionalArguments = append(additionalArguments, "-mod", modName) + } + + // If there is no real mod, let's create a fake mod called "koolo" so we can store our own config + if modName == "koolo" { + err = config.InstallMod() + if err != nil { + return 0, 0, err + } + } + + // Replace game mod settings with the custom ones + err = config.ReplaceGameSettings(modName) + if err != nil { + return 0, 0, err + } + } + + // Add them to the full argument list + fullArgs := append(baseArgs, additionalArguments...) + + if authmethod == "TokenAuth" { + // Entropy buffer + entropy := []byte{0xc8, 0x76, 0xf4, 0xae, 0x4c, 0x95, 0x2e, 0xfe, 0xf2, 0xfa, 0x0f, 0x54, 0x19, 0xc0, 0x9c, 0x43} + tokenBytes := []byte(authToken) + + encryptedToken, err := dpapi.EncryptBytesEntropy(tokenBytes, entropy) + if err != nil { + return 0, 0, fmt.Errorf("failed to encrypt auth token: %v", err) + } + + // Create or Open the OSI registry folder + key, _, err := registry.CreateKey(registry.CURRENT_USER, `SOFTWARE\Blizzard Entertainment\Battle.net\Launch Options\OSI`, registry.ALL_ACCESS) + if err != nil { + return 0, 0, fmt.Errorf("failed to open registry key: %v", err) + } + defer key.Close() + + region := "EU" + switch realm { + case "eu.actual.battle.net": + region = "EU" + case "us.actual.battle.net": + region = "US" + case "kr.actual.battle.net": + region = "KR" + default: + region = "EU" + } + + // Update the region registry + err = key.SetStringValue("REGION", region) + if err != nil { + return 0, 0, fmt.Errorf("failed to set REGION registry value: %v", err) + } + + err = key.SetBinaryValue("WEB_TOKEN", encryptedToken) + if err != nil { + return 0, 0, fmt.Errorf("failed to set WEB_TOKEN registry value: %v", err) + } + + // If we got to here we've successfully updated the auth token :) + } + + // Start the game + cmd := exec.Command(config.Koolo.D2RPath+"\\D2R.exe", fullArgs...) + err = cmd.Start() + if err != nil { + return 0, 0, err + } + + var foundHwnd windows.HWND + cb := syscall.NewCallback(func(hwnd windows.HWND, lParam uintptr) uintptr { + var pid uint32 + windows.GetWindowThreadProcessId(hwnd, &pid) + if pid == uint32(cmd.Process.Pid) { + foundHwnd = hwnd + return 0 + } + return 1 + }) + for { + windows.EnumWindows(cb, unsafe.Pointer(&cmd.Process.Pid)) + if foundHwnd != 0 { + // Small delay and read again, to be sure we are capturing the right hwnd + time.Sleep(time.Second) + windows.EnumWindows(cb, unsafe.Pointer(&cmd.Process.Pid)) + break + } + } + + // Close the handle for the new process, it will allow the user to open another instance of the game + err = KillAllClientHandles() + if err != nil { + return 0, 0, err + } + + return uint32(cmd.Process.Pid), win.HWND(foundHwnd), nil +} diff --git a/internal/game/map_client/client.go b/internal/game/map_client/client.go new file mode 100644 index 000000000..6b00089a8 --- /dev/null +++ b/internal/game/map_client/client.go @@ -0,0 +1,169 @@ +package map_client + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" + "syscall" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" + "github.com/hectorgimenez/d2go/pkg/data/difficulty" + "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/d2go/pkg/data/object" + "github.com/hectorgimenez/koolo/internal/config" +) + +func GetMapData(seed string, difficulty difficulty.Difficulty) (MapData, error) { + cmd := exec.Command("./tools/koolo-map.exe", config.Koolo.D2LoDPath, "-s", seed, "-d", getDifficultyAsNum(difficulty)) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + stdout, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("error fetching Map data from Diablo II: LoD 1.13c game: %w", err) + } + + stdoutLines := strings.Split(string(stdout), "\r\n") + + lvls := make([]serverLevel, 0) + for _, line := range stdoutLines { + var lvl serverLevel + err = json.Unmarshal([]byte(line), &lvl) + // Discard empty lines or lines that don't contain level information + if err == nil && lvl.Type != "" && len(lvl.Map) > 0 { + lvls = append(lvls, lvl) + } + } + + return lvls, nil +} + +func getDifficultyAsNum(df difficulty.Difficulty) string { + switch df { + case difficulty.Normal: + return "0" + case difficulty.Nightmare: + return "1" + case difficulty.Hell: + return "2" + } + + return "0" +} + +type MapData []serverLevel + +func (lvl serverLevel) CollisionGrid() [][]bool { + var cg [][]bool + + for y := 0; y < lvl.Size.Height; y++ { + var row []bool + for x := 0; x < lvl.Size.Width; x++ { + row = append(row, false) + } + + // Documentation about how this works: https://github.com/blacha/diablo2/tree/master/packages/map + if len(lvl.Map) > y { + mapRow := lvl.Map[y] + isWalkable := false + xPos := 0 + for k, xs := range mapRow { + if k != 0 { + for xOffset := 0; xOffset < xs; xOffset++ { + row[xPos+xOffset] = isWalkable + } + } + isWalkable = !isWalkable + xPos += xs + } + for xPos < len(row) { + row[xPos] = isWalkable + xPos++ + } + } + + cg = append(cg, row) + } + + return cg +} + +func (lvl serverLevel) NPCsExitsAndObjects() (data.NPCs, []data.Level, []data.Object, []data.Room) { + var npcs []data.NPC + var exits []data.Level + var objects []data.Object + var rooms []data.Room + + for _, r := range lvl.Rooms { + rooms = append(rooms, data.Room{ + Position: data.Position{X: r.X, + Y: r.Y, + }, + Width: r.Width, + Height: r.Height, + }) + } + + for _, obj := range lvl.Objects { + switch obj.Type { + case "npc": + n := data.NPC{ + ID: npc.ID(obj.ID), + Name: obj.Name, + Positions: []data.Position{{ + X: obj.X + lvl.Offset.X, + Y: obj.Y + lvl.Offset.Y, + }}, + } + npcs = append(npcs, n) + case "exit": + exit := data.Level{ + Area: area.ID(obj.ID), + Position: data.Position{ + X: obj.X + lvl.Offset.X, + Y: obj.Y + lvl.Offset.Y, + }, + IsEntrance: true, + } + exits = append(exits, exit) + case "object": + o := data.Object{ + Name: object.Name(obj.ID), + Position: data.Position{ + X: obj.X + lvl.Offset.X, + Y: obj.Y + lvl.Offset.Y, + }, + } + objects = append(objects, o) + } + } + + for _, obj := range lvl.Objects { + switch obj.Type { + case "exit_area": + found := false + for _, exit := range exits { + if exit.Area == area.ID(obj.ID) { + exit.IsEntrance = false + found = true + break + } + } + + if !found { + lvl := data.Level{ + Area: area.ID(obj.ID), + Position: data.Position{ + X: obj.X + lvl.Offset.X, + Y: obj.Y + lvl.Offset.Y, + }, + IsEntrance: false, + } + exits = append(exits, lvl) + } + } + + } + + return npcs, exits, objects, rooms +} diff --git a/internal/reader/map_client/data.go b/internal/game/map_client/data.go similarity index 100% rename from internal/reader/map_client/data.go rename to internal/game/map_client/data.go diff --git a/internal/game/memory_injector.go b/internal/game/memory_injector.go new file mode 100644 index 000000000..17adbd65f --- /dev/null +++ b/internal/game/memory_injector.go @@ -0,0 +1,201 @@ +package game + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "log/slog" + "strings" + "syscall" + + "github.com/hectorgimenez/d2go/pkg/memory" + "golang.org/x/sys/windows" +) + +const fullAccess = windows.PROCESS_VM_OPERATION | windows.PROCESS_VM_WRITE | windows.PROCESS_VM_READ + +type MemoryInjector struct { + isLoaded bool + pid uint32 + handle windows.Handle + getCursorPosAddr uintptr + getCursorPosOrigBytes [32]byte + trackMouseEventAddr uintptr + trackMouseEventBytes [32]byte + getKeyStateAddr uintptr + getKeyStateOrigBytes [18]byte + setCursorPosAddr uintptr + logger *slog.Logger +} + +func InjectorInit(logger *slog.Logger, pid uint32) (*MemoryInjector, error) { + i := &MemoryInjector{pid: pid, logger: logger} + pHandle, err := windows.OpenProcess(fullAccess, false, pid) + if err != nil { + return nil, fmt.Errorf("error opening process: %w", err) + } + i.handle = pHandle + + return i, nil +} + +func (i *MemoryInjector) Load() error { + if i.isLoaded { + return nil + } + + modules, err := memory.GetProcessModules(i.pid) + if err != nil { + return fmt.Errorf("error getting process modules: %w", err) + } + + syscall.MustLoadDLL("USER32.dll") + + for _, module := range modules { + // GetCursorPos + if strings.Contains(strings.ToLower(module.ModuleName), "user32.dll") { + i.getCursorPosAddr, err = syscall.GetProcAddress(module.ModuleHandle, "GetCursorPos") + i.getKeyStateAddr, _ = syscall.GetProcAddress(module.ModuleHandle, "GetKeyState") + i.trackMouseEventAddr, _ = syscall.GetProcAddress(module.ModuleHandle, "TrackMouseEvent") + i.setCursorPosAddr, _ = syscall.GetProcAddress(module.ModuleHandle, "SetCursorPos") + + err = windows.ReadProcessMemory(i.handle, i.getCursorPosAddr, &i.getCursorPosOrigBytes[0], uintptr(len(i.getCursorPosOrigBytes)), nil) + if err != nil { + return fmt.Errorf("error reading memory: %w", err) + } + + err = i.stopTrackingMouseLeaveEvents() + if err != nil { + return err + } + + err = i.OverrideSetCursorPos() + if err != nil { + return err + } + + err = windows.ReadProcessMemory(i.handle, i.getKeyStateAddr, &i.getKeyStateOrigBytes[0], uintptr(len(i.getKeyStateOrigBytes)), nil) + if err != nil { + return fmt.Errorf("error reading memory: %w", err) + } + } + } + if i.getCursorPosAddr == 0 || i.getKeyStateAddr == 0 { + return errors.New("could not find GetCursorPos address") + } + + i.isLoaded = true + return nil +} + +func (i *MemoryInjector) Unload() error { + if err := i.RestoreMemory(); err != nil { + i.logger.Error(fmt.Sprintf("error restoring memory: %v", err)) + } + + return windows.CloseHandle(i.handle) +} + +func (i *MemoryInjector) RestoreMemory() error { + if !i.isLoaded { + return nil + } + + i.isLoaded = false + err := i.RestoreGetCursorPosAddr() + if err != nil { + return fmt.Errorf("error restoring memory: %v", err) + } + + return i.RestoreGetKeyState() +} + +func (i *MemoryInjector) CursorPos(x, y int) error { + if !i.isLoaded { + return nil + } + + /* + push rax + mov rax, rcx + mov dword ptr [rax], 1 // X + mov dword ptr [rax+4], 2 // Y + pop rax + mov al, 1 + ret + */ + bytes := []byte{0x50, 0x48, 0x89, 0xC8, 0xC7, 0x00, 0x01, 0x00, 0x00, 0x00, 0xC7, 0x40, 0x04, 0x02, 0x00, 0x00, 0x00, 0x58, 0xB0, 0x01, 0xC3} + + buff := make([]byte, 4) + binary.LittleEndian.PutUint32(buff, uint32(x)) + copy(bytes[6:], buff) + + binary.LittleEndian.PutUint32(buff, uint32(y)) + copy(bytes[13:], buff) + + return windows.WriteProcessMemory(i.handle, i.getCursorPosAddr, &bytes[0], uintptr(len(bytes)), nil) +} + +func (i *MemoryInjector) OverrideGetKeyState(key byte) error { + if !i.isLoaded { + return nil + } + /* + Assembly: Compare key byte, set al if match, shift left to create 0x8000 if matched (4 cycles) + cmp cl, key -> Compare key byte + sete al -> Set al to 1 if equal + shl ax, 15 -> Shift left by 15 to create 0x8000 if was 1, else 0 + ret -> Return with result in ax + */ + + bytes := []byte{0x80, 0xF9, key, 0x0F, 0x94, 0xC0, 0x66, 0xC1, 0xE0, 0x0F, 0xC3} + + return windows.WriteProcessMemory(i.handle, i.getKeyStateAddr, &bytes[0], uintptr(len(bytes)), nil) +} +func (i *MemoryInjector) OverrideSetCursorPos() error { + /* + Just do nothing, this prevents the game from moving our cursor, for example when opening inventory or wp list + mov eax, 1 + ret + */ + + blob := []byte{0xB8, 0x01, 0x00, 0x00, 0x00, 0xC3} + return windows.WriteProcessMemory(i.handle, i.setCursorPosAddr, &blob[0], uintptr(len(blob)), nil) +} + +func (i *MemoryInjector) RestoreGetKeyState() error { + return windows.WriteProcessMemory(i.handle, i.getKeyStateAddr, &i.getKeyStateOrigBytes[0], uintptr(len(i.getKeyStateOrigBytes)), nil) +} + +func (i *MemoryInjector) RestoreGetCursorPosAddr() error { + return windows.WriteProcessMemory(i.handle, i.getCursorPosAddr, &i.getCursorPosOrigBytes[0], uintptr(len(i.getCursorPosOrigBytes)), nil) +} + +// This is needed in order to let the game keep processing mouse events even if the mouse is not over the window +func (i *MemoryInjector) stopTrackingMouseLeaveEvents() error { + err := windows.ReadProcessMemory(i.handle, i.trackMouseEventAddr, &i.trackMouseEventBytes[0], uintptr(len(i.trackMouseEventBytes)), nil) + if err != nil { + return err + } + + // and dword ptr [rcx+4], 0xFFFFFFFD + // Modify TRACKMOUSEEVENT struct to disable mouse leave events, since we are injecting our events even if the mouse is not over the window + disableMouseLeaveRequest := []byte{0x81, 0x61, 0x04, 0xFD, 0xFF, 0xFF, 0xFF} + + // Already hooked + if bytes.Contains(i.trackMouseEventBytes[:], disableMouseLeaveRequest) { + return nil + } + + // We need to move back the pointer 7 bytes to get the correct position, since we are injecting 7 bytes in front of it + num := int32(binary.LittleEndian.Uint32(i.trackMouseEventBytes[2:6])) + num -= 7 + numberBytes := make([]byte, 4) + binary.LittleEndian.PutUint32(numberBytes, uint32(num)) + injectBytes := append(i.trackMouseEventBytes[0:2], numberBytes...) + + hook := append(disableMouseLeaveRequest, injectBytes...) + + return windows.WriteProcessMemory(i.handle, i.trackMouseEventAddr, &hook[0], uintptr(len(hook)), nil) +} diff --git a/internal/game/memory_reader.go b/internal/game/memory_reader.go new file mode 100644 index 000000000..eae501d75 --- /dev/null +++ b/internal/game/memory_reader.go @@ -0,0 +1,182 @@ +package game + +import ( + "errors" + "fmt" + "log/slog" + "strconv" + "sync" + "time" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" + "github.com/hectorgimenez/d2go/pkg/memory" + "github.com/hectorgimenez/d2go/pkg/utils" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/game/map_client" + "github.com/lxn/win" + "golang.org/x/sync/errgroup" +) + +type MemoryReader struct { + cfg *config.CharacterCfg + *memory.GameReader + mapSeed uint + HWND win.HWND + WindowLeftX int + WindowTopY int + GameAreaSizeX int + GameAreaSizeY int + supervisorName string + cachedMapData map[area.ID]AreaData + logger *slog.Logger +} + +func NewGameReader(cfg *config.CharacterCfg, supervisorName string, pid uint32, window win.HWND, logger *slog.Logger) (*MemoryReader, error) { + process, err := memory.NewProcessForPID(pid) + if err != nil { + return nil, err + } + + gr := &MemoryReader{ + GameReader: memory.NewGameReader(process), + HWND: window, + supervisorName: supervisorName, + cfg: cfg, + logger: logger, + } + + gr.updateWindowPositionData() + + return gr, nil +} + +func (gd *MemoryReader) MapSeed() uint { + return gd.mapSeed +} + +func (gd *MemoryReader) FetchMapData() error { + d := gd.GameReader.GetData() + gd.mapSeed, _ = gd.getMapSeed(d.PlayerUnit.Address) + t := time.Now() + gd.logger.Debug("Fetching map data...", slog.Uint64("seed", uint64(gd.mapSeed)), slog.String("difficulty", string(config.Characters[gd.supervisorName].Game.Difficulty))) + + mapData, err := map_client.GetMapData(strconv.Itoa(int(gd.mapSeed)), config.Characters[gd.supervisorName].Game.Difficulty) + if err != nil { + return fmt.Errorf("error fetching map data: %w", err) + } + + areas := make(map[area.ID]AreaData) + var mu sync.Mutex + g := errgroup.Group{} + for _, lvl := range mapData { + g.Go(func() error { + cg := lvl.CollisionGrid() + resultGrid := make([][]CollisionType, lvl.Size.Height) + for i := range resultGrid { + resultGrid[i] = make([]CollisionType, lvl.Size.Width) + } + + for y := 0; y < lvl.Size.Height; y++ { + for x := 0; x < lvl.Size.Width; x++ { + if cg[y][x] { + resultGrid[y][x] = CollisionTypeWalkable + } else { + resultGrid[y][x] = CollisionTypeNonWalkable + } + } + } + + npcs, exits, objects, rooms := lvl.NPCsExitsAndObjects() + grid := NewGrid(resultGrid, lvl.Offset.X, lvl.Offset.Y) + mu.Lock() + areas[area.ID(lvl.ID)] = AreaData{ + Area: area.ID(lvl.ID), + Name: lvl.Name, + NPCs: npcs, + AdjacentLevels: exits, + Objects: objects, + Rooms: rooms, + Grid: grid, + } + mu.Unlock() + + return nil + }) + } + + _ = g.Wait() + + gd.cachedMapData = areas + gd.logger.Debug("Fetch completed", slog.Int64("ms", time.Since(t).Milliseconds())) + + return nil +} + +func (gd *MemoryReader) updateWindowPositionData() { + pos := win.WINDOWPLACEMENT{} + point := win.POINT{} + win.ClientToScreen(gd.HWND, &point) + win.GetWindowPlacement(gd.HWND, &pos) + + gd.WindowLeftX = int(point.X) + gd.WindowTopY = int(point.Y) + gd.GameAreaSizeX = int(pos.RcNormalPosition.Right) - gd.WindowLeftX - 9 + gd.GameAreaSizeY = int(pos.RcNormalPosition.Bottom) - gd.WindowTopY - 9 +} + +func (gd *MemoryReader) GetData() Data { + d := gd.GameReader.GetData() + currentArea, ok := gd.cachedMapData[d.PlayerUnit.Area] + if ok { + // This hacky thing is because sometimes if the objects are far away we can not fetch them, basically WP. + memObjects := gd.Objects(d.PlayerUnit.Position, d.HoverData) + for _, clientObject := range currentArea.Objects { + found := false + for _, obj := range memObjects { + // Only consider it a duplicate if same name AND same position + if obj.Name == clientObject.Name && obj.Position.X == clientObject.Position.X && obj.Position.Y == clientObject.Position.Y { + found = true + break + } + } + if !found { + memObjects = append(memObjects, clientObject) + } + } + + d.AreaOrigin = data.Position{X: currentArea.OffsetX, Y: currentArea.OffsetY} + d.NPCs = currentArea.NPCs + d.AdjacentLevels = currentArea.AdjacentLevels + d.Rooms = currentArea.Rooms + d.Objects = memObjects + } + + var cfgCopy config.CharacterCfg + if gd.cfg != nil { + cfgCopy = *gd.cfg + } + + return Data{ + Data: d, + CharacterCfg: cfgCopy, + AreaData: currentArea, + Areas: gd.cachedMapData, + } +} + +func (gd *MemoryReader) getMapSeed(playerUnit uintptr) (uint, error) { + actPtr := uintptr(gd.Process.ReadUInt(playerUnit+0x20, memory.Uint64)) + actMiscPtr := uintptr(gd.Process.ReadUInt(actPtr+0x78, memory.Uint64)) + + dwInitSeedHash1 := gd.Process.ReadUInt(actMiscPtr+0x840, memory.Uint32) + //dwInitSeedHash2 := uintptr(gd.Process.ReadUInt(actMiscPtr+0x844, memory.Uint32)) + dwEndSeedHash1 := gd.Process.ReadUInt(actMiscPtr+0x868, memory.Uint32) + + mapSeed, found := utils.GetMapSeed(dwInitSeedHash1, dwEndSeedHash1) + if !found { + return 0, errors.New("error calculating map seed") + } + + return mapSeed, nil +} diff --git a/internal/game/mouse.go b/internal/game/mouse.go new file mode 100644 index 000000000..93c7f9f87 --- /dev/null +++ b/internal/game/mouse.go @@ -0,0 +1,63 @@ +package game + +import ( + "math/rand" + "time" + + "github.com/lxn/win" +) + +const ( + RightButton MouseButton = win.MK_RBUTTON + LeftButton MouseButton = win.MK_LBUTTON + + ShiftKey ModifierKey = win.VK_SHIFT + CtrlKey ModifierKey = win.VK_CONTROL +) + +type MouseButton uint +type ModifierKey byte + +// MovePointer moves the mouse to the requested position, x and y should be the final position based on +// pixels shown in the screen. Top-left corner is 0,0 +func (hid *HID) MovePointer(x, y int) { + hid.gr.updateWindowPositionData() + x = hid.gr.WindowLeftX + x + y = hid.gr.WindowTopY + y + + hid.gi.CursorPos(x, y) + lParam := calculateLparam(x, y) + win.SendMessage(hid.gr.HWND, win.WM_NCHITTEST, 0, lParam) + win.SendMessage(hid.gr.HWND, win.WM_SETCURSOR, 0x000105A8, 0x2010001) + win.PostMessage(hid.gr.HWND, win.WM_MOUSEMOVE, 0, lParam) +} + +// Click just does a single mouse click at current pointer position +func (hid *HID) Click(btn MouseButton, x, y int) { + hid.MovePointer(x, y) + x = hid.gr.WindowLeftX + x + y = hid.gr.WindowTopY + y + + lParam := calculateLparam(x, y) + buttonDown := uint32(win.WM_LBUTTONDOWN) + buttonUp := uint32(win.WM_LBUTTONUP) + if btn == RightButton { + buttonDown = win.WM_RBUTTONDOWN + buttonUp = win.WM_RBUTTONUP + } + + win.SendMessage(hid.gr.HWND, buttonDown, 1, lParam) + sleepTime := rand.Intn(keyPressMaxTime-keyPressMinTime) + keyPressMinTime + time.Sleep(time.Duration(sleepTime) * time.Millisecond) + win.SendMessage(hid.gr.HWND, buttonUp, 1, lParam) +} + +func (hid *HID) ClickWithModifier(btn MouseButton, x, y int, modifier ModifierKey) { + hid.gi.OverrideGetKeyState(byte(modifier)) + hid.Click(btn, x, y) + hid.gi.RestoreGetKeyState() +} + +func calculateLparam(x, y int) uintptr { + return uintptr(y<<16 | x) +} diff --git a/internal/game/screenshot.go b/internal/game/screenshot.go new file mode 100644 index 000000000..11ffaf693 --- /dev/null +++ b/internal/game/screenshot.go @@ -0,0 +1,72 @@ +package game + +import ( + "image" + "unsafe" + + "github.com/hectorgimenez/koolo/internal/utils/winproc" +) + +func (gd *MemoryReader) Screenshot() image.Image { + // Create a device context compatible with the window + hdcWindow, _, _ := winproc.GetWindowDC.Call(uintptr(gd.HWND)) + hdcMem, _, _ := winproc.CreateCompatibleDC.Call(hdcWindow) + hbmMem, _, _ := winproc.CreateCompatibleBitmap.Call(hdcWindow, uintptr(gd.GameAreaSizeX), uintptr(gd.GameAreaSizeY)) + _, _, _ = winproc.SelectObject.Call(hdcMem, hbmMem) + + // Use PrintWindow to copy the window into the bitmap + winproc.PrintWindow.Call(uintptr(gd.HWND), hdcMem, 3) // use 3 to get window content only + + // map the bitmap structure + bmpInfo := struct { + BiSize uint32 + BiWidth, BiHeight int32 + BiPlanes uint16 + BiBitCount uint16 + BiCompression uint32 + BiSizeImage uint32 + BiXPelsPerMeter int32 + BiYPelsPerMeter int32 + BiClrUsed uint32 + BiClrImportant uint32 + }{ + BiSize: 40, // The size of the BITMAPINFOHEADER structure + BiWidth: int32(gd.GameAreaSizeX), + BiHeight: -int32(gd.GameAreaSizeY), // negative to indicate top-down bitmap + BiPlanes: 1, + BiBitCount: 32, // 32 bits-per-pixel + BiCompression: 0, // BI_RGB, no compression + BiSizeImage: 0, // 0 for BI_RGB + } + + bufSize := gd.GameAreaSizeX * gd.GameAreaSizeY * 4 + buf := make([]byte, bufSize) + winproc.GetDIBits.Call( + hdcMem, + hbmMem, + 0, + uintptr(gd.GameAreaSizeY), + uintptr(unsafe.Pointer(&buf[0])), + uintptr(unsafe.Pointer(&bmpInfo)), + 0, // DIB_RGB_COLORS + ) + + // Convert raw bytes to *image.RGBA + img := image.NewRGBA(image.Rect(0, 0, gd.GameAreaSizeX, gd.GameAreaSizeY)) + copy(img.Pix, buf) + + // Windows is using BRG instead of RGB, let's swap red and blue layers + for y := 0; y < img.Bounds().Dy(); y++ { + for x := 0; x < img.Bounds().Dx(); x++ { + idx := y*img.Stride + x*4 // Calculate index for the start of the pixel + // Swap red and blue (at idx and idx+2) + img.Pix[idx], img.Pix[idx+2] = img.Pix[idx+2], img.Pix[idx] + } + } + + // Cleanup + _, _, _ = winproc.DeleteObject.Call(hbmMem) + _, _, _ = winproc.DeleteDC.Call(hdcMem) + + return img +} diff --git a/internal/health/belt_manager.go b/internal/health/belt_manager.go index e24572aad..659f7a66e 100644 --- a/internal/health/belt_manager.go +++ b/internal/health/belt_manager.go @@ -2,40 +2,43 @@ package health import ( "fmt" + "log/slog" "strings" "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/event/stat" - "github.com/hectorgimenez/koolo/internal/hid" - "go.uber.org/zap" + "github.com/hectorgimenez/koolo/internal/event" + "github.com/hectorgimenez/koolo/internal/game" ) type BeltManager struct { - logger *zap.Logger + data *game.Data + hid *game.HID + logger *slog.Logger + supervisor string } -func NewBeltManager(logger *zap.Logger) BeltManager { - return BeltManager{ - logger: logger, +func NewBeltManager(data *game.Data, hid *game.HID, logger *slog.Logger, supervisor string) *BeltManager { + return &BeltManager{ + data: data, + hid: hid, + logger: logger, + supervisor: supervisor, } } -func (pm BeltManager) DrinkPotion(d data.Data, potionType data.PotionType, merc bool) bool { - p, found := d.Items.Belt.GetFirstPotion(potionType) +func (bm BeltManager) DrinkPotion(potionType data.PotionType, merc bool) bool { + p, found := bm.data.Inventory.Belt.GetFirstPotion(potionType) if found { - binding := pm.getBindingBasedOnColumn(p) + binding := bm.data.KeyBindings.UseBelt[p.X] if merc { - hid.KeyDown("shift") - hid.PressKey(binding) - hid.KeyUp("shift") - pm.logger.Debug(fmt.Sprintf("Using %s potion on Mercenary [Column: %d]. HP: %d", potionType, p.X+1, d.MercHPPercent())) - stat.UsedPotion(potionType, true) + bm.hid.PressKeyWithModifier(binding.Key1[0], game.ShiftKey) + bm.logger.Debug(fmt.Sprintf("Using %s potion on Mercenary [Column: %d]. HP: %d", potionType, p.X+1, bm.data.MercHPPercent())) + event.Send(event.UsedPotion(event.Text(bm.supervisor, ""), potionType, true)) return true } - hid.PressKey(binding) - pm.logger.Debug(fmt.Sprintf("Using %s potion [Column: %d]. HP: %d MP: %d", potionType, p.X+1, d.PlayerUnit.HPPercent(), d.PlayerUnit.MPPercent())) - stat.UsedPotion(potionType, false) + bm.hid.PressKeyBinding(binding) + bm.logger.Debug(fmt.Sprintf("Using %s potion [Column: %d]. HP: %d MP: %d", potionType, p.X+1, bm.data.PlayerUnit.HPPercent(), bm.data.PlayerUnit.MPPercent())) + event.Send(event.UsedPotion(event.Text(bm.supervisor, ""), potionType, false)) return true } @@ -43,15 +46,15 @@ func (pm BeltManager) DrinkPotion(d data.Data, potionType data.PotionType, merc } // ShouldBuyPotions will return true if more than 25% of belt is empty (ignoring rejuv) -func (pm BeltManager) ShouldBuyPotions(d data.Data) bool { - targetHealingAmount := config.Config.Inventory.BeltColumns.Healing * d.Items.Belt.Rows() - targetManaAmount := config.Config.Inventory.BeltColumns.Mana * d.Items.Belt.Rows() - targetRejuvAmount := config.Config.Inventory.BeltColumns.Rejuvenation * d.Items.Belt.Rows() +func (bm BeltManager) ShouldBuyPotions() bool { + targetHealingAmount := bm.data.CharacterCfg.Inventory.BeltColumns.Total(data.HealingPotion) * bm.data.Inventory.Belt.Rows() + targetManaAmount := bm.data.CharacterCfg.Inventory.BeltColumns.Total(data.ManaPotion) * bm.data.Inventory.Belt.Rows() + targetRejuvAmount := bm.data.CharacterCfg.Inventory.BeltColumns.Total(data.RejuvenationPotion) * bm.data.Inventory.Belt.Rows() - currentHealing, currentMana, currentRejuv := pm.getCurrentPotions(d) + currentHealing, currentMana, currentRejuv := bm.getCurrentPotions() - pm.logger.Debug(fmt.Sprintf( - "Belt Status Health: %d/%d healing, %d/%d mana, %d/%d rejuv.", + bm.logger.Debug(fmt.Sprintf( + "Belt Stats Health: %d/%d healing, %d/%d mana, %d/%d rejuv.", currentHealing, targetHealingAmount, currentMana, @@ -61,18 +64,18 @@ func (pm BeltManager) ShouldBuyPotions(d data.Data) bool { )) if currentHealing < int(float32(targetHealingAmount)*0.75) || currentMana < int(float32(targetManaAmount)*0.75) { - pm.logger.Debug("Need more pots, let's buy them.") + bm.logger.Debug("Need more pots, let's buy them.") return true } return false } -func (pm BeltManager) getCurrentPotions(d data.Data) (int, int, int) { +func (bm BeltManager) getCurrentPotions() (int, int, int) { currentHealing := 0 currentMana := 0 currentRejuv := 0 - for _, i := range d.Items.Belt.Items { + for _, i := range bm.data.Inventory.Belt.Items { if strings.Contains(string(i.Name), string(data.HealingPotion)) { currentHealing++ continue @@ -89,26 +92,26 @@ func (pm BeltManager) getCurrentPotions(d data.Data) (int, int, int) { return currentHealing, currentMana, currentRejuv } -func (pm BeltManager) GetMissingCount(d data.Data, potionType data.PotionType) int { - currentHealing, currentMana, currentRejuv := pm.getCurrentPotions(d) +func (bm BeltManager) GetMissingCount(potionType data.PotionType) int { + currentHealing, currentMana, currentRejuv := bm.getCurrentPotions() switch potionType { case data.HealingPotion: - targetAmount := config.Config.Inventory.BeltColumns.Healing * d.Items.Belt.Rows() + targetAmount := bm.data.CharacterCfg.Inventory.BeltColumns.Total(data.HealingPotion) * bm.data.Inventory.Belt.Rows() missingPots := targetAmount - currentHealing if missingPots < 0 { return 0 } return missingPots case data.ManaPotion: - targetAmount := config.Config.Inventory.BeltColumns.Mana * d.Items.Belt.Rows() + targetAmount := bm.data.CharacterCfg.Inventory.BeltColumns.Total(data.ManaPotion) * bm.data.Inventory.Belt.Rows() missingPots := targetAmount - currentMana if missingPots < 0 { return 0 } return missingPots case data.RejuvenationPotion: - targetAmount := config.Config.Inventory.BeltColumns.Rejuvenation * d.Items.Belt.Rows() + targetAmount := bm.data.CharacterCfg.Inventory.BeltColumns.Total(data.RejuvenationPotion) * bm.data.Inventory.Belt.Rows() missingPots := targetAmount - currentRejuv if missingPots < 0 { return 0 @@ -118,18 +121,3 @@ func (pm BeltManager) GetMissingCount(d data.Data, potionType data.PotionType) i return 0 } - -func (pm BeltManager) getBindingBasedOnColumn(position data.Position) string { - switch position.X { - case 0: - return config.Config.Bindings.Potion1 - case 1: - return config.Config.Bindings.Potion2 - case 2: - return config.Config.Bindings.Potion3 - case 3: - return config.Config.Bindings.Potion4 - } - - return "" -} diff --git a/internal/health/health_manager.go b/internal/health/health_manager.go index 559d80f65..d791475c1 100644 --- a/internal/health/health_manager.go +++ b/internal/health/health_manager.go @@ -6,11 +6,7 @@ import ( "time" "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/event" - "github.com/hectorgimenez/koolo/internal/event/stat" - "github.com/hectorgimenez/koolo/internal/helper" - "go.uber.org/zap" + "github.com/hectorgimenez/koolo/internal/game" ) var ErrDied = errors.New("you died :(") @@ -21,85 +17,89 @@ const ( healingInterval = time.Second * 4 healingMercInterval = time.Second * 6 manaInterval = time.Second * 4 - rejuvInterval = time.Second * 2 + rejuvInterval = time.Second * 1 ) // Manager responsibility is to keep our character and mercenary alive, monitoring life and giving potions when needed type Manager struct { - logger *zap.Logger - beltManager BeltManager - gameManager *helper.GameManager lastRejuv time.Time lastRejuvMerc time.Time lastHeal time.Time lastMana time.Time lastMercHeal time.Time + beltManager *BeltManager + data *game.Data } -func NewHealthManager(logger *zap.Logger, beltManager BeltManager, gm *helper.GameManager) Manager { - return Manager{ - logger: logger, - beltManager: beltManager, - gameManager: gm, +func NewHealthManager(bm *BeltManager, data *game.Data) *Manager { + return &Manager{ + beltManager: bm, + data: data, } } -func (hm *Manager) HandleHealthAndMana(d data.Data) error { - hpConfig := config.Config.Health +func (hm *Manager) HandleHealthAndMana() error { + hpConfig := hm.data.CharacterCfg.Health // Safe area, skipping - if d.PlayerUnit.Area.IsTown() { + if hm.data.PlayerUnit.Area.IsTown() { return nil } - if d.PlayerUnit.HPPercent() <= 0 { - stat.FinishCurrentRun(event.Death) - + if hm.data.PlayerUnit.HPPercent() <= 0 { return ErrDied } - usedRejuv := false - if time.Since(hm.lastRejuv) > rejuvInterval && (d.PlayerUnit.HPPercent() <= hpConfig.RejuvPotionAtLife || d.PlayerUnit.MPPercent() < hpConfig.RejuvPotionAtMana) { - usedRejuv = hm.beltManager.DrinkPotion(d, data.RejuvenationPotion, false) - if usedRejuv { - hm.lastRejuv = time.Now() - } + // Player chicken check + if hm.data.PlayerUnit.HPPercent() <= hpConfig.ChickenAt { + return fmt.Errorf("%w: Current Health: %d percent", ErrChicken, hm.data.PlayerUnit.HPPercent()) + } + + // Mercenary chicken check + if hm.data.MercHPPercent() > 0 && hm.data.MercHPPercent() <= hpConfig.MercChickenAt { + return fmt.Errorf("%w: Current Merc Health: %d percent", ErrMercChicken, hm.data.MercHPPercent()) } - if !usedRejuv { - if d.PlayerUnit.HPPercent() <= hpConfig.ChickenAt { - stat.FinishCurrentRun(event.Chicken) - return fmt.Errorf("%w: Current Health: %d percent", ErrChicken, d.PlayerUnit.HPPercent()) + // Player rejuvenation potion check + if time.Since(hm.lastRejuv) > rejuvInterval && + (hm.data.PlayerUnit.HPPercent() <= hpConfig.RejuvPotionAtLife || + hm.data.PlayerUnit.MPPercent() < hpConfig.RejuvPotionAtMana) { + if hm.beltManager.DrinkPotion(data.RejuvenationPotion, false) { + hm.lastRejuv = time.Now() + return nil } + } - if d.PlayerUnit.HPPercent() <= hpConfig.HealingPotionAt && time.Since(hm.lastHeal) > healingInterval { - hm.beltManager.DrinkPotion(d, data.HealingPotion, false) + // Player healing potion check + if hm.data.PlayerUnit.HPPercent() <= hpConfig.HealingPotionAt && + time.Since(hm.lastHeal) > healingInterval { + if hm.beltManager.DrinkPotion(data.HealingPotion, false) { hm.lastHeal = time.Now() } + } - if d.PlayerUnit.MPPercent() <= hpConfig.ManaPotionAt && time.Since(hm.lastMana) > manaInterval { - hm.beltManager.DrinkPotion(d, data.ManaPotion, false) + // Player mana potion check + if hm.data.PlayerUnit.MPPercent() <= hpConfig.ManaPotionAt && + time.Since(hm.lastMana) > manaInterval { + if hm.beltManager.DrinkPotion(data.ManaPotion, false) { hm.lastMana = time.Now() } } - // Mercenary - if d.MercHPPercent() > 0 { - usedMercRejuv := false - if time.Since(hm.lastRejuvMerc) > rejuvInterval && d.MercHPPercent() <= hpConfig.MercRejuvPotionAt { - usedMercRejuv = hm.beltManager.DrinkPotion(d, data.RejuvenationPotion, true) - if usedMercRejuv { + // Mercenary healing logic + if hm.data.MercHPPercent() > 0 { + // Mercenary rejuvenation potion check + if time.Since(hm.lastRejuvMerc) > rejuvInterval && + hm.data.MercHPPercent() <= hpConfig.MercRejuvPotionAt { + if hm.beltManager.DrinkPotion(data.RejuvenationPotion, true) { hm.lastRejuvMerc = time.Now() + return nil } } - if !usedMercRejuv { - if d.MercHPPercent() <= hpConfig.MercChickenAt { - stat.FinishCurrentRun(event.MercChicken) - return fmt.Errorf("%w: Current Merc Health: %d percent", ErrMercChicken, d.MercHPPercent()) - } - - if d.MercHPPercent() <= hpConfig.MercHealingPotionAt && time.Since(hm.lastMercHeal) > healingMercInterval { - hm.beltManager.DrinkPotion(d, data.HealingPotion, true) + // Mercenary healing potion check + if hm.data.MercHPPercent() <= hpConfig.MercHealingPotionAt && + time.Since(hm.lastMercHeal) > healingMercInterval { + if hm.beltManager.DrinkPotion(data.HealingPotion, true) { hm.lastMercHeal = time.Now() } } diff --git a/internal/helper/game_manager.go b/internal/helper/game_manager.go deleted file mode 100644 index 2311af436..000000000 --- a/internal/helper/game_manager.go +++ /dev/null @@ -1,178 +0,0 @@ -package helper - -import ( - "errors" - "fmt" - - "github.com/hectorgimenez/d2go/pkg/data/difficulty" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/hid" - "github.com/hectorgimenez/koolo/internal/reader" - "github.com/hectorgimenez/koolo/internal/ui" -) - -type GameManager struct { - gr *reader.GameReader - tf *ui.TemplateFinder -} - -func NewGameManager(gr *reader.GameReader, tf *ui.TemplateFinder) *GameManager { - return &GameManager{gr: gr, tf: tf} -} - -func (gm *GameManager) ExitGame() error { - // First try to exit game as fast as possible, without any check, useful when chickening - hid.PressKey("esc") - hid.MovePointer(hid.GameAreaSizeX/2, int(float64(hid.GameAreaSizeY)/2.2)) - hid.Click(hid.LeftButton) - - for i := 0; i < 5; i++ { - if !gm.gr.InGame() { - return nil - } - Sleep(1000) - } - - // If we are still in game, probably character is dead, so let's do it nicely. - // Probably closing the socket is more reliable, but was not working properly for me on singleplayer. - for i := 0; i < 10; i++ { - if gm.gr.GetData(false).OpenMenus.QuitMenu { - hid.MovePointer(hid.GameAreaSizeX/2, int(float64(hid.GameAreaSizeY)/2.2)) - hid.Click(hid.LeftButton) - - for i := 0; i < 5; i++ { - if !gm.gr.InGame() { - return nil - } - Sleep(1000) - } - } - hid.PressKey("esc") - Sleep(1000) - } - - return errors.New("error exiting game! Timeout") -} - -func (gm *GameManager) NewGame() error { - if gm.gr.InGame() { - return errors.New("character still in a game") - } - - for i := 0; i < 30; i++ { - if gm.tf.Find("ui_config_gear", Screenshot()).Found { - break - } - Sleep(500) - } - - difficultyPosition := map[difficulty.Difficulty]struct { - X, Y int - }{ - difficulty.Normal: {X: 640, Y: 311}, - difficulty.Nightmare: {X: 640, Y: 355}, - difficulty.Hell: {X: 640, Y: 403}, - } - - createX := difficultyPosition[config.Config.Game.Difficulty].X - createY := difficultyPosition[config.Config.Game.Difficulty].Y - hid.MovePointer(600, 650) - Sleep(250) - hid.Click(hid.LeftButton) - Sleep(250) - hid.MovePointer(createX, createY) - hid.Click(hid.LeftButton) - - for i := 0; i < 30; i++ { - if gm.gr.InGame() { - return nil - } - Sleep(1000) - } - - return errors.New("error creating game! Timeout") -} - -func (gm *GameManager) CreateOnlineGame(gameCounter int) (string, error) { - // Enter bnet lobby - hid.MovePointer(744, 650) - hid.Click(hid.LeftButton) - Sleep(1200) - - // Click "Create game" tab - hid.MovePointer(845, 54) - hid.Click(hid.LeftButton) - Sleep(200) - - // Click the game name textbox, delete text and type new game name - hid.MovePointer(925, 116) - hid.Click(hid.LeftButton) - hid.PressKeyCombination("lctrl", "a") - gameName := config.Config.Companion.GameNameTemplate + fmt.Sprintf("%d", gameCounter) - for _, ch := range gameName { - hid.PressKey(fmt.Sprintf("%c", ch)) - } - - // Same for password - hid.MovePointer(925, 161) - hid.Click(hid.LeftButton) - Sleep(200) - hid.PressKeyCombination("lctrl", "a") - hid.PressKey("x") - hid.PressKey("enter") - - for i := 0; i < 30; i++ { - if gm.gr.InGame() { - return gameName, nil - } - Sleep(1000) - } - - return gameName, errors.New("error creating game! Timeout") -} - -func (gm *GameManager) JoinOnlineGame(gameName, password string) error { - // Enter bnet lobby - hid.MovePointer(744, 650) - hid.Click(hid.LeftButton) - Sleep(1200) - - // Click "Join game" tab - hid.MovePointer(977, 54) - hid.Click(hid.LeftButton) - Sleep(200) - - // Click the game name textbox, delete text and type new game name - hid.MovePointer(836, 100) - hid.Click(hid.LeftButton) - Sleep(200) - hid.PressKeyCombination("lctrl", "a") - Sleep(200) - for _, ch := range gameName { - hid.PressKey(fmt.Sprintf("%c", ch)) - } - - // Same for password - hid.MovePointer(1020, 100) - hid.Click(hid.LeftButton) - Sleep(200) - hid.PressKeyCombination("lctrl", "a") - Sleep(200) - for _, ch := range password { - hid.PressKey(fmt.Sprintf("%c", ch)) - } - hid.PressKey("enter") - - for i := 0; i < 30; i++ { - if gm.gr.InGame() { - return nil - } - Sleep(1000) - } - - return errors.New("error joining game! Timeout") -} - -func (gm *GameManager) InGame() bool { - return gm.gr.InGame() -} diff --git a/internal/helper/ingame.go b/internal/helper/ingame.go deleted file mode 100644 index e1dbcebe9..000000000 --- a/internal/helper/ingame.go +++ /dev/null @@ -1,19 +0,0 @@ -package helper - -import ( - "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/d2go/pkg/data/area" - "github.com/hectorgimenez/d2go/pkg/data/skill" - "github.com/hectorgimenez/koolo/internal/config" -) - -func CanTeleport(d data.Data) bool { - _, found := d.PlayerUnit.Skills[skill.Teleport] - - // Duriel's Lair is bugged and teleport doesn't work here - if d.PlayerUnit.Area == area.DurielsLair { - return false - } - - return found && config.Config.Bindings.Teleport != "" && !d.PlayerUnit.Area.IsTown() -} diff --git a/internal/helper/screenshot.go b/internal/helper/screenshot.go deleted file mode 100644 index f07ab3fab..000000000 --- a/internal/helper/screenshot.go +++ /dev/null @@ -1,20 +0,0 @@ -package helper - -import ( - "image" - - "github.com/go-vgo/robotgo" - "github.com/hectorgimenez/koolo/internal/hid" -) - -func Screenshot() image.Image { - scale := robotgo.ScaleF() - - startX := int(float64(hid.WindowLeftX) * scale) - startY := int(float64(hid.WindowTopY) * scale) - - width := int(float64(hid.GameAreaSizeX) * scale) - height := int(float64(hid.GameAreaSizeY) * scale) - - return robotgo.CaptureImg(startX, startY, width, height) -} diff --git a/internal/helper/spiral.go b/internal/helper/spiral.go deleted file mode 100644 index c6fd1bc4f..000000000 --- a/internal/helper/spiral.go +++ /dev/null @@ -1,14 +0,0 @@ -package helper - -import "math" - -func Spiral(position int) (int, int) { - t := position * 40 - a := 4.0 - b := -2.0 - trad := float64(t) * math.Pi / 180.0 - x := (a + b*trad) * math.Cos(trad) - y := (a + b*trad) * math.Sin(trad) - - return int(x), int(y) -} diff --git a/internal/helper/tcp/const.go b/internal/helper/tcp/const.go deleted file mode 100644 index 705a472bd..000000000 --- a/internal/helper/tcp/const.go +++ /dev/null @@ -1,35 +0,0 @@ -package tcp - -type ( - TCP_TABLE_CLASS int32 - DWORD uint32 - ULONG uint32 - MIB_TCP_STATE int32 -) - -const ( - TCP_TABLE_BASIC_LISTENER TCP_TABLE_CLASS = iota - TCP_TABLE_BASIC_CONNECTIONS - TCP_TABLE_BASIC_ALL - TCP_TABLE_OWNER_PID_LISTENER - TCP_TABLE_OWNER_PID_CONNECTIONS - TCP_TABLE_OWNER_PID_ALL - TCP_TABLE_OWNER_MODULE_LISTENER - TCP_TABLE_OWNER_MODULE_CONNECTIONS - TCP_TABLE_OWNER_MODULE_ALL -) - -const ( - MIB_TCP_STATE_CLOSED MIB_TCP_STATE = 1 - MIB_TCP_STATE_LISTEN MIB_TCP_STATE = 2 - MIB_TCP_STATE_SYN_SENT MIB_TCP_STATE = 3 - MIB_TCP_STATE_SYN_RCVD MIB_TCP_STATE = 4 - MIB_TCP_STATE_ESTAB MIB_TCP_STATE = 5 - MIB_TCP_STATE_FIN_WAIT1 MIB_TCP_STATE = 6 - MIB_TCP_STATE_FIN_WAIT2 MIB_TCP_STATE = 7 - MIB_TCP_STATE_CLOSE_WAIT MIB_TCP_STATE = 8 - MIB_TCP_STATE_CLOSING MIB_TCP_STATE = 9 - MIB_TCP_STATE_LAST_ACK MIB_TCP_STATE = 10 - MIB_TCP_STATE_TIME_WAIT MIB_TCP_STATE = 11 - MIB_TCP_STATE_DELETE_TCB MIB_TCP_STATE = 12 -) diff --git a/internal/helper/tcp/error.go b/internal/helper/tcp/error.go deleted file mode 100644 index 0f70755d4..000000000 --- a/internal/helper/tcp/error.go +++ /dev/null @@ -1,5 +0,0 @@ -package tcp - -import "errors" - -var CloseGameSocketErr = errors.New("error closing game socket") diff --git a/internal/helper/tcp/game.go b/internal/helper/tcp/game.go deleted file mode 100644 index 0ab669fb5..000000000 --- a/internal/helper/tcp/game.go +++ /dev/null @@ -1,45 +0,0 @@ -package tcp - -import ( - "errors" - "unsafe" - - "golang.org/x/sys/windows" -) - -func CloseCurrentGameSocket(gamePID uint) error { - var lastOpenSocket []byte - var buffSize DWORD - _ = GetExtendedTCPTable(uintptr(0), &buffSize, true, 2, TCP_TABLE_OWNER_PID_ALL, 0) - var buffTable = make([]byte, int(buffSize)) - err := GetExtendedTCPTable(uintptr(unsafe.Pointer(&buffTable[0])), &buffSize, true, 2, TCP_TABLE_OWNER_PID_ALL, 0) - if !errors.Is(err, windows.DNS_ERROR_RCODE_NO_ERROR) { - return CloseGameSocketErr - } - - count := *(*uint32)(unsafe.Pointer(&buffTable[0])) - const structLen = 24 - for n, pos := uint32(0), 4; n < count && pos+structLen <= len(buffTable); n, pos = n+1, pos+structLen { - state := *(*uint32)(unsafe.Pointer(&buffTable[pos])) - if state < 1 || state > 12 { - return CloseGameSocketErr - } - pid := *(*uint32)(unsafe.Pointer(&buffTable[pos+20])) - - if uint(pid) == gamePID && state == uint32(MIB_TCP_STATE_ESTAB) { - buffTable[pos] = byte(MIB_TCP_STATE_DELETE_TCB) - lastOpenSocket = buffTable[pos : pos+24] - } - } - - if len(lastOpenSocket) == 0 { - return CloseGameSocketErr - } - - err = SetTCPEntry(uintptr(unsafe.Pointer(&lastOpenSocket[0]))) - if errors.Is(err, windows.DNS_ERROR_RCODE_NO_ERROR) { - return nil - } - - return CloseGameSocketErr -} diff --git a/internal/helper/tcp/helper.go b/internal/helper/tcp/helper.go deleted file mode 100644 index 5a95128a0..000000000 --- a/internal/helper/tcp/helper.go +++ /dev/null @@ -1,38 +0,0 @@ -package tcp - -import ( - "syscall" - "unsafe" -) - -var ( - lib = syscall.MustLoadDLL("iphlpapi.dll") - setTCPEntry = lib.MustFindProc("SetTcpEntry").Addr() - getExtendedTCPTable = lib.MustFindProc("GetExtendedTcpTable").Addr() -) - -func SetTCPEntry(ptr uintptr) error { - _, _, err := syscall.SyscallN(setTCPEntry, ptr) - - return err -} - -func GetExtendedTCPTable(tcpTablePtr uintptr, pdwSize *DWORD, bOrder bool, ulAf ULONG, tableClass TCP_TABLE_CLASS, reserved ULONG) error { - _, _, err := syscall.SyscallN(getExtendedTCPTable, - tcpTablePtr, - uintptr(unsafe.Pointer(pdwSize)), - getUintptrFromBool(bOrder), - uintptr(ulAf), - uintptr(tableClass), - uintptr(reserved)) - - return err -} - -func getUintptrFromBool(b bool) uintptr { - if b { - return 1 - } else { - return 0 - } -} diff --git a/internal/hid/keyboard.go b/internal/hid/keyboard.go deleted file mode 100644 index 771324779..000000000 --- a/internal/hid/keyboard.go +++ /dev/null @@ -1,39 +0,0 @@ -package hid - -import ( - "math/rand" - "time" - - "github.com/go-vgo/robotgo" -) - -const ( - keyPressMinTime = 10 // ms - keyPressMaxTime = 40 // ms -) - -// PressKey toggles a key, it holds the key between keyPressMinTime and keyPressMaxTime ms randomly -func PressKey(key string) { - robotgo.KeyDown(key) - sleepTime := rand.Intn(keyPressMaxTime-keyPressMinTime) + keyPressMinTime - time.Sleep(time.Duration(sleepTime) * time.Millisecond) - robotgo.KeyUp(key) -} - -func KeyDown(key string) { - robotgo.KeyDown(key) -} - -func KeyUp(key string) { - robotgo.KeyUp(key) -} - -func PressKeyCombination(keys ...string) { - for _, k := range keys { - robotgo.KeyDown(k) - } - time.Sleep(time.Millisecond * 200) - for _, k := range keys { - robotgo.KeyUp(k) - } -} diff --git a/internal/hid/mouse.go b/internal/hid/mouse.go deleted file mode 100644 index 47134e4e4..000000000 --- a/internal/hid/mouse.go +++ /dev/null @@ -1,41 +0,0 @@ -package hid - -import ( - "github.com/go-vgo/robotgo" -) - -var ( - WindowLeftX = 0 - WindowTopY = 0 - GameAreaSizeX = 0 - GameAreaSizeY = 0 -) - -const ( - RightButton MouseButton = "right" - LeftButton MouseButton = "left" -) - -type MouseButton string - -// MovePointer moves the mouse to the requested position, x and y should be the final position based on -// pixels shown in the screen. Top-left corner is 0,0 -func MovePointer(x, y int) { - x = WindowLeftX + x - y = WindowTopY + y - - // TODO this is a workaround to prevent moving the mouse outside the game window - if x > WindowLeftX+GameAreaSizeX { - x = WindowLeftX + GameAreaSizeX - 10 - } - if y > WindowTopY+GameAreaSizeY { - y = WindowTopY + GameAreaSizeY - 10 - } - - robotgo.Move(x, y) -} - -// Click just does a single mouse click at current pointer position -func Click(btn MouseButton) { - robotgo.Click(string(btn)) -} diff --git a/internal/pather/astar/astar.go b/internal/pather/astar/astar.go new file mode 100644 index 000000000..8c1559cd8 --- /dev/null +++ b/internal/pather/astar/astar.go @@ -0,0 +1,128 @@ +package astar + +import ( + "container/heap" + "math" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/koolo/internal/game" +) + +var directions = []data.Position{ + {0, 1}, // Down + {1, 0}, // Right + {0, -1}, // Up + {-1, 0}, // Left + {1, 1}, // Down-Right (Southeast) + {-1, 1}, // Down-Left (Southwest) + {1, -1}, // Up-Right (Northeast) + {-1, -1}, // Up-Left (Northwest) +} + +type Node struct { + data.Position + Cost int + Priority int + Index int +} + +func direction(from, to data.Position) (dx, dy int) { + dx = to.X - from.X + dy = to.Y - from.Y + return +} + +func CalculatePath(g *game.Grid, start, goal data.Position) ([]data.Position, int, bool) { + pq := make(PriorityQueue, 0) + heap.Init(&pq) + + // Use a 2D slice to store the cost of each node + costSoFar := make([][]int, g.Width) + cameFrom := make([][]data.Position, g.Width) + for i := range costSoFar { + costSoFar[i] = make([]int, g.Height) + cameFrom[i] = make([]data.Position, g.Height) + for j := range costSoFar[i] { + costSoFar[i][j] = math.MaxInt32 + } + } + + startNode := &Node{Position: start, Cost: 0, Priority: heuristic(start, goal)} + heap.Push(&pq, startNode) + costSoFar[start.X][start.Y] = 0 + + neighbors := make([]data.Position, 0, 8) + + for pq.Len() > 0 { + current := heap.Pop(&pq).(*Node) + + // Let's build the path if we reached the goal + if current.Position == goal { + var path []data.Position + for p := goal; p != start; p = cameFrom[p.X][p.Y] { + path = append([]data.Position{p}, path...) + } + path = append([]data.Position{start}, path...) + return path, len(path), true + } + + updateNeighbors(g, current, &neighbors) + + for _, neighbor := range neighbors { + newCost := costSoFar[current.X][current.Y] + getCost(g.CollisionGrid[neighbor.Y][neighbor.X]) + + // Handicap for changing direction, this prevents zig-zagging around obstacles + //curDirX, curDirY := direction(cameFrom[current.X][current.Y], current.Position) + //newDirX, newDirY := direction(current.Position, neighbor) + //if curDirX != newDirX || curDirY != newDirY { + // newCost++ + //} + + if newCost < costSoFar[neighbor.X][neighbor.Y] { + costSoFar[neighbor.X][neighbor.Y] = newCost + priority := newCost + int(0.5*float64(heuristic(neighbor, goal))) + heap.Push(&pq, &Node{Position: neighbor, Cost: newCost, Priority: priority}) + cameFrom[neighbor.X][neighbor.Y] = current.Position + } + } + } + + return nil, 0, false +} + +// Get walkable neighbors of a given node +func updateNeighbors(grid *game.Grid, node *Node, neighbors *[]data.Position) { + *neighbors = (*neighbors)[:0] + + x, y := node.X, node.Y + gridWidth, gridHeight := grid.Width, grid.Height + + for _, d := range directions { + newX, newY := x+d.X, y+d.Y + // Check if the new neighbor is within grid bounds + if newX >= 0 && newX < gridWidth && newY >= 0 && newY < gridHeight { + *neighbors = append(*neighbors, data.Position{X: newX, Y: newY}) + } + } +} + +func getCost(tileType game.CollisionType) int { + switch tileType { + case game.CollisionTypeWalkable: + return 1 // Walkable + case game.CollisionTypeMonster: + return 16 + case game.CollisionTypeObject: + return 4 // Soft blocker + case game.CollisionTypeLowPriority: + return 20 + default: + return math.MaxInt32 + } +} + +func heuristic(a, b data.Position) int { + dx := math.Abs(float64(a.X - b.X)) + dy := math.Abs(float64(a.Y - b.Y)) + return int(dx + dy + (math.Sqrt(2)-2)*math.Min(dx, dy)) +} diff --git a/internal/pather/astar/astar_test.go b/internal/pather/astar/astar_test.go new file mode 100644 index 000000000..ca60403a3 --- /dev/null +++ b/internal/pather/astar/astar_test.go @@ -0,0 +1,56 @@ +package astar + +import ( + "encoding/gob" + "os" + "testing" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/koolo/internal/game" +) + +func BenchmarkAstar(b *testing.B) { + grid := loadGrid() + + start := data.Position{X: 336, Y: 701} + goal := data.Position{X: 11, Y: 330} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + CalculatePath(grid, start, goal) + } +} + +func TestAstar(t *testing.T) { + grid := loadGrid() + + start := data.Position{X: 336, Y: 701} + goal := data.Position{X: 11, Y: 330} + + p, dist, found := CalculatePath(grid, start, goal) + if dist != 546 { + t.Errorf("Expected distance to be 546, got %d", dist) + } + if len(p) != 546 { + t.Errorf("Expected path length to be 546, got %d", len(p)) + } + if !found { + t.Errorf("Expected path to be found") + } +} + +func loadGrid() *game.Grid { + var grid game.Grid + file, err := os.Open("durance_of_hate_grid.bin") + if err != nil { + panic(err) + } + defer file.Close() + + decoder := gob.NewDecoder(file) + if err := decoder.Decode(&grid); err != nil { + panic(err) + } + + return &grid +} diff --git a/internal/pather/data.bin b/internal/pather/astar/durance_of_hate_grid.bin similarity index 80% rename from internal/pather/data.bin rename to internal/pather/astar/durance_of_hate_grid.bin index c27588b1e..4259e471c 100644 Binary files a/internal/pather/data.bin and b/internal/pather/astar/durance_of_hate_grid.bin differ diff --git a/internal/pather/astar/priority_queue.go b/internal/pather/astar/priority_queue.go new file mode 100644 index 000000000..a41e039b6 --- /dev/null +++ b/internal/pather/astar/priority_queue.go @@ -0,0 +1,25 @@ +package astar + +type PriorityQueue []*Node + +func (pq PriorityQueue) Len() int { return len(pq) } +func (pq PriorityQueue) Less(i, j int) bool { return pq[i].Priority < pq[j].Priority } +func (pq PriorityQueue) Swap(i, j int) { + pq[i], pq[j] = pq[j], pq[i] + pq[i].Index = i + pq[j].Index = j +} +func (pq *PriorityQueue) Push(x interface{}) { + n := x.(*Node) + n.Index = len(*pq) + *pq = append(*pq, n) +} +func (pq *PriorityQueue) Pop() interface{} { + old := *pq + n := len(old) + node := old[n-1] + old[n-1] = nil + node.Index = -1 + *pq = old[0 : n-1] + return node +} diff --git a/internal/pather/path.go b/internal/pather/path.go index 4bd96032e..fe32bb4fb 100644 --- a/internal/pather/path.go +++ b/internal/pather/path.go @@ -1,37 +1,41 @@ package pather import ( - "github.com/beefsack/go-astar" "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/koolo/internal/game" ) -type Pather struct { - AstarPather - Destination data.Position -} +type Path []data.Position -type AstarPather []astar.Pather +func (p Path) To() data.Position { + return data.Position{ + X: p[len(p)-1].X, + Y: p[len(p)-1].Y, + } +} -func (p *Pather) Distance() int { - return len(p.AstarPather) +func (p Path) From() data.Position { + return data.Position{ + X: p[0].X, + Y: p[0].Y, + } } // Intersects checks if the given position intersects with the path, padding parameter is used to increase the area -func (p *Pather) Intersects(d data.Data, position data.Position, padding int) bool { +func (p Path) Intersects(d game.Data, position data.Position, padding int) bool { position = data.Position{ X: position.X - d.AreaOrigin.X, Y: position.Y - d.AreaOrigin.Y, } - for _, path := range p.AstarPather { - pT := path.(*Tile) + for _, point := range p { xMatch := false yMatch := false - for i := 0; i < padding; i++ { - if pT.X == position.X+i || pT.X == position.X-i { + for i := range padding { + if point.X == position.X+i || point.X == position.X-i { xMatch = true } - if pT.Y == position.Y+i || pT.Y == position.Y-i { + if point.Y == position.Y+i || point.Y == position.Y-i { yMatch = true } } diff --git a/internal/pather/path_finder.go b/internal/pather/path_finder.go index e5e34531c..3b89b86f9 100644 --- a/internal/pather/path_finder.go +++ b/internal/pather/path_finder.go @@ -1,157 +1,178 @@ package pather import ( + "fmt" "math" - "math/rand" - "github.com/beefsack/go-astar" "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" - "github.com/hectorgimenez/koolo/internal/reader" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/pather/astar" ) -func GetPath(d data.Data, to data.Position, blacklistedCoords ...[2]int) (path *Pather, distance int, found bool) { - outsideCurrentLevel := outsideBoundary(d, to) - collisionGrid := d.CollisionGrid +type PathFinder struct { + gr *game.MemoryReader + data *game.Data + hid *game.HID + cfg *config.CharacterCfg +} - collisionGridOffset := data.Position{ - X: 0, - Y: 0, +func NewPathFinder(gr *game.MemoryReader, data *game.Data, hid *game.HID, cfg *config.CharacterCfg) *PathFinder { + return &PathFinder{ + gr: gr, + data: data, + hid: hid, + cfg: cfg, } +} - if outsideCurrentLevel { - lvl, lvlFound := reader.CachedMapData.LevelDataForCoords(to, d.PlayerUnit.Area.Act()) - if !lvlFound { - panic("Error occurred calculating path, destination point outside current level and matching level not found") - } +func (pf *PathFinder) GetPath(to data.Position) (Path, int, bool) { + // First try direct path + if path, distance, found := pf.GetPathFrom(pf.data.PlayerUnit.Position, to); found { + return path, distance, true + } - // We're not going to calculate intersection, instead we're going to expand the collision grid, set the neew one - // starting point where is supposed to be and cross finger to make them match - relativeStartX, relativeStartY := lvl.Offset.X-d.AreaOrigin.X, lvl.Offset.Y-d.AreaOrigin.Y + // If direct path fails, try to find nearby walkable position + if walkableTo, found := pf.findNearbyWalkablePosition(to); found { + return pf.GetPathFrom(pf.data.PlayerUnit.Position, walkableTo) + } - collisionGridOffset = data.Position{ - X: relativeStartX, - Y: relativeStartY, - } + return nil, 0, false +} - // Let's create a new collision grid with the new size - expandedCG := make([][]bool, len(collisionGrid)+len(lvl.CollisionGrid)) - for i := range expandedCG { - expandedCG[i] = make([]bool, len(collisionGrid[0])+len(lvl.CollisionGrid[0])) - } +func (pf *PathFinder) GetPathFrom(from, to data.Position) (Path, int, bool) { + a := pf.data.AreaData - // Let's copy both collision grids into the new one - for y, row := range d.CollisionGrid { - cgY := y - if relativeStartY < 0 { - cgY = y + int(math.Abs(float64(relativeStartY))) - } - for x := range row { - cgX := x - if relativeStartX < 0 { - cgX = int(math.Abs(float64(relativeStartX))) + x - } + // We don't want to modify the original grid + grid := a.Grid.Copy() - if cgY >= len(expandedCG) { - continue + // Special handling for Arcane Sanctuary (to allow pathing with platforms) + if pf.data.PlayerUnit.Area == area.ArcaneSanctuary && pf.data.CanTeleport() { + // Make all non-walkable tiles into low priority tiles for teleport pathing + for y := 0; y < len(grid.CollisionGrid); y++ { + for x := 0; x < len(grid.CollisionGrid[y]); x++ { + if grid.CollisionGrid[y][x] == game.CollisionTypeNonWalkable { + grid.CollisionGrid[y][x] = game.CollisionTypeLowPriority } - expandedCG[cgY][cgX] = d.CollisionGrid[y][x] } } + } + // Lut Gholein map is a bit bugged, we should close this fake path to avoid pathing issues + if a.Area == area.LutGholein { + a.CollisionGrid[13][210] = game.CollisionTypeNonWalkable + } - for y, row := range lvl.CollisionGrid { - cgY := y - if relativeStartY > 0 { - cgY = y + int(math.Abs(float64(relativeStartY))) - } - for x := range row { - cgX := x - if relativeStartX > 0 { - cgX = int(math.Abs(float64(relativeStartX))) + x - } - - expandedCG[cgY][cgX] = lvl.CollisionGrid[y][x] - } + if !a.IsInside(to) { + expandedGrid, err := pf.mergeGrids(to) + if err != nil { + return nil, 0, false } - - collisionGrid = expandedCG + grid = expandedGrid } - // Convert to relative coordinates (Current pelayer position) - fromX, fromY := relativePosition(d, d.PlayerUnit.Position, collisionGridOffset) + from = grid.RelativePosition(from) + to = grid.RelativePosition(to) - // Convert to relative coordinates (Target position) - toX, toY := relativePosition(d, to, collisionGridOffset) + // Add objects to the collision grid as obstacles + for _, o := range pf.data.AreaData.Objects { + if !grid.IsWalkable(o.Position) { + continue + } + relativePos := grid.RelativePosition(o.Position) + grid.CollisionGrid[relativePos.Y][relativePos.X] = game.CollisionTypeObject + for i := -2; i <= 2; i++ { + for j := -2; j <= 2; j++ { + if i == 0 && j == 0 { + continue + } + if relativePos.Y+i < 0 || relativePos.Y+i >= len(grid.CollisionGrid) || relativePos.X+j < 0 || relativePos.X+j >= len(grid.CollisionGrid[relativePos.Y]) { + continue + } + if grid.CollisionGrid[relativePos.Y+i][relativePos.X+j] == game.CollisionTypeWalkable { + grid.CollisionGrid[relativePos.Y+i][relativePos.X+j] = game.CollisionTypeLowPriority - // Origin and destination are the same point - if fromX == toX && fromY == toY { - return nil, 0, true + } + } + } } - // Lut Gholein map is a bit bugged, we should close this fake path to avoid pathing issues - if d.PlayerUnit.Area == area.LutGholein { - collisionGrid[13][210] = false + // Add monsters to the collision grid as obstacles + for _, m := range pf.data.Monsters { + if !grid.IsWalkable(m.Position) { + continue + } + relativePos := grid.RelativePosition(m.Position) + grid.CollisionGrid[relativePos.Y][relativePos.X] = game.CollisionTypeMonster } - for _, cord := range blacklistedCoords { - collisionGrid[cord[1]][cord[0]] = false + path, distance, found := astar.CalculatePath(grid, from, to) + + if config.Koolo.Debug.RenderMap { + pf.renderMap(grid, from, to, path) } - // Add some padding to the origin/destination, sometimes when the origin or destination are close to a non-walkable - // area, pather is not able to calculate the path, so we add some padding around origin/dest to avoid this - // If character can not teleport if apply this hacky thing it will try to kill monsters across walls - if helper.CanTeleport(d) { - for i := -3; i < 4; i++ { - for k := -3; k < 4; k++ { - if i == 0 && k == 0 { - continue - } + return path, distance, found +} - //collisionGrid[ensureValueInCG(fromY+i, len(collisionGrid))][ensureValueInCG(fromX+k, len(collisionGrid[0]))] = true - collisionGrid[ensureValueInCG(toY+i, len(collisionGrid))][ensureValueInCG(toX+k, len(collisionGrid[0]))] = true - } - } - } +func (pf *PathFinder) mergeGrids(to data.Position) (*game.Grid, error) { + for _, a := range pf.data.AreaData.AdjacentLevels { + destination := pf.data.Areas[a.Area] + if destination.IsInside(to) { + origin := pf.data.AreaData - w := parseWorld(collisionGrid, d) + endX1 := origin.OffsetX + len(origin.Grid.CollisionGrid[0]) + endY1 := origin.OffsetY + len(origin.Grid.CollisionGrid) + endX2 := destination.OffsetX + len(destination.Grid.CollisionGrid[0]) + endY2 := destination.OffsetY + len(destination.Grid.CollisionGrid) - // Set Origin and Destination points - w.SetTile(w.NewTile(KindFrom, fromX, fromY)) - w.SetTile(w.NewTile(KindTo, toX, toY)) + minX := min(origin.OffsetX, destination.OffsetX) + minY := min(origin.OffsetY, destination.OffsetY) + maxX := max(endX1, endX2) + maxY := max(endY1, endY2) - //w.renderPathImg(d, nil, collisionGridOffset) + width := maxX - minX + height := maxY - minY - p, distFloat, found := astar.Path(w.From(), w.To()) + resultGrid := make([][]game.CollisionType, height) + for i := range resultGrid { + resultGrid[i] = make([]game.CollisionType, width) + } - distance = int(distFloat) + // Let's copy both grids into the result grid + copyGrid(resultGrid, origin.CollisionGrid, origin.OffsetX-minX, origin.OffsetY-minY) + copyGrid(resultGrid, destination.CollisionGrid, destination.OffsetX-minX, destination.OffsetY-minY) - // Debug only, this will render a png file with map and origin/destination points - if config.Config.Debug.RenderMap { - w.renderPathImg(d, p, collisionGridOffset) + grid := game.NewGrid(resultGrid, minX, minY) + + return grid, nil + } } - return &Pather{AstarPather: p, Destination: data.Position{ - X: w.To().X + d.AreaOrigin.X, - Y: w.To().Y + d.AreaOrigin.Y, - }}, distance, found + return nil, fmt.Errorf("destination grid not found") } -func ensureValueInCG(val, cgSize int) int { - if val < 0 { - return 0 - } - - if val >= cgSize { - return cgSize - 1 +func copyGrid(dest [][]game.CollisionType, src [][]game.CollisionType, offsetX, offsetY int) { + for y := 0; y < len(src); y++ { + for x := 0; x < len(src[0]); x++ { + dest[offsetY+y][offsetX+x] = src[y][x] + } } +} - return val +func (pf *PathFinder) GetClosestWalkablePath(dest data.Position) (Path, int, bool) { + return pf.GetClosestWalkablePathFrom(pf.data.PlayerUnit.Position, dest) } -func GetClosestWalkablePath(d data.Data, dest data.Position, blacklistedCoords ...[2]int) (path *Pather, distance int, found bool) { +func (pf *PathFinder) GetClosestWalkablePathFrom(from, dest data.Position) (Path, int, bool) { + a := pf.data.AreaData + if a.IsWalkable(dest) || !a.IsInside(dest) { + path, distance, found := pf.GetPath(dest) + if found { + return path, distance, found + } + } + maxRange := 20 step := 4 dst := 1 @@ -160,13 +181,13 @@ func GetClosestWalkablePath(d data.Data, dest data.Position, blacklistedCoords . for i := -dst; i < dst; i += 1 { for j := -dst; j < dst; j += 1 { if math.Abs(float64(i)) >= math.Abs(float64(dst)) || math.Abs(float64(j)) >= math.Abs(float64(dst)) { - cgY := dest.Y - d.AreaOrigin.Y + j - cgX := dest.X - d.AreaOrigin.X + i - if cgX > 0 && cgY > 0 && len(d.CollisionGrid) > cgY && len(d.CollisionGrid[cgY]) > cgX && d.CollisionGrid[cgY][cgX] { - return GetPath(d, data.Position{ + cgY := dest.Y - pf.data.AreaOrigin.Y + j + cgX := dest.X - pf.data.AreaOrigin.X + i + if cgX > 0 && cgY > 0 && a.Height > cgY && a.Width > cgX && a.CollisionGrid[cgY][cgX] == game.CollisionTypeWalkable { + return pf.GetPathFrom(from, data.Position{ X: dest.X + i, Y: dest.Y + j, - }, blacklistedCoords...) + }) } } } @@ -177,87 +198,20 @@ func GetClosestWalkablePath(d data.Data, dest data.Position, blacklistedCoords . return nil, 0, false } -func MoveThroughPath(p *Pather, distance int, teleport bool) { - if len(p.AstarPather) == 0 { - if teleport { - hid.Click(hid.RightButton) - } else { - hid.PressKey(config.Config.Bindings.ForceMove) - } - return - } - - moveTo := p.AstarPather[0].(*Tile) - if distance > 0 && len(p.AstarPather) > distance { - moveTo = p.AstarPather[len(p.AstarPather)-distance].(*Tile) - } - - screenX, screenY := GameCoordsToScreenCords(p.AstarPather[len(p.AstarPather)-1].(*Tile).X, p.AstarPather[len(p.AstarPather)-1].(*Tile).Y, moveTo.X, moveTo.Y) - // Prevent mouse overlap the HUD - if screenY > int(float32(hid.GameAreaSizeY)/1.21) { - screenY = int(float32(hid.GameAreaSizeY) / 1.21) - } - - hid.MovePointer(screenX, screenY) - if distance > 0 { - if teleport { - hid.Click(hid.RightButton) - } else { - hid.PressKey(config.Config.Bindings.ForceMove) +func (pf *PathFinder) findNearbyWalkablePosition(target data.Position) (data.Position, bool) { + // Search in expanding squares around the target position + for radius := 1; radius <= 3; radius++ { + for x := -radius; x <= radius; x++ { + for y := -radius; y <= radius; y++ { + if x == 0 && y == 0 { + continue + } + pos := data.Position{X: target.X + x, Y: target.Y + y} + if pf.data.AreaData.IsWalkable(pos) { + return pos, true + } + } } } -} - -func GameCoordsToScreenCords(playerX, playerY, destinationX, destinationY int) (int, int) { - // Calculate diff between current player position and destination - diffX := destinationX - playerX - diffY := destinationY - playerY - - // Transform cartesian movement (world) to isometric (screen)e - // Helpful documentation: https://clintbellanger.net/articles/isometric_math/ - screenX := int((float32(diffX-diffY) * 19.8) + float32(hid.GameAreaSizeX/2)) - screenY := int((float32(diffX+diffY) * 9.9) + float32(hid.GameAreaSizeY/2)) - - return screenX, screenY -} - -func DistanceFromMe(d data.Data, p data.Position) int { - return DistanceFromPoint(d.PlayerUnit.Position, p) -} - -func DistanceFromPoint(from data.Position, to data.Position) int { - first := math.Pow(float64(to.X-from.X), 2) - second := math.Pow(float64(to.Y-from.Y), 2) - - return int(math.Sqrt(first + second)) -} - -func RandomMovement() { - midGameX := hid.GameAreaSizeX / 2 - midGameY := hid.GameAreaSizeY / 2 - x := midGameX + rand.Intn(midGameX) - (midGameX / 2) - y := midGameY + rand.Intn(midGameY) - (midGameY / 2) - hid.MovePointer(x, y) - hid.PressKey(config.Config.Bindings.ForceMove) -} - -func relativePosition(d data.Data, p data.Position, cgOffset data.Position) (int, int) { - x, y := p.X-d.AreaOrigin.X, p.Y-d.AreaOrigin.Y - - if cgOffset.X < 0 { - x += int(math.Abs(float64(cgOffset.X))) - } - - if cgOffset.Y < 0 { - y += int(math.Abs(float64(cgOffset.Y))) - } - - return x, y -} - -func outsideBoundary(d data.Data, p data.Position) bool { - relativeToX := p.X - d.AreaOrigin.X - relativeToY := p.Y - d.AreaOrigin.Y - - return relativeToX < 0 || relativeToY < 0 || relativeToX > len(d.CollisionGrid[0]) || relativeToY > len(d.CollisionGrid) + return data.Position{}, false } diff --git a/internal/pather/path_finding_tools.go b/internal/pather/path_finding_tools.go deleted file mode 100644 index 20ff4321f..000000000 --- a/internal/pather/path_finding_tools.go +++ /dev/null @@ -1,182 +0,0 @@ -package pather - -import ( - "fmt" - "image" - "image/color" - "image/draw" - "image/png" - "os" - - "github.com/beefsack/go-astar" - "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/d2go/pkg/data/area" - "github.com/hectorgimenez/koolo/internal/helper" -) - -// World is a two dimensional map of Tiles. -//type World map[int]map[int]*Tile - -type World [][]*Tile - -// Tile gets the tile at the given coordinates in the world. -func (w World) Tile(x, y int) *Tile { - if x < 0 || x > len(w)-1 || w[x] == nil || y > len(w[x])-1 || y < 0 { - return nil - } - - return w[x][y] -} - -// SetTile sets a tile at the given coordinates in the world. -func (w World) SetTile(t *Tile) { - w[t.X][t.Y] = t -} - -func (w World) NewTile(kind uint8, x, y int) *Tile { - return &Tile{ - Kind: kind, - X: x, - Y: y, - W: w, - } -} - -// FirstOfKind gets the first tile on the board of a kind, used to get the from -// and to tiles as there should only be one of each. -func (w World) FirstOfKind(kind uint8) *Tile { - for _, row := range w { - for _, t := range row { - if t.Kind == kind { - return t - } - } - } - return nil -} - -// From gets the from tile from the world. -func (w World) From() *Tile { - return w.FirstOfKind(KindFrom) -} - -// To gets the to tile from the world. -func (w World) To() *Tile { - return w.FirstOfKind(KindTo) -} - -// parseWorld parses a textual representation of a world into a world map. -func parseWorld(collisionGrid [][]bool, d data.Data) World { - gridSizeX := len(collisionGrid[0]) - gridSizeY := len(collisionGrid) - - w := make(World, gridSizeX) - for x := 0; x < gridSizeX; x++ { - w[x] = make([]*Tile, gridSizeY) - } - - for x, xValues := range collisionGrid { - for y, walkable := range xValues { - kind := KindBlocker - - // Hacky solution to avoid Arcane Sanctuary A* errors - if d.PlayerUnit.Area == area.ArcaneSanctuary && helper.CanTeleport(d) { - kind = KindSoftBlocker - } - - if walkable { - // Add some padding around non-walkable areas, this prevents problems when cornering - if (y > 1 && (!xValues[y-1] || !xValues[y-2])) || (y < len(xValues)-2 && (!xValues[y+1] || !xValues[y+2])) || - (x > 1 && (!collisionGrid[x-1][y] || !collisionGrid[x-2][y])) || (x < len(collisionGrid)-2 && (!collisionGrid[x+1][y] || !collisionGrid[x+2][y])) { - kind = KindSoftBlocker - } else { - kind = KindPlain - } - } - - w.SetTile(w.NewTile(kind, y, x)) - } - } - - return w -} - -// RenderPathImg renders a path on top of a world. -func (w World) renderPathImg(d data.Data, path []astar.Pather, cgOffset data.Position) { - width := len(w) - if width == 0 { - return - } - height := len(w[0]) - - img := image.NewRGBA(image.Rect(0, 0, width, height)) - draw.Draw(img, img.Bounds(), img, image.Point{}, draw.Over) - - pathLocs := map[string]bool{} - for _, p := range path { - pT := p.(*Tile) - pathLocs[fmt.Sprintf("%d,%d", pT.X, pT.Y)] = true - } - for x := 0; x < width; x++ { - for y := 0; y < height; y++ { - t := w.Tile(x, y) - if pathLocs[fmt.Sprintf("%d,%d", x, y)] { - img.Set(x, y, color.RGBA{ - R: 36, - G: 255, - B: 0, - A: 255, - }) - } else if t != nil { - switch t.Kind { - case KindPlain: - img.Set(x, y, color.White) - case KindBlocker: - img.Set(x, y, color.Black) - case KindSoftBlocker: - img.Set(x, y, color.RGBA{238, 238, 238, 255}) - } - } - } - } - - for _, r := range d.Rooms { - rPosX, rPosY := relativePosition(d, r.GetCenter(), cgOffset) - img.Set(rPosX, rPosY, color.RGBA{204, 204, 0, 255}) - } - - img.Set(w.From().X, w.From().Y, color.RGBA{ - R: 255, G: 0, B: 0, A: 255, - }) - - img.Set(w.To().X, w.To().Y, color.RGBA{ - R: 0, G: 0, B: 255, A: 255, - }) - - for _, o := range d.Objects { - oPosX, oPosY := relativePosition(d, o.Position, cgOffset) - if o.IsDoor() { - img.Set(oPosX, oPosY, color.RGBA{101, 67, 33, 255}) - } else { - img.Set(oPosX, oPosY, color.RGBA{255, 165, 0, 255}) - } - } - - for _, m := range d.Monsters { - mPosX, mPosY := relativePosition(d, m.Position, cgOffset) - img.Set(mPosX, mPosY, color.RGBA{255, 0, 255, 255}) - } - - outFile, _ := os.Create("cg.png") - defer outFile.Close() - png.Encode(outFile, img) -} - -func IsNarrowMap(a area.Area) bool { - switch a { - case area.MaggotLairLevel1, area.MaggotLairLevel2, area.MaggotLairLevel3, area.ArcaneSanctuary, area.ClawViperTempleLevel2, area.RiverOfFlame, area.ChaosSanctuary: - return true - } - - return false -} diff --git a/internal/pather/render_map.go b/internal/pather/render_map.go new file mode 100644 index 000000000..97e6a1509 --- /dev/null +++ b/internal/pather/render_map.go @@ -0,0 +1,57 @@ +package pather + +import ( + "fmt" + "image" + "image/color" + "image/draw" + "image/png" + "os" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/koolo/internal/game" +) + +func (pf *PathFinder) renderMap(grid *game.Grid, from, to data.Position, path Path) { + img := image.NewRGBA(image.Rect(0, 0, grid.Width, grid.Height)) + draw.Draw(img, img.Bounds(), img, image.Point{}, draw.Over) + + pathLocs := map[string]bool{} + for _, p := range path { + pathLocs[fmt.Sprintf("%d,%d", p.X, p.Y)] = true + } + + for x := 0; x < grid.Width; x++ { + for y := 0; y < grid.Height; y++ { + if pathLocs[fmt.Sprintf("%d,%d", x, y)] { + img.Set(x, y, color.RGBA{R: 36, G: 255, B: 0, A: 255}) + } else { + switch grid.CollisionGrid[y][x] { + case game.CollisionTypeNonWalkable: + img.Set(x, y, color.Black) + case game.CollisionTypeWalkable: + img.Set(x, y, color.White) + case game.CollisionTypeLowPriority: + img.Set(x, y, color.RGBA{R: 200, G: 200, B: 200, A: 255}) // Gray + case game.CollisionTypeMonster: + img.Set(x, y, color.RGBA{R: 255, A: 255}) // Red + case game.CollisionTypeObject: + img.Set(x, y, color.RGBA{R: 160, G: 32, B: 240, A: 255}) // Purple + } + } + } + } + + for _, r := range pf.data.Rooms { + pos := grid.RelativePosition(r.GetCenter()) + img.Set(pos.X, pos.Y, color.RGBA{R: 204, G: 204, A: 255}) // Dark yellow + } + + img.Set(from.X, from.Y, color.RGBA{R: 158, G: 0, B: 0, A: 255}) // Garnet + + img.Set(to.X, to.Y, color.RGBA{R: 0, G: 0, B: 255, A: 255}) // Blue + + outFile, _ := os.Create("cg.png") + defer outFile.Close() + png.Encode(outFile, img) +} diff --git a/internal/pather/tile.go b/internal/pather/tile.go deleted file mode 100644 index 58a5ca11d..000000000 --- a/internal/pather/tile.go +++ /dev/null @@ -1,77 +0,0 @@ -package pather - -import "github.com/beefsack/go-astar" - -// Kind* constants refer to tile kinds for input and output. -const ( - // KindPlain (.) is a plain tile with a movement cost of 1. - KindPlain uint8 = iota - // KindBlocker (X) is a tile which blocks movement. - KindBlocker - // KindFrom (F) is a tile which marks where the path should be calculated - // from. - KindFrom - // KindTo (T) is a tile which marks the goal of the path. - KindTo - // KindSoftBlocker (S) is a tile to fake some blocking areas with increased cost of moving, like Arcane Sanctuary platforms - KindSoftBlocker -) - -// KindCosts map tile kinds to movement costs. -var KindCosts = map[uint8]float64{ - KindPlain: 1.0, - KindFrom: 1.0, - KindTo: 1.0, - KindSoftBlocker: 1000.0, -} - -// A Tile is a tile in a grid which implements Pather. -type Tile struct { - // Kind is the kind of tile, potentially affecting movement. - Kind uint8 - // X and Y are the coordinates of the tile. - X, Y int - // W is a reference to the World that the tile is a part of. - W World -} - -// PathNeighbors returns the neighbors of the tile, excluding blockers and -// tiles off the edge of the board. -func (t *Tile) PathNeighbors() []astar.Pather { - var neighbors []astar.Pather - if t == nil { - return neighbors - } - for _, offset := range [][]int{ - {-1, 0}, - {1, 0}, - {0, -1}, - {0, 1}, - } { - if n := t.W.Tile(t.X+offset[0], t.Y+offset[1]); n != nil && n.Kind != KindBlocker { - neighbors = append(neighbors, n) - } - } - - return neighbors -} - -// PathNeighborCost returns the movement cost of the directly neighboring tile. -func (t *Tile) PathNeighborCost(to astar.Pather) float64 { - return KindCosts[to.(*Tile).Kind] -} - -// PathEstimatedCost uses Manhattan distance to estimate orthogonal distance -// between non-adjacent nodes. -func (t *Tile) PathEstimatedCost(to astar.Pather) float64 { - toT := to.(*Tile) - absX := toT.X - t.X - if absX < 0 { - absX = -absX - } - absY := toT.Y - t.Y - if absY < 0 { - absY = -absY - } - return float64(absX + absY) -} diff --git a/internal/pather/utils.go b/internal/pather/utils.go new file mode 100644 index 000000000..b338e793a --- /dev/null +++ b/internal/pather/utils.go @@ -0,0 +1,206 @@ +package pather + +import ( + "math" + "math/rand" + "time" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/utils" +) + +func (pf *PathFinder) RandomMovement() { + midGameX := pf.gr.GameAreaSizeX / 2 + midGameY := pf.gr.GameAreaSizeY / 2 + x := midGameX + rand.Intn(midGameX) - (midGameX / 2) + y := midGameY + rand.Intn(midGameY) - (midGameY / 2) + pf.hid.MovePointer(x, y) + pf.hid.PressKeyBinding(pf.data.KeyBindings.ForceMove) + utils.Sleep(50) +} + +func (pf *PathFinder) DistanceFromMe(p data.Position) int { + return DistanceFromPoint(pf.data.PlayerUnit.Position, p) +} + +func (pf *PathFinder) OptimizeRoomsTraverseOrder() []data.Room { + distanceMatrix := make(map[data.Room]map[data.Room]int) + + for _, room1 := range pf.data.Rooms { + distanceMatrix[room1] = make(map[data.Room]int) + for _, room2 := range pf.data.Rooms { + if room1 != room2 { + distance := DistanceFromPoint(room1.GetCenter(), room2.GetCenter()) + distanceMatrix[room1][room2] = distance + } else { + distanceMatrix[room1][room2] = 0 + } + } + } + + currentRoom := data.Room{} + for _, r := range pf.data.Rooms { + if r.IsInside(pf.data.PlayerUnit.Position) { + currentRoom = r + } + } + + visited := make(map[data.Room]bool) + order := []data.Room{currentRoom} + visited[currentRoom] = true + + for len(order) < len(pf.data.Rooms) { + nextRoom := data.Room{} + minDistance := math.MaxInt + + // Find the nearest unvisited room + for _, room := range pf.data.Rooms { + if !visited[room] && distanceMatrix[currentRoom][room] < minDistance { + nextRoom = room + minDistance = distanceMatrix[currentRoom][room] + } + } + + // Add the next room to the order of visit + order = append(order, nextRoom) + visited[nextRoom] = true + currentRoom = nextRoom + } + + return order +} + +func (pf *PathFinder) MoveThroughPath(p Path, walkDuration time.Duration) { + // Calculate the max distance we can walk in the given duration + maxDistance := int(float64(25) * walkDuration.Seconds()) + + // Let's try to calculate how close to the window border we can go + screenCords := data.Position{} + for distance, pos := range p { + screenX, screenY := pf.gameCoordsToScreenCords(p.From().X, p.From().Y, pos.X, pos.Y) + + // We reached max distance, let's stop (if we are not teleporting) + if !pf.data.CanTeleport() && maxDistance > 0 && distance > maxDistance { + break + } + + // Prevent mouse overlap the HUD + if screenY > int(float32(pf.gr.GameAreaSizeY)/1.21) { + break + } + + // We are getting out of the window, let's stop + if screenX < 0 || screenY < 0 || screenX > pf.gr.GameAreaSizeX || screenY > pf.gr.GameAreaSizeY { + break + } + screenCords = data.Position{X: screenX, Y: screenY} + } + + pf.MoveCharacter(screenCords.X, screenCords.Y) +} + +func (pf *PathFinder) MoveCharacter(x, y int) { + if pf.data.CanTeleport() { + pf.hid.Click(game.RightButton, x, y) + } else { + pf.hid.MovePointer(x, y) + pf.hid.PressKeyBinding(pf.data.KeyBindings.ForceMove) + utils.Sleep(50) + } +} + +func (pf *PathFinder) GameCoordsToScreenCords(destinationX, destinationY int) (int, int) { + return pf.gameCoordsToScreenCords(pf.data.PlayerUnit.Position.X, pf.data.PlayerUnit.Position.Y, destinationX, destinationY) +} + +func (pf *PathFinder) gameCoordsToScreenCords(playerX, playerY, destinationX, destinationY int) (int, int) { + // Calculate diff between current player position and destination + diffX := destinationX - playerX + diffY := destinationY - playerY + + // Transform cartesian movement (World) to isometric (screen) + // Helpful documentation: https://clintbellanger.net/articles/isometric_math/ + screenX := int((float32(diffX-diffY) * 19.8) + float32(pf.gr.GameAreaSizeX/2)) + screenY := int((float32(diffX+diffY) * 9.9) + float32(pf.gr.GameAreaSizeY/2)) + + return screenX, screenY +} + +func IsNarrowMap(a area.ID) bool { + switch a { + case area.MaggotLairLevel1, area.MaggotLairLevel2, area.MaggotLairLevel3, area.ArcaneSanctuary, area.ClawViperTempleLevel2, area.RiverOfFlame, area.ChaosSanctuary: + return true + } + + return false +} + +func DistanceFromPoint(from data.Position, to data.Position) int { + first := math.Pow(float64(to.X-from.X), 2) + second := math.Pow(float64(to.Y-from.Y), 2) + + return int(math.Sqrt(first + second)) +} + +func (pf *PathFinder) LineOfSight(origin data.Position, destination data.Position) bool { + dx := int(math.Abs(float64(destination.X - origin.X))) + dy := int(math.Abs(float64(destination.Y - origin.Y))) + sx, sy := 1, 1 + + if origin.X > destination.X { + sx = -1 + } + if origin.Y > destination.Y { + sy = -1 + } + + err := dx - dy + + x, y := origin.X, origin.Y + + for { + if !pf.data.AreaData.Grid.IsWalkable(data.Position{X: x, Y: y}) { + return false + } + if x == destination.X && y == destination.Y { + break + } + e2 := 2 * err + if e2 > -dy { + err -= dy + x += sx + } + if e2 < dx { + err += dx + y += sy + } + } + + return true +} + +// BeyondPosition calculates a new position that is a specified distance beyond the target position when viewed from the start position +func (pf *PathFinder) BeyondPosition(start, target data.Position, distance int) data.Position { + // Calculate direction vector + dx := float64(target.X - start.X) + dy := float64(target.Y - start.Y) + + // Normalize + length := math.Sqrt(dx*dx + dy*dy) + if length == 0 { + // If positions are identical, pick arbitrary direction + dx = 1 + dy = 0 + } else { + dx = dx / length + dy = dy / length + } + + // Return position extended beyond target + return data.Position{ + X: target.X + int(dx*float64(distance)), + Y: target.Y + int(dy*float64(distance)), + } +} diff --git a/internal/reader/map_client/client.go b/internal/reader/map_client/client.go deleted file mode 100644 index bfb271a33..000000000 --- a/internal/reader/map_client/client.go +++ /dev/null @@ -1,238 +0,0 @@ -package map_client - -import ( - "encoding/json" - "fmt" - "os/exec" - "strings" - - "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/d2go/pkg/data/area" - "github.com/hectorgimenez/d2go/pkg/data/difficulty" - "github.com/hectorgimenez/d2go/pkg/data/npc" - "github.com/hectorgimenez/d2go/pkg/data/object" - "github.com/hectorgimenez/koolo/internal/config" -) - -func GetMapData(seed string, difficulty difficulty.Difficulty) MapData { - stdout, err := exec.Command("./koolo-map.exe", config.Config.D2LoDPath, "-s", seed, "-d", getDifficultyAsNum(difficulty)).Output() - if err != nil { - panic(fmt.Sprintf( - "Error detected fetching Map Data from Diablo II: LoD 1.13c game, please make sure you have the classic expansion game installed AND config.yaml D2LoDPath is pointing to the correct game path. Error: %s", - stdout, - )) - } - - stdoutLines := strings.Split(string(stdout), "\r\n") - - lvls := make([]serverLevel, 0) - for _, line := range stdoutLines { - var lvl serverLevel - err = json.Unmarshal([]byte(line), &lvl) - // Discard empty lines or lines that don't contain level information - if err == nil && lvl.Type != "" && len(lvl.Map) > 0 { - lvls = append(lvls, lvl) - } - } - - return lvls -} - -func getDifficultyAsNum(df difficulty.Difficulty) string { - switch df { - case difficulty.Normal: - return "0" - case difficulty.Nightmare: - return "1" - case difficulty.Hell: - return "2" - } - - return "0" -} - -type MapData []serverLevel - -func (md MapData) CollisionGrid(area area.Area) [][]bool { - level := md.getLevel(area) - - var cg [][]bool - - for y := 0; y < level.Size.Height; y++ { - var row []bool - for x := 0; x < level.Size.Width; x++ { - row = append(row, false) - } - - // Documentation about how this works: https://github.com/blacha/diablo2/tree/master/packages/map - if len(level.Map) > y { - mapRow := level.Map[y] - isWalkable := false - xPos := 0 - for k, xs := range mapRow { - if k != 0 { - for xOffset := 0; xOffset < xs; xOffset++ { - row[xPos+xOffset] = isWalkable - } - } - isWalkable = !isWalkable - xPos += xs - } - for xPos < len(row) { - row[xPos] = isWalkable - xPos++ - } - } - - cg = append(cg, row) - } - - return cg -} - -func (md MapData) NPCsExitsAndObjects(areaOrigin data.Position, a area.Area) (data.NPCs, []data.Level, []data.Object, []data.Room) { - var npcs []data.NPC - var exits []data.Level - var objects []data.Object - var rooms []data.Room - - level := md.getLevel(a) - - for _, r := range level.Rooms { - rooms = append(rooms, data.Room{ - Position: data.Position{X: r.X, - Y: r.Y, - }, - Width: r.Width, - Height: r.Height, - }) - } - - for _, obj := range level.Objects { - switch obj.Type { - case "npc": - n := data.NPC{ - ID: npc.ID(obj.ID), - Name: obj.Name, - Positions: []data.Position{{ - X: obj.X + areaOrigin.X, - Y: obj.Y + areaOrigin.Y, - }}, - } - npcs = append(npcs, n) - case "exit": - lvl := data.Level{ - Area: area.Area(obj.ID), - Position: data.Position{ - X: obj.X + areaOrigin.X, - Y: obj.Y + areaOrigin.Y, - }, - IsEntrance: true, - } - exits = append(exits, lvl) - case "object": - o := data.Object{ - Name: object.Name(obj.ID), - Position: data.Position{ - X: obj.X + areaOrigin.X, - Y: obj.Y + areaOrigin.Y, - }, - } - objects = append(objects, o) - } - } - - for _, obj := range level.Objects { - switch obj.Type { - case "exit_area": - found := false - for _, exit := range exits { - if exit.Area == area.Area(obj.ID) { - exit.IsEntrance = false - found = true - break - } - } - - if !found { - lvl := data.Level{ - Area: area.Area(obj.ID), - Position: data.Position{ - X: obj.X + areaOrigin.X, - Y: obj.Y + areaOrigin.Y, - }, - IsEntrance: false, - } - exits = append(exits, lvl) - } - } - - } - - return npcs, exits, objects, rooms -} - -func (md MapData) Origin(area area.Area) data.Position { - level := md.getLevel(area) - - return data.Position{ - X: level.Offset.X, - Y: level.Offset.Y, - } -} - -func (md MapData) getLevel(area area.Area) serverLevel { - for _, level := range md { - if level.ID == int(area) { - return level - } - } - - return serverLevel{} -} - -func (md MapData) LevelDataForCoords(p data.Position, act int) (LevelData, bool) { - for _, lvl := range md { - lvlMaxX := lvl.Offset.X + lvl.Size.Width - lvlMaxY := lvl.Offset.Y + lvl.Size.Height - if area.Area(lvl.ID).Act() == act && lvl.Offset.X <= p.X && p.X <= lvlMaxX && lvl.Offset.Y <= p.Y && p.Y <= lvlMaxY { - return LevelData{ - Area: area.Area(lvl.ID), - Name: lvl.Name, - Offset: data.Position{ - X: lvl.Offset.X, - Y: lvl.Offset.Y, - }, - Size: data.Position{ - X: lvl.Size.Width, - Y: lvl.Size.Height, - }, - CollisionGrid: md.CollisionGrid(area.Area(lvl.ID)), - }, true - } - } - - return LevelData{}, false -} - -func (md MapData) GetLevelData(id area.Area) (LevelData, bool) { - for _, lvl := range md { - if lvl.ID == int(id) { - return LevelData{ - Area: area.Area(lvl.ID), - Name: lvl.Name, - Offset: data.Position{ - X: lvl.Offset.X, - Y: lvl.Offset.Y, - }, - Size: data.Position{ - X: lvl.Size.Width, - Y: lvl.Size.Height, - }, - CollisionGrid: md.CollisionGrid(area.Area(lvl.ID)), - }, true - } - } - - return LevelData{}, false -} diff --git a/internal/reader/map_client/structures.go b/internal/reader/map_client/structures.go deleted file mode 100644 index 3ac0c756f..000000000 --- a/internal/reader/map_client/structures.go +++ /dev/null @@ -1,14 +0,0 @@ -package map_client - -import ( - "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/d2go/pkg/data/area" -) - -type LevelData struct { - Area area.Area - Name string - Offset data.Position - Size data.Position - CollisionGrid [][]bool -} diff --git a/internal/reader/reader.go b/internal/reader/reader.go deleted file mode 100644 index a38e9155a..000000000 --- a/internal/reader/reader.go +++ /dev/null @@ -1,69 +0,0 @@ -package reader - -import ( - "fmt" - "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/d2go/pkg/memory" - "github.com/hectorgimenez/d2go/pkg/utils" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/reader/map_client" - "strconv" -) - -var CachedMapData map_client.MapData - -type GameReader struct { - *memory.GameReader - cachedMapSeed uint -} - -func (gd *GameReader) GetData(isNewGame bool) data.Data { - d := gd.GameReader.GetData() - - if isNewGame { - playerUnitPtr, _ := gd.GameReader.GetPlayerUnitPtr(d.Roster) - gd.cachedMapSeed, _ = gd.getMapSeed(playerUnitPtr) - CachedMapData = map_client.GetMapData(strconv.Itoa(int(gd.cachedMapSeed)), config.Config.Game.Difficulty) - } - - origin := CachedMapData.Origin(d.PlayerUnit.Area) - npcs, exits, objects, rooms := CachedMapData.NPCsExitsAndObjects(origin, d.PlayerUnit.Area) - // This hacky thing is because sometimes if the objects are far away we can not fetch them, basically WP. - memObjects := gd.Objects(d.PlayerUnit.Position, d.HoverData) - for _, clientObject := range objects { - found := false - for _, obj := range memObjects { - if obj.Name == clientObject.Name { - found = true - } - } - if !found { - memObjects = append(memObjects, clientObject) - } - } - - d.AreaOrigin = origin - d.NPCs = npcs - d.AdjacentLevels = exits - d.Rooms = rooms - d.Objects = memObjects - d.CollisionGrid = CachedMapData.CollisionGrid(d.PlayerUnit.Area) - - return d -} - -func (gd *GameReader) getMapSeed(playerUnit uintptr) (uint, error) { - actPtr := uintptr(gd.Process.ReadUInt(playerUnit+0x20, memory.Uint64)) - actMiscPtr := uintptr(gd.Process.ReadUInt(actPtr+0x78, memory.Uint64)) - - dwInitSeedHash1 := uintptr(gd.Process.ReadUInt(actMiscPtr+0x840, memory.Uint32)) - //dwInitSeedHash2 := uintptr(gd.Process.ReadUInt(actMiscPtr+0x844, memory.Uint32)) - dwEndSeedHash1 := uintptr(gd.Process.ReadUInt(actMiscPtr+0x868, memory.Uint32)) - - mapSeed, found := utils.GetMapSeed(uint(dwInitSeedHash1), uint(dwEndSeedHash1)) - if !found { - return 0, fmt.Errorf("error calculating map seed") - } - - return mapSeed, nil -} diff --git a/internal/remote/discord/discord_bot.go b/internal/remote/discord/discord_bot.go index 4ce52b699..815353c4b 100644 --- a/internal/remote/discord/discord_bot.go +++ b/internal/remote/discord/discord_bot.go @@ -3,22 +3,21 @@ package discord import ( "context" "fmt" + "slices" "strings" - koolo "github.com/hectorgimenez/koolo/internal" - "github.com/bwmarrin/discordgo" - "github.com/hectorgimenez/koolo/internal/event/stat" + "github.com/hectorgimenez/koolo/internal/bot" + "github.com/hectorgimenez/koolo/internal/config" ) type Bot struct { discordSession *discordgo.Session channelID string - supervisor koolo.Supervisor - companion koolo.Companion + manager *bot.SupervisorManager } -func NewBot(token, channelID string, supervisor koolo.Supervisor, companion koolo.Companion) (*Bot, error) { +func NewBot(token, channelID string, manager *bot.SupervisorManager) (*Bot, error) { dg, err := discordgo.New("Bot " + token) if err != nil { return nil, fmt.Errorf("error creating Discord session: %w", err) @@ -27,12 +26,12 @@ func NewBot(token, channelID string, supervisor koolo.Supervisor, companion kool return &Bot{ discordSession: dg, channelID: channelID, - supervisor: supervisor, - companion: companion, + manager: manager, }, nil } func (b *Bot) Start(ctx context.Context) error { + //b.discordSession.Debug = true b.discordSession.AddHandler(b.onMessageCreated) b.discordSession.Identify.Intents = discordgo.IntentsGuildMessages err := b.discordSession.Open() @@ -45,46 +44,27 @@ func (b *Bot) Start(ctx context.Context) error { return b.discordSession.Close() } -func (b *Bot) onMessageCreated(s *discordgo.Session, m *discordgo.MessageCreate) { - if strings.Contains(m.Content, "New game created.") { - gameData := strings.SplitAfter(m.Content, "GameName: ") - gameData = strings.Split(gameData[1], "|||") - - b.companion.JoinGame(gameData[0], gameData[1]) - } +func (b *Bot) onMessageCreated(s *discordgo.Session, m *discordgo.MessageCreate) { if m.Author.ID == s.State.User.ID { return } - switch strings.ToLower(m.Content) { - case "stats": - b.publishStats() - case "start": - // TODO: Implement - case "stop": - b.supervisor.Stop() + // Check if the message is from a bot admin + if !slices.Contains(config.Koolo.Discord.BotAdmins, m.Author.ID) { + return } -} -func (b *Bot) publishStats() { - msg := "Run | Items | Deaths | Chickens | Merc Chickens | Errors | Healing Pots | Mana Pots | Reju Pots | Merc Pots \n" - for run, st := range stat.Status.RunStats { - msg += fmt.Sprintf( - "%s | %d | %d | %d | %d | %d | %d | %d| %d | %d | %d \n", - run, - len(st.ItemsFound), - st.Kills, - st.Deaths, - st.Chickens, - st.MerChicken, - st.Errors, - st.HealingPotionsUsed, - st.ManaPotionsUsed, - st.RejuvPotionsUsed, - st.MercHealingPotionsUsed, - ) + prefix := strings.Split(m.Content, " ")[0] + switch prefix { + case "!start": + b.handleStartRequest(s, m) + case "!stop": + b.handleStopRequest(s, m) + case "!stats": + b.handleStatsRequest(s, m) + case "!status": + b.handleStatusRequest(s, m) } - b.discordSession.ChannelMessageSend(b.channelID, msg) } diff --git a/internal/remote/discord/discord_command_handlers.go b/internal/remote/discord/discord_command_handlers.go new file mode 100644 index 000000000..5eb3eec04 --- /dev/null +++ b/internal/remote/discord/discord_command_handlers.go @@ -0,0 +1,181 @@ +package discord + +import ( + "fmt" + "slices" + "strings" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/hectorgimenez/koolo/internal/bot" +) + +func (b *Bot) supervisorExists(supervisor string) bool { + supervisors := b.manager.AvailableSupervisors() + return slices.Contains(supervisors, supervisor) +} + +func (b *Bot) handleStartRequest(s *discordgo.Session, m *discordgo.MessageCreate) { + + // Start supervisor(s) provided as in the stop command + words := strings.Fields(m.Content) + + if len(words) > 1 { + // Iterate through the supervisors specified + for _, supervisor := range words[1:] { + + if !b.supervisorExists(supervisor) { + s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("Supervisor '%s' not found.", supervisor)) + continue + } + + // Attempt to start the specified supervisor + b.manager.Start(supervisor, false) + + // Wait for the supervisor to start + time.Sleep(1 * time.Second) + + // Send a confirmation message + s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("Supervisor '%s' has been started.", supervisor)) + } + } else { + // If no supervisors were specified, send a usage message + s.ChannelMessageSend(m.ChannelID, "Usage: !start [supervisor2] ...") + } +} + +func (b *Bot) handleStopRequest(s *discordgo.Session, m *discordgo.MessageCreate) { + + // Split the message content into words + words := strings.Fields(m.Content) + + // Check if there are any supervisors specified after "!stop" + if len(words) > 1 { + // Iterate through the supervisors specified + for _, supervisor := range words[1:] { + + if !b.supervisorExists(supervisor) { + s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("Supervisor '%s' not found.", supervisor)) + continue + } + + // Check if the supervisor is running + if b.manager.Status(supervisor).SupervisorStatus == bot.NotStarted || b.manager.Status(supervisor).SupervisorStatus == "" { + s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("Supervisor '%s' is not running.", supervisor)) + continue + } + + // Attempt to stop the specified supervisor + b.manager.Stop(supervisor) + + // Wait for the supervisor to stop + time.Sleep(1 * time.Second) + + // Send a confirmation message + s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("Supervisor '%s' has been stopped.", supervisor)) + } + } else { + // If no supervisors were specified, send a usage message + s.ChannelMessageSend(m.ChannelID, "Usage: !stop [supervisor2] ...") + } +} + +func (b *Bot) handleStatusRequest(s *discordgo.Session, m *discordgo.MessageCreate) { + + // Split the message content into words + words := strings.Fields(m.Content) + + if len(words) > 1 { + for _, supervisor := range words[1:] { + if !b.supervisorExists(supervisor) { + s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("Supervisor '%s' not found.", supervisor)) + continue + } + + status := b.manager.Status(supervisor) + if status.SupervisorStatus == bot.NotStarted || status.SupervisorStatus == "" { + s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("Supervisor '%s' is offline.", supervisor)) + continue + } + + s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("Supervisor '%s' is %s", supervisor, status.SupervisorStatus)) + } + } else { + // If no supervisors were specified, send a usage message + s.ChannelMessageSend(m.ChannelID, "Usage: !status [supervisor2] ...") + } +} + +func (b *Bot) handleStatsRequest(s *discordgo.Session, m *discordgo.MessageCreate) { + + // Split the message content into words + words := strings.Fields(m.Content) + + // Check if there are any supervisors specified after "!stats" + if len(words) > 1 { + // Iterate through the supervisors specified + for _, supervisor := range words[1:] { + + if !b.supervisorExists(supervisor) { + s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("Supervisor '%s' not found.", supervisor)) + continue + } + + // Fix for the status not being started + supStatus := string(b.manager.Status(supervisor).SupervisorStatus) + if supStatus == string(bot.NotStarted) || supStatus == "" { + supStatus = "Offline" + } + // Create the embed + embed := &discordgo.MessageEmbed{ + Title: fmt.Sprintf("Stats for %s", supervisor), + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Status", + Value: supStatus, + Inline: true, + }, + { + Name: "Uptime", + Value: time.Since(b.manager.Status(supervisor).StartedAt).String(), + Inline: true, + }, + + // Runs data + + { + Name: "Games", + Value: fmt.Sprintf("%d", b.manager.GetSupervisorStats(supervisor).TotalGames()), + Inline: true, + }, + { + Name: "Drops", + Value: fmt.Sprintf("%d", len(b.manager.GetSupervisorStats(supervisor).Drops)), + Inline: true, + }, + { + Name: "Deaths", + Value: fmt.Sprintf("%d", b.manager.GetSupervisorStats(supervisor).TotalDeaths()), + Inline: true, + }, + { + Name: "Chickens", + Value: fmt.Sprintf("%d", b.manager.GetSupervisorStats(supervisor).TotalChickens()), + Inline: true, + }, + { + Name: "Errors", + Value: fmt.Sprintf("%d", b.manager.GetSupervisorStats(supervisor).TotalErrors()), + Inline: true, + }, + }, + } + + // Send the embed to the channel + s.ChannelMessageSendEmbed(m.ChannelID, embed) + } + } else { + // If no supervisors were specified, send a usage message + s.ChannelMessageSend(m.ChannelID, "Usage: !stats [supervisor2] ...") + } +} diff --git a/internal/remote/discord/discord_event_handler.go b/internal/remote/discord/discord_event_handler.go index 869a4cfa4..ad4324f9d 100644 --- a/internal/remote/discord/discord_event_handler.go +++ b/internal/remote/discord/discord_event_handler.go @@ -3,29 +3,69 @@ package discord import ( "bytes" "context" - "image/png" + "fmt" + "image/jpeg" "github.com/bwmarrin/discordgo" + "github.com/hectorgimenez/koolo/internal/config" "github.com/hectorgimenez/koolo/internal/event" ) -func (b *Bot) Handle(_ context.Context, m event.Message) error { - if m.Image != nil { +func (b *Bot) Handle(_ context.Context, e event.Event) error { + if b.shouldPublish(e) { + + switch evt := e.(type) { + case event.GameCreatedEvent: + message := fmt.Sprintf("%s\nGame: %s\nPassword: %s", evt.Message(), evt.Name, evt.Password) + _, err := b.discordSession.ChannelMessageSend(b.channelID, message) + return err + case event.GameFinishedEvent, event.RunStartedEvent, event.RunFinishedEvent: + _, err := b.discordSession.ChannelMessageSend(b.channelID, e.Message()) + return err + default: + break + } + buf := new(bytes.Buffer) - err := png.Encode(buf, m.Image) + err := jpeg.Encode(buf, e.Image(), &jpeg.Options{Quality: 80}) if err != nil { return err } _, err = b.discordSession.ChannelMessageSendComplex(b.channelID, &discordgo.MessageSend{ - File: &discordgo.File{Name: "Screenshot.png", ContentType: "image/png", Reader: buf}, - Content: m.Message, + File: &discordgo.File{Name: "Screenshot.jpeg", ContentType: "image/jpeg", Reader: buf}, + Content: e.Message(), }) return err } - _, err := b.discordSession.ChannelMessageSend(b.channelID, m.Message) + return nil +} + +func (b *Bot) shouldPublish(e event.Event) bool { + + switch evt := e.(type) { + case event.GameFinishedEvent: + if evt.Reason == event.FinishedError { + return config.Koolo.Discord.EnableDiscordErrorMessages + } + if evt.Reason == event.FinishedChicken || evt.Reason == event.FinishedMercChicken || evt.Reason == event.FinishedDied { + return config.Koolo.Discord.EnableDiscordChickenMessages + } + if evt.Reason == event.FinishedOK { + return false // supress game finished messages until we add proper option for it + } + return true + case event.GameCreatedEvent: + return config.Koolo.Discord.EnableGameCreatedMessages + case event.RunStartedEvent: + return config.Koolo.Discord.EnableNewRunMessages + case event.RunFinishedEvent: + return config.Koolo.Discord.EnableRunFinishMessages + default: + break + } - return err + return e.Image() != nil } diff --git a/internal/remote/telegram/telegram_bot.go b/internal/remote/telegram/telegram_bot.go index c4f6566c3..188b1e48f 100644 --- a/internal/remote/telegram/telegram_bot.go +++ b/internal/remote/telegram/telegram_bot.go @@ -2,33 +2,28 @@ package telegram import ( "context" - "fmt" + "log/slog" "strings" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" - koolo "github.com/hectorgimenez/koolo/internal" - "github.com/hectorgimenez/koolo/internal/event/stat" - "go.uber.org/zap" ) type Bot struct { - bot *tgbotapi.BotAPI - chatID int64 - logger *zap.Logger - sueprvisor koolo.Supervisor + bot *tgbotapi.BotAPI + chatID int64 + logger *slog.Logger } -func NewBot(token string, chatID int64, logger *zap.Logger, supervisor koolo.Supervisor) (*Bot, error) { +func NewBot(token string, chatID int64, logger *slog.Logger) (*Bot, error) { bot, err := tgbotapi.NewBotAPI(token) if err != nil { return nil, err } return &Bot{ - bot: bot, - chatID: chatID, - logger: logger, - sueprvisor: supervisor, + bot: bot, + chatID: chatID, + logger: logger, }, nil } @@ -46,12 +41,12 @@ func (b *Bot) Start(_ context.Context) error { switch strings.ToLower(update.Message.Text) { case "stats": if err := b.publishStats(); err != nil { - b.logger.Error("error sending telegram message", zap.Error(err)) + b.logger.Error("error sending telegram message", slog.Any("error", err)) } case "start": // TODO: Implement case "stop": - b.sueprvisor.Stop() + //b.sueprvisor.Stop() } } } @@ -75,25 +70,26 @@ func (b *Bot) getLatestOffset() (int, error) { } func (b *Bot) publishStats() error { - msg := "Run | Items | Deaths | Chickens | Merc Chickens | Errors | Healing Pots | Mana Pots | Reju Pots | Merc Pots \n" - for run, st := range stat.Status.RunStats { - msg += fmt.Sprintf( - "%s | %d | %d | %d | %d | %d | %d | %d| %d | %d | %d \n", - run, - len(st.ItemsFound), - st.Kills, - st.Deaths, - st.Chickens, - st.MerChicken, - st.Errors, - st.HealingPotionsUsed, - st.ManaPotionsUsed, - st.RejuvPotionsUsed, - st.MercHealingPotionsUsed, - ) - } - - _, err := b.bot.Send(tgbotapi.NewMessage(b.chatID, msg)) - - return err + return nil + //msg := "Run | Items | Deaths | Chickens | Merc Chickens | Errors | Healing Pots | Mana Pots | Reju Pots | Merc Pots \n" + //for run, st := range stat.Status.RunStats { + // msg += fmt.Sprintf( + // "%s | %d | %d | %d | %d | %d | %d | %d| %d | %d | %d \n", + // run, + // len(st.ItemsFound), + // st.Kills, + // st.Deaths, + // st.Chickens, + // st.MerChicken, + // st.Errors, + // st.HealingPotionsUsed, + // st.ManaPotionsUsed, + // st.RejuvPotionsUsed, + // st.MercHealingPotionsUsed, + // ) + //} + // + //_, err := b.bot.Send(tgbotapi.NewMessage(b.chatID, msg)) + // + //return err } diff --git a/internal/remote/telegram/telegram_event_handler.go b/internal/remote/telegram/telegram_event_handler.go index fe7d9e7b5..4582c4f0b 100644 --- a/internal/remote/telegram/telegram_event_handler.go +++ b/internal/remote/telegram/telegram_event_handler.go @@ -9,26 +9,26 @@ import ( "github.com/hectorgimenez/koolo/internal/event" ) -func (b *Bot) Handle(_ context.Context, m event.Message) error { - if m.Image != nil { +func (b *Bot) Handle(_ context.Context, e event.Event) error { + if e.Image() != nil { buf := new(bytes.Buffer) - err := jpeg.Encode(buf, m.Image, nil) + err := jpeg.Encode(buf, e.Image(), nil) if err != nil { return err } photo := tgbotapi.NewPhoto(b.chatID, tgbotapi.FileBytes{ - Name: m.Message, + Name: e.Message(), Bytes: buf.Bytes(), }) - photo.Caption = m.Message + photo.Caption = e.Message() _, err = b.bot.Send(photo) return err } - _, err := b.bot.Send(tgbotapi.NewMessage(b.chatID, m.Message)) + _, err := b.bot.Send(tgbotapi.NewMessage(b.chatID, e.Message())) return err } diff --git a/internal/run/ancient_tunnels.go b/internal/run/ancient_tunnels.go index 19a49ded1..3e5fc5d0c 100644 --- a/internal/run/ancient_tunnels.go +++ b/internal/run/ancient_tunnels.go @@ -4,30 +4,45 @@ import ( "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/koolo/internal/action" - "github.com/hectorgimenez/koolo/internal/action/step" "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" ) type AncientTunnels struct { - baseRun + ctx *context.Status +} + +func NewAncientTunnels() *AncientTunnels { + return &AncientTunnels{ + ctx: context.Get(), + } } func (a AncientTunnels) Name() string { - return "AncientTunnels" + return string(config.AncientTunnelsRun) } -func (a AncientTunnels) BuildActions() []action.Action { - actions := []action.Action{ - a.builder.WayPoint(area.LostCity), // Moving to starting point (Lost City) - a.builder.MoveToArea(area.AncientTunnels), // Travel to ancient tunnels +func (a AncientTunnels) Run() error { + openChests := a.ctx.CharacterCfg.Game.AncientTunnels.OpenChests + onlyElites := a.ctx.CharacterCfg.Game.AncientTunnels.FocusOnElitePacks + filter := data.MonsterAnyFilter() + + if onlyElites { + filter = data.MonsterEliteFilter() } - if config.Config.Companion.Enabled && config.Config.Companion.Leader { - actions = append(actions, action.NewStepChain(func(_ data.Data) []step.Step { - return []step.Step{step.OpenPortal()} - })) + err := action.WayPoint(area.LostCity) // Moving to starting point (Lost City) + if err != nil { + return err } + err = action.MoveToArea(area.AncientTunnels) // Travel to ancient tunnels + if err != nil { + return err + } + action.OpenTPIfLeader() + // Clear Ancient Tunnels - return append(actions, a.builder.ClearArea(true, data.MonsterAnyFilter())) + + return action.ClearCurrentLevel(openChests, filter) } diff --git a/internal/run/andariel.go b/internal/run/andariel.go index c12103a12..ea402e599 100644 --- a/internal/run/andariel.go +++ b/internal/run/andariel.go @@ -4,6 +4,8 @@ import ( "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/koolo/internal/action" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" ) var andarielStartingPosition = data.Position{ @@ -11,20 +13,100 @@ var andarielStartingPosition = data.Position{ Y: 9553, } +var andarielClearPos1 = data.Position{ + X: 22570, + Y: 9591, +} + +var andarielClearPos2 = data.Position{ + X: 22547, + Y: 9593, +} + +var andarielClearPos3 = data.Position{ + X: 22533, + Y: 9591, +} + +var andarielClearPos4 = data.Position{ + X: 22535, + Y: 9579, +} + +var andarielClearPos5 = data.Position{ + X: 22548, + Y: 9580, +} + +var andarielAttackPos1 = data.Position{ + X: 22548, + Y: 9570, +} + +// Placeholder for second attack position +//var andarielAttackPos2 = data.Position{ +// X: 22548, +// Y: 9590, +//} + type Andariel struct { - baseRun + ctx *context.Status +} + +func NewAndariel() *Andariel { + return &Andariel{ + ctx: context.Get(), + } } func (a Andariel) Name() string { - return "Andariel" + return string(config.AndarielRun) } -func (a Andariel) BuildActions() []action.Action { - return []action.Action{ - a.builder.WayPoint(area.CatacombsLevel2), // Moving to starting point (Catacombs Level 2) - a.builder.MoveToArea(area.CatacombsLevel3), - a.builder.MoveToArea(area.CatacombsLevel4), - a.builder.MoveToCoords(andarielStartingPosition), // Travel to boss position - a.char.KillAndariel(), // Kill Andariel +func (a Andariel) Run() error { + // Moving to Catacombs Level 4 + a.ctx.Logger.Info("Moving to Catacombs 4") + err := action.WayPoint(area.CatacombsLevel2) + if err != nil { + return err } + + err = action.MoveToArea(area.CatacombsLevel3) + action.MoveToArea(area.CatacombsLevel4) + if err != nil { + return err + } + + // Clearing inside room + a.ctx.Logger.Info("Clearing inside room") + + if a.ctx.CharacterCfg.Game.Andariel.ClearRoom { + action.MoveToCoords(andarielClearPos1) + action.ClearAreaAroundPlayer(10, data.MonsterAnyFilter()) + action.MoveToCoords(andarielClearPos2) + action.ClearAreaAroundPlayer(10, data.MonsterAnyFilter()) + action.MoveToCoords(andarielClearPos3) + action.ClearAreaAroundPlayer(10, data.MonsterAnyFilter()) + action.MoveToCoords(andarielClearPos4) + action.ClearAreaAroundPlayer(10, data.MonsterAnyFilter()) + action.MoveToCoords(andarielClearPos5) + action.ClearAreaAroundPlayer(10, data.MonsterAnyFilter()) + action.MoveToCoords(andarielAttackPos1) + action.ClearAreaAroundPlayer(20, data.MonsterAnyFilter()) + + } else { + action.MoveToCoords(andarielStartingPosition) + } + + // Disable item pickup while fighting Andariel (prevent picking up items if nearby monsters die) + a.ctx.DisableItemPickup() + + // Attacking Andariel + a.ctx.Logger.Info("Killing Andariel") + err = a.ctx.Char.KillAndariel() + + // Enable item pickup after the fight + a.ctx.EnableItemPickup() + + return err } diff --git a/internal/run/arachnid_lair.go b/internal/run/arachnid_lair.go index 5d5157416..5e98dd1e7 100644 --- a/internal/run/arachnid_lair.go +++ b/internal/run/arachnid_lair.go @@ -4,30 +4,42 @@ import ( "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/koolo/internal/action" - "github.com/hectorgimenez/koolo/internal/action/step" "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" ) type ArachnidLair struct { - baseRun + ctx *context.Status +} + +func NewArachnidLair() *ArachnidLair { + return &ArachnidLair{ + ctx: context.Get(), + } } func (a ArachnidLair) Name() string { - return "ArachnidLair" + return string(config.ArachnidLairRun) } -func (a ArachnidLair) BuildActions() []action.Action { - actions := []action.Action{ - a.builder.WayPoint(area.SpiderForest), // Moving to starting point (Spider Forest) - a.builder.MoveToArea(area.SpiderCave), // Travel to ArachnidLair +func (a ArachnidLair) Run() error { + filter := data.MonsterAnyFilter() + if a.ctx.CharacterCfg.Game.ArachnidLair.FocusOnElitePacks { + filter = data.MonsterEliteFilter() } - if config.Config.Companion.Enabled && config.Config.Companion.Leader { - actions = append(actions, action.NewStepChain(func(_ data.Data) []step.Step { - return []step.Step{step.OpenPortal()} - })) + err := action.WayPoint(area.SpiderForest) + if err != nil { + return err } + err = action.MoveToArea(area.SpiderCave) + if err != nil { + return err + } + + action.OpenTPIfLeader() + // Clear ArachnidLair - return append(actions, a.builder.ClearArea(true, data.MonsterAnyFilter())) + return action.ClearCurrentLevel(a.ctx.CharacterCfg.Game.ArachnidLair.OpenChests, filter) } diff --git a/internal/run/baal.go b/internal/run/baal.go index c10dc95fd..b6b469de5 100644 --- a/internal/run/baal.go +++ b/internal/run/baal.go @@ -1,7 +1,7 @@ package run import ( - "time" + "errors" "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" @@ -9,7 +9,8 @@ import ( "github.com/hectorgimenez/d2go/pkg/data/object" "github.com/hectorgimenez/koolo/internal/action" "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/pather" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/utils" ) var baalThronePosition = data.Position{ @@ -18,80 +19,137 @@ var baalThronePosition = data.Position{ } type Baal struct { - baseRun + ctx *context.Status + clearMonsterFilter data.MonsterFilter // Used to clear area (basically TZ) +} + +func NewBaal(clearMonsterFilter data.MonsterFilter) *Baal { + return &Baal{ + ctx: context.Get(), + clearMonsterFilter: clearMonsterFilter, + } } func (s Baal) Name() string { - return "Baal" + return string(config.BaalRun) } -func (s Baal) BuildActions() (actions []action.Action) { - actions = append(actions, - // Moving to starting point (The World StoneKeep Level 2) - s.builder.WayPoint(area.TheWorldStoneKeepLevel2), - // Travel to boss position - s.builder.MoveToArea(area.TheWorldStoneKeepLevel3), - s.builder.MoveToArea(area.ThroneOfDestruction), - s.builder.MoveToCoords(baalThronePosition), - // Kill monsters inside Baal throne - s.builder.ClearAreaAroundPlayer(50), - s.builder.Buff(), - ) +func (s Baal) Run() error { + // Set filter + filter := data.MonsterAnyFilter() + if s.ctx.CharacterCfg.Game.Baal.OnlyElites { + filter = data.MonsterEliteFilter() + } + if s.clearMonsterFilter != nil { + filter = s.clearMonsterFilter + } + + err := action.WayPoint(area.TheWorldStoneKeepLevel2) + if err != nil { + return err + } + + if s.ctx.CharacterCfg.Game.Baal.ClearFloors || s.clearMonsterFilter != nil { + action.ClearCurrentLevel(false, filter) + } + + err = action.MoveToArea(area.TheWorldStoneKeepLevel3) + if err != nil { + return err + } + + if s.ctx.CharacterCfg.Game.Baal.ClearFloors || s.clearMonsterFilter != nil { + action.ClearCurrentLevel(false, filter) + } + + err = action.MoveToArea(area.ThroneOfDestruction) + if err != nil { + return err + } + err = action.MoveToCoords(baalThronePosition) + if err != nil { + return err + } + if s.checkForSoulsOrDolls() { + return errors.New("souls or dolls detected, skipping") + } // Let's move to a safe area and open the portal in companion mode - if config.Config.Companion.Enabled && config.Config.Companion.Leader { - actions = append(actions, - s.builder.MoveToCoords(data.Position{ - X: 15116, - Y: 5071, - }), - ) + if s.ctx.CharacterCfg.Companion.Leader { + action.MoveToCoords(data.Position{ + X: 15116, + Y: 5071, + }) + action.OpenTPIfLeader() } + err = action.ClearAreaAroundPlayer(50, data.MonsterAnyFilter()) + if err != nil { + return err + } + + // Force rebuff before waves + action.Buff() + // Come back to previous position - actions = append(actions, s.builder.MoveToCoords(baalThronePosition)) + err = action.MoveToCoords(baalThronePosition) + if err != nil { + return err + } lastWave := false - actions = append(actions, action.NewChain(func(d data.Data) []action.Action { - if !lastWave { - if _, found := d.Monsters.FindOne(npc.BaalsMinion, data.MonsterTypeMinion); found { - lastWave = true - } - - enemies := false - for _, e := range d.Monsters.Enemies() { - dist := pather.DistanceFromPoint(baalThronePosition, e.Position) - if dist < 50 { - enemies = true - } - } - if !enemies { - return []action.Action{ - s.builder.ItemPickup(false, 50), - s.builder.MoveToCoords(baalThronePosition), - } - } - - return []action.Action{s.builder.ClearAreaAroundPlayer(50)} + for !lastWave { + if _, found := s.ctx.Data.Monsters.FindOne(npc.BaalsMinion, data.MonsterTypeMinion); found { + lastWave = true + } + // Return to throne position between waves + err = action.ClearAreaAroundPosition(baalThronePosition, 50, data.MonsterAnyFilter()) + if err != nil { + return err } - return nil - }, action.RepeatUntilNoSteps())) + action.MoveToCoords(baalThronePosition) + } - actions = append(actions, s.builder.ItemPickup(false, 30)) + // Let's be sure everything is dead + err = action.ClearAreaAroundPosition(baalThronePosition, 50, data.MonsterAnyFilter()) - _, isLevelingChar := s.char.(action.LevelingCharacter) - if config.Config.Game.Baal.KillBaal || isLevelingChar { - actions = append(actions, - s.builder.Wait(time.Second*10), - s.builder.Buff(), - s.builder.InteractObject(object.BaalsPortal, func(d data.Data) bool { - return d.PlayerUnit.Area == area.TheWorldstoneChamber - }), - s.char.KillBaal(), - s.builder.ItemPickup(true, 50), - ) + _, isLevelingChar := s.ctx.Char.(context.LevelingCharacter) + if s.ctx.CharacterCfg.Game.Baal.KillBaal || isLevelingChar { + utils.Sleep(15000) + action.Buff() + // Exception: Baal portal has no destination in memory + baalPortal, _ := s.ctx.Data.Objects.FindOne(object.BaalsPortal) + err = action.InteractObject(baalPortal, func() bool { + return s.ctx.Data.PlayerUnit.Area == area.TheWorldstoneChamber + }) + if err != nil { + return err + } + + _ = action.MoveToCoords(data.Position{X: 15136, Y: 5943}) + + return s.ctx.Char.KillBaal() + } + + return nil +} + +func (s Baal) checkForSoulsOrDolls() bool { + var npcIds []npc.ID + + if s.ctx.CharacterCfg.Game.Baal.DollQuit { + npcIds = append(npcIds, npc.UndeadStygianDoll2, npc.UndeadSoulKiller2) + } + if s.ctx.CharacterCfg.Game.Baal.SoulQuit { + npcIds = append(npcIds, npc.BlackSoul2, npc.BurningSoul2) + } + + for _, id := range npcIds { + if _, found := s.ctx.Data.Monsters.FindOne(id, data.MonsterTypeNone); found { + return true + } } - return + return false } diff --git a/internal/run/companion.go b/internal/run/companion.go deleted file mode 100644 index 68082ba0c..000000000 --- a/internal/run/companion.go +++ /dev/null @@ -1,65 +0,0 @@ -package run - -import ( - "time" - - "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/d2go/pkg/data/area" - "github.com/hectorgimenez/koolo/internal/action" - "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/town" -) - -type Companion struct { - baseRun -} - -func (s Companion) Name() string { - return "Companion" -} - -func (s Companion) BuildActions() (actions []action.Action) { - actions = append(actions, s.builder.MoveTo(func(d data.Data) (data.Position, bool) { - tpArea := town.GetTownByArea(d.PlayerUnit.Area).TPWaitingArea(d) - - return tpArea, true - })) - - // Wait for the portal, once it's up, enter and wait - portalFound := false - actions = append(actions, action.NewStepChain(func(d data.Data) []step.Step { - if !portalFound { - for _, o := range d.Objects { - if o.IsPortal() { - portalFound = true - return []step.Step{ - step.MoveTo(o.Position), - step.InteractObject(o.Name, func(d data.Data) bool { - return !d.PlayerUnit.Area.IsTown() - }), - } - } - } - - return []step.Step{step.Wait(time.Second)} - } - - return nil - })) - - actions = append(actions, action.NewChain(func(d data.Data) []action.Action { - rm, found := d.Roster.FindByName(config.Config.Companion.LeaderName) - if !found { - return nil - } - - if d.PlayerUnit.Area == area.ThroneOfDestruction { - return []action.Action{s.builder.Wait(time.Second)} - } - - return []action.Action{s.builder.MoveToCoords(rm.Position)} - })) - - return actions -} diff --git a/internal/run/council.go b/internal/run/council.go deleted file mode 100644 index 69da3d7eb..000000000 --- a/internal/run/council.go +++ /dev/null @@ -1,33 +0,0 @@ -package run - -import ( - "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/d2go/pkg/data/area" - "github.com/hectorgimenez/koolo/internal/action" -) - -type Council struct { - baseRun -} - -func (s Council) Name() string { - return "Council" -} - -func (s Council) BuildActions() []action.Action { - return []action.Action{ - s.builder.WayPoint(area.Travincal), // Moving to starting point (Travincal) - s.builder.MoveTo(func(d data.Data) (data.Position, bool) { - for _, al := range d.AdjacentLevels { - if al.Area == area.DuranceOfHateLevel1 { - return data.Position{ - X: al.Position.X - 1, - Y: al.Position.Y + 3, - }, true - } - } - return data.Position{}, false - }), - s.char.KillCouncil(), // Kill Council - } -} diff --git a/internal/run/countess.go b/internal/run/countess.go index 090c07297..97a0ad3b7 100644 --- a/internal/run/countess.go +++ b/internal/run/countess.go @@ -6,44 +6,65 @@ import ( "github.com/hectorgimenez/d2go/pkg/data/npc" "github.com/hectorgimenez/d2go/pkg/data/object" "github.com/hectorgimenez/koolo/internal/action" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" ) type Countess struct { - baseRun + ctx *context.Status +} + +func NewCountess() *Countess { + return &Countess{ + ctx: context.Get(), + } } func (c Countess) Name() string { - return "Countess" + return string(config.CountessRun) } -func (c Countess) BuildActions() (actions []action.Action) { +func (c Countess) Run() error { // Travel to boss level - actions = append(actions, - c.builder.WayPoint(area.BlackMarsh), // Moving to starting point (Black Marsh) - c.builder.MoveToArea(area.ForgottenTower), - c.builder.MoveToArea(area.TowerCellarLevel1), - c.builder.MoveToArea(area.TowerCellarLevel2), - c.builder.MoveToArea(area.TowerCellarLevel3), - c.builder.MoveToArea(area.TowerCellarLevel4), - c.builder.MoveToArea(area.TowerCellarLevel5), - ) + err := action.WayPoint(area.BlackMarsh) + if err != nil { + return err + } + + areas := []area.ID{ + area.ForgottenTower, + area.TowerCellarLevel1, + area.TowerCellarLevel2, + area.TowerCellarLevel3, + area.TowerCellarLevel4, + area.TowerCellarLevel5, + } + + for _, a := range areas { + err = action.MoveToArea(a) + if err != nil { + return err + } + } // Try to move around Countess area - actions = append(actions, c.builder.MoveTo(func(d data.Data) (data.Position, bool) { - for _, o := range d.Objects { - if o.Name == object.GoodChest { - return o.Position, true + action.MoveTo(func() (data.Position, bool) { + if areaData, ok := context.Get().GameReader.GetData().Areas[area.TowerCellarLevel5]; ok { + for _, o := range areaData.Objects { + if o.Name == object.GoodChest { + return o.Position, true + } // Countess Chest position from 1.13c fetch } } // Try to teleport over Countess in case we are not able to find the chest position, a bit more risky - if countess, found := d.Monsters.FindOne(npc.DarkStalker, data.MonsterTypeSuperUnique); found { + if countess, found := c.ctx.Data.Monsters.FindOne(npc.DarkStalker, data.MonsterTypeSuperUnique); found { return countess.Position, true } return data.Position{}, false - })) + }) // Kill Countess - return append(actions, c.char.KillCountess()) + return c.ctx.Char.KillCountess() } diff --git a/internal/run/cows.go b/internal/run/cows.go index a2dee99da..9957dbc60 100644 --- a/internal/run/cows.go +++ b/internal/run/cows.go @@ -1,9 +1,8 @@ package run import ( - "slices" + "errors" "strings" - "time" "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" @@ -12,113 +11,250 @@ import ( "github.com/hectorgimenez/d2go/pkg/data/object" "github.com/hectorgimenez/koolo/internal/action" "github.com/hectorgimenez/koolo/internal/action/step" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/utils" ) type Cows struct { - baseRun + ctx *context.Status +} + +func NewCows() *Cows { + return &Cows{ + ctx: context.Get(), + } } func (a Cows) Name() string { - return "Cows" + return string(config.CowsRun) } -func (a Cows) BuildActions() []action.Action { - actions := []action.Action{ - a.getWirtsLeg(), - } +func (a Cows) Run() error { - // Sell junk, refill potions, etc. (basically ensure space for getting the TP tome) - actions = append(actions, a.builder.PreRun(false)...) + // Check if we already have the items in cube so we can skip. + if a.hasWristAndBookInCube() { - return append(actions, - a.preparePortal(), - a.builder.InteractObject(object.PermanentTownPortal, func(d data.Data) bool { - return d.PlayerUnit.Area == area.MooMooFarm - }), - a.builder.Buff(), - a.builder.ClearArea(true, data.MonsterAnyFilter()), - ) -} + // Sell junk, refill potions, etc. (basically ensure space for getting the TP tome) + action.PreRun(false) -func (a Cows) getWirtsLeg() action.Action { - return action.NewChain(func(d data.Data) []action.Action { - if _, found := d.Items.Find("WirtsLeg", item.LocationStash, item.LocationInventory); found { - a.logger.Info("WirtsLeg found, skip finding it") - return nil + a.ctx.Logger.Info("Wrist Leg and Book found in cube") + // Move to town if needed + if !a.ctx.Data.PlayerUnit.Area.IsTown() { + if err := action.ReturnTown(); err != nil { + return err + } } - return []action.Action{ - a.builder.WayPoint(area.StonyField), // Moving to starting point (Stony Field) - action.NewChain(func(d data.Data) []action.Action { - for _, o := range d.Objects { - if o.Name == object.CairnStoneAlpha { - return []action.Action{a.builder.MoveToCoords(o.Position)} - } - } + // Find and interact with stash + bank, found := a.ctx.Data.Objects.FindOne(object.Bank) + if !found { + return errors.New("stash not found") + } + err := action.InteractObject(bank, func() bool { + return a.ctx.Data.OpenMenus.Stash + }) + if err != nil { + return err + } - return nil - }), - a.builder.ClearAreaAroundPlayer(10), - a.builder.ItemPickup(false, 15), - a.builder.InteractObject(object.PermanentTownPortal, func(d data.Data) bool { - return d.PlayerUnit.Area == area.Tristram - }, step.Wait(time.Second)), - a.builder.InteractObject(object.WirtCorpse, func(d data.Data) bool { - _, found := d.Items.Find("WirtsLeg") - - return found - }), - a.builder.ItemPickup(false, 30), - a.builder.ReturnTown(), + // Open cube and transmute Cow Level portal + if err := action.CubeTransmute(); err != nil { + return err + } + // If we dont have Wirstleg and Book in cube + } else { + // First clean up any extra tomes if needed + err := a.cleanupExtraPortalTomes() + if err != nil { + return err } + + // Get Wrist leg + err = a.getWirtsLeg() + if err != nil { + return err + } + // Sell junk, refill potions, etc. (basically ensure space for getting the TP tome) + action.PreRun(false) + + err = a.preparePortal() + if err != nil { + return err + } + } + // Make sure all menus are closed before interacting with cow portal + if err := step.CloseAllMenus(); err != nil { + return err + } + + // Add a small delay to ensure everything is settled + utils.Sleep(700) + + townPortal, found := a.ctx.Data.Objects.FindOne(object.PermanentTownPortal) + if !found { + return errors.New("cow portal not found") + } + + err := action.InteractObject(townPortal, func() bool { + return a.ctx.Data.AreaData.Area == area.MooMooFarm && a.ctx.Data.AreaData.IsInside(a.ctx.Data.PlayerUnit.Position) }) + if err != nil { + return err + } + + return action.ClearCurrentLevel(a.ctx.CharacterCfg.Game.Cows.OpenChests, data.MonsterAnyFilter()) } -func (a Cows) preparePortal() action.Action { - return action.NewChain(func(d data.Data) (actions []action.Action) { - if d.PlayerUnit.Area != area.RogueEncampment { - actions = append(actions, a.builder.WayPoint(area.RogueEncampment)) +func (a Cows) getWirtsLeg() error { + if a.hasWirtsLeg() { + a.ctx.Logger.Info("WirtsLeg found from previous game, we can skip") + return nil + } + + err := action.WayPoint(area.StonyField) + if err != nil { + return err + } + + cainStone, found := a.ctx.Data.Objects.FindOne(object.CairnStoneAlpha) + if !found { + return errors.New("cain stones not found") + } + err = action.MoveToCoords(cainStone.Position) + if err != nil { + return err + } + action.ClearAreaAroundPlayer(10, data.MonsterAnyFilter()) + + portal, found := a.ctx.Data.Objects.FindOne(object.PermanentTownPortal) + if !found { + return errors.New("tristram not found") + } + err = action.InteractObject(portal, func() bool { + return a.ctx.Data.AreaData.Area == area.Tristram && a.ctx.Data.AreaData.IsInside(a.ctx.Data.PlayerUnit.Position) + }) + if err != nil { + return err + } + + wirtCorpse, found := a.ctx.Data.Objects.FindOne(object.WirtCorpse) + if !found { + return errors.New("wirt corpse not found") + } + err = action.InteractObject(wirtCorpse, func() bool { + return a.hasWirtsLeg() + }) + + return action.ReturnTown() +} + +func (a Cows) preparePortal() error { + err := action.WayPoint(area.RogueEncampment) + if err != nil { + return err + } + + leg, found := a.ctx.Data.Inventory.Find("WirtsLeg", + item.LocationStash, + item.LocationInventory, + item.LocationCube) + if !found { + return errors.New("WirtsLeg could not be found, portal cannot be opened") + } + + // Track if we found a usable spare tome + var spareTome data.Item + + // Look for an existing spare tome (not in locked inventory slots) + for _, itm := range a.ctx.Data.Inventory.ByLocation(item.LocationInventory) { + if strings.EqualFold(string(itm.Name), item.TomeOfTownPortal) && !action.IsInLockedInventorySlot(itm) { + spareTome = itm + break } + } - currentWPTomes := make([]data.UnitID, 0) - leg, found := d.Items.Find("WirtsLeg") - if !found { - a.logger.Error("WirtsLeg could not be found, portal cannot be opened") - return nil + // If no spare tome found, buy a new one + if spareTome.UnitID == 0 { + err = action.BuyAtVendor(npc.Akara, action.VendorItemRequest{ + Item: item.TomeOfTownPortal, + Quantity: 1, + Tab: 4, + }) + if err != nil { + return err } - // Backup current WP tomes in inventory, before getting new one at Akara - for _, itm := range d.Items.ByLocation(item.LocationInventory) { - if strings.EqualFold(string(itm.Name), item.TomeOfTownPortal) { - currentWPTomes = append(currentWPTomes, itm.UnitID) + // Find the newly bought tome (not in locked slots) + for _, itm := range a.ctx.Data.Inventory.ByLocation(item.LocationInventory) { + if strings.EqualFold(string(itm.Name), item.TomeOfTownPortal) && !action.IsInLockedInventorySlot(itm) { + spareTome = itm + break } } + } - return append(actions, - a.builder.BuyAtVendor(npc.Akara, action.VendorItemRequest{ - Item: item.TomeOfTownPortal, - Quantity: 1, - Tab: 4, - }), - action.NewChain(func(d data.Data) []action.Action { - // Ensure we are using the new WP tome and not the one that we are using for TPs - var newWPTome data.Item - for _, itm := range d.Items.ByLocation(item.LocationInventory) { - if strings.EqualFold(string(itm.Name), item.TomeOfTownPortal) && !slices.Contains(currentWPTomes, itm.UnitID) { - newWPTome = itm - } - } + if spareTome.UnitID == 0 { + return errors.New("failed to obtain spare TomeOfTownPortal for cow portal") + } + + err = action.CubeAddItems(leg, spareTome) + if err != nil { + return err + } - if newWPTome.UnitID == 0 { - a.logger.Error("TomeOfTownPortal could not be found, portal cannot be opened") - return nil + return action.CubeTransmute() +} +func (a Cows) cleanupExtraPortalTomes() error { + // Only attempt cleanup if we don't have Wirt's Leg + if _, hasLeg := a.ctx.Data.Inventory.Find("WirtsLeg", item.LocationStash, item.LocationInventory, item.LocationCube); !hasLeg { + // Find all portal tomes, keeping track of which are in locked slots + var protectedTomes []data.Item + var unprotectedTomes []data.Item + + for _, itm := range a.ctx.Data.Inventory.ByLocation(item.LocationInventory) { + if strings.EqualFold(string(itm.Name), item.TomeOfTownPortal) { + if action.IsInLockedInventorySlot(itm) { + protectedTomes = append(protectedTomes, itm) + } else { + unprotectedTomes = append(unprotectedTomes, itm) } + } + } - return []action.Action{ - a.builder.CubeAddItems(leg, newWPTome), - a.builder.CubeTransmute(), + // Only drop extra unprotected tomes if we have any + if len(unprotectedTomes) > 0 { + a.ctx.Logger.Info("Extra TomeOfTownPortal found - dropping it") + for i := 0; i < len(unprotectedTomes); i++ { + err := action.DropInventoryItem(unprotectedTomes[i]) + if err != nil { + return err } - }), - ) - }) + } + } + } + return nil +} +func (a Cows) hasWristAndBookInCube() bool { + cubeItems := a.ctx.Data.Inventory.ByLocation(item.LocationCube) + + var hasLeg, hasTome bool + for _, item := range cubeItems { + if strings.EqualFold(string(item.Name), "WirtsLeg") { + hasLeg = true + } + if strings.EqualFold(string(item.Name), "TomeOfTownPortal") { + hasTome = true + } + } + + return hasLeg && hasTome +} + +func (a Cows) hasWirtsLeg() bool { + _, found := a.ctx.Data.Inventory.Find("WirtsLeg", + item.LocationStash, + item.LocationInventory, + item.LocationCube) + return found } diff --git a/internal/run/diablo.go b/internal/run/diablo.go index f82a79392..90a0b4e87 100644 --- a/internal/run/diablo.go +++ b/internal/run/diablo.go @@ -6,235 +6,187 @@ import ( "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" - "github.com/hectorgimenez/d2go/pkg/data/npc" "github.com/hectorgimenez/d2go/pkg/data/object" "github.com/hectorgimenez/koolo/internal/action" - "github.com/hectorgimenez/koolo/internal/action/step" "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/health" - "github.com/hectorgimenez/koolo/internal/pather" - "go.uber.org/zap" + "github.com/hectorgimenez/koolo/internal/context" ) -var diabloSpawnPosition = data.Position{ - X: 7800, - Y: 5286, -} +var diabloSpawnPosition = data.Position{X: 7792, Y: 5294} +var chaosNavToPosition = data.Position{X: 7732, Y: 5292} //into path towards vizier -var chaosSanctuaryEntrancePosition = data.Position{ - X: 7796, - Y: 5561, +type Diablo struct { + ctx *context.Status } -type Diablo struct { - baseRun - bm health.BeltManager +func NewDiablo() *Diablo { + return &Diablo{ + ctx: context.Get(), + } } -func (a Diablo) Name() string { - return "Diablo" +func (d *Diablo) Name() string { + return string(config.DiabloRun) } -func (a Diablo) BuildActions() (actions []action.Action) { - actions = append(actions, - // Moving to starting point (RiverOfFlame) - a.builder.WayPoint(area.RiverOfFlame), - // Travel to diablo spawn location - a.builder.MoveToCoords(chaosSanctuaryEntrancePosition), - a.builder.MoveToCoords(diabloSpawnPosition), - ) - - seals := []object.Name{object.DiabloSeal4, object.DiabloSeal5, object.DiabloSeal3, object.DiabloSeal2, object.DiabloSeal1} - - // Move across all the seals, try to find the most clear spot around them, kill monsters and activate the seal. - for i, s := range seals { - seal := s - sealNumber := i - - actions = append(actions, action.NewChain(func(d data.Data) []action.Action { - _, isLevelingChar := a.char.(action.LevelingCharacter) - if isLevelingChar && (a.bm.ShouldBuyPotions(d) || (config.Config.Character.UseMerc && d.MercHPPercent() <= 0)) { - a.logger.Debug("Let's go back town to buy more pots", zap.Int("seal", sealNumber+1)) - return a.builder.InRunReturnTownRoutine() - } +func (d *Diablo) Run() error { + // Just to be sure we always re-enable item pickup after the run + defer func() { + d.ctx.EnableItemPickup() + }() - return nil - })) + if err := action.WayPoint(area.RiverOfFlame); err != nil { + return err + } - actions = append(actions, a.builder.MoveTo(func(d data.Data) (data.Position, bool) { - a.logger.Debug("Moving to next seal", zap.Int("seal", sealNumber+1)) - if obj, found := d.Objects.FindOne(seal); found { - if d := pather.DistanceFromMe(d, obj.Position); d < 7 { - a.logger.Debug("We are close enough to the seal", zap.Int("seal", sealNumber+1)) - return data.Position{}, false - } + action.MoveToArea(area.ChaosSanctuary) - a.logger.Debug("Seal found, start teleporting", zap.Int("seal", sealNumber+1)) + // We move directly to Diablo spawn position if StartFromStar is enabled, not clearing the path + if d.ctx.CharacterCfg.Game.Diablo.StartFromStar { + //move to star + if err := action.MoveToCoords(diabloSpawnPosition); err != nil { + return err + } + //open portal if leader + if d.ctx.CharacterCfg.Companion.Leader { + action.OpenTPIfLeader() + action.Buff() + action.ClearAreaAroundPlayer(30, data.MonsterAnyFilter()) + } + } else { + //open portal in entrance + if d.ctx.CharacterCfg.Companion.Leader { + action.OpenTPIfLeader() + action.Buff() + action.ClearAreaAroundPlayer(30, data.MonsterAnyFilter()) + } + //path through towards vizier + err := action.ClearThroughPath(chaosNavToPosition, 30, d.getMonsterFilter()) + if err != nil { + return err + } + } - return obj.Position, true + sealGroups := map[string][]object.Name{ + "Vizier": {object.DiabloSeal4, object.DiabloSeal5}, // Vizier + "Lord De Seis": {object.DiabloSeal3}, // Lord De Seis + "Infector": {object.DiabloSeal1, object.DiabloSeal2}, // Infector + } + + // Thanks Go for the lack of ordered maps + for _, bossName := range []string{"Vizier", "Lord De Seis", "Infector"} { + d.ctx.Logger.Debug("Heading to", bossName) + + for _, sealID := range sealGroups[bossName] { + seal, found := d.ctx.Data.Objects.FindOne(sealID) + if !found { + return fmt.Errorf("seal not found: %d", sealID) } - a.logger.Debug("Seal NOT found", zap.Int("seal", sealNumber+1)) - - return data.Position{}, false - }, step.StopAtDistance(7))) - - // Try to calculate based on a square boundary around the seal which corner is safer, then tele there - //actions = append(actions, action.NewStepChain(func(d data.Data) []step.Step { - // if obj, found := d.Objects.FindOne(seal); found { - // pos := a.getLessConcurredCornerAroundSeal(d, obj.Position) - // return []step.Step{step.MoveTo(pos)} - // } - // return []step.Step{} - //})) - - // Kill all the monsters close to the seal and item pickup - //actions = append(actions, - // a.builder.ClearAreaAroundPlayer(13), - // a.builder.ItemPickup(false, 40), - //) - - // Activate the seal - actions = append(actions, - a.builder.ClearAreaAroundPlayer(15), - action.NewStepChain(func(d data.Data) []step.Step { - a.logger.Debug("Trying to activate seal...", zap.Int("seal", sealNumber+1)) - lastInteractionAt := time.Now() - return []step.Step{ - step.InteractObject(seal, func(d data.Data) bool { - if obj, found := d.Objects.FindOne(seal); found { - if !obj.Selectable { - a.logger.Debug("Seal activated, waiting for elite group to spawn", zap.Int("seal", sealNumber+1)) - } - return !obj.Selectable - } - if time.Since(lastInteractionAt) > time.Second*3 { - lastInteractionAt = time.Now() - pather.RandomMovement() - time.Sleep(time.Second) - } - return false - }), - } - }), - ) - - // Only if we are not in the first seal - if sealNumber != 0 { - if sealNumber == 2 { - actions = append(actions, a.builder.MoveToCoords(data.Position{ - X: 7773, - Y: 5195, - })) + + err := action.ClearThroughPath(seal.Position, 20, d.getMonsterFilter()) + if err != nil { + return err } - // Now wait & try to kill the Elite packs (maybe are already dead, killed during previous action) - startTime := time.Time{} - actions = append(actions, action.NewStepChain(func(d data.Data) []step.Step { - if startTime.IsZero() { - startTime = time.Now() - } - for _, m := range d.Monsters.Enemies(data.MonsterEliteFilter()) { - if a.isSealElite(m) { - a.logger.Debug("Seal defender found!") - return nil - } + // Handle the special case for DiabloSeal3 + if sealID == object.DiabloSeal3 && seal.Position.X == 7773 && seal.Position.Y == 5155 { + if err = action.MoveToCoords(data.Position{X: 7768, Y: 5160}); err != nil { + return fmt.Errorf("failed to move to bugged seal position: %w", err) } + } - if time.Since(startTime) < time.Second*5 { - return []step.Step{step.Wait(time.Millisecond * 100)} - } + // Clear everything around the seal + action.ClearAreaAroundPlayer(10, d.ctx.Data.MonsterFilterAnyReachable()) - return nil - }, action.RepeatUntilNoSteps())) + //Buff refresh before Infector + if object.DiabloSeal1 == sealID { + action.Buff() + } - actions = append(actions, a.char.KillMonsterSequence(func(d data.Data) (data.UnitID, bool) { - for _, m := range d.Monsters.Enemies(data.MonsterEliteFilter()) { - if a.isSealElite(m) { - _, _, found := pather.GetPath(d, m.Position) - return m.UnitID, found - } + if err = action.InteractObject(seal, func() bool { + seal, _ = d.ctx.Data.Objects.FindOne(sealID) + return !seal.Selectable + }); err != nil { + return fmt.Errorf("failed to interact with seal: %w", err) + } + + // Infector spawns when first seal is enabled + if object.DiabloSeal1 == sealID { + if err = d.killSealElite(bossName); err != nil { + return err } + } + } - return 0, false - }, nil)) + // Skip Infector boss because was already killed + if bossName != "Infector" { + // Wait for the boss to spawn and kill it. + // Lord De Seis sometimes it's far, and we can not detect him, but we will kill him anyway heading to the next seal + if err := d.killSealElite(bossName); err != nil && bossName != "Lord De Seis" { + return err + } } - actions = append(actions, a.builder.ItemPickup(false, 40)) } - // Go back to town to buy potions if needed - actions = append(actions, action.NewChain(func(d data.Data) []action.Action { - _, isLevelingChar := a.char.(action.LevelingCharacter) - if isLevelingChar && (a.bm.ShouldBuyPotions(d) || (config.Config.Character.UseMerc && d.MercHPPercent() <= 0)) { - return a.builder.InRunReturnTownRoutine() - } + if d.ctx.CharacterCfg.Game.Diablo.KillDiablo { + action.Buff() - return nil - })) + action.MoveToCoords(diabloSpawnPosition) - actions = append(actions, - a.builder.Buff(), - a.builder.MoveToCoords(diabloSpawnPosition), - a.char.KillDiablo(), - ) + // Check if we should disable item pickup for Diablo + if d.ctx.CharacterCfg.Game.Diablo.DisableItemPickupDuringBosses { + d.ctx.DisableItemPickup() + } + + return d.ctx.Char.KillDiablo() + } - return + return nil } -func (a Diablo) getLessConcurredCornerAroundSeal(d data.Data, sealPosition data.Position) data.Position { - corners := [4]data.Position{ - { - X: sealPosition.X + 7, - Y: sealPosition.Y + 7, - }, - { - X: sealPosition.X - 7, - Y: sealPosition.Y + 7, - }, - { - X: sealPosition.X - 7, - Y: sealPosition.Y - 7, - }, - { - X: sealPosition.X + 7, - Y: sealPosition.Y - 7, - }, - } +func (d *Diablo) killSealElite(boss string) error { + d.ctx.Logger.Debug(fmt.Sprintf("Starting kill sequence for %s", boss)) + startTime := time.Now() + timeout := 4 * time.Second + + for time.Since(startTime) < timeout { + for _, m := range d.ctx.Data.Monsters.Enemies(d.ctx.Data.MonsterFilterAnyReachable()) { + if action.IsMonsterSealElite(m) { + d.ctx.Logger.Debug(fmt.Sprintf("Seal elite found: %s at position X: %d, Y: %d", m.Name, m.Position.X, m.Position.Y)) - bestCorner := 0 - bestCornerDistance := 0 - for i, c := range corners { - averageDistance := 0 - monstersFound := 0 - for _, m := range d.Monsters.Enemies() { - distance := pather.DistanceFromPoint(c, m.Position) - // Ignore enemies not close to the seal - if distance < 5 { - monstersFound++ - averageDistance += pather.DistanceFromPoint(c, m.Position) + return action.ClearAreaAroundPosition(m.Position, 30, func(monsters data.Monsters) (filteredMonsters []data.Monster) { + if action.IsMonsterSealElite(m) { + filteredMonsters = append(filteredMonsters, m) + } + + return filteredMonsters + }) } } - if averageDistance > bestCornerDistance { - bestCorner = i - bestCornerDistance = averageDistance - } - // No monsters found here, don't need to keep checking - if monstersFound == 0 { - fmt.Printf("Moving to corner %d. Has no monsters.\n", i) - return corners[i] - } - fmt.Printf("Corner %d. Average monster distance: %d\n", i, averageDistance) + time.Sleep(100 * time.Millisecond) } - fmt.Printf("Moving to corner %d. Average monster distance: %d\n", bestCorner, bestCornerDistance) - - return corners[bestCorner] + return fmt.Errorf("no seal elite found for %s within %v seconds", boss, timeout.Seconds()) } -func (a Diablo) isSealElite(monster data.Monster) bool { - if monster.Type == data.MonsterTypeSuperUnique && (monster.Name == npc.OblivionKnight || monster.Name == npc.VenomLord || monster.Name == npc.StormCaster) { - return true - } +func (d *Diablo) getMonsterFilter() data.MonsterFilter { + return func(monsters data.Monsters) (filteredMonsters []data.Monster) { + for _, m := range monsters { + if !d.ctx.Data.AreaData.IsWalkable(m.Position) { + continue + } - return false + // If FocusOnElitePacks is enabled, only return elite monsters and seal bosses + if d.ctx.CharacterCfg.Game.Diablo.FocusOnElitePacks { + if m.IsElite() || action.IsMonsterSealElite(m) { + filteredMonsters = append(filteredMonsters, m) + } + } else { + filteredMonsters = append(filteredMonsters, m) + } + } + + return filteredMonsters + } } diff --git a/internal/run/drifter_cavern.go b/internal/run/drifter_cavern.go new file mode 100644 index 000000000..73f19ab21 --- /dev/null +++ b/internal/run/drifter_cavern.go @@ -0,0 +1,47 @@ +package run + +import ( + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" + "github.com/hectorgimenez/koolo/internal/action" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" +) + +type DrifterCavern struct { + ctx *context.Status +} + +func NewDriverCavern() *DrifterCavern { + return &DrifterCavern{ + ctx: context.Get(), + } +} + +func (s DrifterCavern) Name() string { + return string(config.DrifterCavernRun) +} + +func (s DrifterCavern) Run() error { + // Define a default monster filter + monsterFilter := data.MonsterAnyFilter() + + // Update filter if we selected to clear only elites + if s.ctx.CharacterCfg.Game.DrifterCavern.FocusOnElitePacks { + monsterFilter = data.MonsterEliteFilter() + } + + // Use the waypoint + err := action.WayPoint(area.GlacialTrail) + if err != nil { + return err + } + + // Move to the correct area + if err = action.MoveToArea(area.DrifterCavern); err != nil { + return err + } + + // Clear the area + return action.ClearCurrentLevel(s.ctx.CharacterCfg.Game.DrifterCavern.OpenChests, monsterFilter) +} diff --git a/internal/run/duriel.go b/internal/run/duriel.go new file mode 100644 index 000000000..64f13b80d --- /dev/null +++ b/internal/run/duriel.go @@ -0,0 +1,135 @@ +package run + +import ( + "errors" + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" + "github.com/hectorgimenez/d2go/pkg/data/mode" + "github.com/hectorgimenez/d2go/pkg/data/object" + "github.com/hectorgimenez/koolo/internal/action" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/utils" +) + +const ( + maxOrificeAttempts = 10 + orificeCheckDelay = 200 +) + +var talTombs = []area.ID{area.TalRashasTomb1, area.TalRashasTomb2, area.TalRashasTomb3, area.TalRashasTomb4, area.TalRashasTomb5, area.TalRashasTomb6, area.TalRashasTomb7} + +type Duriel struct { + ctx *context.Status +} + +func NewDuriel() *Duriel { + return &Duriel{ + ctx: context.Get(), + } +} + +func (d Duriel) Name() string { + return string(config.DurielRun) +} + +func (d Duriel) Run() error { + err := action.WayPoint(area.CanyonOfTheMagi) + if err != nil { + return err + } + + // Find and move to the real Tal Rasha tomb + realTalRashaTomb, err := d.findRealTomb() + if err != nil { + return err + } + + err = action.MoveToArea(realTalRashaTomb) + if err != nil { + return err + } + + // Wait for area to fully load and get synchronized + utils.Sleep(500) + d.ctx.RefreshGameData() + + // Find orifice with retry logic + var orifice data.Object + var found bool + + for attempts := 0; attempts < maxOrificeAttempts; attempts++ { + orifice, found = d.ctx.Data.Objects.FindOne(object.HoradricOrifice) + if found && orifice.Mode == mode.ObjectModeOpened { + break + } + utils.Sleep(orificeCheckDelay) + d.ctx.RefreshGameData() + } + + if !found { + return errors.New("failed to find Duriel's Lair entrance after multiple attempts") + } + + // Move to orifice and clear the area + err = action.MoveToCoords(orifice.Position) + if err != nil { + return err + } + + err = action.ClearAreaAroundPlayer(10, data.MonsterAnyFilter()) + if err != nil { + return err + } + + // Pre-fight buff + action.Buff() + + // Find portal and enter Duriel's Lair + var portal data.Object + for attempts := 0; attempts < maxOrificeAttempts; attempts++ { + portal, found = d.ctx.Data.Objects.FindOne(object.DurielsLairPortal) + if found && portal.Mode == mode.ObjectModeOpened { + break + } + utils.Sleep(orificeCheckDelay) + d.ctx.RefreshGameData() + } + + if !found { + return errors.New("failed to find Duriel's portal after multiple attempts") + } + + err = action.InteractObject(portal, func() bool { + return d.ctx.Data.PlayerUnit.Area == area.DurielsLair + }) + if err != nil { + return err + } + + // Final refresh before fight + d.ctx.RefreshGameData() + + utils.Sleep(700) + + return d.ctx.Char.KillDuriel() +} + +func (d Duriel) findRealTomb() (area.ID, error) { + var realTomb area.ID + + for _, tomb := range talTombs { + for _, obj := range d.ctx.Data.Areas[tomb].Objects { + if obj.Name == object.HoradricOrifice { + realTomb = tomb + break + } + } + } + + if realTomb == 0 { + return 0, errors.New("failed to find the real Tal Rasha tomb") + } + + return realTomb, nil +} diff --git a/internal/run/eldritch.go b/internal/run/eldritch.go index 10bc967bc..9f45a3a6e 100644 --- a/internal/run/eldritch.go +++ b/internal/run/eldritch.go @@ -1,29 +1,67 @@ package run import ( + "errors" + "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/d2go/pkg/data/stat" "github.com/hectorgimenez/koolo/internal/action" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" ) type Eldritch struct { - baseRun + ctx *context.Status } -func (a Eldritch) Name() string { - return "Eldritch" +func NewEldritch() *Eldritch { + return &Eldritch{ + ctx: context.Get(), + } } -func (a Eldritch) BuildActions() (actions []action.Action) { - return []action.Action{ - a.builder.WayPoint(area.FrigidHighlands), // Moving to starting point (Frigid Highlands) - a.char.KillMonsterSequence(func(d data.Data) (data.UnitID, bool) { - if m, found := d.Monsters.FindOne(npc.MinionExp, data.MonsterTypeSuperUnique); found { - return m.UnitID, true +func (e Eldritch) Name() string { + return string(config.EldritchRun) +} + +func (e Eldritch) Run() error { + // Travel to FrigidHighlands + err := action.WayPoint(area.FrigidHighlands) + if err != nil { + return err + } + + // Kill Eldritch + e.ctx.Char.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + if m, found := d.Monsters.FindOne(npc.MinionExp, data.MonsterTypeSuperUnique); found { + return m.UnitID, true + } + + return 0, false + }, nil) + + // Move to Shenk and kill him, if enabled + if e.ctx.CharacterCfg.Game.Eldritch.KillShenk { + // Move into position + if err = action.MoveToCoords(data.Position{X: 3876, Y: 5130}); err != nil { + return errors.New("failed to move to shenk") + } + + // Kill Shenk + return e.ctx.Char.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + if m, found := d.Monsters.FindOne(npc.OverSeer, data.MonsterTypeSuperUnique); found { + if m.Stats[stat.Life] > 0 { + return m.UnitID, true + } + return 0, false } return 0, false - }, nil), + }, nil) } + + return nil } diff --git a/internal/run/endugu.go b/internal/run/endugu.go new file mode 100644 index 000000000..305b742da --- /dev/null +++ b/internal/run/endugu.go @@ -0,0 +1,74 @@ +package run + +import ( + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" + "github.com/hectorgimenez/d2go/pkg/data/object" + "github.com/hectorgimenez/koolo/internal/action" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" +) + +type Endugu struct { + ctx *context.Status +} + +func NewEndugu() *Endugu { + return &Endugu{ + ctx: context.Get(), + } +} + +func (e Endugu) Name() string { + return string(config.EnduguRun) +} + +func (e Endugu) Run() error { + + // Use waypoint to FlayerJungle + err := action.WayPoint(area.FlayerJungle) + if err != nil { + return err + } + + // Move to FlayerDungeonLevel1 + if err = action.MoveToArea(area.FlayerDungeonLevel1); err != nil { + return err + } + + // Move to FlayerDungeonLevel2 + if err = action.MoveToArea(area.FlayerDungeonLevel2); err != nil { + return err + } + + // Move to FlayerDungeonLevel3 + if err = action.MoveToArea(area.FlayerDungeonLevel3); err != nil { + return err + } + + var khalimChest2 data.Object + + // Move to KhalimChest + action.MoveTo(func() (data.Position, bool) { + for _, o := range e.ctx.Data.Objects { + if o.Name == object.KhalimChest2 { + khalimChest2 = o + return o.Position, true + } + } + return data.Position{}, false + }) + + // Clear monsters around player + action.ClearAreaAroundPlayer(15, data.MonsterEliteFilter()) + + // Open the chest + return action.InteractObject(khalimChest2, func() bool { + for _, obj := range e.ctx.Data.Objects { + if obj.Name == object.KhalimChest2 && !obj.Selectable { + return true + } + } + return false + }) +} diff --git a/internal/run/leveling.go b/internal/run/leveling.go index 42bb948b5..d6bc198c2 100644 --- a/internal/run/leveling.go +++ b/internal/run/leveling.go @@ -1,27 +1,30 @@ package run import ( - "github.com/hectorgimenez/koolo/internal/action" - "github.com/hectorgimenez/koolo/internal/health" - "github.com/hectorgimenez/koolo/internal/reader" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" ) type Leveling struct { - baseRun - gr *reader.GameReader - bm health.BeltManager + ctx *context.Status +} + +func NewLeveling() *Leveling { + return &Leveling{ + ctx: context.Get(), + } } func (a Leveling) Name() string { - return "Leveling" + return string(config.LevelingRun) } -func (a Leveling) BuildActions() []action.Action { - return []action.Action{ - a.act1(), - a.act2(), - a.act3(), - a.act4(), - a.act5(), - } +func (a Leveling) Run() error { + a.act1() + a.act2() + a.act3() + a.act4() + a.act5() + + return nil } diff --git a/internal/run/leveling_act1.go b/internal/run/leveling_act1.go index 22edc78ea..fedc6f949 100644 --- a/internal/run/leveling_act1.go +++ b/internal/run/leveling_act1.go @@ -6,216 +6,236 @@ import ( "github.com/hectorgimenez/d2go/pkg/data/item" "github.com/hectorgimenez/d2go/pkg/data/npc" "github.com/hectorgimenez/d2go/pkg/data/object" + "github.com/hectorgimenez/d2go/pkg/data/quest" "github.com/hectorgimenez/d2go/pkg/data/stat" "github.com/hectorgimenez/koolo/internal/action" - "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" + "github.com/hectorgimenez/koolo/internal/game" "github.com/hectorgimenez/koolo/internal/ui" + "github.com/hectorgimenez/koolo/internal/utils" + "github.com/lxn/win" ) -const scrollOfInifuss = "ScrollOfInifuss" - -func (a Leveling) act1() action.Action { +func (a Leveling) act1() error { running := false - return action.NewChain(func(d data.Data) []action.Action { - if running || d.PlayerUnit.Area != area.RogueEncampment { - return nil - } + if running || a.ctx.Data.PlayerUnit.Area != area.RogueEncampment { + return nil + } - quests := a.builder.GetCompletedQuests(1) + running = true - running = true - if !quests[0] { - return a.denOfEvil() - } + // clear Blood Moor until level 3 + if lvl, _ := a.ctx.Data.PlayerUnit.FindStat(stat.Level, 0); lvl.Value < 3 { + a.bloodMoor() + } - if d.PlayerUnit.Stats[stat.Level] < 13 { - return a.countess() - } + // clear Cold Plains until level 6 + if lvl, _ := a.ctx.Data.PlayerUnit.FindStat(stat.Level, 0); lvl.Value < 6 { + a.coldPlains() + } - if !a.isCainInTown(d) && !quests[2] { - return a.deckardCain(d) - } + if lvl, _ := a.ctx.Data.PlayerUnit.FindStat(stat.Level, 0); lvl.Value == 6 || !a.ctx.Data.Quests[quest.Act1DenOfEvil].Completed() { + a.denOfEvil() + } - return a.andariel(d) - }) + // clear Stony Field until level 9 + if lvl, _ := a.ctx.Data.PlayerUnit.FindStat(stat.Level, 0); lvl.Value < 9 { + a.stonyField() + } + + if !a.isCainInTown() && !a.ctx.Data.Quests[quest.Act1TheSearchForCain].Completed() { + a.deckardCain() + } + + // do Tristram Runs until level 14 + if lvl, _ := a.ctx.Data.PlayerUnit.FindStat(stat.Level, 0); lvl.Value < 14 { + a.tristram() + } + + // do Countess Runs until level 17 + if lvl, _ := a.ctx.Data.PlayerUnit.FindStat(stat.Level, 0); lvl.Value < 17 { + a.countess() + } + + return a.andariel() } -func (a Leveling) denOfEvil() []action.Action { - a.logger.Info("Starting Den of Evil run") - return []action.Action{ - a.builder.MoveToArea(area.BloodMoor), - a.builder.Buff(), - a.builder.MoveToArea(area.DenOfEvil), - a.builder.Buff(), - a.builder.ClearArea(false, data.MonsterAnyFilter()), +func (a Leveling) bloodMoor() error { + err := action.MoveToArea(area.BloodMoor) + if err != nil { + return err } + + return action.ClearCurrentLevel(false, data.MonsterAnyFilter()) +} + +func (a Leveling) coldPlains() error { + err := action.MoveToArea(area.ColdPlains) + if err != nil { + return err + } + + return action.ClearCurrentLevel(false, data.MonsterAnyFilter()) +} + +func (a Leveling) denOfEvil() error { + err := action.MoveToArea(area.BloodMoor) + if err != nil { + return err + } + + err = action.MoveToArea(area.DenOfEvil) + if err != nil { + return err + } + + action.ClearCurrentLevel(false, data.MonsterAnyFilter()) + action.ReturnTown() + action.InteractNPC(npc.Akara) + a.ctx.HID.PressKey(win.VK_ESCAPE) + + return nil } -//func (a Leveling) bloodRaven() action.Action { -// return action.NewChain(func(d data.Data) []action.Action { -// a.logger.Info("Starting Blood Raven quest") -// return []action.Action{ -// a.builder.WayPoint(area.ColdPlains), -// a.builder.MoveToArea(area.BurialGrounds), -// a.char.Buff(), -// action.NewStepChain(func(d data.Data) []step.Step { -// for _, l := range d.AdjacentLevels { -// if l.Area == area.Mausoleum { -// return []step.Step{step.MoveTo(l.Position, step.StopAtDistance(50))} -// } -// } -// -// return []step.Step{} -// }), -// a.char.KillMonsterSequence(func(d data.Data) (data.UnitID, bool) { -// for _, m := range d.Monsters.Enemies() { -// if pather.DistanceFromMe(d, m.Position) < 3 { -// return m.UnitID, true -// } -// -// if m.Name == npc.BloodRaven { -// return m.UnitID, true -// } -// } -// -// return 0, false -// }, nil, step.Distance(5, 15)), -// } -// }) -//} - -func (a Leveling) countess() []action.Action { - a.logger.Info("Starting Countess run") - return Countess{baseRun: a.baseRun}.BuildActions() +func (a Leveling) stonyField() error { + err := action.WayPoint(area.StonyField) + if err != nil { + return err + } + + return action.ClearCurrentLevel(false, data.MonsterAnyFilter()) +} + +func (a Leveling) isCainInTown() bool { + _, found := a.ctx.Data.Monsters.FindOne(npc.DeckardCain5, data.MonsterTypeNone) + + return found } -func (a Leveling) deckardCain(d data.Data) (actions []action.Action) { - a.logger.Info("Rescuing Cain") - if _, found := d.Items.Find("KeyToTheCairnStones"); !found { - actions = []action.Action{ - a.builder.WayPoint(area.DarkWood), - a.builder.Buff(), - a.builder.MoveTo(func(d data.Data) (data.Position, bool) { - for _, o := range d.Objects { - if o.Name == object.InifussTree { - return o.Position, true - } - } - - return data.Position{}, false - }), - a.builder.InteractObject(object.InifussTree, func(d data.Data) bool { - _, found := d.Items.Find(scrollOfInifuss) - return found - }), - a.builder.ItemPickup(false, 30), - a.builder.ReturnTown(), - a.builder.InteractNPC( - npc.Akara, - step.KeySequence("esc"), - ), +func (a Leveling) deckardCain() error { + action.WayPoint(area.RogueEncampment) + err := action.WayPoint(area.DarkWood) + if err != nil { + return err + } + + err = action.MoveTo(func() (data.Position, bool) { + for _, o := range a.ctx.Data.Objects { + if o.Name == object.InifussTree { + return o.Position, true + } + } + return data.Position{}, false + }) + if err != nil { + return err + } + + action.ClearAreaAroundPlayer(30, data.MonsterAnyFilter()) + + obj, found := a.ctx.Data.Objects.FindOne(object.InifussTree) + if !found { + a.ctx.Logger.Debug("InifussTree not found") + } + + err = action.InteractObject(obj, func() bool { + updatedObj, found := a.ctx.Data.Objects.FindOne(object.InifussTree) + if found { + if !updatedObj.Selectable { + a.ctx.Logger.Debug("Interacted with InifussTree") + } + return !updatedObj.Selectable } + return false + }) + if err != nil { + return err + } + + action.ItemPickup(0) + action.ReturnTown() + action.InteractNPC(npc.Akara) + a.ctx.HID.PressKey(win.VK_ESCAPE) + + //Reuse Tristram Run actions + err = Tristram{}.Run() + if err != nil { + return err + } + + return nil +} - // Heal and refill pots - actions = append(actions, - a.builder.ReturnTown(), - a.builder.EnsureStatPoints(), - a.builder.EnsureSkillPoints(), - a.builder.RecoverCorpse(), - a.builder.IdentifyAll(false), - a.builder.Stash(false), - a.builder.VendorRefill(false, true), - a.builder.EnsureSkillBindings(), - a.builder.Heal(), - a.builder.ReviveMerc(), - a.builder.HireMerc(), - a.builder.Repair(), - ) - } - - // Reuse Tristram Run actions - actions = append(actions, Tristram{baseRun: a.baseRun}.BuildActions()...) - - return actions +func (a Leveling) tristram() error { + return Tristram{}.Run() } -func (a Leveling) andariel(d data.Data) []action.Action { - a.logger.Info("Starting Andariel run") - actions := []action.Action{ - a.builder.WayPoint(area.CatacombsLevel2), - a.builder.Buff(), - a.builder.MoveToArea(area.CatacombsLevel3), - a.builder.MoveToArea(area.CatacombsLevel4), +func (a Leveling) countess() error { + return Countess{}.Run() +} + +func (a Leveling) andariel() error { + err := action.WayPoint(area.CatacombsLevel2) + if err != nil { + return err } - actions = append(actions, a.builder.ReturnTown()) // Return town to pickup pots and heal, just in case... + + err = action.MoveToArea(area.CatacombsLevel3) + action.MoveToArea(area.CatacombsLevel4) + if err != nil { + return err + } + + // Return to the city, ensure we have pots and everything, and get some antidote potions + action.ReturnTown() potsToBuy := 4 - if d.MercHPPercent() > 0 { + if a.ctx.Data.MercHPPercent() > 0 { potsToBuy = 8 } - // Return to the city, ensure we have pots and everything, and get some antidote potions - actions = append(actions, - a.builder.ReturnTown(), - a.builder.VendorRefill(false, true), - a.builder.BuyAtVendor(npc.Akara, action.VendorItemRequest{ - Item: "AntidotePotion", - Quantity: potsToBuy, - Tab: 4, - }), - action.NewStepChain(func(d data.Data) []step.Step { - return []step.Step{ - step.SyncStep(func(d data.Data) error { - hid.PressKey(config.Config.Bindings.OpenInventory) - x := 0 - for _, itm := range d.Items.ByLocation(item.LocationInventory) { - if itm.Name != "AntidotePotion" { - continue - } - - pos := ui.GetScreenCoordsForItem(itm) - hid.MovePointer(pos.X, pos.Y) - helper.Sleep(500) - - if x > 3 { - hid.Click(hid.LeftButton) - helper.Sleep(300) - hid.MovePointer(ui.MercAvatarPositionX, ui.MercAvatarPositionY) - helper.Sleep(300) - hid.Click(hid.LeftButton) - } else { - hid.Click(hid.RightButton) - } - x++ - } - - hid.PressKey("esc") - return nil - }), + action.VendorRefill(false, true) + action.BuyAtVendor(npc.Akara, action.VendorItemRequest{ + Item: "AntidotePotion", + Quantity: potsToBuy, + Tab: 4, + }) + + a.ctx.HID.PressKeyBinding(a.ctx.Data.KeyBindings.Inventory) + + x := 0 + for _, itm := range a.ctx.Data.Inventory.ByLocation(item.LocationInventory) { + if itm.Name != "AntidotePotion" { + continue + } + pos := ui.GetScreenCoordsForItem(itm) + utils.Sleep(500) + + if x > 3 { + a.ctx.HID.Click(game.LeftButton, pos.X, pos.Y) + utils.Sleep(300) + if a.ctx.Data.LegacyGraphics { + a.ctx.HID.Click(game.LeftButton, ui.MercAvatarPositionXClassic, ui.MercAvatarPositionYClassic) + } else { + a.ctx.HID.Click(game.LeftButton, ui.MercAvatarPositionX, ui.MercAvatarPositionY) } - }), - a.builder.UsePortalInTown(), - a.builder.Buff(), - ) - - actions = append(actions, - a.builder.UsePortalInTown(), - a.builder.MoveTo(func(d data.Data) (data.Position, bool) { - return andarielStartingPosition, true - }), - a.char.KillAndariel(), - a.builder.ReturnTown(), - a.builder.InteractNPC(npc.Warriv, step.KeySequence("home", "down", "enter")), - ) - - return actions -} + } else { + a.ctx.HID.Click(game.RightButton, pos.X, pos.Y) + } + x++ + } -func (a Leveling) isCainInTown(d data.Data) bool { - _, found := d.Monsters.FindOne(npc.DeckardCain5, data.MonsterTypeNone) + a.ctx.HID.PressKey(win.VK_ESCAPE) - return found + action.UsePortalInTown() + action.Buff() + + action.MoveTo(func() (data.Position, bool) { + return andarielAttackPos1, true + }) + a.ctx.Char.KillAndariel() + action.ReturnTown() + action.InteractNPC(npc.Warriv) + a.ctx.HID.KeySequence(win.VK_HOME, win.VK_DOWN, win.VK_RETURN) + + return nil } diff --git a/internal/run/leveling_act2.go b/internal/run/leveling_act2.go index e43d4b242..0f5c563d3 100644 --- a/internal/run/leveling_act2.go +++ b/internal/run/leveling_act2.go @@ -6,220 +6,339 @@ import ( "github.com/hectorgimenez/d2go/pkg/data/item" "github.com/hectorgimenez/d2go/pkg/data/npc" "github.com/hectorgimenez/d2go/pkg/data/object" + "github.com/hectorgimenez/d2go/pkg/data/quest" "github.com/hectorgimenez/d2go/pkg/data/stat" "github.com/hectorgimenez/koolo/internal/action" - "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" - "github.com/hectorgimenez/koolo/internal/pather" - "github.com/hectorgimenez/koolo/internal/reader" + "github.com/hectorgimenez/koolo/internal/game" "github.com/hectorgimenez/koolo/internal/ui" + "github.com/hectorgimenez/koolo/internal/utils" + "github.com/lxn/win" ) -func (a Leveling) act2() action.Action { +func (a Leveling) act2() error { running := false - return action.NewChain(func(d data.Data) []action.Action { - if running || d.PlayerUnit.Area != area.LutGholein { - return nil + + if running || a.ctx.Data.PlayerUnit.Area != area.LutGholein { + return nil + } + + running = true + // Find Horadric Cube + _, found := a.ctx.Data.Inventory.Find("HoradricCube", item.LocationInventory, item.LocationStash) + if found { + a.ctx.Logger.Info("Horadric Cube found, skipping quest") + } else { + a.ctx.Logger.Info("Horadric Cube not found, starting quest") + return a.findHoradricCube() + } + + if a.ctx.Data.Quests[quest.Act2TheSummoner].Completed() { + // Try to get level 21 before moving to Duriel and Act3 + + if lvl, _ := a.ctx.Data.PlayerUnit.FindStat(stat.Level, 0); lvl.Value < 18 { + return TalRashaTombs{}.Run() } - running = true - // Find Horadric Cube - _, found := d.Items.Find("HoradricCube", item.LocationInventory, item.LocationStash) + return a.duriel(a.ctx.Data.Quests[quest.Act2TheHoradricStaff].Completed(), *a.ctx.Data) + } + + _, horadricStaffFound := a.ctx.Data.Inventory.Find("HoradricStaff", item.LocationInventory, item.LocationStash, item.LocationEquipped) + + // Find Staff of Kings + _, found = a.ctx.Data.Inventory.Find("StaffOfKings", item.LocationInventory, item.LocationStash, item.LocationEquipped) + if found || horadricStaffFound { + a.ctx.Logger.Info("StaffOfKings found, skipping quest") + } else { + a.ctx.Logger.Info("StaffOfKings not found, starting quest") + return a.findStaff() + } + + // Find Amulet + _, found = a.ctx.Data.Inventory.Find("AmuletOfTheViper", item.LocationInventory, item.LocationStash, item.LocationEquipped) + if found || horadricStaffFound { + a.ctx.Logger.Info("Amulet of the Viper found, skipping quest") + } else { + a.ctx.Logger.Info("Amulet of the Viper not found, starting quest") + return a.findAmulet() + } + + // Summoner + a.ctx.Logger.Info("Starting summoner quest") + return a.summoner() + +} + +func (a Leveling) findHoradricCube() error { + err := action.WayPoint(area.HallsOfTheDeadLevel2) + if err != nil { + return err + } + + err = action.MoveToArea(area.HallsOfTheDeadLevel3) + if err != nil { + return err + } + + err = action.MoveTo(func() (data.Position, bool) { + chest, found := a.ctx.Data.Objects.FindOne(object.HoradricCubeChest) if found { - a.logger.Info("Horadric Cube found, skipping quest") - } else { - a.logger.Info("Horadric Cube not found, starting quest") - return a.findHoradricCube() + a.ctx.Logger.Info("Horadric Cube chest found, moving to that room") + return chest.Position, true } + return data.Position{}, false + }) + if err != nil { + return err + } - quests := a.builder.GetCompletedQuests(2) - if quests[4] { - // Try to get level 21 before moving to Duriel and Act3 - if d.PlayerUnit.Stats[stat.Level] < 18 { - return TalRashaTombs{a.baseRun}.BuildActions() - } + action.ClearAreaAroundPlayer(15, data.MonsterAnyFilter()) + + obj, found := a.ctx.Data.Objects.FindOne(object.HoradricCubeChest) + if !found { + return err + } - return a.duriel(quests[1], d) + err = action.InteractObject(obj, func() bool { + updatedObj, found := a.ctx.Data.Objects.FindOne(object.HoradricCubeChest) + if found { + if !updatedObj.Selectable { + a.ctx.Logger.Debug("Interacted with Horadric Cube Chest") + } + return !updatedObj.Selectable } + return false + }) + if err != nil { + return err + } - _, horadricStaffFound := d.Items.Find("HoradricStaff", item.LocationInventory, item.LocationStash, item.LocationEquipped) + return nil +} - // Find Staff of Kings - _, found = d.Items.Find("StaffOfKings", item.LocationInventory, item.LocationStash, item.LocationEquipped) - if found || horadricStaffFound { - a.logger.Info("StaffOfKings found, skipping quest") - } else { - a.logger.Info("StaffOfKings not found, starting quest") - return a.findStaff() - } +func (a Leveling) findStaff() error { + err := action.WayPoint(area.FarOasis) + if err != nil { + return err + } - // Find Amulet - _, found = d.Items.Find("AmuletOfTheViper", item.LocationInventory, item.LocationStash, item.LocationEquipped) - if found || horadricStaffFound { - a.logger.Info("Amulet of the Viper found, skipping quest") - } else { - a.logger.Info("Amulet of the Viper not found, starting quest") - return a.findAmulet() + err = action.MoveToArea(area.MaggotLairLevel1) + if err != nil { + return err + } + + err = action.MoveToArea(area.MaggotLairLevel2) + if err != nil { + return err + } + + err = action.MoveToArea(area.MaggotLairLevel3) + if err != nil { + return err + } + err = action.MoveTo(func() (data.Position, bool) { + chest, found := a.ctx.Data.Objects.FindOne(object.StaffOfKingsChest) + if found { + a.ctx.Logger.Info("Staff Of Kings chest found, moving to that room") + return chest.Position, true } + return data.Position{}, false + }) + if err != nil { + return err + } + + action.ClearAreaAroundPlayer(15, data.MonsterAnyFilter()) - // Summoner - a.logger.Info("Starting summoner quest") - return a.summoner() + obj, found := a.ctx.Data.Objects.FindOne(object.StaffOfKingsChest) + if !found { + return err + } + + err = action.InteractObject(obj, func() bool { + updatedObj, found := a.ctx.Data.Objects.FindOne(object.StaffOfKingsChest) + if found { + if !updatedObj.Selectable { + a.ctx.Logger.Debug("Interacted with Staff Of Kings Chest") + } + return !updatedObj.Selectable + } + return false }) + if err != nil { + return err + } + + return nil } -//func (a Leveling) radament() action.Action { -// return action.NewChain(func(d data.Data) (actions []action.Action) { -// actions = append(actions, -// a.builder.WayPoint(area.SewersLevel2Act2), -// a.builder.MoveToArea(area.SewersLevel3Act2), -// ) -// -// // TODO: Find Radament (use 355 object to locate him) -// return -// }) -//} - -func (a Leveling) findHoradricCube() []action.Action { - return []action.Action{ - a.builder.WayPoint(area.HallsOfTheDeadLevel2), - a.builder.MoveToArea(area.HallsOfTheDeadLevel3), - a.builder.MoveTo(func(d data.Data) (data.Position, bool) { - a.logger.Info("Horadric Cube chest found, moving to that room") - chest, found := d.Objects.FindOne(object.HoradricCubeChest) - - return chest.Position, found - }), - a.builder.ClearAreaAroundPlayer(15), - a.builder.InteractObject(object.HoradricCubeChest, func(d data.Data) bool { - chest, _ := d.Objects.FindOne(object.HoradricCubeChest) - return !chest.Selectable - }), - a.builder.ItemPickup(true, 10), +func (a Leveling) findAmulet() error { + err := action.WayPoint(area.LostCity) + if err != nil { + return err } -} -func (a Leveling) findStaff() []action.Action { - return []action.Action{ - a.builder.WayPoint(area.FarOasis), - a.builder.MoveToArea(area.MaggotLairLevel1), - a.builder.MoveToArea(area.MaggotLairLevel2), - a.builder.MoveToArea(area.MaggotLairLevel3), - a.builder.MoveTo(func(d data.Data) (data.Position, bool) { - a.logger.Info("Staff Of Kings chest found, moving to that room") - chest, found := d.Objects.FindOne(object.StaffOfKingsChest) - - return chest.Position, found - }), - a.builder.ClearAreaAroundPlayer(15), - a.builder.InteractObject(object.StaffOfKingsChest, func(d data.Data) bool { - chest, _ := d.Objects.FindOne(object.StaffOfKingsChest) - return !chest.Selectable - }), - a.builder.ItemPickup(true, 10), + err = action.MoveToArea(area.ValleyOfSnakes) + if err != nil { + return err } -} -func (a Leveling) findAmulet() []action.Action { - return []action.Action{ - a.builder.WayPoint(area.LostCity), - a.builder.MoveToArea(area.ValleyOfSnakes), - a.builder.MoveToArea(area.ClawViperTempleLevel1), - a.builder.MoveToArea(area.ClawViperTempleLevel2), - a.builder.MoveTo(func(d data.Data) (data.Position, bool) { - a.logger.Info("Altar found, moving closer") - chest, found := d.Objects.FindOne(object.TaintedSunAltar) - - return chest.Position, found - }), - a.builder.InteractObject(object.TaintedSunAltar, func(d data.Data) bool { - chest, _ := d.Objects.FindOne(object.TaintedSunAltar) - return !chest.Selectable - }), - a.builder.ItemPickup(true, 10), + err = action.MoveToArea(area.ClawViperTempleLevel1) + if err != nil { + return err } + + err = action.MoveToArea(area.ClawViperTempleLevel2) + if err != nil { + return err + } + err = action.MoveTo(func() (data.Position, bool) { + chest, found := a.ctx.Data.Objects.FindOne(object.TaintedSunAltar) + if found { + a.ctx.Logger.Info("Tainted Sun Altar found, moving to that room") + return chest.Position, true + } + return data.Position{}, false + }) + if err != nil { + return err + } + + action.ClearAreaAroundPlayer(15, data.MonsterAnyFilter()) + + obj, found := a.ctx.Data.Objects.FindOne(object.TaintedSunAltar) + if !found { + return err + } + + err = action.InteractObject(obj, func() bool { + updatedObj, found := a.ctx.Data.Objects.FindOne(object.TaintedSunAltar) + if found { + if !updatedObj.Selectable { + a.ctx.Logger.Debug("Interacted with Tainted Sun Altar") + } + return !updatedObj.Selectable + } + return false + }) + if err != nil { + return err + } + + return nil } -func (a Leveling) summoner() []action.Action { - actions := Summoner{baseRun: a.baseRun}.BuildActions() +func (a Leveling) summoner() error { + // Start Summoner run to find and kill Summoner + err := Summoner{}.Run() + if err != nil { + return err + } + + tome, found := a.ctx.Data.Objects.FindOne(object.YetAnotherTome) + if !found { + return err + } // Try to use the portal and discover the waypoint - actions = append(actions, - a.builder.InteractObject(object.YetAnotherTome, func(d data.Data) bool { - _, found := d.Objects.FindOne(object.PermanentTownPortal) - return found - }), - a.builder.InteractObject(object.PermanentTownPortal, func(d data.Data) bool { - return d.PlayerUnit.Area == area.CanyonOfTheMagi - }), - a.builder.DiscoverWaypoint(), - a.builder.ReturnTown(), - a.builder.InteractNPC(npc.Atma, step.KeySequence("esc")), - ) - - return actions + err = action.InteractObject(tome, func() bool { + _, found := a.ctx.Data.Objects.FindOne(object.PermanentTownPortal) + return found + }) + if err != nil { + return err + } + + portal, found := a.ctx.Data.Objects.FindOne(object.PermanentTownPortal) + if !found { + return err + } + + err = action.InteractObject(portal, func() bool { + return a.ctx.Data.PlayerUnit.Area == area.CanyonOfTheMagi && a.ctx.Data.AreaData.IsInside(a.ctx.Data.PlayerUnit.Position) + }) + if err != nil { + return err + } + + err = action.DiscoverWaypoint() + if err != nil { + return err + } + + err = action.ReturnTown() + if err != nil { + return err + } + + err = action.InteractNPC(npc.Atma) + if err != nil { + return err + } + + a.ctx.HID.PressKey(win.VK_ESCAPE) + + return nil } -func (a Leveling) prepareStaff() action.Action { - return action.NewChain(func(d data.Data) (actions []action.Action) { - horadricStaff, found := d.Items.Find("HoradricStaff", item.LocationInventory, item.LocationStash, item.LocationEquipped) - if found { - a.logger.Info("Horadric Staff found!") - if horadricStaff.Location == item.LocationStash { - a.logger.Info("It's in the stash, let's pickup it (not done yet)") - - return []action.Action{ - a.builder.InteractObject(object.Bank, func(d data.Data) bool { - return d.OpenMenus.Stash - }, - step.SyncStep(func(d data.Data) error { - screenPos := ui.GetScreenCoordsForItem(horadricStaff) - hid.MovePointer(screenPos.X, screenPos.Y) - - hid.KeyDown("control") - helper.Sleep(300) - hid.Click(hid.LeftButton) - helper.Sleep(200) - hid.KeyUp("control") - helper.Sleep(300) - hid.PressKey("esc") - return nil - }), - ), - } +func (a Leveling) prepareStaff() error { + horadricStaff, found := a.ctx.Data.Inventory.Find("HoradricStaff", item.LocationInventory, item.LocationStash, item.LocationEquipped) + if found { + a.ctx.Logger.Info("Horadric Staff found!") + if horadricStaff.Location.LocationType == item.LocationStash { + a.ctx.Logger.Info("It's in the stash, let's pick it up") + + bank, found := a.ctx.Data.Objects.FindOne(object.Bank) + if !found { + a.ctx.Logger.Info("bank object not found") } - return nil - } + err := action.InteractObject(bank, func() bool { + return a.ctx.Data.OpenMenus.Stash + }) + if err != nil { + return err + } - staff, found := d.Items.Find("StaffOfKings", item.LocationInventory, item.LocationStash, item.LocationEquipped) - if !found { - a.logger.Info("Staff of Kings not found, skipping") - return nil - } + screenPos := ui.GetScreenCoordsForItem(horadricStaff) + a.ctx.HID.ClickWithModifier(game.LeftButton, screenPos.X, screenPos.Y, game.CtrlKey) + utils.Sleep(300) + a.ctx.HID.PressKey(win.VK_ESCAPE) - amulet, found := d.Items.Find("AmuletOfTheViper", item.LocationInventory, item.LocationStash, item.LocationEquipped) - if !found { - a.logger.Info("AmuletOfTheViper not found, skipping") return nil } + } - return []action.Action{ - a.builder.CubeAddItems(staff, amulet), - a.builder.CubeTransmute(), - } - }) + staff, found := a.ctx.Data.Inventory.Find("StaffOfKings", item.LocationInventory, item.LocationStash, item.LocationEquipped) + if !found { + a.ctx.Logger.Info("Staff of Kings not found, skipping") + return nil + } + + amulet, found := a.ctx.Data.Inventory.Find("AmuletOfTheViper", item.LocationInventory, item.LocationStash, item.LocationEquipped) + if !found { + a.ctx.Logger.Info("Amulet of the Viper not found, skipping") + return nil + } + + err := action.CubeAddItems(staff, amulet) + if err != nil { + return err + } + + err = action.CubeTransmute() + if err != nil { + return err + } + + return nil } -func (a Leveling) duriel(staffAlreadyUsed bool, d data.Data) (actions []action.Action) { - a.logger.Info("Starting Duriel....") +func (a Leveling) duriel(staffAlreadyUsed bool, d game.Data) error { + a.ctx.Logger.Info("Starting Duriel....") - var realTomb area.Area + var realTomb area.ID for _, tomb := range talRashaTombs { - _, _, objects, _ := reader.CachedMapData.NPCsExitsAndObjects(data.Position{}, tomb) - for _, obj := range objects { + for _, obj := range a.ctx.Data.Areas[tomb].Objects { if obj.Name == object.HoradricOrifice { realTomb = tomb break @@ -228,54 +347,49 @@ func (a Leveling) duriel(staffAlreadyUsed bool, d data.Data) (actions []action.A } if realTomb == 0 { - a.logger.Info("Could not find the real tomb :(") + a.ctx.Logger.Info("Could not find the real tomb :(") return nil } if !staffAlreadyUsed { - actions = append(actions, a.prepareStaff()) + a.prepareStaff() } // Move close to the Horadric Orifice - actions = append(actions, - a.builder.WayPoint(area.CanyonOfTheMagi), - a.builder.Buff(), - a.builder.MoveToArea(realTomb), - a.builder.MoveTo(func(d data.Data) (data.Position, bool) { - orifice, _ := d.Objects.FindOne(object.HoradricOrifice) - - return orifice.Position, true - }), - ) + action.WayPoint(area.CanyonOfTheMagi) + action.Buff() + action.MoveToArea(realTomb) + action.MoveTo(func() (data.Position, bool) { + orifice, found := d.Objects.FindOne(object.HoradricOrifice) + if !found { + return data.Position{}, false + } + return orifice.Position, true + }) // If staff has not been used, then put it in the orifice and wait for the entrance to open if !staffAlreadyUsed { - actions = append(actions, - a.builder.ClearAreaAroundPlayer(20), - a.builder.InteractObject(object.HoradricOrifice, func(d data.Data) bool { - return d.OpenMenus.Anvil - }, - step.SyncStep(func(d data.Data) error { - staff, _ := d.Items.Find("HoradricStaff", item.LocationInventory) - - screenPos := ui.GetScreenCoordsForItem(staff) - hid.MovePointer(screenPos.X, screenPos.Y) - - helper.Sleep(300) - hid.Click(hid.LeftButton) - hid.MovePointer(ui.AnvilCenterX, ui.AnvilCenterY) - helper.Sleep(300) - hid.Click(hid.LeftButton) - helper.Sleep(300) - hid.MovePointer(ui.AnvilBtnX, ui.AnvilBtnY) - helper.Sleep(500) - hid.Click(hid.LeftButton) - helper.Sleep(20000) - - return nil - }), - ), - ) + action.ClearAreaAroundPlayer(30, data.MonsterAnyFilter()) + + orifice, found := a.ctx.Data.Objects.FindOne(object.HoradricOrifice) + if !found { + a.ctx.Logger.Info("Horadric Orifice not found") + return nil + } + + action.InteractObject(orifice, func() bool { + return d.OpenMenus.Anvil + }) + + staff, _ := d.Inventory.Find("HoradricStaff", item.LocationInventory) + screenPos := ui.GetScreenCoordsForItem(staff) + + a.ctx.HID.Click(game.LeftButton, screenPos.X, screenPos.Y) + utils.Sleep(300) + a.ctx.HID.Click(game.LeftButton, ui.AnvilCenterX, ui.AnvilCenterY) + utils.Sleep(500) + a.ctx.HID.Click(game.LeftButton, ui.AnvilBtnX, ui.AnvilBtnY) + utils.Sleep(20000) } potsToBuy := 4 @@ -284,84 +398,76 @@ func (a Leveling) duriel(staffAlreadyUsed bool, d data.Data) (actions []action.A } // Return to the city, ensure we have pots and everything, and get some thawing potions - actions = append(actions, - a.builder.ReturnTown(), - a.builder.ReviveMerc(), - a.builder.VendorRefill(false, true), - a.builder.BuyAtVendor(npc.Lysander, action.VendorItemRequest{ - Item: "ThawingPotion", - Quantity: potsToBuy, - Tab: 4, - }), - action.NewStepChain(func(d data.Data) []step.Step { - return []step.Step{ - step.SyncStep(func(d data.Data) error { - hid.PressKey(config.Config.Bindings.OpenInventory) - x := 0 - for _, itm := range d.Items.ByLocation(item.LocationInventory) { - if itm.Name != "ThawingPotion" { - continue - } - - pos := ui.GetScreenCoordsForItem(itm) - hid.MovePointer(pos.X, pos.Y) - helper.Sleep(500) - - if x > 3 { - hid.Click(hid.LeftButton) - helper.Sleep(300) - hid.MovePointer(ui.MercAvatarPositionX, ui.MercAvatarPositionY) - helper.Sleep(300) - hid.Click(hid.LeftButton) - } else { - hid.Click(hid.RightButton) - } - x++ - } - - hid.PressKey("esc") - return nil - }), - } - }), - a.builder.UsePortalInTown(), - a.builder.Buff(), - ) - - return append(actions, - action.NewChain(func(d data.Data) []action.Action { - _, found := d.Objects.FindOne(object.DurielsLairPortal) - if found { - return []action.Action{a.builder.InteractObject(object.DurielsLairPortal, func(d data.Data) bool { - return d.PlayerUnit.Area == area.DurielsLair - })} - } - return nil - }), - a.char.KillDuriel(), - a.builder.ItemPickup(true, 30), - a.builder.MoveToCoords(data.Position{ - X: 22577, - Y: 15613, - }), - a.builder.InteractNPCWithCheck(npc.Tyrael, func(d data.Data) bool { - obj, found := d.Objects.FindOne(object.TownPortal) - if found && pather.DistanceFromMe(d, obj.Position) < 10 { - return true + action.ReturnTown() + action.ReviveMerc() + action.VendorRefill(false, true) + action.BuyAtVendor(npc.Lysander, action.VendorItemRequest{ + Item: "ThawingPotion", + Quantity: potsToBuy, + Tab: 4, + }) + + a.ctx.HID.PressKeyBinding(d.KeyBindings.Inventory) + x := 0 + for _, itm := range d.Inventory.ByLocation(item.LocationInventory) { + if itm.Name != "ThawingPotion" { + continue + } + + pos := ui.GetScreenCoordsForItem(itm) + utils.Sleep(500) + + if x > 3 { + a.ctx.HID.Click(game.LeftButton, pos.X, pos.Y) + utils.Sleep(300) + if d.LegacyGraphics { + a.ctx.HID.Click(game.LeftButton, ui.MercAvatarPositionXClassic, ui.MercAvatarPositionYClassic) + } else { + a.ctx.HID.Click(game.LeftButton, ui.MercAvatarPositionX, ui.MercAvatarPositionY) } + } else { + a.ctx.HID.Click(game.RightButton, pos.X, pos.Y) + } + x++ + } + + a.ctx.HID.PressKey(win.VK_ESCAPE) + + action.UsePortalInTown() + action.Buff() + + duriellair, found := d.Objects.FindOne(object.DurielsLairPortal) + if found { + action.InteractObject(duriellair, func() bool { + return d.PlayerUnit.Area == area.DurielsLair + }) + } + + a.ctx.Char.KillDuriel() + + action.MoveToCoords(data.Position{ + X: 22577, + Y: 15613, + }) + + action.InteractNPC(npc.Tyrael) + a.ctx.HID.PressKey(win.VK_ESCAPE) + + action.ReturnTown() + action.MoveToCoords(data.Position{ + X: 5092, + Y: 5144, + }) + + action.InteractNPC(npc.Jerhyn) + a.ctx.HID.PressKey(win.VK_ESCAPE) + + action.MoveToCoords(data.Position{ + X: 5195, + Y: 5060, + }) + action.InteractNPC(npc.Meshif) + a.ctx.HID.KeySequence(win.VK_HOME, win.VK_DOWN, win.VK_RETURN) - return false - }), - a.builder.ReturnTown(), - a.builder.MoveToCoords(data.Position{ - X: 5092, - Y: 5144, - }), - a.builder.InteractNPC(npc.Jerhyn, step.KeySequence("esc")), - a.builder.MoveToCoords(data.Position{ - X: 5195, - Y: 5060, - }), - a.builder.InteractNPC(npc.Meshif, step.KeySequence("home", "down", "enter")), - ) + return nil } diff --git a/internal/run/leveling_act3.go b/internal/run/leveling_act3.go index 1c4838d6c..09ac2e3fa 100644 --- a/internal/run/leveling_act3.go +++ b/internal/run/leveling_act3.go @@ -3,220 +3,356 @@ package run import ( "time" + "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/d2go/pkg/data/quest" + "github.com/hectorgimenez/koolo/internal/action" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/ui" + "github.com/hectorgimenez/koolo/internal/utils" + "github.com/lxn/win" + "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/d2go/pkg/data/item" "github.com/hectorgimenez/d2go/pkg/data/object" - "github.com/hectorgimenez/koolo/internal/action" - "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" - "github.com/hectorgimenez/koolo/internal/ui" ) -func (a Leveling) act3() action.Action { +func (a Leveling) act3() error { running := false - return action.NewChain(func(d data.Data) []action.Action { - if running || d.PlayerUnit.Area != area.KurastDocks { - return nil - } - quests := a.builder.GetCompletedQuests(3) + if running || a.ctx.Data.PlayerUnit.Area != area.KurastDocks { + return nil + } - running = true - _, willFound := d.Items.Find("KhalimsWill", item.LocationInventory, item.LocationStash) - if willFound { - return a.openMephistoStairs() - } + // Try to find Hratli at pier, if he's there, talk to him, so he will move to the normal position later + hratli, found := a.ctx.Data.Monsters.FindOne(npc.Hratli, data.MonsterTypeNone) + if found { + action.InteractNPC(hratli.Name) + } - if quests[2] { - actions := Mephisto{baseRun: a.baseRun}.BuildActions() - return append(actions, a.builder.ItemPickup(true, 25), - a.builder.InteractObject(object.HellGate, func(d data.Data) bool { - return d.PlayerUnit.Area == area.ThePandemoniumFortress - }), - ) - } + running = true + _, willFound := a.ctx.Data.Inventory.Find("KhalimsWill", item.LocationInventory, item.LocationStash) + if willFound { + a.openMephistoStairs() + } - // Find KhalimsEye - _, found := d.Items.Find("KhalimsEye", item.LocationInventory, item.LocationStash) - if found { - a.logger.Info("KhalimsEye found, skipping quest") - } else { - a.logger.Info("KhalimsEye not found, starting quest") - return a.findKhalimsEye() - } + hellgate, found := a.ctx.Data.Objects.FindOne(object.HellGate) + if !found { + a.ctx.Logger.Info("Gate to Pandemonium Fortress not found") + } - // Find KhalimsBrain - _, found = d.Items.Find("KhalimsBrain", item.LocationInventory, item.LocationStash) - if found { - a.logger.Info("KhalimsBrain found, skipping quest") - } else { - a.logger.Info("KhalimsBrain not found, starting quest") - return a.findKhalimsBrain() + if a.ctx.Data.Quests[quest.Act3KhalimsWill].Completed() { + //Mephisto{}.Run() + action.InteractObject(hellgate, func() bool { + return a.ctx.Data.PlayerUnit.Area == area.ThePandemoniumFortress + }) + } + + // Find KhalimsEye + _, found = a.ctx.Data.Inventory.Find("KhalimsEye", item.LocationInventory, item.LocationStash) + if found { + a.ctx.Logger.Info("KhalimsEye found, skipping quest") + } else { + a.ctx.Logger.Info("KhalimsEye not found, starting quest") + a.findKhalimsEye() + } + + // Find KhalimsBrain + _, found = a.ctx.Data.Inventory.Find("KhalimsBrain", item.LocationInventory, item.LocationStash) + if found { + a.ctx.Logger.Info("KhalimsBrain found, skipping quest") + } else { + a.ctx.Logger.Info("KhalimsBrain not found, starting quest") + a.findKhalimsBrain() + } + + // Find KhalimsHeart + _, found = a.ctx.Data.Inventory.Find("KhalimsHeart", item.LocationInventory, item.LocationStash) + if found { + a.ctx.Logger.Info("KhalimsHeart found, skipping quest") + } else { + a.ctx.Logger.Info("KhalimsHeart not found, starting quest") + a.findKhalimsHeart() + } + + // Trav + a.openMephistoStairs() + + return a.ctx.Char.KillMephisto() +} + +func (a Leveling) findKhalimsEye() error { + err := action.WayPoint(area.SpiderForest) + if err != nil { + return err + } + action.Buff() + + err = action.MoveToArea(area.SpiderCavern) + if err != nil { + return err + } + action.Buff() + + err = action.MoveTo(func() (data.Position, bool) { + for _, o := range a.ctx.Data.Objects { + if o.Name == object.InifussTree { + return o.Position, true + } } + return data.Position{}, false + }) + if err != nil { + return err + } - // Find KhalimsHeart - _, found = d.Items.Find("KhalimsHeart", item.LocationInventory, item.LocationStash) + err = action.MoveTo(func() (data.Position, bool) { + chest, found := a.ctx.Data.Objects.FindOne(object.KhalimChest3) if found { - a.logger.Info("KhalimsHeart found, skipping quest") - } else { - a.logger.Info("KhalimsHeart not found, starting quest") - return a.findKhalimsHeart() + a.ctx.Logger.Info("Khalm Chest found, moving to that room") + return chest.Position, true } + return data.Position{}, false + }) + if err != nil { + return err + } + + action.ClearAreaAroundPlayer(15, data.MonsterAnyFilter()) - // Trav - return a.openMephistoStairs() + kalimchest3, found := a.ctx.Data.Objects.FindOne(object.KhalimChest3) + if !found { + a.ctx.Logger.Debug("Khalim Chest not found") + } + + err = action.InteractObject(kalimchest3, func() bool { + chest, _ := a.ctx.Data.Objects.FindOne(object.KhalimChest3) + return !chest.Selectable }) + if err != nil { + return err + } + + return nil } -func (a Leveling) findKhalimsEye() []action.Action { - return []action.Action{ - a.builder.WayPoint(area.SpiderForest), - a.builder.Buff(), - a.builder.MoveToArea(area.SpiderCavern), - a.builder.Buff(), - a.builder.MoveTo(func(d data.Data) (data.Position, bool) { - a.logger.Info("Khalm Chest found, moving to that room") - chest, found := d.Objects.FindOne(object.KhalimChest3) - - return chest.Position, found - }), - a.builder.ClearAreaAroundPlayer(15), - a.builder.InteractObject(object.KhalimChest3, func(d data.Data) bool { - chest, _ := d.Objects.FindOne(object.KhalimChest3) - return !chest.Selectable - }), - a.builder.ItemPickup(true, 10), +func (a Leveling) findKhalimsBrain() error { + err := action.WayPoint(area.FlayerJungle) + if err != nil { + return err } -} + action.Buff() + + err = action.MoveToArea(area.FlayerDungeonLevel1) + if err != nil { + return err + } + action.Buff() -func (a Leveling) findKhalimsBrain() []action.Action { - return []action.Action{ - a.builder.WayPoint(area.FlayerJungle), - a.builder.Buff(), - a.builder.MoveToArea(area.FlayerDungeonLevel1), - a.builder.Buff(), - a.builder.MoveToArea(area.FlayerDungeonLevel2), - a.builder.Buff(), - a.builder.MoveToArea(area.FlayerDungeonLevel3), - a.builder.Buff(), - a.builder.MoveTo(func(d data.Data) (data.Position, bool) { - a.logger.Info("Khalm Chest found, moving to that room") - chest, found := d.Objects.FindOne(object.KhalimChest2) - - return chest.Position, found - }), - //a.builder.ClearAreaAroundPlayer(15), - a.builder.InteractObject(object.KhalimChest2, func(d data.Data) bool { - chest, _ := d.Objects.FindOne(object.KhalimChest2) - return !chest.Selectable - }), - a.builder.ItemPickup(true, 10), + err = action.MoveToArea(area.FlayerDungeonLevel2) + if err != nil { + return err } + action.Buff() + + err = action.MoveToArea(area.FlayerDungeonLevel3) + if err != nil { + return err + } + action.Buff() + + err = action.MoveTo(func() (data.Position, bool) { + a.ctx.Logger.Info("Khalm Chest found, moving to that room") + chest, found := a.ctx.Data.Objects.FindOne(object.KhalimChest2) + + return chest.Position, found + }) + if err != nil { + return err + } + + action.ClearAreaAroundPlayer(15, data.MonsterAnyFilter()) + + kalimchest2, found := a.ctx.Data.Objects.FindOne(object.KhalimChest2) + if !found { + a.ctx.Logger.Debug("Khalim Chest not found") + } + + err = action.InteractObject(kalimchest2, func() bool { + chest, _ := a.ctx.Data.Objects.FindOne(object.KhalimChest2) + return !chest.Selectable + }) + if err != nil { + return err + } + + return nil } -func (a Leveling) findKhalimsHeart() []action.Action { - return []action.Action{ - a.builder.WayPoint(area.KurastBazaar), - a.builder.Buff(), - a.builder.MoveToArea(area.SewersLevel1Act3), - a.builder.Buff(), - a.builder.MoveTo(func(d data.Data) (data.Position, bool) { - for _, l := range d.AdjacentLevels { - if l.Area == area.SewersLevel2Act3 { - return l.Position, true - } +func (a Leveling) findKhalimsHeart() error { + err := action.WayPoint(area.KurastBazaar) + if err != nil { + return err + } + action.Buff() + + err = action.MoveToArea(area.SewersLevel1Act3) + if err != nil { + return err + } + action.Buff() + + err = action.MoveTo(func() (data.Position, bool) { + for _, l := range a.ctx.Data.AdjacentLevels { + if l.Area == area.SewersLevel2Act3 { + return l.Position, true } - return data.Position{}, false - }), - a.builder.ClearAreaAroundPlayer(10), - a.builder.InteractObject(object.Act3SewerStairsToLevel3, func(d data.Data) bool { - o, _ := d.Objects.FindOne(object.Act3SewerStairsToLevel3) - - return !o.Selectable - }), - a.builder.Wait(time.Second * 3), - a.builder.InteractObject(object.Act3SewerStairs, func(d data.Data) bool { - return d.PlayerUnit.Area == area.SewersLevel2Act3 - }), - a.builder.Buff(), - a.builder.MoveTo(func(d data.Data) (data.Position, bool) { - a.logger.Info("Khalm Chest found, moving to that room") - chest, found := d.Objects.FindOne(object.KhalimChest1) - - return chest.Position, found - }), - a.builder.ClearAreaAroundPlayer(15), - a.builder.InteractObject(object.KhalimChest1, func(d data.Data) bool { - chest, _ := d.Objects.FindOne(object.KhalimChest1) - return !chest.Selectable - }), - a.builder.ItemPickup(true, 10), + } + return data.Position{}, false + }) + if err != nil { + return err + } + + action.ClearAreaAroundPlayer(10, data.MonsterAnyFilter()) + + stairs, found := a.ctx.Data.Objects.FindOne(object.Act3SewerStairsToLevel3) + if !found { + a.ctx.Logger.Debug("Khalim Chest not found") } + + err = action.InteractObject(stairs, func() bool { + o, _ := a.ctx.Data.Objects.FindOne(object.Act3SewerStairsToLevel3) + + return !o.Selectable + }) + if err != nil { + return err + } + + time.Sleep(3000) + + err = action.InteractObject(stairs, func() bool { + return a.ctx.Data.PlayerUnit.Area == area.SewersLevel2Act3 + }) + if err != nil { + return err + } + action.Buff() + + err = action.MoveTo(func() (data.Position, bool) { + a.ctx.Logger.Info("Khalm Chest found, moving to that room") + chest, found := a.ctx.Data.Objects.FindOne(object.KhalimChest1) + + return chest.Position, found + }) + if err != nil { + return err + } + + action.ClearAreaAroundPlayer(15, data.MonsterAnyFilter()) + + kalimchest1, found := a.ctx.Data.Objects.FindOne(object.KhalimChest1) + if !found { + a.ctx.Logger.Debug("Khalim Chest not found") + } + + err = action.InteractObject(kalimchest1, func() bool { + chest, _ := a.ctx.Data.Objects.FindOne(object.KhalimChest1) + return !chest.Selectable + }) + if err != nil { + return err + } + + return nil } -func (a Leveling) openMephistoStairs() []action.Action { - actions := Council{baseRun: a.baseRun}.BuildActions() - - return append(actions, - a.builder.ItemPickup(true, 40), - a.builder.ReturnTown(), - action.NewChain(func(d data.Data) []action.Action { - eye, _ := d.Items.Find("KhalimsEye", item.LocationInventory, item.LocationStash) - brain, _ := d.Items.Find("KhalimsBrain", item.LocationInventory, item.LocationStash) - heart, _ := d.Items.Find("KhalimsHeart", item.LocationInventory, item.LocationStash) - flail, _ := d.Items.Find("KhalimsFlail", item.LocationInventory, item.LocationStash) - - return []action.Action{ - a.builder.CubeAddItems(eye, brain, heart, flail), - a.builder.CubeTransmute(), - } - }), - - a.builder.UsePortalInTown(), - action.NewStepChain(func(d data.Data) []step.Step { - return []step.Step{ - // Let's asume we don't have secondary weapon, so we swap to it and equip Khalim's Will - step.SyncStep(func(d data.Data) error { - khalimsWill, found := d.Items.Find("KhalimsWill") - if !found { - return nil - } - - hid.PressKey(config.Config.Bindings.SwapWeapon) - helper.Sleep(1000) - hid.PressKey(config.Config.Bindings.OpenInventory) - screenPos := ui.GetScreenCoordsForItem(khalimsWill) - hid.MovePointer(screenPos.X, screenPos.Y) - hid.KeyDown("shift") - helper.Sleep(500) - hid.Click(hid.LeftButton) - helper.Sleep(500) - hid.KeyUp("shift") - helper.Sleep(300) - hid.PressKey("esc") - return nil - }), - } - }), - a.builder.InteractObject(object.CompellingOrb, - func(d data.Data) bool { - o, _ := d.Objects.FindOne(object.CompellingOrb) - - return !o.Selectable - }, - step.SyncStep(func(d data.Data) error { - helper.Sleep(1000) - hid.PressKey(config.Config.Bindings.SwapWeapon) - return nil - })), - a.builder.Wait(time.Second*12), - a.builder.InteractObject(object.StairSR, func(d data.Data) bool { - return d.PlayerUnit.Area == area.DuranceOfHateLevel1 - }), - a.builder.MoveToArea(area.DuranceOfHateLevel2), - a.builder.DiscoverWaypoint(), - ) +func (a Leveling) openMephistoStairs() error { + // Use Travincal/Council run to kill the council + err := NewTravincal().Run() + if err != nil { + return err + } + + err = action.ReturnTown() + if err != nil { + return err + } + + eye, _ := a.ctx.Data.Inventory.Find("KhalimsEye", item.LocationInventory, item.LocationStash) + brain, _ := a.ctx.Data.Inventory.Find("KhalimsBrain", item.LocationInventory, item.LocationStash) + heart, _ := a.ctx.Data.Inventory.Find("KhalimsHeart", item.LocationInventory, item.LocationStash) + flail, _ := a.ctx.Data.Inventory.Find("KhalimsFlail", item.LocationInventory, item.LocationStash) + + // Combine Khalim's items in the Horadric Cube to create Khalim's Will + err = action.CubeAddItems(eye, brain, heart, flail) + if err != nil { + return err + } + + err = action.CubeTransmute() + if err != nil { + return err + } + + err = action.UsePortalInTown() + if err != nil { + return err + } + + // Assume we don't have a secondary weapon equipped, so swap to it and equip Khalim's Will + khalimsWill, found := a.ctx.Data.Inventory.Find("KhalimsWill", item.LocationInventory, item.LocationStash) + if !found { + a.ctx.Logger.Info("Khalim's Will not found, aborting mission.") + return nil + } + + // Swap weapons, equip Khalim's Will + a.ctx.HID.PressKeyBinding(a.ctx.Data.KeyBindings.SwapWeapons) + utils.Sleep(1000) + a.ctx.HID.PressKeyBinding(a.ctx.Data.KeyBindings.Inventory) + + screenPos := ui.GetScreenCoordsForItem(khalimsWill) + a.ctx.HID.ClickWithModifier(game.LeftButton, screenPos.X, screenPos.Y, game.ShiftKey) + utils.Sleep(300) + a.ctx.HID.PressKey(win.VK_ESCAPE) + + // Interact with the Compelling Orb to open the stairs + compellingorb, found := a.ctx.Data.Objects.FindOne(object.CompellingOrb) + if !found { + a.ctx.Logger.Debug("Khalim Chest not found") + } + + err = action.InteractObject(compellingorb, func() bool { + o, _ := a.ctx.Data.Objects.FindOne(object.CompellingOrb) + return !o.Selectable + }) + if err != nil { + return err + } + + utils.Sleep(1000) + a.ctx.HID.PressKeyBinding(a.ctx.Data.KeyBindings.SwapWeapons) + + time.Sleep(12000) + + // Interact with the stairs to go to Durance of Hate Level 1 + stairsr, found := a.ctx.Data.Objects.FindOne(object.StairSR) + if !found { + a.ctx.Logger.Debug("Khalim Chest not found") + } + + err = action.InteractObject(stairsr, func() bool { + return a.ctx.Data.PlayerUnit.Area == area.DuranceOfHateLevel1 + }) + if err != nil { + return err + } + + // Move to Durance of Hate Level 2 and discover the waypoint + action.MoveToArea(area.DuranceOfHateLevel2) + action.DiscoverWaypoint() + + return nil } diff --git a/internal/run/leveling_act4.go b/internal/run/leveling_act4.go index 32e5e9ea2..ec16e2abe 100644 --- a/internal/run/leveling_act4.go +++ b/internal/run/leveling_act4.go @@ -4,44 +4,70 @@ import ( "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/d2go/pkg/data/quest" "github.com/hectorgimenez/koolo/internal/action" ) -func (a Leveling) act4() action.Action { +func (a Leveling) act4() error { running := false - return action.NewChain(func(d data.Data) []action.Action { - if running || d.PlayerUnit.Area != area.ThePandemoniumFortress { - return nil - } + if running || a.ctx.Data.PlayerUnit.Area != area.ThePandemoniumFortress { + return nil + } + + running = true + + if !a.ctx.Data.Quests[quest.Act4TheFallenAngel].Completed() { + a.izual() + } + + diabloRun := NewDiablo() + err := diabloRun.Run() + if err != nil { + return err + } + + return nil +} - quests := a.builder.GetCompletedQuests(4) +func (a Leveling) izual() error { + err := action.MoveToArea(area.OuterSteppes) + if err != nil { + return err + } + action.Buff() - running = true + err = action.MoveToArea(area.PlainsOfDespair) + if err != nil { + return err + } + action.Buff() - if !quests[0] { - return a.izual() + err = action.MoveTo(func() (data.Position, bool) { + izual, found := a.ctx.Data.NPCs.FindOne(npc.Izual) + if !found { + return data.Position{}, false } - return Diablo{baseRun: a.baseRun, bm: a.bm}.BuildActions() + return izual.Positions[0], true }) -} + if err != nil { + return err + } -func (a Leveling) izual() []action.Action { - return []action.Action{ - a.builder.MoveToArea(area.OuterSteppes), - a.builder.Buff(), - a.builder.MoveToArea(area.PlainsOfDespair), - a.builder.Buff(), - a.builder.MoveTo(func(d data.Data) (data.Position, bool) { - izual, found := d.NPCs.FindOne(npc.Izual) - if !found { - return data.Position{}, false - } - - return izual.Positions[0], true - }), - a.char.KillIzual(), - a.builder.ReturnTown(), - a.builder.InteractNPC(npc.Tyrael2), + err = a.ctx.Char.KillIzual() + if err != nil { + return err } + + err = action.ReturnTown() + if err != nil { + return err + } + + err = action.InteractNPC(npc.Tyrael2) + if err != nil { + return err + } + + return nil } diff --git a/internal/run/leveling_act5.go b/internal/run/leveling_act5.go index 48270fb51..afe528cf9 100644 --- a/internal/run/leveling_act5.go +++ b/internal/run/leveling_act5.go @@ -1,132 +1,226 @@ package run import ( + "time" + "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/d2go/pkg/data/difficulty" "github.com/hectorgimenez/d2go/pkg/data/npc" "github.com/hectorgimenez/d2go/pkg/data/object" + "github.com/hectorgimenez/d2go/pkg/data/quest" "github.com/hectorgimenez/d2go/pkg/data/stat" "github.com/hectorgimenez/koolo/internal/action" - "github.com/hectorgimenez/koolo/internal/action/step" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" "github.com/hectorgimenez/koolo/internal/ui" + "github.com/hectorgimenez/koolo/internal/utils" + "github.com/lxn/win" ) -func (a Leveling) act5() action.Action { - return action.NewChain(func(d data.Data) []action.Action { - if d.PlayerUnit.Area != area.Harrogath { - return nil - } +func (a Leveling) act5() error { + if a.ctx.Data.PlayerUnit.Area != area.Harrogath { + return nil + } - quests := a.builder.GetCompletedQuests(5) - if quests[4] { - a.logger.Info("Starting Baal run...") - actions := Baal{baseRun: a.baseRun}.BuildActions() - return append(actions, action.NewStepChain(func(d data.Data) []step.Step { - if d.PlayerUnit.Area == area.TheWorldstoneChamber && len(d.Monsters.Enemies()) == 0 { - switch config.Config.Game.Difficulty { - case difficulty.Normal: - if d.PlayerUnit.Stats[stat.Level] >= 46 { - config.Config.Game.Difficulty = difficulty.Nightmare - } - case difficulty.Nightmare: - if d.PlayerUnit.Stats[stat.Level] >= 65 { - config.Config.Game.Difficulty = difficulty.Hell - } - } + if a.ctx.Data.Quests[quest.Act5RiteOfPassage].Completed() { + a.ctx.Logger.Info("Starting Baal run...") + Baal{}.Run() + + lvl, _ := a.ctx.Data.PlayerUnit.FindStat(stat.Level, 0) + if a.ctx.Data.PlayerUnit.Area == area.TheWorldstoneChamber && len(a.ctx.Data.Monsters.Enemies()) == 0 { + switch a.ctx.CharacterCfg.Game.Difficulty { + case difficulty.Normal: + if lvl.Value >= 46 { + a.ctx.CharacterCfg.Game.Difficulty = difficulty.Nightmare + } + case difficulty.Nightmare: + if lvl.Value >= 65 { + a.ctx.CharacterCfg.Game.Difficulty = difficulty.Hell } - return nil - })) + } } + return nil - wp, _ := d.Objects.FindOne(object.ExpansionWaypoint) - actions := []action.Action{a.builder.MoveToCoords(wp.Position)} - actions = append(actions, action.NewChain(func(d data.Data) []action.Action { - if _, found := d.Monsters.FindOne(npc.Drehya, data.MonsterTypeNone); !found { - return a.anya() - } + } - return a.ancients() - })) + wp, _ := a.ctx.Data.Objects.FindOne(object.ExpansionWaypoint) + action.MoveToCoords(wp.Position) - return actions - }) + if _, found := a.ctx.Data.Monsters.FindOne(npc.Drehya, data.MonsterTypeNone); !found { + a.anya() + } + + err := a.ancients() + if err != nil { + return err + } + + return nil } -func (a Leveling) anya() []action.Action { - a.logger.Info("Rescuing Anya...") - return []action.Action{ - a.builder.WayPoint(area.CrystallinePassage), - a.builder.MoveToArea(area.FrozenRiver), - a.builder.MoveTo(func(d data.Data) (data.Position, bool) { - anya, found := d.NPCs.FindOne(793) - return anya.Positions[0], found - }), - a.builder.MoveTo(func(d data.Data) (data.Position, bool) { - anya, found := d.Objects.FindOne(object.FrozenAnya) - return anya.Position, found - }), - a.builder.ClearAreaAroundPlayer(15), - a.builder.InteractObject(object.FrozenAnya, nil), - a.builder.ReturnTown(), - a.builder.IdentifyAll(false), - a.builder.Stash(false), - a.builder.ReviveMerc(), - a.builder.Repair(), - a.builder.VendorRefill(false, true), - a.builder.InteractNPC(npc.Malah), - a.builder.UsePortalInTown(), - a.builder.InteractObject(object.FrozenAnya, nil), - a.builder.ReturnTown(), - a.builder.InteractNPC(npc.Malah, - step.SyncStep(func(d data.Data) error { - hid.PressKey("esc") - hid.PressKey(config.Config.Bindings.OpenInventory) - itm, _ := d.Items.Find("ScrollOfResistance") - screenPos := ui.GetScreenCoordsForItem(itm) - hid.MovePointer(screenPos.X, screenPos.Y) - helper.Sleep(200) - hid.Click(hid.RightButton) - hid.PressKey("esc") - - return nil - }), - ), +func (a Leveling) anya() error { + a.ctx.Logger.Info("Rescuing Anya...") + + err := action.WayPoint(area.CrystallinePassage) + if err != nil { + return err + } + action.Buff() + + err = action.MoveToArea(area.FrozenRiver) + if err != nil { + return err + } + action.Buff() + + err = action.MoveTo(func() (data.Position, bool) { + anya, found := a.ctx.Data.NPCs.FindOne(793) + return anya.Positions[0], found + }) + if err != nil { + return err + } + + err = action.MoveTo(func() (data.Position, bool) { + anya, found := a.ctx.Data.Objects.FindOne(object.FrozenAnya) + return anya.Position, found + }) + if err != nil { + return err + } + + action.ClearAreaAroundPlayer(15, data.MonsterAnyFilter()) + + anya, found := a.ctx.Data.Objects.FindOne(object.FrozenAnya) + if !found { + a.ctx.Logger.Debug("Frozen Anya not found") + } + + err = action.InteractObject(anya, nil) + if err != nil { + return err + } + + err = action.ReturnTown() + if err != nil { + return err + } + + action.IdentifyAll(false) + action.Stash(false) + action.ReviveMerc() + action.Repair() + action.VendorRefill(false, true) + + err = action.InteractNPC(npc.Malah) + if err != nil { + return err + } + + err = action.UsePortalInTown() + if err != nil { + return err } + + err = action.InteractObject(anya, nil) + if err != nil { + return err + } + + err = action.ReturnTown() + if err != nil { + return err + } + + time.Sleep(8000) + + err = action.InteractNPC(npc.Malah) + if err != nil { + return err + } + + a.ctx.HID.PressKey(win.VK_ESCAPE) + a.ctx.HID.PressKeyBinding(a.ctx.Data.KeyBindings.Inventory) + itm, _ := a.ctx.Data.Inventory.Find("ScrollOfResistance") + screenPos := ui.GetScreenCoordsForItem(itm) + utils.Sleep(200) + a.ctx.HID.Click(game.RightButton, screenPos.X, screenPos.Y) + a.ctx.HID.PressKey(win.VK_ESCAPE) + + return nil } -func (a Leveling) ancients() []action.Action { - char := a.char.(action.LevelingCharacter) - a.logger.Info("Kill the Ancients...") - actions := []action.Action{ - a.builder.WayPoint(area.TheAncientsWay), - a.builder.MoveToArea(area.ArreatSummit), - a.builder.ReturnTown(), +func (a Leveling) ancients() error { + char := a.ctx.Char.(context.LevelingCharacter) + + a.ctx.Logger.Info("Kill the Ancients...") + + err := action.WayPoint(area.TheAncientsWay) + if err != nil { + return err } + action.Buff() - actions = append(actions, a.builder.InRunReturnTownRoutine()...) + err = action.MoveToArea(area.ArreatSummit) + if err != nil { + return err + } + action.Buff() - return append(actions, - a.builder.UsePortalInTown(), - a.builder.Buff(), - a.builder.InteractObject(object.AncientsAltar, func(d data.Data) bool { - if len(d.Monsters.Enemies()) > 0 { - return true - } - hid.Click(hid.LeftButton) - helper.Sleep(1000) - return false - }), - char.KillAncients(), - a.builder.InteractObject(object.ArreatSummitDoorToWorldstone, func(d data.Data) bool { - obj, _ := d.Objects.FindOne(object.ArreatSummitDoorToWorldstone) - return !obj.Selectable - }), - a.builder.MoveToArea(area.TheWorldStoneKeepLevel1), - a.builder.MoveToArea(area.TheWorldStoneKeepLevel2), - a.builder.DiscoverWaypoint(), - ) + action.ReturnTown() + action.InRunReturnTownRoutine() + action.UsePortalInTown() + action.Buff() + + ancientsaltar, found := a.ctx.Data.Objects.FindOne(object.AncientsAltar) + if !found { + a.ctx.Logger.Debug("Ancients Altar not found") + } + + err = action.InteractObject(ancientsaltar, func() bool { + if len(a.ctx.Data.Monsters.Enemies()) > 0 { + return true + } + a.ctx.HID.Click(game.LeftButton, 300, 300) + utils.Sleep(1000) + return false + }) + if err != nil { + return err + } + + err = char.KillAncients() + if err != nil { + return err + } + + summitdoor, found := a.ctx.Data.Objects.FindOne(object.ArreatSummitDoorToWorldstone) + if !found { + a.ctx.Logger.Debug("Worldstone Door not found") + } + + err = action.InteractObject(summitdoor, func() bool { + obj, _ := a.ctx.Data.Objects.FindOne(object.ArreatSummitDoorToWorldstone) + return !obj.Selectable + }) + if err != nil { + return err + } + + time.Sleep(5000) + + err = action.MoveToArea(area.TheWorldStoneKeepLevel1) + if err != nil { + return err + } + + err = action.MoveToArea(area.TheWorldStoneKeepLevel2) + if err != nil { + return err + } + + action.DiscoverWaypoint() + + return nil } diff --git a/internal/run/lower_kurast.go b/internal/run/lower_kurast.go index 5b20835da..53859ce77 100644 --- a/internal/run/lower_kurast.go +++ b/internal/run/lower_kurast.go @@ -4,19 +4,33 @@ import ( "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/koolo/internal/action" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" ) type LowerKurast struct { - baseRun + ctx *context.Status +} + +func NewLowerKurast() *LowerKurast { + return &LowerKurast{ + ctx: context.Get(), + } } func (a LowerKurast) Name() string { - return "LowerKurast" + return string(config.LowerKurastRun) } -func (a LowerKurast) BuildActions() (actions []action.Action) { - return []action.Action{ - a.builder.WayPoint(area.LowerKurast), // Moving to starting point (Lower Kurast) - a.builder.ClearArea(true, data.MonsterEliteFilter()), +func (a LowerKurast) Run() error { + + // Use Waypoint to Lower Kurast + err := action.WayPoint(area.LowerKurast) + if err != nil { + return err } + + // Clear Lower Kurast + return action.ClearCurrentLevel(true, data.MonsterAnyFilter()) + } diff --git a/internal/run/lower_kurast_chests.go b/internal/run/lower_kurast_chests.go new file mode 100644 index 000000000..91f71a27b --- /dev/null +++ b/internal/run/lower_kurast_chests.go @@ -0,0 +1,129 @@ +package run + +import ( + "fmt" + "slices" + "sort" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" + "github.com/hectorgimenez/d2go/pkg/data/object" + "github.com/hectorgimenez/koolo/internal/action" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/pather" + "github.com/hectorgimenez/koolo/internal/utils" +) + +var minChestDistanceFromBonfire = 25 +var maxChestDistanceFromBonfire = 45 + +type LowerKurastChests struct { + ctx *context.Status +} + +func NewLowerKurastChest() *LowerKurastChests { + return &LowerKurastChests{ + ctx: context.Get(), + } +} + +func (run LowerKurastChests) Name() string { + return string(config.LowerKurastChestRun) +} + +func (run LowerKurastChests) Run() error { + run.ctx.Logger.Debug("Running a Lower Kurast Chest run") + + // Use Waypoint to Lower Kurast + err := action.WayPoint(area.LowerKurast) + if err != nil { + return err + } + + // Get bonfires from cached map data + var bonFirePositions []data.Position + if areaData, ok := run.ctx.GameReader.GetData().Areas[area.LowerKurast]; ok { + for _, obj := range areaData.Objects { + if obj.Name == object.Name(160) { // SmallFire + run.ctx.Logger.Debug("Found bonfire at:", "position", obj.Position) + bonFirePositions = append(bonFirePositions, obj.Position) + } + } + } + + run.ctx.Logger.Debug("Total bonfires found", "count", len(bonFirePositions)) + + // Define objects to interact with : chests + weapon racks/armor stands (if enabled) + interactableObjects := []object.Name{object.JungleMediumChestLeft, object.JungleChest} + + if run.ctx.CharacterCfg.Game.LowerKurastChest.OpenRacks { + interactableObjects = append(interactableObjects, + object.ArmorStandRight, + object.ArmorStandLeft, + object.WeaponRackRight, + object.WeaponRackLeft, + ) + } + + // Move to each of the bonfires one by one + for _, bonfirePos := range bonFirePositions { + // Move to the bonfire + err = action.MoveToCoords(bonfirePos) + if err != nil { + return err + } + + // Find the interactable objects + var objects []data.Object + for _, o := range run.ctx.Data.Objects { + if slices.Contains(interactableObjects, o.Name) && isChestWithinBonfireRange(o, bonfirePos) { + objects = append(objects, o) + } + } + + // Interact with objects in the order of shortest travel + for len(objects) > 0 { + + playerPos := run.ctx.Data.PlayerUnit.Position + + sort.Slice(objects, func(i, j int) bool { + return pather.DistanceFromPoint(objects[i].Position, playerPos) < + pather.DistanceFromPoint(objects[j].Position, playerPos) + }) + + // Interact with the closest object + closestObject := objects[0] + err = action.InteractObject(closestObject, func() bool { + object, _ := run.ctx.Data.Objects.FindByID(closestObject.ID) + return !object.Selectable + }) + if err != nil { + run.ctx.Logger.Warn(fmt.Sprintf("[%s] failed interacting with object [%v] in Area: [%s]", run.ctx.Name, closestObject.Name, run.ctx.Data.PlayerUnit.Area.Area().Name), err) + } + utils.Sleep(500) // Add small delay to allow the game to open the object and drop the content + + // Remove the interacted container from the list + objects = objects[1:] + } + } + + // Return to town + if err = action.ReturnTown(); err != nil { + return err + } + + // Move to A4 if possible to shorten the run time + err = action.WayPoint(area.ThePandemoniumFortress) + if err != nil { + return err + } + + // Done + return nil +} + +func isChestWithinBonfireRange(chest data.Object, bonfirePosition data.Position) bool { + distance := pather.DistanceFromPoint(chest.Position, bonfirePosition) + return distance >= minChestDistanceFromBonfire && distance <= maxChestDistanceFromBonfire +} diff --git a/internal/run/mausoleum.go b/internal/run/mausoleum.go new file mode 100644 index 000000000..40cd4eac8 --- /dev/null +++ b/internal/run/mausoleum.go @@ -0,0 +1,56 @@ +package run + +import ( + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" + "github.com/hectorgimenez/koolo/internal/action" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" +) + +type Mausoleum struct { + ctx *context.Status +} + +func NewMausoleum() *Mausoleum { + return &Mausoleum{ + ctx: context.Get(), + } +} + +func (a Mausoleum) Name() string { + return string(config.MausoleumRun) +} + +func (a Mausoleum) Run() error { + + // Define a defaut filter + monsterFilter := data.MonsterAnyFilter() + + // Update filter if we selected to clear only elites + if a.ctx.CharacterCfg.Game.Mausoleum.FocusOnElitePacks { + monsterFilter = data.MonsterEliteFilter() + } + + // Use the waypoint + err := action.WayPoint(area.ColdPlains) + if err != nil { + return err + } + + // Move to the BurialGrounds + if err = action.MoveToArea(area.BurialGrounds); err != nil { + return err + } + + // Move to the Mausoleum + if err = action.MoveToArea(area.Mausoleum); err != nil { + return err + } + + // Open a TP If we're the leader + action.OpenTPIfLeader() + + // Clear the area + return action.ClearCurrentLevel(a.ctx.CharacterCfg.Game.Mausoleum.OpenChests, monsterFilter) +} diff --git a/internal/run/mephisto.go b/internal/run/mephisto.go index 02f5c437a..c6b2dd2fa 100644 --- a/internal/run/mephisto.go +++ b/internal/run/mephisto.go @@ -4,51 +4,99 @@ import ( "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/d2go/pkg/data/object" "github.com/hectorgimenez/koolo/internal/action" "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/utils" ) -var mephistoSafePosition = data.Position{ - X: 17568, - Y: 8069, +type Mephisto struct { + ctx *context.Status + clearMonsterFilter data.MonsterFilter // Used to clear area (basically TZ) } -type Mephisto struct { - baseRun +func NewMephisto(tzClearFilter data.MonsterFilter) *Mephisto { + return &Mephisto{ + ctx: context.Get(), + clearMonsterFilter: tzClearFilter, + } } func (m Mephisto) Name() string { - return "Mephisto" + return string(config.MephistoRun) } -func (m Mephisto) BuildActions() []action.Action { - actions := []action.Action{ - m.builder.WayPoint(area.DuranceOfHateLevel2), // Moving to starting point (Durance of Hate Level 2) - m.builder.MoveToArea(area.DuranceOfHateLevel3), - m.builder.MoveToCoords(mephistoSafePosition), // Travel to boss position - m.char.KillMephisto(), // Kill Mephisto +func (m Mephisto) Run() error { + + // Use waypoint to DuranceOfHateLevel2 + err := action.WayPoint(area.DuranceOfHateLevel2) + if err != nil { + return err + } + + if m.clearMonsterFilter != nil { + if err = action.ClearCurrentLevel(m.ctx.CharacterCfg.Game.Mephisto.OpenChests, m.clearMonsterFilter); err != nil { + return err + } + } + + // Move to DuranceOfHateLevel3 + if err = action.MoveToArea(area.DuranceOfHateLevel3); err != nil { + return err + } + + // Move to the Safe position + action.MoveToCoords(data.Position{ + X: 17568, + Y: 8069, + }) + + // Disable item pickup while fighting Mephisto (prevent picking up items if nearby monsters die) + m.ctx.DisableItemPickup() + + // Kill Mephisto + err = m.ctx.Char.KillMephisto() + + // Enable item pickup after the fight + m.ctx.EnableItemPickup() + + if err != nil { + return err + } + + if m.ctx.CharacterCfg.Game.Mephisto.OpenChests || m.ctx.CharacterCfg.Game.Mephisto.KillCouncilMembers { + // Clear the area with the selected options + return action.ClearCurrentLevel(m.ctx.CharacterCfg.Game.Mephisto.OpenChests, m.CouncilMemberFilter()) } - if config.Config.Game.Mephisto.KillCouncilMembers || config.Config.Game.Mephisto.OpenChests { - actions = append(actions, - m.builder.ItemPickup(true, 40), - m.builder.ClearArea(config.Config.Game.Mephisto.OpenChests, func(monsters data.Monsters) []data.Monster { - councilMembers := make([]data.Monster, 0) - // Let's skip all the monsters in case we don't want to kill them but open chests - if !config.Config.Game.Mephisto.KillCouncilMembers { - return councilMembers - } + if m.ctx.CharacterCfg.Game.Mephisto.ExitToA4 { + m.ctx.Logger.Debug("Moving to bridge") + action.MoveToCoords(data.Position{X: 17588, Y: 8068}) + //Wait for bridge to rise + utils.Sleep(1000) - for _, mo := range monsters { - if mo.Name == npc.CouncilMember || mo.Name == npc.CouncilMember2 || mo.Name == npc.CouncilMember3 { - councilMembers = append(councilMembers, mo) - } - } + m.ctx.Logger.Debug("Moving to red portal") + portal, _ := m.ctx.Data.Objects.FindOne(object.HellGate) + action.MoveToCoords(portal.Position) - return councilMembers - }), - ) + action.InteractObject(portal, func() bool { + return m.ctx.Data.PlayerUnit.Area == area.ThePandemoniumFortress + }) } - return actions + return nil +} + +func (m Mephisto) CouncilMemberFilter() data.MonsterFilter { + return func(m data.Monsters) []data.Monster { + var filteredMonsters []data.Monster + for _, mo := range m { + if mo.Name == npc.CouncilMember || mo.Name == npc.CouncilMember2 || mo.Name == npc.CouncilMember3 { + filteredMonsters = append(filteredMonsters, mo) + } + } + + return filteredMonsters + } } diff --git a/internal/run/nihlathak.go b/internal/run/nihlathak.go index 3adfe4d15..91fde3875 100644 --- a/internal/run/nihlathak.go +++ b/internal/run/nihlathak.go @@ -1,101 +1,124 @@ package run import ( - "fmt" + "errors" + "log/slog" "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/d2go/pkg/data/object" "github.com/hectorgimenez/koolo/internal/action" - "github.com/hectorgimenez/koolo/internal/action/step" "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" "github.com/hectorgimenez/koolo/internal/pather" - "go.uber.org/zap" ) type Nihlathak struct { - baseRun + ctx *context.Status } -func (a Nihlathak) Name() string { - return "Nihlathak" +func NewNihlathak() *Nihlathak { + return &Nihlathak{ + ctx: context.Get(), + } } -func (a Nihlathak) BuildActions() (actions []action.Action) { - // Moving to starting point (Halls of Pain) - actions = append(actions, a.builder.WayPoint(area.HallsOfPain)) +func (n Nihlathak) Name() string { + return string(config.NihlathakRun) +} - // Travel to boss position - actions = append(actions, a.builder.MoveToArea(area.HallsOfVaught)) +func (n Nihlathak) Run() error { + // Use the waypoint to HallsOfPain + err := action.WayPoint(area.HallsOfPain) + if err != nil { + return err + } - // Move close to Nilhatak, but don't teleport over all the monsters - var nilaO data.Object - actions = append(actions, action.NewStepChain(func(d data.Data) []step.Step { - for _, o := range d.Objects { - if o.Name == object.NihlathakWildernessStartPositionName { - nilaO = o - return []step.Step{step.MoveTo(o.Position, step.StopAtDistance(40))} - } - } + // Move to Halls Of Vaught + if err = action.MoveToArea(area.HallsOfVaught); err != nil { + return err + } - return []step.Step{} - })) - - // Try to find the safest place - actions = append(actions, action.NewStepChain(func(d data.Data) []step.Step { - corners := [4]data.Position{ - { - X: nilaO.Position.X + 13, - Y: nilaO.Position.Y + 13, - }, - { - X: nilaO.Position.X - 13, - Y: nilaO.Position.Y + 13, - }, - { - X: nilaO.Position.X - 13, - Y: nilaO.Position.Y - 13, - }, - { - X: nilaO.Position.X + 13, - Y: nilaO.Position.Y - 13, - }, - } + var nihlaObject data.Object - bestCorner := 0 - bestCornerDistance := 0 - for i, c := range corners { - averageDistance := 0 - for _, m := range d.Monsters.Enemies() { - averageDistance += pather.DistanceFromPoint(c, m.Position) - } - if averageDistance > bestCornerDistance { - bestCorner = i - bestCornerDistance = averageDistance - } - fmt.Printf("Corner %d. Average monster distance: %d\n", i, averageDistance) - } + o, found := n.ctx.Data.Objects.FindOne(object.NihlathakWildernessStartPositionName) + if !found { + return errors.New("failed to find Nihlathak's Start Position") + } + + // Move to Nihlathak + action.MoveToCoords(o.Position) - fmt.Printf("Moving to corner %d. Average monster distance: %d\n", bestCorner, bestCornerDistance) - return []step.Step{step.MoveTo(corners[bestCorner])} - })) + // Try to position in the safest corner + action.MoveToCoords(n.findBestCorner(o.Position)) + + // Disable item pickup before the fight + n.ctx.DisableItemPickup() // Kill Nihlathak - actions = append(actions, a.char.KillNihlathak()) + if err = n.ctx.Char.KillNihlathak(); err != nil { + // Re-enable item pickup even if kill fails + n.ctx.EnableItemPickup() + return err + } + + // Re-enable item pickup after kill + n.ctx.EnableItemPickup() // Clear monsters around the area, sometimes it makes difficult to pickup items if there are many monsters around the area - if config.Config.Game.Nihlathak.ClearArea { - actions = append(actions, a.char.KillMonsterSequence(func(d data.Data) (data.UnitID, bool) { + if n.ctx.CharacterCfg.Game.Nihlathak.ClearArea { + n.ctx.Logger.Debug("Clearing monsters around Nihlathak position") + + n.ctx.Char.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { for _, m := range d.Monsters.Enemies() { - if d := pather.DistanceFromPoint(nilaO.Position, m.Position); d < 15 { - a.logger.Debug("Clearing monsters around Nihlathak position", zap.Any("monster", m)) + if d := pather.DistanceFromPoint(nihlaObject.Position, m.Position); d < 15 { return m.UnitID, true } } return 0, false - }, nil)) + }, nil) + } + + return nil +} + +func (n Nihlathak) findBestCorner(nihlathakPosition data.Position) data.Position { + corners := [4]data.Position{ + { + X: nihlathakPosition.X + 20, + Y: nihlathakPosition.Y + 20, + }, + { + X: nihlathakPosition.X - 20, + Y: nihlathakPosition.Y + 20, + }, + { + X: nihlathakPosition.X - 20, + Y: nihlathakPosition.Y - 20, + }, + { + X: nihlathakPosition.X + 20, + Y: nihlathakPosition.Y - 20, + }, + } + + bestCorner := 0 + bestCornerDistance := 0 + for i, c := range corners { + if n.ctx.Data.AreaData.IsWalkable(c) { + averageDistance := 0 + for _, m := range n.ctx.Data.Monsters.Enemies() { + averageDistance += pather.DistanceFromPoint(c, m.Position) + } + if averageDistance > bestCornerDistance { + bestCorner = i + bestCornerDistance = averageDistance + } + n.ctx.Logger.Debug("Corner", slog.Int("corner", i), slog.Int("monsters", len(n.ctx.Data.Monsters.Enemies())), slog.Int("distance", averageDistance)) + } } - return + return corners[bestCorner] } diff --git a/internal/run/pindleskin.go b/internal/run/pindleskin.go index af832f19e..1f0b0f390 100644 --- a/internal/run/pindleskin.go +++ b/internal/run/pindleskin.go @@ -1,11 +1,14 @@ package run import ( + "errors" + "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/d2go/pkg/data/object" - "github.com/hectorgimenez/d2go/pkg/data/stat" "github.com/hectorgimenez/koolo/internal/action" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" ) var fixedPlaceNearRedPortal = data.Position{ @@ -19,22 +22,40 @@ var pindleSafePosition = data.Position{ } type Pindleskin struct { - SkipOnImmunities []stat.Resist - baseRun + ctx *context.Status +} + +func NewPindleskin() *Pindleskin { + return &Pindleskin{ + ctx: context.Get(), + } } func (p Pindleskin) Name() string { - return "Pindleskin" + return string(config.PindleskinRun) } -func (p Pindleskin) BuildActions() (actions []action.Action) { - return []action.Action{ - p.builder.WayPoint(area.Harrogath), // Move to Act 5 - p.builder.MoveToCoords(fixedPlaceNearRedPortal), // Moving closer to the portal to detect it - p.builder.InteractObject(object.PermanentTownPortal, func(d data.Data) bool { - return d.PlayerUnit.Area == area.NihlathaksTemple - }), // Enter Nihlathak's Temple - p.builder.MoveToCoords(pindleSafePosition), // Travel to boss position - p.char.KillPindle(p.SkipOnImmunities), // Kill Pindleskin +func (p Pindleskin) Run() error { + err := action.WayPoint(area.Harrogath) + if err != nil { + return err } + + _ = action.MoveToCoords(fixedPlaceNearRedPortal) + + redPortal, found := p.ctx.Data.Objects.FindOne(object.PermanentTownPortal) + if !found { + return errors.New("red portal not found") + } + + err = action.InteractObject(redPortal, func() bool { + return p.ctx.Data.AreaData.Area == area.NihlathaksTemple && p.ctx.Data.AreaData.IsInside(p.ctx.Data.PlayerUnit.Position) + }) + if err != nil { + return err + } + + _ = action.MoveToCoords(pindleSafePosition) + + return p.ctx.Char.KillPindle() } diff --git a/internal/run/pit.go b/internal/run/pit.go index e64b537ab..5f6bc5d3f 100644 --- a/internal/run/pit.go +++ b/internal/run/pit.go @@ -4,37 +4,75 @@ import ( "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/koolo/internal/action" - "github.com/hectorgimenez/koolo/internal/action/step" "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" ) type Pit struct { - baseRun + ctx *context.Status } -func (a Pit) Name() string { - return "Pit" +func NewPit() *Pit { + return &Pit{ + ctx: context.Get(), + } +} + +func (p Pit) Name() string { + return string(config.PitRun) } -func (a Pit) BuildActions() (actions []action.Action) { - actions = append(actions, - a.builder.WayPoint(area.BlackMarsh), // Moving to starting point (OuterCloister) - a.builder.MoveToArea(area.TamoeHighland), // Move to TamoeHighland - ) +func (p Pit) Run() error { + // Define a default filter + monsterFilter := data.MonsterAnyFilter() + + // Update filter if we selected to clear only elites + if p.ctx.CharacterCfg.Game.Pit.FocusOnElitePacks { + monsterFilter = data.MonsterEliteFilter() + } + + if !p.ctx.CharacterCfg.Game.Pit.MoveThroughBlackMarsh { + err := action.WayPoint(area.OuterCloister) + if err != nil { + return err + } + + if err = action.MoveToArea(area.MonasteryGate); err != nil { + return err + } - // Travel to pit level 1 - a.logger.Info("Travel to pit level 1") - actions = append(actions, a.builder.MoveToArea(area.PitLevel1)) + if err = action.MoveToArea(area.TamoeHighland); err != nil { + return err + } + } else { + err := action.WayPoint(area.BlackMarsh) + if err != nil { + return err + } + + if err = action.MoveToArea(area.TamoeHighland); err != nil { + return err + } + } + if err := action.MoveToArea(area.PitLevel1); err != nil { + return err + } + + // Open a TP If we're the leader + action.OpenTPIfLeader() + + // Clear the area if we don't have only clear lvl2 selected + if !p.ctx.CharacterCfg.Game.Pit.OnlyClearLevel2 { + if err := action.ClearCurrentLevel(p.ctx.CharacterCfg.Game.Pit.OpenChests, monsterFilter); err != nil { + return err + } + } - if config.Config.Companion.Enabled && config.Config.Companion.Leader { - actions = append(actions, action.NewStepChain(func(_ data.Data) []step.Step { - return []step.Step{step.OpenPortal()} - })) + // Move to PitLvl2 + if err := action.MoveToArea(area.PitLevel2); err != nil { + return err } - return append(actions, - a.builder.ClearArea(true, data.MonsterAnyFilter()), // Clear pit level 1 - a.builder.MoveToArea(area.PitLevel2), // Travel to pit level 2 - a.builder.ClearArea(true, data.MonsterAnyFilter()), // Clear pit level 2 - ) + // Clear it + return action.ClearCurrentLevel(p.ctx.CharacterCfg.Game.Pit.OpenChests, monsterFilter) } diff --git a/internal/run/quests.go b/internal/run/quests.go new file mode 100644 index 000000000..070e786d1 --- /dev/null +++ b/internal/run/quests.go @@ -0,0 +1,678 @@ +package run + +import ( + "time" + + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" + "github.com/hectorgimenez/d2go/pkg/data/item" + "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/d2go/pkg/data/object" + "github.com/hectorgimenez/d2go/pkg/data/quest" + "github.com/hectorgimenez/koolo/internal/action" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/ui" + "github.com/hectorgimenez/koolo/internal/utils" + "github.com/lxn/win" +) + +type Quests struct { + ctx *context.Status +} + +func NewQuests() *Quests { + return &Quests{ + ctx: context.Get(), + } +} + +func (a Quests) Name() string { + return string(config.QuestsRun) +} + +func (a Quests) Run() error { + if a.ctx.CharacterCfg.Game.Quests.ClearDen && !a.ctx.Data.Quests[quest.Act1DenOfEvil].Completed() { + a.clearDenQuest() + } + + if a.ctx.CharacterCfg.Game.Quests.RescueCain && !a.ctx.Data.Quests[quest.Act1TheSearchForCain].Completed() { + a.rescueCainQuest() + } + + if a.ctx.CharacterCfg.Game.Quests.RetrieveHammer && !a.ctx.Data.Quests[quest.Act1ToolsOfTheTrade].Completed() { + a.retrieveHammerQuest() + } + + if a.ctx.CharacterCfg.Game.Quests.KillRadament && !a.ctx.Data.Quests[quest.Act2RadamentsLair].Completed() { + a.killRadamentQuest() + } + + if a.ctx.CharacterCfg.Game.Quests.GetCube { + _, found := a.ctx.Data.Inventory.Find("HoradricCube", item.LocationInventory, item.LocationStash) + if !found { + a.getHoradricCube() + } + } + + if a.ctx.CharacterCfg.Game.Quests.RetrieveBook && !a.ctx.Data.Quests[quest.Act3LamEsensTome].Completed() { + a.retrieveBookQuest() + } + + if a.ctx.CharacterCfg.Game.Quests.KillIzual && !a.ctx.Data.Quests[quest.Act4TheFallenAngel].Completed() { + a.killIzualQuest() + } + + if a.ctx.CharacterCfg.Game.Quests.KillShenk && !a.ctx.Data.Quests[quest.Act5SiegeOnHarrogath].Completed() { + a.killShenkQuest() + } + + if a.ctx.CharacterCfg.Game.Quests.RescueAnya && !a.ctx.Data.Quests[quest.Act5PrisonOfIce].Completed() { + a.rescueAnyaQuest() + } + if a.ctx.CharacterCfg.Game.Quests.KillAncients && !a.ctx.Data.Quests[quest.Act5RiteOfPassage].Completed() { + a.killAncientsQuest() + } + + return nil +} + +func (a Quests) clearDenQuest() error { + a.ctx.Logger.Info("Starting Den of Evil Quest...") + + err := action.MoveToArea(area.BloodMoor) + if err != nil { + return err + } + + err = action.MoveToArea(area.DenOfEvil) + if err != nil { + return err + } + + action.ClearCurrentLevel(false, data.MonsterAnyFilter()) + + err = action.ReturnTown() + if err != nil { + return err + } + + err = action.InteractNPC(npc.Akara) + if err != nil { + return err + } + + a.ctx.HID.PressKey(win.VK_ESCAPE) + + return nil +} + +func (a Quests) rescueCainQuest() error { + a.ctx.Logger.Info("Starting Rescue Cain Quest...") + + err := action.WayPoint(area.RogueEncampment) + if err != nil { + return err + } + + err = action.WayPoint(area.DarkWood) + if err != nil { + return err + } + + err = action.MoveTo(func() (data.Position, bool) { + for _, o := range a.ctx.Data.Objects { + if o.Name == object.InifussTree { + return o.Position, true + } + } + return data.Position{}, false + }) + if err != nil { + return err + } + + action.ClearAreaAroundPlayer(30, data.MonsterAnyFilter()) + + obj, found := a.ctx.Data.Objects.FindOne(object.InifussTree) + if !found { + a.ctx.Logger.Debug("InifussTree not found") + } + + err = action.InteractObject(obj, func() bool { + updatedObj, found := a.ctx.Data.Objects.FindOne(object.InifussTree) + if found { + if !updatedObj.Selectable { + a.ctx.Logger.Debug("Interacted with InifussTree") + } + return !updatedObj.Selectable + } + return false + }) + if err != nil { + return err + } + + action.ItemPickup(0) + err = action.ReturnTown() + if err != nil { + return err + } + + err = action.InteractNPC(npc.Akara) + if err != nil { + return err + } + + a.ctx.HID.PressKey(win.VK_ESCAPE) + + //Reuse Tristram Run actions + err = Tristram{}.Run() + if err != nil { + return err + } + + action.ReturnTown() + + return nil +} + +func (a Quests) retrieveHammerQuest() error { + a.ctx.Logger.Info("Starting Retrieve Hammer Quest...") + + err := action.WayPoint(area.RogueEncampment) + if err != nil { + return err + } + + err = action.WayPoint(area.OuterCloister) + if err != nil { + return err + } + action.Buff() + + err = action.MoveToArea(area.Barracks) + if err != nil { + return err + } + + err = action.MoveTo(func() (data.Position, bool) { + for _, o := range a.ctx.Data.Objects { + if o.Name == object.Malus { + return o.Position, true + } + } + return data.Position{}, false + }) + if err != nil { + return err + } + + action.ClearAreaAroundPlayer(20, data.MonsterAnyFilter()) + + malus, found := a.ctx.Data.Objects.FindOne(object.Malus) + if !found { + a.ctx.Logger.Debug("Malus not found") + } + + err = action.InteractObject(malus, nil) + if err != nil { + return err + } + + action.ItemPickup(0) + + err = action.ReturnTown() + if err != nil { + return err + } + + err = action.InteractNPC(npc.Charsi) + if err != nil { + return err + } + + a.ctx.HID.PressKey(win.VK_ESCAPE) + + return nil +} + +func (a Quests) killRadamentQuest() error { + var startingPositionAtma = data.Position{ + X: 5138, + Y: 5057, + } + + a.ctx.Logger.Info("Starting Kill Radament Quest...") + + err := action.WayPoint(area.LutGholein) + if err != nil { + return err + } + + err = action.WayPoint(area.SewersLevel2Act2) + if err != nil { + return err + } + action.Buff() + + err = action.MoveToArea(area.SewersLevel3Act2) + if err != nil { + return err + } + action.Buff() + + // cant find npc.Radament for some reason, using the sparkly chest with ID 355 next him to find him + err = action.MoveTo(func() (data.Position, bool) { + for _, o := range a.ctx.Data.Objects { + if o.Name == object.Name(355) { + return o.Position, true + } + } + + return data.Position{}, false + }) + if err != nil { + return err + } + + action.ClearAreaAroundPlayer(30, data.MonsterAnyFilter()) + + // Sometimes it moves too far away from the book to pick it up, making sure it moves back to the chest + err = action.MoveTo(func() (data.Position, bool) { + for _, o := range a.ctx.Data.Objects { + if o.Name == object.Name(355) { + return o.Position, true + } + } + + return data.Position{}, false + }) + if err != nil { + return err + } + + // If its still too far away, we're making sure it detects it + action.ItemPickup(50) + + err = action.ReturnTown() + if err != nil { + return err + } + + err = action.MoveToCoords(startingPositionAtma) + if err != nil { + return err + } + + err = action.InteractNPC(npc.Atma) + if err != nil { + return err + } + + a.ctx.HID.PressKey(win.VK_ESCAPE) + a.ctx.HID.PressKeyBinding(a.ctx.Data.KeyBindings.Inventory) + itm, _ := a.ctx.Data.Inventory.Find("BookofSkill") + screenPos := ui.GetScreenCoordsForItem(itm) + utils.Sleep(200) + a.ctx.HID.Click(game.RightButton, screenPos.X, screenPos.Y) + a.ctx.HID.PressKey(win.VK_ESCAPE) + + return nil +} + +func (a Quests) getHoradricCube() error { + a.ctx.Logger.Info("Starting Retrieve the Cube Quest...") + + err := action.WayPoint(area.LutGholein) + if err != nil { + return err + } + + err = action.WayPoint(area.HallsOfTheDeadLevel2) + if err != nil { + return err + } + action.Buff() + + err = action.MoveToArea(area.HallsOfTheDeadLevel3) + if err != nil { + return err + } + action.Buff() + + err = action.MoveTo(func() (data.Position, bool) { + chest, found := a.ctx.Data.Objects.FindOne(object.HoradricCubeChest) + if found { + a.ctx.Logger.Info("Horadric Cube chest found, moving to that room") + return chest.Position, true + } + return data.Position{}, false + }) + if err != nil { + return err + } + + action.ClearAreaAroundPlayer(15, data.MonsterAnyFilter()) + + obj, found := a.ctx.Data.Objects.FindOne(object.HoradricCubeChest) + if !found { + return err + } + + err = action.InteractObject(obj, func() bool { + updatedObj, found := a.ctx.Data.Objects.FindOne(object.HoradricCubeChest) + if found { + if !updatedObj.Selectable { + a.ctx.Logger.Debug("Interacted with Horadric Cube Chest") + } + return !updatedObj.Selectable + } + return false + }) + if err != nil { + return err + } + + // Making sure we pick up the cube + action.ItemPickup(10) + + err = action.ReturnTown() + if err != nil { + return err + } + + return nil +} + +func (a Quests) retrieveBookQuest() error { + a.ctx.Logger.Info("Starting Retrieve Book Quest...") + + err := action.WayPoint(area.KurastDocks) + if err != nil { + return err + } + + err = action.WayPoint(area.KurastBazaar) + if err != nil { + return err + } + action.Buff() + + err = action.MoveToArea(area.RuinedTemple) + if err != nil { + return err + } + action.Buff() + + err = action.MoveTo(func() (data.Position, bool) { + for _, o := range a.ctx.Data.Objects { + if o.Name == object.LamEsensTome { + return o.Position, true + } + } + + return data.Position{}, false + }) + if err != nil { + return err + } + + action.ClearAreaAroundPlayer(30, data.MonsterAnyFilter()) + + tome, found := a.ctx.Data.Objects.FindOne(object.LamEsensTome) + if !found { + return err + } + + err = action.InteractObject(tome, nil) + if err != nil { + return err + } + + // Making sure we pick up the tome + action.ItemPickup(10) + + err = action.ReturnTown() + if err != nil { + return err + } + + err = action.InteractNPC(npc.Alkor) + if err != nil { + return err + } + + a.ctx.HID.PressKey(win.VK_ESCAPE) + + return nil +} + +func (a Quests) killIzualQuest() error { + a.ctx.Logger.Info("Starting Kill Izual Quest...") + + err := action.MoveToArea(area.OuterSteppes) + if err != nil { + return err + } + action.Buff() + + err = action.MoveToArea(area.PlainsOfDespair) + if err != nil { + return err + } + action.Buff() + + err = action.MoveTo(func() (data.Position, bool) { + izual, found := a.ctx.Data.NPCs.FindOne(npc.Izual) + if !found { + return data.Position{}, false + } + + return izual.Positions[0], true + }) + if err != nil { + return err + } + + err = a.ctx.Char.KillIzual() + if err != nil { + return err + } + + err = action.ReturnTown() + if err != nil { + return err + } + + err = action.InteractNPC(npc.Tyrael2) + if err != nil { + return err + } + + return nil +} + +func (a Quests) killShenkQuest() error { + var shenkPosition = data.Position{ + X: 3895, + Y: 5120, + } + + a.ctx.Logger.Info("Starting Kill Shenk Quest...") + + err := action.WayPoint(area.Harrogath) + if err != nil { + return err + } + + err = action.WayPoint(area.FrigidHighlands) + if err != nil { + return err + } + action.Buff() + + err = action.MoveToArea(area.BloodyFoothills) + if err != nil { + return err + } + action.Buff() + + err = action.MoveToCoords(shenkPosition) + if err != nil { + return err + } + + action.ClearAreaAroundPlayer(25, data.MonsterAnyFilter()) + + err = action.ReturnTown() + if err != nil { + return err + } + + err = action.InteractNPC(npc.Larzuk) + if err != nil { + return err + } + + a.ctx.HID.PressKey(win.VK_ESCAPE) + + return nil +} + +func (a Quests) rescueAnyaQuest() error { + a.ctx.Logger.Info("Starting Rescuing Anya Quest...") + + err := action.WayPoint(area.CrystallinePassage) + if err != nil { + return err + } + action.Buff() + + err = action.MoveToArea(area.FrozenRiver) + if err != nil { + return err + } + action.Buff() + + err = action.MoveTo(func() (data.Position, bool) { + anya, found := a.ctx.Data.NPCs.FindOne(793) + return anya.Positions[0], found + }) + if err != nil { + return err + } + + err = action.MoveTo(func() (data.Position, bool) { + anya, found := a.ctx.Data.Objects.FindOne(object.FrozenAnya) + return anya.Position, found + }) + if err != nil { + return err + } + + action.ClearAreaAroundPlayer(15, data.MonsterAnyFilter()) + + anya, found := a.ctx.Data.Objects.FindOne(object.FrozenAnya) + if !found { + a.ctx.Logger.Debug("Frozen Anya not found") + } + + err = action.InteractObject(anya, nil) + if err != nil { + return err + } + + err = action.ReturnTown() + if err != nil { + return err + } + + action.IdentifyAll(false) + action.Stash(false) + action.ReviveMerc() + action.Repair() + action.VendorRefill(false, true) + + err = action.InteractNPC(npc.Malah) + if err != nil { + return err + } + + err = action.UsePortalInTown() + if err != nil { + return err + } + + err = action.InteractObject(anya, nil) + if err != nil { + return err + } + + err = action.ReturnTown() + if err != nil { + return err + } + + time.Sleep(8000) + + err = action.InteractNPC(npc.Malah) + if err != nil { + return err + } + + a.ctx.HID.PressKey(win.VK_ESCAPE) + a.ctx.HID.PressKeyBinding(a.ctx.Data.KeyBindings.Inventory) + itm, _ := a.ctx.Data.Inventory.Find("ScrollOfResistance") + screenPos := ui.GetScreenCoordsForItem(itm) + utils.Sleep(200) + a.ctx.HID.Click(game.RightButton, screenPos.X, screenPos.Y) + a.ctx.HID.PressKey(win.VK_ESCAPE) + + return nil +} + +func (a Quests) killAncientsQuest() error { + var ancientsAltar = data.Position{ + X: 10049, + Y: 12623, + } + + err := action.WayPoint(area.Harrogath) + if err != nil { + return err + } + action.Buff() + + err = action.WayPoint(area.TheAncientsWay) + if err != nil { + return err + } + action.Buff() + + err = action.MoveToArea(area.ArreatSummit) + if err != nil { + return err + } + action.Buff() + + action.ReturnTown() + action.InRunReturnTownRoutine() + action.UsePortalInTown() + action.Buff() + + action.MoveToCoords(ancientsAltar) + + utils.Sleep(1000) + a.ctx.HID.Click(game.LeftButton, 720, 260) + utils.Sleep(1000) + a.ctx.HID.PressKey(win.VK_RETURN) + utils.Sleep(2000) + + action.ClearAreaAroundPlayer(50, data.MonsterEliteFilter()) + + action.ReturnTown() + + return nil +} diff --git a/internal/run/run.go b/internal/run/run.go index 6a44c9eb3..b05188cdb 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -1,127 +1,92 @@ package run import ( - "strings" - "time" - - "github.com/hectorgimenez/koolo/internal/action" "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/health" - "github.com/hectorgimenez/koolo/internal/reader" - "go.uber.org/zap" ) type Run interface { Name() string - BuildActions() []action.Action -} - -type baseRun struct { - builder *action.Builder - char action.Character - logger *zap.Logger -} - -type Factory struct { - logger *zap.Logger - builder *action.Builder - char action.Character - gr *reader.GameReader - bm health.BeltManager -} - -func NewFactory(logger *zap.Logger, builder *action.Builder, char action.Character, gr *reader.GameReader, bm health.BeltManager) *Factory { - return &Factory{ - logger: logger, - builder: builder, - char: char, - gr: gr, - bm: bm, - } + Run() error } -func (f *Factory) BuildRuns() (runs []Run) { - t := time.Now() - f.logger.Debug("Fetching map data...") - d := f.gr.GetData(true) - f.logger.Debug("Fetch completed", zap.Int64("ms", time.Since(t).Milliseconds())) - - baseRun := baseRun{ - builder: f.builder, - char: f.char, - logger: f.logger, - } - - if config.Config.Companion.Enabled && !config.Config.Companion.Leader { - return []Run{Companion{baseRun: baseRun}} - } +func BuildRuns(cfg *config.CharacterCfg) (runs []Run) { + //if cfg.Companion.Enabled && !cfg.Companion.Leader { + // return []Run{Companion{baseRun: baseRun}} + //} - for _, run := range config.Config.Game.Runs { + for _, run := range cfg.Game.Runs { // Prepend terror zone runs, we want to run it always first - if run == "terror_zone" { - tz := TerrorZone{baseRun: baseRun} + if run == config.TerrorZoneRun { + tz := NewTerrorZone() - if len(tz.AvailableTZs(d)) > 0 { + if len(tz.AvailableTZs()) > 0 { runs = append(runs, tz) // If we are skipping other runs, we can return here - if config.Config.Game.TerrorZone.SkipOtherRuns { + if cfg.Game.TerrorZone.SkipOtherRuns { return runs } } } } - for _, run := range config.Config.Game.Runs { - run = strings.ToLower(run) + for _, run := range cfg.Game.Runs { switch run { - case "countess": - runs = append(runs, Countess{baseRun}) - case "andariel": - runs = append(runs, Andariel{baseRun}) - case "summoner": - runs = append(runs, Summoner{baseRun}) - case "mephisto": - runs = append(runs, Mephisto{baseRun}) - case "council": - runs = append(runs, Council{baseRun}) - case "diablo": - runs = append(runs, Diablo{ - baseRun: baseRun, - bm: f.bm, - }) - case "eldritch": - runs = append(runs, Eldritch{ - baseRun: baseRun, - }) - case "pindleskin": - runs = append(runs, Pindleskin{ - SkipOnImmunities: config.Config.Game.Pindleskin.SkipOnImmunities, - baseRun: baseRun, - }) - case "nihlathak": - runs = append(runs, Nihlathak{baseRun}) - case "ancient_tunnels": - runs = append(runs, AncientTunnels{baseRun}) - case "pit": - runs = append(runs, Pit{baseRun}) - case "stony_tomb": - runs = append(runs, StonyTomb{baseRun}) - case "arachnid_lair": - runs = append(runs, ArachnidLair{baseRun}) - case "tristram": - runs = append(runs, Tristram{baseRun}) - case "lower_kurast": - runs = append(runs, LowerKurast{baseRun}) - case "baal": - runs = append(runs, Baal{baseRun}) - case "tal_rasha_tombs": - runs = append(runs, TalRashaTombs{baseRun}) - case "leveling": - runs = append(runs, Leveling{baseRun, f.gr, f.bm}) - case "cows": - runs = append(runs, Cows{baseRun}) + case config.CountessRun: + runs = append(runs, NewCountess()) + case config.AndarielRun: + runs = append(runs, NewAndariel()) + case config.SummonerRun: + runs = append(runs, NewSummoner()) + case config.DurielRun: + runs = append(runs, NewDuriel()) + case config.MephistoRun: + runs = append(runs, NewMephisto(nil)) + case config.TravincalRun: + runs = append(runs, NewTravincal()) + case config.DiabloRun: + runs = append(runs, NewDiablo()) + case config.EldritchRun: + runs = append(runs, NewEldritch()) + case config.PindleskinRun: + runs = append(runs, NewPindleskin()) + case config.NihlathakRun: + runs = append(runs, NewNihlathak()) + case config.AncientTunnelsRun: + runs = append(runs, NewAncientTunnels()) + case config.MausoleumRun: + runs = append(runs, NewMausoleum()) + case config.PitRun: + runs = append(runs, NewPit()) + case config.StonyTombRun: + runs = append(runs, NewStonyTomb()) + case config.ArachnidLairRun: + runs = append(runs, NewArachnidLair()) + case config.TristramRun: + runs = append(runs, NewTristram()) + case config.LowerKurastRun: + runs = append(runs, NewLowerKurast()) + case config.LowerKurastChestRun: + runs = append(runs, NewLowerKurastChest()) + case config.BaalRun: + runs = append(runs, NewBaal(nil)) + case config.TalRashaTombsRun: + runs = append(runs, NewTalRashaTombs()) + case config.LevelingRun: + runs = append(runs, NewLeveling()) + case config.QuestsRun: + runs = append(runs, NewQuests()) + case config.CowsRun: + runs = append(runs, NewCows()) + case config.ThreshsocketRun: + runs = append(runs, NewThreshsocket()) + case config.SpiderCavernRun: + runs = append(runs, NewSpiderCavern()) + case config.DrifterCavernRun: + runs = append(runs, NewDriverCavern()) + case config.EnduguRun: + runs = append(runs, NewEndugu()) } } - return + return runs } diff --git a/internal/run/spider_cavern.go b/internal/run/spider_cavern.go new file mode 100644 index 000000000..7646c12fe --- /dev/null +++ b/internal/run/spider_cavern.go @@ -0,0 +1,60 @@ +package run + +import ( + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" + "github.com/hectorgimenez/koolo/internal/action" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" +) + +type SpiderCavern struct { + ctx *context.Status +} + +func NewSpiderCavern() *SpiderCavern { + return &SpiderCavern{ + ctx: context.Get(), + } +} + +func (run SpiderCavern) Name() string { + return string(config.SpiderCavernRun) +} + +func (run SpiderCavern) Run() error { + // Define a default monster filter + monsterFilter := data.MonsterAnyFilter() + + // Update filter if we selected to clear only elites + if run.ctx.CharacterCfg.Game.SpiderCavern.FocusOnElitePacks { + monsterFilter = data.MonsterEliteFilter() + } + + // Use waypoint to Spider Forest + err := action.WayPoint(area.SpiderForest) + if err != nil { + return err + } + + // Move to the correct area + if err = action.MoveToArea(area.SpiderCavern); err != nil { + return err + } + + // Clear the area + action.ClearCurrentLevel(run.ctx.CharacterCfg.Game.SpiderCavern.OpenChests, monsterFilter) + + // Return to town + if err = action.ReturnTown(); err != nil { + return err + } + + // Move to A4 if possible to shorten the run time + err = action.WayPoint(area.ThePandemoniumFortress) + if err != nil { + return err + } + + return nil +} diff --git a/internal/run/stony_tomb.go b/internal/run/stony_tomb.go index 7fcd8f0a5..c79aed3aa 100644 --- a/internal/run/stony_tomb.go +++ b/internal/run/stony_tomb.go @@ -4,37 +4,63 @@ import ( "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/koolo/internal/action" - "github.com/hectorgimenez/koolo/internal/action/step" "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" ) type StonyTomb struct { - baseRun + ctx *context.Status } -func (a StonyTomb) Name() string { - return "StonyTomb" +func NewStonyTomb() *StonyTomb { + return &StonyTomb{ + ctx: context.Get(), + } +} + +func (s StonyTomb) Name() string { + return string(config.StonyTombRun) } -func (a StonyTomb) BuildActions() (actions []action.Action) { - actions = append(actions, - a.builder.WayPoint(area.DryHills), - a.builder.MoveToArea(area.RockyWaste), - a.builder.MoveToArea(area.StonyTombLevel1), - ) - - if config.Config.Companion.Enabled && config.Config.Companion.Leader { - actions = append(actions, action.NewStepChain(func(_ data.Data) []step.Step { - return []step.Step{step.OpenPortal()} - })) +func (s StonyTomb) Run() error { + + // Setup default filter + monsterFilter := data.MonsterAnyFilter() + + // Update filter if we selected to clear only elites + if s.ctx.CharacterCfg.Game.StonyTomb.FocusOnElitePacks { + monsterFilter = data.MonsterEliteFilter() + } + + // Use the waypoint + err := action.WayPoint(area.DryHills) + if err != nil { + return err } - // Clear both level of Stony Tomb - actions = append(actions, - a.builder.ClearArea(true, data.MonsterAnyFilter()), - a.builder.MoveToArea(area.StonyTombLevel2), - a.builder.ClearArea(true, data.MonsterAnyFilter()), - ) + // Move to the correct area + if err = action.MoveToArea(area.RockyWaste); err != nil { + return err + } + + // Move to the correct area + if err = action.MoveToArea(area.StonyTombLevel1); err != nil { + return err + } + + // Open a TP If we're the leader + action.OpenTPIfLeader() + + // Clear the area + if err = action.ClearCurrentLevel(s.ctx.CharacterCfg.Game.StonyTomb.OpenChests, monsterFilter); err != nil { + return err + } + + // Move to lvl2 + if err = action.MoveToArea(area.StonyTombLevel2); err != nil { + return err + } - return + // Clear the area + return action.ClearCurrentLevel(s.ctx.CharacterCfg.Game.StonyTomb.OpenChests, monsterFilter) } diff --git a/internal/run/summoner.go b/internal/run/summoner.go index 31e67d63c..78fcc32f1 100644 --- a/internal/run/summoner.go +++ b/internal/run/summoner.go @@ -1,28 +1,46 @@ package run import ( - "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/d2go/pkg/data/npc" "github.com/hectorgimenez/koolo/internal/action" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" ) type Summoner struct { - baseRun + ctx *context.Status +} + +func NewSummoner() *Summoner { + return &Summoner{ + ctx: context.Get(), + } } func (s Summoner) Name() string { - return "Summoner" + return string(config.SummonerRun) } -func (s Summoner) BuildActions() (actions []action.Action) { - return []action.Action{ - s.builder.WayPoint(area.ArcaneSanctuary), // Moving to starting point (Arcane Sanctuary) - s.builder.MoveTo(func(d data.Data) (data.Position, bool) { - m, found := d.NPCs.FindOne(npc.Summoner) +func (s Summoner) Run() error { + // Use the waypoint to get to Arcane Sanctuary + err := action.WayPoint(area.ArcaneSanctuary) + if err != nil { + return err + } - return m.Positions[0], found - }), // Travel to boss position - s.char.KillSummoner(), // Kill Summoner + // Get the Summoner's position from the cached map data + areaData := s.ctx.Data.Areas[area.ArcaneSanctuary] + summonerNPC, found := areaData.NPCs.FindOne(npc.Summoner) + if !found || len(summonerNPC.Positions) == 0 { + return err } + + // Move to the Summoner's position using the static coordinates from map data + if err = action.MoveToCoords(summonerNPC.Positions[0]); err != nil { + return err + } + + // Kill Summoner + return s.ctx.Char.KillSummoner() } diff --git a/internal/run/tal_rasha_tombs.go b/internal/run/tal_rasha_tombs.go index d8539d7e3..bf114f04f 100644 --- a/internal/run/tal_rasha_tombs.go +++ b/internal/run/tal_rasha_tombs.go @@ -4,31 +4,58 @@ import ( "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/koolo/internal/action" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" ) -var talRashaTombs = []area.Area{area.TalRashasTomb1, area.TalRashasTomb2, area.TalRashasTomb3, area.TalRashasTomb4, area.TalRashasTomb5, area.TalRashasTomb6, area.TalRashasTomb7} - type TalRashaTombs struct { - baseRun + ctx *context.Status +} + +func NewTalRashaTombs() *TalRashaTombs { + return &TalRashaTombs{ + ctx: context.Get(), + } } func (a TalRashaTombs) Name() string { - return "TalRashaTombs" + return string(config.TalRashaTombsRun) } -func (a TalRashaTombs) BuildActions() (actions []action.Action) { - a.logger.Info("Starting Tal Rasha Tombs run....") +var talRashaTombs = []area.ID{area.TalRashasTomb1, area.TalRashasTomb2, area.TalRashasTomb3, area.TalRashasTomb4, area.TalRashasTomb5, area.TalRashasTomb6, area.TalRashasTomb7} + +func (a TalRashaTombs) Run() error { + // Iterate over all Tal Rasha Tombs for _, tomb := range talRashaTombs { - actions = append(actions, - a.builder.WayPoint(area.CanyonOfTheMagi), - a.builder.MoveToArea(tomb), - a.builder.Buff(), - a.builder.ClearArea(true, data.MonsterAnyFilter()), - a.builder.ReturnTown(), - ) - actions = append(actions, a.builder.PreRun(false)...) + + // Use the waypoint to travel to Canyon Of The Magi + err := action.WayPoint(area.CanyonOfTheMagi) + if err != nil { + return err + } + + // Move to the next Tomb + if err = action.MoveToArea(tomb); err != nil { + return err + } + + // Open a TP if we're the leader + action.OpenTPIfLeader() + + // Buff before we start + action.Buff() + + // Clear the Tomb + if err = action.ClearCurrentLevel(true, data.MonsterAnyFilter()); err != nil { + return err + } + + // Return to town + if err = action.ReturnTown(); err != nil { + return err + } } - return actions + return nil } diff --git a/internal/run/terror_zone.go b/internal/run/terror_zone.go index 5bd80561a..f9249c5dc 100644 --- a/internal/run/terror_zone.go +++ b/internal/run/terror_zone.go @@ -1,192 +1,195 @@ package run import ( + "fmt" + "slices" + "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" - "github.com/hectorgimenez/d2go/pkg/data/stat" "github.com/hectorgimenez/koolo/internal/action" - "github.com/hectorgimenez/koolo/internal/config" - "go.uber.org/zap" + "github.com/hectorgimenez/koolo/internal/context" ) type TerrorZone struct { - baseRun - currentTZ string + ctx *context.Status } -func (a TerrorZone) Name() string { - return a.currentTZ +func NewTerrorZone() *TerrorZone { + return &TerrorZone{ + ctx: context.Get(), + } } -func (a TerrorZone) BuildActions() (actions []action.Action) { - act := action.NewChain(func(d data.Data) (actions []action.Action) { - if len(d.TerrorZones) == 0 { - a.logger.Info("No TerrorZones detected, skipping TerrorZone run") - return - } +func (tz TerrorZone) Name() string { + tzNames := make([]string, 0) + for _, tzArea := range tz.AvailableTZs() { + tzNames = append(tzNames, tzArea.Area().Name) + } - // Try to match terror zones with an existing predefined run - for _, tz := range d.TerrorZones { - switch tz { - case area.PitLevel1: - return Pit{baseRun: a.baseRun}.BuildActions() - case area.Tristram: - return Tristram{baseRun: a.baseRun}.BuildActions() - case area.MooMooFarm: - return Cows{baseRun: a.baseRun}.BuildActions() - case area.TalRashasTomb1: - return TalRashaTombs{baseRun: a.baseRun}.BuildActions() - case area.AncientTunnels: - return AncientTunnels{baseRun: a.baseRun}.BuildActions() - case area.RockyWaste: - return StonyTomb{baseRun: a.baseRun}.BuildActions() - case area.Travincal: - return Council{baseRun: a.baseRun}.BuildActions() - case area.DuranceOfHateLevel1: - return Mephisto{baseRun: a.baseRun}.BuildActions() - case area.ChaosSanctuary: - return Diablo{baseRun: a.baseRun}.BuildActions() - case area.NihlathaksTemple: - return Nihlathak{baseRun: a.baseRun}.BuildActions() - case area.TheWorldStoneKeepLevel1: - return Baal{baseRun: a.baseRun}.BuildActions() - } - } + return fmt.Sprintf("TerrorZone Run: %v", tzNames) +} - // If no predefined run is found, we build a custom one - areasGroups := a.tzAreaChain(d.TerrorZones[0]) - for _, areaGroup := range areasGroups { - for _, ar := range areaGroup { - actions = append(actions, a.buildTZAction(ar)) - } - } +func (tz TerrorZone) Run() error { - return - }) + availableTzs := tz.AvailableTZs() + if len(availableTzs) == 0 { + return nil + } - return []action.Action{act} -} + switch availableTzs[0] { + case area.PitLevel1: + return NewPit().Run() + case area.Tristram: + return NewTristram().Run() + case area.MooMooFarm: + return NewCows().Run() + case area.TalRashasTomb1: + return NewTalRashaTombs().Run() + case area.AncientTunnels: + return NewAncientTunnels().Run() + case area.RockyWaste: + return NewStonyTomb().Run() + case area.Travincal: + return NewTravincal().Run() + case area.DuranceOfHateLevel1: + return NewMephisto(tz.customTZEnemyFilter()).Run() + case area.ChaosSanctuary: + return NewDiablo().Run() + case area.NihlathaksTemple: + return NewNihlathak().Run() + case area.TheWorldStoneKeepLevel1: + return NewBaal(tz.customTZEnemyFilter()).Run() + } -func (a TerrorZone) AvailableTZs(d data.Data) []area.Area { - var availableTZs []area.Area - for _, tz := range d.TerrorZones { - for _, tzArea := range config.Config.Game.TerrorZone.Areas { - if tz == tzArea { - availableTZs = append(availableTZs, tz) - } - } + tzAreaGroups := tz.tzAreaGroups(tz.ctx.Data.TerrorZones[0]) + if len(tzAreaGroups) == 0 { + return nil } - return availableTZs -} + for _, tzAreaGroup := range tzAreaGroups { + matchingTzInGroup := false + for _, tzArea := range tzAreaGroup { + if slices.Contains(availableTzs, tzArea) { + matchingTzInGroup = true + break + } + } -func (a TerrorZone) buildTZAction(dstArea area.Area) action.Action { - return action.NewChain(func(d data.Data) (actions []action.Action) { - if d.PlayerUnit.Area != dstArea && d.PlayerUnit.Area.IsTown() { - actions = append(actions, a.builder.WayPoint(dstArea)) + if !matchingTzInGroup { + continue } - actions = append(actions, - a.builder.MoveToArea(dstArea), - ) - - // Clear only TZ areas, skip traversing areas - clearArea := false - for _, terrorizedArea := range d.TerrorZones { - if terrorizedArea == dstArea { - // Skip areas that are not selected in the configuration - for _, tz := range config.Config.Game.TerrorZone.Areas { - if tz == dstArea { - clearArea = true - } + for k, tzArea := range tzAreaGroup { + if k == 0 { + err := action.WayPoint(tzArea) + if err != nil { + return err } + } else { + err := action.MoveToArea(tzArea) + if err != nil { + return err + } + } + if slices.Contains(availableTzs, tzArea) { + action.ClearCurrentLevel(tz.ctx.CharacterCfg.Game.TerrorZone.OpenChests, tz.customTZEnemyFilter()) + } else { + tz.ctx.Logger.Debug("Skipping area %v", tzArea.Area().Name) } } + } - if clearArea { - a.logger.Debug("Clearing TZ area", zap.Any("area", dstArea)) - actions = append(actions, a.builder.ClearArea(true, customTZEnemyFilter(config.Config.Game.TerrorZone.SkipOnImmunities...))) - } else { - a.logger.Debug("TZ area skipped", zap.Any("area", dstArea)) + return nil +} + +func (tz TerrorZone) AvailableTZs() []area.ID { + tz.ctx.RefreshGameData() + var availableTZs []area.ID + for _, tzone := range tz.ctx.Data.TerrorZones { + for _, tzArea := range tz.ctx.CharacterCfg.Game.TerrorZone.Areas { + if tzone == tzArea { + availableTZs = append(availableTZs, tzone) + } } + } - return actions - }) + return availableTZs } -func (a TerrorZone) tzAreaChain(firstTZ area.Area) [][]area.Area { +func (tz TerrorZone) tzAreaGroups(firstTZ area.ID) [][]area.ID { switch firstTZ { // Act 1 case area.BloodMoor: - return [][]area.Area{{area.RogueEncampment, area.BloodMoor, area.DenOfEvil}} + return [][]area.ID{{area.RogueEncampment, area.BloodMoor, area.DenOfEvil}} case area.ColdPlains: - return [][]area.Area{{area.ColdPlains, area.CaveLevel1, area.CaveLevel2}} + return [][]area.ID{{area.ColdPlains, area.CaveLevel1, area.CaveLevel2}} case area.BurialGrounds: - return [][]area.Area{{area.ColdPlains, area.BurialGrounds, area.Crypt}, {area.ColdPlains, area.BurialGrounds, area.Mausoleum}} + return [][]area.ID{{area.ColdPlains, area.BurialGrounds, area.Crypt}, {area.ColdPlains, area.BurialGrounds, area.Mausoleum}} case area.StonyField: - return [][]area.Area{{area.StonyField}} + return [][]area.ID{{area.StonyField}} case area.DarkWood: - return [][]area.Area{{area.DarkWood, area.UndergroundPassageLevel1, area.UndergroundPassageLevel2}} + return [][]area.ID{{area.DarkWood, area.UndergroundPassageLevel1, area.UndergroundPassageLevel2}} case area.BlackMarsh: - return [][]area.Area{{area.BlackMarsh, area.HoleLevel1, area.HoleLevel2}} + return [][]area.ID{{area.BlackMarsh, area.HoleLevel1, area.HoleLevel2}} case area.ForgottenTower: - return [][]area.Area{{area.BlackMarsh, area.ForgottenTower, area.TowerCellarLevel1, area.TowerCellarLevel2, area.TowerCellarLevel3, area.TowerCellarLevel4, area.TowerCellarLevel5}} - case area.JailLevel1: - return [][]area.Area{{area.JailLevel1, area.JailLevel2, area.JailLevel3}} + return [][]area.ID{{area.BlackMarsh, area.ForgottenTower, area.TowerCellarLevel1, area.TowerCellarLevel2, area.TowerCellarLevel3, area.TowerCellarLevel4, area.TowerCellarLevel5}} + case area.Barracks: + return [][]area.ID{{area.JailLevel1, area.Barracks}, {area.JailLevel1, area.JailLevel2, area.JailLevel3}} case area.Cathedral: - return [][]area.Area{{area.InnerCloister, area.Cathedral, area.CatacombsLevel1, area.CatacombsLevel2, area.CatacombsLevel3}} + return [][]area.ID{{area.InnerCloister, area.Cathedral, area.CatacombsLevel1, area.CatacombsLevel2, area.CatacombsLevel3}} // Act 2 case area.SewersLevel1Act2: - return [][]area.Area{{area.LutGholein, area.SewersLevel1Act2, area.SewersLevel2Act2, area.SewersLevel3Act2}} + return [][]area.ID{{area.LutGholein, area.SewersLevel1Act2, area.SewersLevel2Act2, area.SewersLevel3Act2}} case area.DryHills: - return [][]area.Area{{area.DryHills, area.HallsOfTheDeadLevel1, area.HallsOfTheDeadLevel2, area.HallsOfTheDeadLevel3}} + return [][]area.ID{{area.DryHills, area.HallsOfTheDeadLevel1, area.HallsOfTheDeadLevel2, area.HallsOfTheDeadLevel3}} case area.FarOasis: - return [][]area.Area{{area.FarOasis}} + return [][]area.ID{{area.FarOasis}} case area.LostCity: - return [][]area.Area{{area.LostCity, area.ValleyOfSnakes, area.ClawViperTempleLevel1, area.ClawViperTempleLevel2}} + return [][]area.ID{{area.LostCity, area.ValleyOfSnakes, area.ClawViperTempleLevel1, area.ClawViperTempleLevel2}} case area.ArcaneSanctuary: - return [][]area.Area{{area.ArcaneSanctuary}} + return [][]area.ID{{area.ArcaneSanctuary}} // Act 3 case area.SpiderForest: - return [][]area.Area{{area.SpiderForest, area.SpiderCavern}} + return [][]area.ID{{area.SpiderForest, area.SpiderCavern}} case area.GreatMarsh: - return [][]area.Area{{area.GreatMarsh}} + return [][]area.ID{{area.GreatMarsh}} case area.FlayerJungle: - return [][]area.Area{{area.FlayerJungle, area.FlayerDungeonLevel1, area.FlayerDungeonLevel2, area.FlayerDungeonLevel3}} + return [][]area.ID{{area.FlayerJungle, area.FlayerDungeonLevel1, area.FlayerDungeonLevel2, area.FlayerDungeonLevel3}} case area.KurastBazaar: - return [][]area.Area{{area.KurastBazaar, area.RuinedTemple, area.DisusedFane}} + return [][]area.ID{{area.KurastBazaar, area.RuinedTemple, area.DisusedFane}} // Act 4 case area.OuterSteppes: - return [][]area.Area{{area.ThePandemoniumFortress, area.OuterSteppes, area.PlainsOfDespair}} + return [][]area.ID{{area.ThePandemoniumFortress, area.OuterSteppes, area.PlainsOfDespair}} case area.RiverOfFlame: - return [][]area.Area{{area.CityOfTheDamned, area.RiverOfFlame}} + return [][]area.ID{{area.CityOfTheDamned, area.RiverOfFlame}, {area.RiverOfFlame}} // Act 5 case area.BloodyFoothills: - return [][]area.Area{{area.Harrogath, area.BloodyFoothills, area.FrigidHighlands, area.Abaddon}} + return [][]area.ID{{area.Harrogath, area.BloodyFoothills, area.FrigidHighlands, area.Abaddon}} case area.GlacialTrail: - return [][]area.Area{{area.GlacialTrail, area.DrifterCavern}} + return [][]area.ID{{area.GlacialTrail, area.DrifterCavern}} case area.CrystallinePassage: - return [][]area.Area{{area.CrystallinePassage, area.FrozenRiver}} + return [][]area.ID{{area.CrystallinePassage, area.FrozenRiver}} case area.ArreatPlateau: - return [][]area.Area{{area.ArreatPlateau, area.PitOfAcheron}} + return [][]area.ID{{area.ArreatPlateau, area.PitOfAcheron}} case area.TheAncientsWay: - return [][]area.Area{{area.TheAncientsWay, area.IcyCellar}} + return [][]area.ID{{area.TheAncientsWay, area.IcyCellar}} } - return [][]area.Area{} + return [][]area.ID{} } -func customTZEnemyFilter(resists ...stat.Resist) data.MonsterFilter { +func (tz TerrorZone) customTZEnemyFilter() data.MonsterFilter { + return func(m data.Monsters) []data.Monster { var filteredMonsters []data.Monster monsterFilter := data.MonsterAnyFilter() - if config.Config.Game.TerrorZone.FocusOnElitePacks { + if tz.ctx.CharacterCfg.Game.TerrorZone.FocusOnElitePacks { monsterFilter = data.MonsterEliteFilter() } for _, mo := range m.Enemies(monsterFilter) { isImmune := false - for _, resist := range resists { + for _, resist := range tz.ctx.CharacterCfg.Game.TerrorZone.SkipOnImmunities { if mo.IsImmune(resist) { isImmune = true } diff --git a/internal/run/threshsocket.go b/internal/run/threshsocket.go new file mode 100644 index 000000000..9264f44cb --- /dev/null +++ b/internal/run/threshsocket.go @@ -0,0 +1,48 @@ +package run + +import ( + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" + "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/koolo/internal/action" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" +) + +type Threshsocket struct { + ctx *context.Status +} + +func NewThreshsocket() *Threshsocket { + return &Threshsocket{ + ctx: context.Get(), + } +} + +func (t Threshsocket) Name() string { + return string(config.ThreshsocketRun) +} + +func (t Threshsocket) Run() error { + + // Use waypoint to crystalinepassage + err := action.WayPoint(area.CrystallinePassage) + if err != nil { + return err + } + + // Move to ArreatPlateau + if err = action.MoveToArea(area.ArreatPlateau); err != nil { + return err + } + + // Kill Threshsocket + return t.ctx.Char.KillMonsterSequence(func(d game.Data) (data.UnitID, bool) { + if m, found := d.Monsters.FindOne(npc.BloodBringer, data.MonsterTypeSuperUnique); found { + return m.UnitID, true + } + + return 0, false + }, nil) +} diff --git a/internal/run/travincal.go b/internal/run/travincal.go new file mode 100644 index 000000000..9821131f3 --- /dev/null +++ b/internal/run/travincal.go @@ -0,0 +1,72 @@ +package run + +import ( + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" + "github.com/hectorgimenez/koolo/internal/action" + "github.com/hectorgimenez/koolo/internal/character" + "github.com/hectorgimenez/koolo/internal/config" + "github.com/hectorgimenez/koolo/internal/context" +) + +type Travincal struct { + ctx *context.Status +} + +func NewTravincal() *Travincal { + return &Travincal{ + ctx: context.Get(), + } +} + +func (t *Travincal) Name() string { + return string(config.TravincalRun) +} + +func (t *Travincal) Run() error { + defer func() { + t.ctx.CurrentGame.AreaCorrection.Enabled = false + }() + + // Check if the character is a Berserker and swap to combat gear + if berserker, ok := t.ctx.Char.(*character.Berserker); ok { + if t.ctx.CharacterCfg.Character.BerserkerBarb.FindItemSwitch { + berserker.SwapToSlot(0) // Swap to combat gear (lowest Gold Find) + } + } + + err := action.WayPoint(area.Travincal) + if err != nil { + return err + } + + // Only Enable Area Correction for Travincal + t.ctx.CurrentGame.AreaCorrection.ExpectedArea = area.Travincal + t.ctx.CurrentGame.AreaCorrection.Enabled = true + + //TODO This is temporary needed for barb because have no cta; isrebuffrequired not working for him. We have ActiveWeaponSlot in d2go ready for that + action.Buff() + + councilPosition := t.findCouncilPosition() + + err = action.MoveToCoords(councilPosition) + if err != nil { + t.ctx.Logger.Warn("Error moving to council area", "error", err) + return err + } + + return t.ctx.Char.KillCouncil() +} + +func (t *Travincal) findCouncilPosition() data.Position { + for _, al := range t.ctx.Data.AdjacentLevels { + if al.Area == area.DuranceOfHateLevel1 { + return data.Position{ + X: al.Position.X - 1, + Y: al.Position.Y + 3, + } + } + } + + return data.Position{} +} diff --git a/internal/run/tristram.go b/internal/run/tristram.go index 15d1a107a..f63a935ab 100644 --- a/internal/run/tristram.go +++ b/internal/run/tristram.go @@ -1,120 +1,158 @@ package run import ( - "time" + "errors" + "fmt" "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/d2go/pkg/data/object" "github.com/hectorgimenez/koolo/internal/action" - "github.com/hectorgimenez/koolo/internal/action/step" "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/helper" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/utils" ) type Tristram struct { - baseRun + ctx *context.Status } -func (a Tristram) Name() string { - return "Tristram" +func NewTristram() *Tristram { + return &Tristram{ + ctx: context.Get(), + } } -func (a Tristram) BuildActions() []action.Action { - actions := []action.Action{ - a.builder.WayPoint(area.StonyField), // Moving to starting point (Stony Field) - action.NewChain(func(d data.Data) []action.Action { - for _, o := range d.Objects { - if o.Name == object.CairnStoneAlpha { - return []action.Action{a.builder.MoveToCoords(o.Position)} - } - } +func (t Tristram) Name() string { + return string(config.TristramRun) +} - return nil - }), +func (t Tristram) Run() error { + + // Use waypoint to StonyField + err := action.WayPoint(area.StonyField) + if err != nil { + return err } - // Clear monsters around the portal - if config.Config.Game.Tristram.ClearPortal { - actions = append(actions, a.builder.ClearAreaAroundPlayer(10)) + // Find the Cairn Stone Alpha + cairnStone := data.Object{} + for _, o := range t.ctx.Data.Objects { + if o.Name == object.CairnStoneAlpha { + cairnStone = o + } } - actions = append(actions, a.openPortalIfNotOpened()) + // Move to the cairnStone + action.MoveToCoords(cairnStone.Position) + + // Clear area around the portal + if t.ctx.CharacterCfg.Game.Tristram.ClearPortal || t.ctx.CharacterCfg.Game.Runs[0] == "leveling" { + action.ClearAreaAroundPlayer(10, data.MonsterAnyFilter()) + } + + // Handle opening Tristram Portal, will be skipped if its already opened + if err = t.openPortalIfNotOpened(); err != nil { + return err + } // Enter Tristram portal - actions = append(actions, a.builder.InteractObject(object.PermanentTownPortal, func(d data.Data) bool { - return d.PlayerUnit.Area == area.Tristram - }, step.Wait(time.Second))) - - if config.Config.Companion.Enabled && config.Config.Companion.Leader { - actions = append(actions, action.NewStepChain(func(d data.Data) []step.Step { - return []step.Step{step.OpenPortal()} - })) + + // Find the portal object + tristPortal, _ := t.ctx.Data.Objects.FindOne(object.PermanentTownPortal) + + // Interact with the portal + if err = action.InteractObject(tristPortal, func() bool { + return t.ctx.Data.PlayerUnit.Area == area.Tristram && t.ctx.Data.AreaData.IsInside(t.ctx.Data.PlayerUnit.Position) + }); err != nil { + return err } - // Clear Tristram or rescue Cain - return append(actions, action.NewChain(func(d data.Data) []action.Action { - if o, found := d.Objects.FindOne(object.CainGibbet); found && o.Selectable { - return []action.Action{a.builder.InteractObject(object.CainGibbet, func(d data.Data) bool { - obj, _ := d.Objects.FindOne(object.CainGibbet) - - return !obj.Selectable - })} - } else { - filter := data.MonsterAnyFilter() - if config.Config.Game.Tristram.FocusOnElitePacks { - filter = data.MonsterEliteFilter() - } - return []action.Action{a.builder.ClearArea(false, filter)} + // Open a TP if we're the leader + action.OpenTPIfLeader() + + // Check if Cain is rescued + if o, found := t.ctx.Data.Objects.FindOne(object.CainGibbet); found && o.Selectable { + + // Move to cain + action.MoveToCoords(o.Position) + + action.InteractObject(o, func() bool { + obj, _ := t.ctx.Data.Objects.FindOne(object.CainGibbet) + + return !obj.Selectable + }) + } else { + filter := data.MonsterAnyFilter() + if t.ctx.CharacterCfg.Game.Tristram.FocusOnElitePacks && t.ctx.CharacterCfg.Game.Runs[0] != "leveling" { + filter = data.MonsterEliteFilter() } - })) + + return action.ClearCurrentLevel(false, filter) + } + + return nil } -func (a Tristram) openPortalIfNotOpened() action.Action { - logged := false +func (t Tristram) openPortalIfNotOpened() error { - return action.NewChain(func(d data.Data) (actions []action.Action) { - _, found := d.Objects.FindOne(object.PermanentTownPortal) - if found { - return nil - } + // If the portal already exists, skip this + if _, found := t.ctx.Data.Objects.FindOne(object.PermanentTownPortal); found { + return nil + } - if !logged { - a.logger.Debug("Tristram portal not detected, trying to open it") - logged = true - } + t.ctx.Logger.Debug("Tristram portal not detected, trying to open it") + + for range 6 { + stoneTries := 0 + activeStones := 0 + for _, cainStone := range []object.Name{ + object.CairnStoneAlpha, + object.CairnStoneBeta, + object.CairnStoneGamma, + object.CairnStoneDelta, + object.CairnStoneLambda, + } { + st := cainStone + stone, _ := t.ctx.Data.Objects.FindOne(st) + if stone.Selectable { + + action.InteractObject(stone, func() bool { + + if stoneTries < 5 { + stoneTries++ + utils.Sleep(200) + x, y := t.ctx.PathFinder.GameCoordsToScreenCords(stone.Position.X, stone.Position.Y) + t.ctx.HID.Click(game.LeftButton, x+3*stoneTries, y) + t.ctx.Logger.Debug(fmt.Sprintf("Tried to click %s at screen pos %vx%v", stone.Desc().Name, x, y)) + return false + } + stoneTries = 0 + return true + }) - // We don't know which order the stones are, so we activate all of them one by one in sequential order, 5 times - for i := 0; i < 5; i++ { - for _, cainStone := range []object.Name{ - object.CairnStoneAlpha, - object.CairnStoneBeta, - object.CairnStoneGamma, - object.CairnStoneDelta, - object.CairnStoneLambda, - } { - st := cainStone - stone, _ := d.Objects.FindOne(st) - actions = append(actions, a.builder.InteractObject(stone.Name, nil)) + } else { + utils.Sleep(200) + activeStones++ + } + _, tristPortal := t.ctx.Data.Objects.FindOne(object.PermanentTownPortal) + if activeStones >= 5 || tristPortal { + break } } - // Wait until portal is open - actions = append(actions, action.NewStepChain(func(d data.Data) []step.Step { - return []step.Step{ - step.SyncStepWithCheck(func(d data.Data) error { - helper.Sleep(1000) - return nil - }, func(d data.Data) step.Status { - _, found := d.Objects.FindOne(object.PermanentTownPortal) - if !found { - return step.StatusInProgress - } - return step.StatusCompleted - }), - } - })) + } + + // Wait upto 15 seconds for the portal to open, checking every second if its up + for range 15 { + // Wait a second + utils.Sleep(1000) + + if _, portalFound := t.ctx.Data.Objects.FindOne(object.PermanentTownPortal); portalFound { + return nil + } + } - return actions - }) + return errors.New("failed to open Tristram portal") } diff --git a/internal/server/assets/css/bootstrap-icons.css b/internal/server/assets/css/bootstrap-icons.css new file mode 100644 index 000000000..3c1537f1b --- /dev/null +++ b/internal/server/assets/css/bootstrap-icons.css @@ -0,0 +1,1556 @@ +@font-face { + font-family: "bootstrap-icons"; + src: url("./fonts/bootstrap-icons.woff2?30af91bf14e37666a085fb8a161ff36d") format("woff2"), +url("./fonts/bootstrap-icons.woff?30af91bf14e37666a085fb8a161ff36d") format("woff"); +} + +.bi::before, +[class^="bi-"]::before, +[class*=" bi-"]::before { + display: inline-block; + font-family: bootstrap-icons !important; + font-style: normal; + font-weight: normal !important; + font-variant: normal; + text-transform: none; + line-height: 1; + vertical-align: -.125em; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.bi-123::before { content: "\f67f"; } +.bi-alarm-fill::before { content: "\f101"; } +.bi-alarm::before { content: "\f102"; } +.bi-align-bottom::before { content: "\f103"; } +.bi-align-center::before { content: "\f104"; } +.bi-align-end::before { content: "\f105"; } +.bi-align-middle::before { content: "\f106"; } +.bi-align-start::before { content: "\f107"; } +.bi-align-top::before { content: "\f108"; } +.bi-alt::before { content: "\f109"; } +.bi-app-indicator::before { content: "\f10a"; } +.bi-app::before { content: "\f10b"; } +.bi-archive-fill::before { content: "\f10c"; } +.bi-archive::before { content: "\f10d"; } +.bi-arrow-90deg-down::before { content: "\f10e"; } +.bi-arrow-90deg-left::before { content: "\f10f"; } +.bi-arrow-90deg-right::before { content: "\f110"; } +.bi-arrow-90deg-up::before { content: "\f111"; } +.bi-arrow-bar-down::before { content: "\f112"; } +.bi-arrow-bar-left::before { content: "\f113"; } +.bi-arrow-bar-right::before { content: "\f114"; } +.bi-arrow-bar-up::before { content: "\f115"; } +.bi-arrow-clockwise::before { content: "\f116"; } +.bi-arrow-counterclockwise::before { content: "\f117"; } +.bi-arrow-down-circle-fill::before { content: "\f118"; } +.bi-arrow-down-circle::before { content: "\f119"; } +.bi-arrow-down-left-circle-fill::before { content: "\f11a"; } +.bi-arrow-down-left-circle::before { content: "\f11b"; } +.bi-arrow-down-left-square-fill::before { content: "\f11c"; } +.bi-arrow-down-left-square::before { content: "\f11d"; } +.bi-arrow-down-left::before { content: "\f11e"; } +.bi-arrow-down-right-circle-fill::before { content: "\f11f"; } +.bi-arrow-down-right-circle::before { content: "\f120"; } +.bi-arrow-down-right-square-fill::before { content: "\f121"; } +.bi-arrow-down-right-square::before { content: "\f122"; } +.bi-arrow-down-right::before { content: "\f123"; } +.bi-arrow-down-short::before { content: "\f124"; } +.bi-arrow-down-square-fill::before { content: "\f125"; } +.bi-arrow-down-square::before { content: "\f126"; } +.bi-arrow-down-up::before { content: "\f127"; } +.bi-arrow-down::before { content: "\f128"; } +.bi-arrow-left-circle-fill::before { content: "\f129"; } +.bi-arrow-left-circle::before { content: "\f12a"; } +.bi-arrow-left-right::before { content: "\f12b"; } +.bi-arrow-left-short::before { content: "\f12c"; } +.bi-arrow-left-square-fill::before { content: "\f12d"; } +.bi-arrow-left-square::before { content: "\f12e"; } +.bi-arrow-left::before { content: "\f12f"; } +.bi-arrow-repeat::before { content: "\f130"; } +.bi-arrow-return-left::before { content: "\f131"; } +.bi-arrow-return-right::before { content: "\f132"; } +.bi-arrow-right-circle-fill::before { content: "\f133"; } +.bi-arrow-right-circle::before { content: "\f134"; } +.bi-arrow-right-short::before { content: "\f135"; } +.bi-arrow-right-square-fill::before { content: "\f136"; } +.bi-arrow-right-square::before { content: "\f137"; } +.bi-arrow-right::before { content: "\f138"; } +.bi-arrow-up-circle-fill::before { content: "\f139"; } +.bi-arrow-up-circle::before { content: "\f13a"; } +.bi-arrow-up-left-circle-fill::before { content: "\f13b"; } +.bi-arrow-up-left-circle::before { content: "\f13c"; } +.bi-arrow-up-left-square-fill::before { content: "\f13d"; } +.bi-arrow-up-left-square::before { content: "\f13e"; } +.bi-arrow-up-left::before { content: "\f13f"; } +.bi-arrow-up-right-circle-fill::before { content: "\f140"; } +.bi-arrow-up-right-circle::before { content: "\f141"; } +.bi-arrow-up-right-square-fill::before { content: "\f142"; } +.bi-arrow-up-right-square::before { content: "\f143"; } +.bi-arrow-up-right::before { content: "\f144"; } +.bi-arrow-up-short::before { content: "\f145"; } +.bi-arrow-up-square-fill::before { content: "\f146"; } +.bi-arrow-up-square::before { content: "\f147"; } +.bi-arrow-up::before { content: "\f148"; } +.bi-arrows-angle-contract::before { content: "\f149"; } +.bi-arrows-angle-expand::before { content: "\f14a"; } +.bi-arrows-collapse::before { content: "\f14b"; } +.bi-arrows-expand::before { content: "\f14c"; } +.bi-arrows-fullscreen::before { content: "\f14d"; } +.bi-arrows-move::before { content: "\f14e"; } +.bi-aspect-ratio-fill::before { content: "\f14f"; } +.bi-aspect-ratio::before { content: "\f150"; } +.bi-asterisk::before { content: "\f151"; } +.bi-at::before { content: "\f152"; } +.bi-award-fill::before { content: "\f153"; } +.bi-award::before { content: "\f154"; } +.bi-back::before { content: "\f155"; } +.bi-backspace-fill::before { content: "\f156"; } +.bi-backspace-reverse-fill::before { content: "\f157"; } +.bi-backspace-reverse::before { content: "\f158"; } +.bi-backspace::before { content: "\f159"; } +.bi-badge-3d-fill::before { content: "\f15a"; } +.bi-badge-3d::before { content: "\f15b"; } +.bi-badge-4k-fill::before { content: "\f15c"; } +.bi-badge-4k::before { content: "\f15d"; } +.bi-badge-8k-fill::before { content: "\f15e"; } +.bi-badge-8k::before { content: "\f15f"; } +.bi-badge-ad-fill::before { content: "\f160"; } +.bi-badge-ad::before { content: "\f161"; } +.bi-badge-ar-fill::before { content: "\f162"; } +.bi-badge-ar::before { content: "\f163"; } +.bi-badge-cc-fill::before { content: "\f164"; } +.bi-badge-cc::before { content: "\f165"; } +.bi-badge-hd-fill::before { content: "\f166"; } +.bi-badge-hd::before { content: "\f167"; } +.bi-badge-tm-fill::before { content: "\f168"; } +.bi-badge-tm::before { content: "\f169"; } +.bi-badge-vo-fill::before { content: "\f16a"; } +.bi-badge-vo::before { content: "\f16b"; } +.bi-badge-vr-fill::before { content: "\f16c"; } +.bi-badge-vr::before { content: "\f16d"; } +.bi-badge-wc-fill::before { content: "\f16e"; } +.bi-badge-wc::before { content: "\f16f"; } +.bi-bag-check-fill::before { content: "\f170"; } +.bi-bag-check::before { content: "\f171"; } +.bi-bag-dash-fill::before { content: "\f172"; } +.bi-bag-dash::before { content: "\f173"; } +.bi-bag-fill::before { content: "\f174"; } +.bi-bag-plus-fill::before { content: "\f175"; } +.bi-bag-plus::before { content: "\f176"; } +.bi-bag-x-fill::before { content: "\f177"; } +.bi-bag-x::before { content: "\f178"; } +.bi-bag::before { content: "\f179"; } +.bi-bar-chart-fill::before { content: "\f17a"; } +.bi-bar-chart-line-fill::before { content: "\f17b"; } +.bi-bar-chart-line::before { content: "\f17c"; } +.bi-bar-chart-steps::before { content: "\f17d"; } +.bi-bar-chart::before { content: "\f17e"; } +.bi-basket-fill::before { content: "\f17f"; } +.bi-basket::before { content: "\f180"; } +.bi-basket2-fill::before { content: "\f181"; } +.bi-basket2::before { content: "\f182"; } +.bi-basket3-fill::before { content: "\f183"; } +.bi-basket3::before { content: "\f184"; } +.bi-battery-charging::before { content: "\f185"; } +.bi-battery-full::before { content: "\f186"; } +.bi-battery-half::before { content: "\f187"; } +.bi-battery::before { content: "\f188"; } +.bi-bell-fill::before { content: "\f189"; } +.bi-bell::before { content: "\f18a"; } +.bi-bezier::before { content: "\f18b"; } +.bi-bezier2::before { content: "\f18c"; } +.bi-bicycle::before { content: "\f18d"; } +.bi-binoculars-fill::before { content: "\f18e"; } +.bi-binoculars::before { content: "\f18f"; } +.bi-blockquote-left::before { content: "\f190"; } +.bi-blockquote-right::before { content: "\f191"; } +.bi-book-fill::before { content: "\f192"; } +.bi-book-half::before { content: "\f193"; } +.bi-book::before { content: "\f194"; } +.bi-bookmark-check-fill::before { content: "\f195"; } +.bi-bookmark-check::before { content: "\f196"; } +.bi-bookmark-dash-fill::before { content: "\f197"; } +.bi-bookmark-dash::before { content: "\f198"; } +.bi-bookmark-fill::before { content: "\f199"; } +.bi-bookmark-heart-fill::before { content: "\f19a"; } +.bi-bookmark-heart::before { content: "\f19b"; } +.bi-bookmark-plus-fill::before { content: "\f19c"; } +.bi-bookmark-plus::before { content: "\f19d"; } +.bi-bookmark-star-fill::before { content: "\f19e"; } +.bi-bookmark-star::before { content: "\f19f"; } +.bi-bookmark-x-fill::before { content: "\f1a0"; } +.bi-bookmark-x::before { content: "\f1a1"; } +.bi-bookmark::before { content: "\f1a2"; } +.bi-bookmarks-fill::before { content: "\f1a3"; } +.bi-bookmarks::before { content: "\f1a4"; } +.bi-bookshelf::before { content: "\f1a5"; } +.bi-bootstrap-fill::before { content: "\f1a6"; } +.bi-bootstrap-reboot::before { content: "\f1a7"; } +.bi-bootstrap::before { content: "\f1a8"; } +.bi-border-all::before { content: "\f1a9"; } +.bi-border-bottom::before { content: "\f1aa"; } +.bi-border-center::before { content: "\f1ab"; } +.bi-border-inner::before { content: "\f1ac"; } +.bi-border-left::before { content: "\f1ad"; } +.bi-border-middle::before { content: "\f1ae"; } +.bi-border-outer::before { content: "\f1af"; } +.bi-border-right::before { content: "\f1b0"; } +.bi-border-style::before { content: "\f1b1"; } +.bi-border-top::before { content: "\f1b2"; } +.bi-border-width::before { content: "\f1b3"; } +.bi-border::before { content: "\f1b4"; } +.bi-bounding-box-circles::before { content: "\f1b5"; } +.bi-bounding-box::before { content: "\f1b6"; } +.bi-box-arrow-down-left::before { content: "\f1b7"; } +.bi-box-arrow-down-right::before { content: "\f1b8"; } +.bi-box-arrow-down::before { content: "\f1b9"; } +.bi-box-arrow-in-down-left::before { content: "\f1ba"; } +.bi-box-arrow-in-down-right::before { content: "\f1bb"; } +.bi-box-arrow-in-down::before { content: "\f1bc"; } +.bi-box-arrow-in-left::before { content: "\f1bd"; } +.bi-box-arrow-in-right::before { content: "\f1be"; } +.bi-box-arrow-in-up-left::before { content: "\f1bf"; } +.bi-box-arrow-in-up-right::before { content: "\f1c0"; } +.bi-box-arrow-in-up::before { content: "\f1c1"; } +.bi-box-arrow-left::before { content: "\f1c2"; } +.bi-box-arrow-right::before { content: "\f1c3"; } +.bi-box-arrow-up-left::before { content: "\f1c4"; } +.bi-box-arrow-up-right::before { content: "\f1c5"; } +.bi-box-arrow-up::before { content: "\f1c6"; } +.bi-box-seam::before { content: "\f1c7"; } +.bi-box::before { content: "\f1c8"; } +.bi-braces::before { content: "\f1c9"; } +.bi-bricks::before { content: "\f1ca"; } +.bi-briefcase-fill::before { content: "\f1cb"; } +.bi-briefcase::before { content: "\f1cc"; } +.bi-brightness-alt-high-fill::before { content: "\f1cd"; } +.bi-brightness-alt-high::before { content: "\f1ce"; } +.bi-brightness-alt-low-fill::before { content: "\f1cf"; } +.bi-brightness-alt-low::before { content: "\f1d0"; } +.bi-brightness-high-fill::before { content: "\f1d1"; } +.bi-brightness-high::before { content: "\f1d2"; } +.bi-brightness-low-fill::before { content: "\f1d3"; } +.bi-brightness-low::before { content: "\f1d4"; } +.bi-broadcast-pin::before { content: "\f1d5"; } +.bi-broadcast::before { content: "\f1d6"; } +.bi-brush-fill::before { content: "\f1d7"; } +.bi-brush::before { content: "\f1d8"; } +.bi-bucket-fill::before { content: "\f1d9"; } +.bi-bucket::before { content: "\f1da"; } +.bi-bug-fill::before { content: "\f1db"; } +.bi-bug::before { content: "\f1dc"; } +.bi-building::before { content: "\f1dd"; } +.bi-bullseye::before { content: "\f1de"; } +.bi-calculator-fill::before { content: "\f1df"; } +.bi-calculator::before { content: "\f1e0"; } +.bi-calendar-check-fill::before { content: "\f1e1"; } +.bi-calendar-check::before { content: "\f1e2"; } +.bi-calendar-date-fill::before { content: "\f1e3"; } +.bi-calendar-date::before { content: "\f1e4"; } +.bi-calendar-day-fill::before { content: "\f1e5"; } +.bi-calendar-day::before { content: "\f1e6"; } +.bi-calendar-event-fill::before { content: "\f1e7"; } +.bi-calendar-event::before { content: "\f1e8"; } +.bi-calendar-fill::before { content: "\f1e9"; } +.bi-calendar-minus-fill::before { content: "\f1ea"; } +.bi-calendar-minus::before { content: "\f1eb"; } +.bi-calendar-month-fill::before { content: "\f1ec"; } +.bi-calendar-month::before { content: "\f1ed"; } +.bi-calendar-plus-fill::before { content: "\f1ee"; } +.bi-calendar-plus::before { content: "\f1ef"; } +.bi-calendar-range-fill::before { content: "\f1f0"; } +.bi-calendar-range::before { content: "\f1f1"; } +.bi-calendar-week-fill::before { content: "\f1f2"; } +.bi-calendar-week::before { content: "\f1f3"; } +.bi-calendar-x-fill::before { content: "\f1f4"; } +.bi-calendar-x::before { content: "\f1f5"; } +.bi-calendar::before { content: "\f1f6"; } +.bi-calendar2-check-fill::before { content: "\f1f7"; } +.bi-calendar2-check::before { content: "\f1f8"; } +.bi-calendar2-date-fill::before { content: "\f1f9"; } +.bi-calendar2-date::before { content: "\f1fa"; } +.bi-calendar2-day-fill::before { content: "\f1fb"; } +.bi-calendar2-day::before { content: "\f1fc"; } +.bi-calendar2-event-fill::before { content: "\f1fd"; } +.bi-calendar2-event::before { content: "\f1fe"; } +.bi-calendar2-fill::before { content: "\f1ff"; } +.bi-calendar2-minus-fill::before { content: "\f200"; } +.bi-calendar2-minus::before { content: "\f201"; } +.bi-calendar2-month-fill::before { content: "\f202"; } +.bi-calendar2-month::before { content: "\f203"; } +.bi-calendar2-plus-fill::before { content: "\f204"; } +.bi-calendar2-plus::before { content: "\f205"; } +.bi-calendar2-range-fill::before { content: "\f206"; } +.bi-calendar2-range::before { content: "\f207"; } +.bi-calendar2-week-fill::before { content: "\f208"; } +.bi-calendar2-week::before { content: "\f209"; } +.bi-calendar2-x-fill::before { content: "\f20a"; } +.bi-calendar2-x::before { content: "\f20b"; } +.bi-calendar2::before { content: "\f20c"; } +.bi-calendar3-event-fill::before { content: "\f20d"; } +.bi-calendar3-event::before { content: "\f20e"; } +.bi-calendar3-fill::before { content: "\f20f"; } +.bi-calendar3-range-fill::before { content: "\f210"; } +.bi-calendar3-range::before { content: "\f211"; } +.bi-calendar3-week-fill::before { content: "\f212"; } +.bi-calendar3-week::before { content: "\f213"; } +.bi-calendar3::before { content: "\f214"; } +.bi-calendar4-event::before { content: "\f215"; } +.bi-calendar4-range::before { content: "\f216"; } +.bi-calendar4-week::before { content: "\f217"; } +.bi-calendar4::before { content: "\f218"; } +.bi-camera-fill::before { content: "\f219"; } +.bi-camera-reels-fill::before { content: "\f21a"; } +.bi-camera-reels::before { content: "\f21b"; } +.bi-camera-video-fill::before { content: "\f21c"; } +.bi-camera-video-off-fill::before { content: "\f21d"; } +.bi-camera-video-off::before { content: "\f21e"; } +.bi-camera-video::before { content: "\f21f"; } +.bi-camera::before { content: "\f220"; } +.bi-camera2::before { content: "\f221"; } +.bi-capslock-fill::before { content: "\f222"; } +.bi-capslock::before { content: "\f223"; } +.bi-card-checklist::before { content: "\f224"; } +.bi-card-heading::before { content: "\f225"; } +.bi-card-image::before { content: "\f226"; } +.bi-card-list::before { content: "\f227"; } +.bi-card-text::before { content: "\f228"; } +.bi-caret-down-fill::before { content: "\f229"; } +.bi-caret-down-square-fill::before { content: "\f22a"; } +.bi-caret-down-square::before { content: "\f22b"; } +.bi-caret-down::before { content: "\f22c"; } +.bi-caret-left-fill::before { content: "\f22d"; } +.bi-caret-left-square-fill::before { content: "\f22e"; } +.bi-caret-left-square::before { content: "\f22f"; } +.bi-caret-left::before { content: "\f230"; } +.bi-caret-right-fill::before { content: "\f231"; } +.bi-caret-right-square-fill::before { content: "\f232"; } +.bi-caret-right-square::before { content: "\f233"; } +.bi-caret-right::before { content: "\f234"; } +.bi-caret-up-fill::before { content: "\f235"; } +.bi-caret-up-square-fill::before { content: "\f236"; } +.bi-caret-up-square::before { content: "\f237"; } +.bi-caret-up::before { content: "\f238"; } +.bi-cart-check-fill::before { content: "\f239"; } +.bi-cart-check::before { content: "\f23a"; } +.bi-cart-dash-fill::before { content: "\f23b"; } +.bi-cart-dash::before { content: "\f23c"; } +.bi-cart-fill::before { content: "\f23d"; } +.bi-cart-plus-fill::before { content: "\f23e"; } +.bi-cart-plus::before { content: "\f23f"; } +.bi-cart-x-fill::before { content: "\f240"; } +.bi-cart-x::before { content: "\f241"; } +.bi-cart::before { content: "\f242"; } +.bi-cart2::before { content: "\f243"; } +.bi-cart3::before { content: "\f244"; } +.bi-cart4::before { content: "\f245"; } +.bi-cash-stack::before { content: "\f246"; } +.bi-cash::before { content: "\f247"; } +.bi-cast::before { content: "\f248"; } +.bi-chat-dots-fill::before { content: "\f249"; } +.bi-chat-dots::before { content: "\f24a"; } +.bi-chat-fill::before { content: "\f24b"; } +.bi-chat-left-dots-fill::before { content: "\f24c"; } +.bi-chat-left-dots::before { content: "\f24d"; } +.bi-chat-left-fill::before { content: "\f24e"; } +.bi-chat-left-quote-fill::before { content: "\f24f"; } +.bi-chat-left-quote::before { content: "\f250"; } +.bi-chat-left-text-fill::before { content: "\f251"; } +.bi-chat-left-text::before { content: "\f252"; } +.bi-chat-left::before { content: "\f253"; } +.bi-chat-quote-fill::before { content: "\f254"; } +.bi-chat-quote::before { content: "\f255"; } +.bi-chat-right-dots-fill::before { content: "\f256"; } +.bi-chat-right-dots::before { content: "\f257"; } +.bi-chat-right-fill::before { content: "\f258"; } +.bi-chat-right-quote-fill::before { content: "\f259"; } +.bi-chat-right-quote::before { content: "\f25a"; } +.bi-chat-right-text-fill::before { content: "\f25b"; } +.bi-chat-right-text::before { content: "\f25c"; } +.bi-chat-right::before { content: "\f25d"; } +.bi-chat-square-dots-fill::before { content: "\f25e"; } +.bi-chat-square-dots::before { content: "\f25f"; } +.bi-chat-square-fill::before { content: "\f260"; } +.bi-chat-square-quote-fill::before { content: "\f261"; } +.bi-chat-square-quote::before { content: "\f262"; } +.bi-chat-square-text-fill::before { content: "\f263"; } +.bi-chat-square-text::before { content: "\f264"; } +.bi-chat-square::before { content: "\f265"; } +.bi-chat-text-fill::before { content: "\f266"; } +.bi-chat-text::before { content: "\f267"; } +.bi-chat::before { content: "\f268"; } +.bi-check-all::before { content: "\f269"; } +.bi-check-circle-fill::before { content: "\f26a"; } +.bi-check-circle::before { content: "\f26b"; } +.bi-check-square-fill::before { content: "\f26c"; } +.bi-check-square::before { content: "\f26d"; } +.bi-check::before { content: "\f26e"; } +.bi-check2-all::before { content: "\f26f"; } +.bi-check2-circle::before { content: "\f270"; } +.bi-check2-square::before { content: "\f271"; } +.bi-check2::before { content: "\f272"; } +.bi-chevron-bar-contract::before { content: "\f273"; } +.bi-chevron-bar-down::before { content: "\f274"; } +.bi-chevron-bar-expand::before { content: "\f275"; } +.bi-chevron-bar-left::before { content: "\f276"; } +.bi-chevron-bar-right::before { content: "\f277"; } +.bi-chevron-bar-up::before { content: "\f278"; } +.bi-chevron-compact-down::before { content: "\f279"; } +.bi-chevron-compact-left::before { content: "\f27a"; } +.bi-chevron-compact-right::before { content: "\f27b"; } +.bi-chevron-compact-up::before { content: "\f27c"; } +.bi-chevron-contract::before { content: "\f27d"; } +.bi-chevron-double-down::before { content: "\f27e"; } +.bi-chevron-double-left::before { content: "\f27f"; } +.bi-chevron-double-right::before { content: "\f280"; } +.bi-chevron-double-up::before { content: "\f281"; } +.bi-chevron-down::before { content: "\f282"; } +.bi-chevron-expand::before { content: "\f283"; } +.bi-chevron-left::before { content: "\f284"; } +.bi-chevron-right::before { content: "\f285"; } +.bi-chevron-up::before { content: "\f286"; } +.bi-circle-fill::before { content: "\f287"; } +.bi-circle-half::before { content: "\f288"; } +.bi-circle-square::before { content: "\f289"; } +.bi-circle::before { content: "\f28a"; } +.bi-clipboard-check::before { content: "\f28b"; } +.bi-clipboard-data::before { content: "\f28c"; } +.bi-clipboard-minus::before { content: "\f28d"; } +.bi-clipboard-plus::before { content: "\f28e"; } +.bi-clipboard-x::before { content: "\f28f"; } +.bi-clipboard::before { content: "\f290"; } +.bi-clock-fill::before { content: "\f291"; } +.bi-clock-history::before { content: "\f292"; } +.bi-clock::before { content: "\f293"; } +.bi-cloud-arrow-down-fill::before { content: "\f294"; } +.bi-cloud-arrow-down::before { content: "\f295"; } +.bi-cloud-arrow-up-fill::before { content: "\f296"; } +.bi-cloud-arrow-up::before { content: "\f297"; } +.bi-cloud-check-fill::before { content: "\f298"; } +.bi-cloud-check::before { content: "\f299"; } +.bi-cloud-download-fill::before { content: "\f29a"; } +.bi-cloud-download::before { content: "\f29b"; } +.bi-cloud-drizzle-fill::before { content: "\f29c"; } +.bi-cloud-drizzle::before { content: "\f29d"; } +.bi-cloud-fill::before { content: "\f29e"; } +.bi-cloud-fog-fill::before { content: "\f29f"; } +.bi-cloud-fog::before { content: "\f2a0"; } +.bi-cloud-fog2-fill::before { content: "\f2a1"; } +.bi-cloud-fog2::before { content: "\f2a2"; } +.bi-cloud-hail-fill::before { content: "\f2a3"; } +.bi-cloud-hail::before { content: "\f2a4"; } +.bi-cloud-haze-1::before { content: "\f2a5"; } +.bi-cloud-haze-fill::before { content: "\f2a6"; } +.bi-cloud-haze::before { content: "\f2a7"; } +.bi-cloud-haze2-fill::before { content: "\f2a8"; } +.bi-cloud-lightning-fill::before { content: "\f2a9"; } +.bi-cloud-lightning-rain-fill::before { content: "\f2aa"; } +.bi-cloud-lightning-rain::before { content: "\f2ab"; } +.bi-cloud-lightning::before { content: "\f2ac"; } +.bi-cloud-minus-fill::before { content: "\f2ad"; } +.bi-cloud-minus::before { content: "\f2ae"; } +.bi-cloud-moon-fill::before { content: "\f2af"; } +.bi-cloud-moon::before { content: "\f2b0"; } +.bi-cloud-plus-fill::before { content: "\f2b1"; } +.bi-cloud-plus::before { content: "\f2b2"; } +.bi-cloud-rain-fill::before { content: "\f2b3"; } +.bi-cloud-rain-heavy-fill::before { content: "\f2b4"; } +.bi-cloud-rain-heavy::before { content: "\f2b5"; } +.bi-cloud-rain::before { content: "\f2b6"; } +.bi-cloud-slash-fill::before { content: "\f2b7"; } +.bi-cloud-slash::before { content: "\f2b8"; } +.bi-cloud-sleet-fill::before { content: "\f2b9"; } +.bi-cloud-sleet::before { content: "\f2ba"; } +.bi-cloud-snow-fill::before { content: "\f2bb"; } +.bi-cloud-snow::before { content: "\f2bc"; } +.bi-cloud-sun-fill::before { content: "\f2bd"; } +.bi-cloud-sun::before { content: "\f2be"; } +.bi-cloud-upload-fill::before { content: "\f2bf"; } +.bi-cloud-upload::before { content: "\f2c0"; } +.bi-cloud::before { content: "\f2c1"; } +.bi-clouds-fill::before { content: "\f2c2"; } +.bi-clouds::before { content: "\f2c3"; } +.bi-cloudy-fill::before { content: "\f2c4"; } +.bi-cloudy::before { content: "\f2c5"; } +.bi-code-slash::before { content: "\f2c6"; } +.bi-code-square::before { content: "\f2c7"; } +.bi-code::before { content: "\f2c8"; } +.bi-collection-fill::before { content: "\f2c9"; } +.bi-collection-play-fill::before { content: "\f2ca"; } +.bi-collection-play::before { content: "\f2cb"; } +.bi-collection::before { content: "\f2cc"; } +.bi-columns-gap::before { content: "\f2cd"; } +.bi-columns::before { content: "\f2ce"; } +.bi-command::before { content: "\f2cf"; } +.bi-compass-fill::before { content: "\f2d0"; } +.bi-compass::before { content: "\f2d1"; } +.bi-cone-striped::before { content: "\f2d2"; } +.bi-cone::before { content: "\f2d3"; } +.bi-controller::before { content: "\f2d4"; } +.bi-cpu-fill::before { content: "\f2d5"; } +.bi-cpu::before { content: "\f2d6"; } +.bi-credit-card-2-back-fill::before { content: "\f2d7"; } +.bi-credit-card-2-back::before { content: "\f2d8"; } +.bi-credit-card-2-front-fill::before { content: "\f2d9"; } +.bi-credit-card-2-front::before { content: "\f2da"; } +.bi-credit-card-fill::before { content: "\f2db"; } +.bi-credit-card::before { content: "\f2dc"; } +.bi-crop::before { content: "\f2dd"; } +.bi-cup-fill::before { content: "\f2de"; } +.bi-cup-straw::before { content: "\f2df"; } +.bi-cup::before { content: "\f2e0"; } +.bi-cursor-fill::before { content: "\f2e1"; } +.bi-cursor-text::before { content: "\f2e2"; } +.bi-cursor::before { content: "\f2e3"; } +.bi-dash-circle-dotted::before { content: "\f2e4"; } +.bi-dash-circle-fill::before { content: "\f2e5"; } +.bi-dash-circle::before { content: "\f2e6"; } +.bi-dash-square-dotted::before { content: "\f2e7"; } +.bi-dash-square-fill::before { content: "\f2e8"; } +.bi-dash-square::before { content: "\f2e9"; } +.bi-dash::before { content: "\f2ea"; } +.bi-diagram-2-fill::before { content: "\f2eb"; } +.bi-diagram-2::before { content: "\f2ec"; } +.bi-diagram-3-fill::before { content: "\f2ed"; } +.bi-diagram-3::before { content: "\f2ee"; } +.bi-diamond-fill::before { content: "\f2ef"; } +.bi-diamond-half::before { content: "\f2f0"; } +.bi-diamond::before { content: "\f2f1"; } +.bi-dice-1-fill::before { content: "\f2f2"; } +.bi-dice-1::before { content: "\f2f3"; } +.bi-dice-2-fill::before { content: "\f2f4"; } +.bi-dice-2::before { content: "\f2f5"; } +.bi-dice-3-fill::before { content: "\f2f6"; } +.bi-dice-3::before { content: "\f2f7"; } +.bi-dice-4-fill::before { content: "\f2f8"; } +.bi-dice-4::before { content: "\f2f9"; } +.bi-dice-5-fill::before { content: "\f2fa"; } +.bi-dice-5::before { content: "\f2fb"; } +.bi-dice-6-fill::before { content: "\f2fc"; } +.bi-dice-6::before { content: "\f2fd"; } +.bi-disc-fill::before { content: "\f2fe"; } +.bi-disc::before { content: "\f2ff"; } +.bi-discord::before { content: "\f300"; } +.bi-display-fill::before { content: "\f301"; } +.bi-display::before { content: "\f302"; } +.bi-distribute-horizontal::before { content: "\f303"; } +.bi-distribute-vertical::before { content: "\f304"; } +.bi-door-closed-fill::before { content: "\f305"; } +.bi-door-closed::before { content: "\f306"; } +.bi-door-open-fill::before { content: "\f307"; } +.bi-door-open::before { content: "\f308"; } +.bi-dot::before { content: "\f309"; } +.bi-download::before { content: "\f30a"; } +.bi-droplet-fill::before { content: "\f30b"; } +.bi-droplet-half::before { content: "\f30c"; } +.bi-droplet::before { content: "\f30d"; } +.bi-earbuds::before { content: "\f30e"; } +.bi-easel-fill::before { content: "\f30f"; } +.bi-easel::before { content: "\f310"; } +.bi-egg-fill::before { content: "\f311"; } +.bi-egg-fried::before { content: "\f312"; } +.bi-egg::before { content: "\f313"; } +.bi-eject-fill::before { content: "\f314"; } +.bi-eject::before { content: "\f315"; } +.bi-emoji-angry-fill::before { content: "\f316"; } +.bi-emoji-angry::before { content: "\f317"; } +.bi-emoji-dizzy-fill::before { content: "\f318"; } +.bi-emoji-dizzy::before { content: "\f319"; } +.bi-emoji-expressionless-fill::before { content: "\f31a"; } +.bi-emoji-expressionless::before { content: "\f31b"; } +.bi-emoji-frown-fill::before { content: "\f31c"; } +.bi-emoji-frown::before { content: "\f31d"; } +.bi-emoji-heart-eyes-fill::before { content: "\f31e"; } +.bi-emoji-heart-eyes::before { content: "\f31f"; } +.bi-emoji-laughing-fill::before { content: "\f320"; } +.bi-emoji-laughing::before { content: "\f321"; } +.bi-emoji-neutral-fill::before { content: "\f322"; } +.bi-emoji-neutral::before { content: "\f323"; } +.bi-emoji-smile-fill::before { content: "\f324"; } +.bi-emoji-smile-upside-down-fill::before { content: "\f325"; } +.bi-emoji-smile-upside-down::before { content: "\f326"; } +.bi-emoji-smile::before { content: "\f327"; } +.bi-emoji-sunglasses-fill::before { content: "\f328"; } +.bi-emoji-sunglasses::before { content: "\f329"; } +.bi-emoji-wink-fill::before { content: "\f32a"; } +.bi-emoji-wink::before { content: "\f32b"; } +.bi-envelope-fill::before { content: "\f32c"; } +.bi-envelope-open-fill::before { content: "\f32d"; } +.bi-envelope-open::before { content: "\f32e"; } +.bi-envelope::before { content: "\f32f"; } +.bi-eraser-fill::before { content: "\f330"; } +.bi-eraser::before { content: "\f331"; } +.bi-exclamation-circle-fill::before { content: "\f332"; } +.bi-exclamation-circle::before { content: "\f333"; } +.bi-exclamation-diamond-fill::before { content: "\f334"; } +.bi-exclamation-diamond::before { content: "\f335"; } +.bi-exclamation-octagon-fill::before { content: "\f336"; } +.bi-exclamation-octagon::before { content: "\f337"; } +.bi-exclamation-square-fill::before { content: "\f338"; } +.bi-exclamation-square::before { content: "\f339"; } +.bi-exclamation-triangle-fill::before { content: "\f33a"; } +.bi-exclamation-triangle::before { content: "\f33b"; } +.bi-exclamation::before { content: "\f33c"; } +.bi-exclude::before { content: "\f33d"; } +.bi-eye-fill::before { content: "\f33e"; } +.bi-eye-slash-fill::before { content: "\f33f"; } +.bi-eye-slash::before { content: "\f340"; } +.bi-eye::before { content: "\f341"; } +.bi-eyedropper::before { content: "\f342"; } +.bi-eyeglasses::before { content: "\f343"; } +.bi-facebook::before { content: "\f344"; } +.bi-file-arrow-down-fill::before { content: "\f345"; } +.bi-file-arrow-down::before { content: "\f346"; } +.bi-file-arrow-up-fill::before { content: "\f347"; } +.bi-file-arrow-up::before { content: "\f348"; } +.bi-file-bar-graph-fill::before { content: "\f349"; } +.bi-file-bar-graph::before { content: "\f34a"; } +.bi-file-binary-fill::before { content: "\f34b"; } +.bi-file-binary::before { content: "\f34c"; } +.bi-file-break-fill::before { content: "\f34d"; } +.bi-file-break::before { content: "\f34e"; } +.bi-file-check-fill::before { content: "\f34f"; } +.bi-file-check::before { content: "\f350"; } +.bi-file-code-fill::before { content: "\f351"; } +.bi-file-code::before { content: "\f352"; } +.bi-file-diff-fill::before { content: "\f353"; } +.bi-file-diff::before { content: "\f354"; } +.bi-file-earmark-arrow-down-fill::before { content: "\f355"; } +.bi-file-earmark-arrow-down::before { content: "\f356"; } +.bi-file-earmark-arrow-up-fill::before { content: "\f357"; } +.bi-file-earmark-arrow-up::before { content: "\f358"; } +.bi-file-earmark-bar-graph-fill::before { content: "\f359"; } +.bi-file-earmark-bar-graph::before { content: "\f35a"; } +.bi-file-earmark-binary-fill::before { content: "\f35b"; } +.bi-file-earmark-binary::before { content: "\f35c"; } +.bi-file-earmark-break-fill::before { content: "\f35d"; } +.bi-file-earmark-break::before { content: "\f35e"; } +.bi-file-earmark-check-fill::before { content: "\f35f"; } +.bi-file-earmark-check::before { content: "\f360"; } +.bi-file-earmark-code-fill::before { content: "\f361"; } +.bi-file-earmark-code::before { content: "\f362"; } +.bi-file-earmark-diff-fill::before { content: "\f363"; } +.bi-file-earmark-diff::before { content: "\f364"; } +.bi-file-earmark-easel-fill::before { content: "\f365"; } +.bi-file-earmark-easel::before { content: "\f366"; } +.bi-file-earmark-excel-fill::before { content: "\f367"; } +.bi-file-earmark-excel::before { content: "\f368"; } +.bi-file-earmark-fill::before { content: "\f369"; } +.bi-file-earmark-font-fill::before { content: "\f36a"; } +.bi-file-earmark-font::before { content: "\f36b"; } +.bi-file-earmark-image-fill::before { content: "\f36c"; } +.bi-file-earmark-image::before { content: "\f36d"; } +.bi-file-earmark-lock-fill::before { content: "\f36e"; } +.bi-file-earmark-lock::before { content: "\f36f"; } +.bi-file-earmark-lock2-fill::before { content: "\f370"; } +.bi-file-earmark-lock2::before { content: "\f371"; } +.bi-file-earmark-medical-fill::before { content: "\f372"; } +.bi-file-earmark-medical::before { content: "\f373"; } +.bi-file-earmark-minus-fill::before { content: "\f374"; } +.bi-file-earmark-minus::before { content: "\f375"; } +.bi-file-earmark-music-fill::before { content: "\f376"; } +.bi-file-earmark-music::before { content: "\f377"; } +.bi-file-earmark-person-fill::before { content: "\f378"; } +.bi-file-earmark-person::before { content: "\f379"; } +.bi-file-earmark-play-fill::before { content: "\f37a"; } +.bi-file-earmark-play::before { content: "\f37b"; } +.bi-file-earmark-plus-fill::before { content: "\f37c"; } +.bi-file-earmark-plus::before { content: "\f37d"; } +.bi-file-earmark-post-fill::before { content: "\f37e"; } +.bi-file-earmark-post::before { content: "\f37f"; } +.bi-file-earmark-ppt-fill::before { content: "\f380"; } +.bi-file-earmark-ppt::before { content: "\f381"; } +.bi-file-earmark-richtext-fill::before { content: "\f382"; } +.bi-file-earmark-richtext::before { content: "\f383"; } +.bi-file-earmark-ruled-fill::before { content: "\f384"; } +.bi-file-earmark-ruled::before { content: "\f385"; } +.bi-file-earmark-slides-fill::before { content: "\f386"; } +.bi-file-earmark-slides::before { content: "\f387"; } +.bi-file-earmark-spreadsheet-fill::before { content: "\f388"; } +.bi-file-earmark-spreadsheet::before { content: "\f389"; } +.bi-file-earmark-text-fill::before { content: "\f38a"; } +.bi-file-earmark-text::before { content: "\f38b"; } +.bi-file-earmark-word-fill::before { content: "\f38c"; } +.bi-file-earmark-word::before { content: "\f38d"; } +.bi-file-earmark-x-fill::before { content: "\f38e"; } +.bi-file-earmark-x::before { content: "\f38f"; } +.bi-file-earmark-zip-fill::before { content: "\f390"; } +.bi-file-earmark-zip::before { content: "\f391"; } +.bi-file-earmark::before { content: "\f392"; } +.bi-file-easel-fill::before { content: "\f393"; } +.bi-file-easel::before { content: "\f394"; } +.bi-file-excel-fill::before { content: "\f395"; } +.bi-file-excel::before { content: "\f396"; } +.bi-file-fill::before { content: "\f397"; } +.bi-file-font-fill::before { content: "\f398"; } +.bi-file-font::before { content: "\f399"; } +.bi-file-image-fill::before { content: "\f39a"; } +.bi-file-image::before { content: "\f39b"; } +.bi-file-lock-fill::before { content: "\f39c"; } +.bi-file-lock::before { content: "\f39d"; } +.bi-file-lock2-fill::before { content: "\f39e"; } +.bi-file-lock2::before { content: "\f39f"; } +.bi-file-medical-fill::before { content: "\f3a0"; } +.bi-file-medical::before { content: "\f3a1"; } +.bi-file-minus-fill::before { content: "\f3a2"; } +.bi-file-minus::before { content: "\f3a3"; } +.bi-file-music-fill::before { content: "\f3a4"; } +.bi-file-music::before { content: "\f3a5"; } +.bi-file-person-fill::before { content: "\f3a6"; } +.bi-file-person::before { content: "\f3a7"; } +.bi-file-play-fill::before { content: "\f3a8"; } +.bi-file-play::before { content: "\f3a9"; } +.bi-file-plus-fill::before { content: "\f3aa"; } +.bi-file-plus::before { content: "\f3ab"; } +.bi-file-post-fill::before { content: "\f3ac"; } +.bi-file-post::before { content: "\f3ad"; } +.bi-file-ppt-fill::before { content: "\f3ae"; } +.bi-file-ppt::before { content: "\f3af"; } +.bi-file-richtext-fill::before { content: "\f3b0"; } +.bi-file-richtext::before { content: "\f3b1"; } +.bi-file-ruled-fill::before { content: "\f3b2"; } +.bi-file-ruled::before { content: "\f3b3"; } +.bi-file-slides-fill::before { content: "\f3b4"; } +.bi-file-slides::before { content: "\f3b5"; } +.bi-file-spreadsheet-fill::before { content: "\f3b6"; } +.bi-file-spreadsheet::before { content: "\f3b7"; } +.bi-file-text-fill::before { content: "\f3b8"; } +.bi-file-text::before { content: "\f3b9"; } +.bi-file-word-fill::before { content: "\f3ba"; } +.bi-file-word::before { content: "\f3bb"; } +.bi-file-x-fill::before { content: "\f3bc"; } +.bi-file-x::before { content: "\f3bd"; } +.bi-file-zip-fill::before { content: "\f3be"; } +.bi-file-zip::before { content: "\f3bf"; } +.bi-file::before { content: "\f3c0"; } +.bi-files-alt::before { content: "\f3c1"; } +.bi-files::before { content: "\f3c2"; } +.bi-film::before { content: "\f3c3"; } +.bi-filter-circle-fill::before { content: "\f3c4"; } +.bi-filter-circle::before { content: "\f3c5"; } +.bi-filter-left::before { content: "\f3c6"; } +.bi-filter-right::before { content: "\f3c7"; } +.bi-filter-square-fill::before { content: "\f3c8"; } +.bi-filter-square::before { content: "\f3c9"; } +.bi-filter::before { content: "\f3ca"; } +.bi-flag-fill::before { content: "\f3cb"; } +.bi-flag::before { content: "\f3cc"; } +.bi-flower1::before { content: "\f3cd"; } +.bi-flower2::before { content: "\f3ce"; } +.bi-flower3::before { content: "\f3cf"; } +.bi-folder-check::before { content: "\f3d0"; } +.bi-folder-fill::before { content: "\f3d1"; } +.bi-folder-minus::before { content: "\f3d2"; } +.bi-folder-plus::before { content: "\f3d3"; } +.bi-folder-symlink-fill::before { content: "\f3d4"; } +.bi-folder-symlink::before { content: "\f3d5"; } +.bi-folder-x::before { content: "\f3d6"; } +.bi-folder::before { content: "\f3d7"; } +.bi-folder2-open::before { content: "\f3d8"; } +.bi-folder2::before { content: "\f3d9"; } +.bi-fonts::before { content: "\f3da"; } +.bi-forward-fill::before { content: "\f3db"; } +.bi-forward::before { content: "\f3dc"; } +.bi-front::before { content: "\f3dd"; } +.bi-fullscreen-exit::before { content: "\f3de"; } +.bi-fullscreen::before { content: "\f3df"; } +.bi-funnel-fill::before { content: "\f3e0"; } +.bi-funnel::before { content: "\f3e1"; } +.bi-gear-fill::before { content: "\f3e2"; } +.bi-gear-wide-connected::before { content: "\f3e3"; } +.bi-gear-wide::before { content: "\f3e4"; } +.bi-gear::before { content: "\f3e5"; } +.bi-gem::before { content: "\f3e6"; } +.bi-geo-alt-fill::before { content: "\f3e7"; } +.bi-geo-alt::before { content: "\f3e8"; } +.bi-geo-fill::before { content: "\f3e9"; } +.bi-geo::before { content: "\f3ea"; } +.bi-gift-fill::before { content: "\f3eb"; } +.bi-gift::before { content: "\f3ec"; } +.bi-github::before { content: "\f3ed"; } +.bi-globe::before { content: "\f3ee"; } +.bi-globe2::before { content: "\f3ef"; } +.bi-google::before { content: "\f3f0"; } +.bi-graph-down::before { content: "\f3f1"; } +.bi-graph-up::before { content: "\f3f2"; } +.bi-grid-1x2-fill::before { content: "\f3f3"; } +.bi-grid-1x2::before { content: "\f3f4"; } +.bi-grid-3x2-gap-fill::before { content: "\f3f5"; } +.bi-grid-3x2-gap::before { content: "\f3f6"; } +.bi-grid-3x2::before { content: "\f3f7"; } +.bi-grid-3x3-gap-fill::before { content: "\f3f8"; } +.bi-grid-3x3-gap::before { content: "\f3f9"; } +.bi-grid-3x3::before { content: "\f3fa"; } +.bi-grid-fill::before { content: "\f3fb"; } +.bi-grid::before { content: "\f3fc"; } +.bi-grip-horizontal::before { content: "\f3fd"; } +.bi-grip-vertical::before { content: "\f3fe"; } +.bi-hammer::before { content: "\f3ff"; } +.bi-hand-index-fill::before { content: "\f400"; } +.bi-hand-index-thumb-fill::before { content: "\f401"; } +.bi-hand-index-thumb::before { content: "\f402"; } +.bi-hand-index::before { content: "\f403"; } +.bi-hand-thumbs-down-fill::before { content: "\f404"; } +.bi-hand-thumbs-down::before { content: "\f405"; } +.bi-hand-thumbs-up-fill::before { content: "\f406"; } +.bi-hand-thumbs-up::before { content: "\f407"; } +.bi-handbag-fill::before { content: "\f408"; } +.bi-handbag::before { content: "\f409"; } +.bi-hash::before { content: "\f40a"; } +.bi-hdd-fill::before { content: "\f40b"; } +.bi-hdd-network-fill::before { content: "\f40c"; } +.bi-hdd-network::before { content: "\f40d"; } +.bi-hdd-rack-fill::before { content: "\f40e"; } +.bi-hdd-rack::before { content: "\f40f"; } +.bi-hdd-stack-fill::before { content: "\f410"; } +.bi-hdd-stack::before { content: "\f411"; } +.bi-hdd::before { content: "\f412"; } +.bi-headphones::before { content: "\f413"; } +.bi-headset::before { content: "\f414"; } +.bi-heart-fill::before { content: "\f415"; } +.bi-heart-half::before { content: "\f416"; } +.bi-heart::before { content: "\f417"; } +.bi-heptagon-fill::before { content: "\f418"; } +.bi-heptagon-half::before { content: "\f419"; } +.bi-heptagon::before { content: "\f41a"; } +.bi-hexagon-fill::before { content: "\f41b"; } +.bi-hexagon-half::before { content: "\f41c"; } +.bi-hexagon::before { content: "\f41d"; } +.bi-hourglass-bottom::before { content: "\f41e"; } +.bi-hourglass-split::before { content: "\f41f"; } +.bi-hourglass-top::before { content: "\f420"; } +.bi-hourglass::before { content: "\f421"; } +.bi-house-door-fill::before { content: "\f422"; } +.bi-house-door::before { content: "\f423"; } +.bi-house-fill::before { content: "\f424"; } +.bi-house::before { content: "\f425"; } +.bi-hr::before { content: "\f426"; } +.bi-hurricane::before { content: "\f427"; } +.bi-image-alt::before { content: "\f428"; } +.bi-image-fill::before { content: "\f429"; } +.bi-image::before { content: "\f42a"; } +.bi-images::before { content: "\f42b"; } +.bi-inbox-fill::before { content: "\f42c"; } +.bi-inbox::before { content: "\f42d"; } +.bi-inboxes-fill::before { content: "\f42e"; } +.bi-inboxes::before { content: "\f42f"; } +.bi-info-circle-fill::before { content: "\f430"; } +.bi-info-circle::before { content: "\f431"; } +.bi-info-square-fill::before { content: "\f432"; } +.bi-info-square::before { content: "\f433"; } +.bi-info::before { content: "\f434"; } +.bi-input-cursor-text::before { content: "\f435"; } +.bi-input-cursor::before { content: "\f436"; } +.bi-instagram::before { content: "\f437"; } +.bi-intersect::before { content: "\f438"; } +.bi-journal-album::before { content: "\f439"; } +.bi-journal-arrow-down::before { content: "\f43a"; } +.bi-journal-arrow-up::before { content: "\f43b"; } +.bi-journal-bookmark-fill::before { content: "\f43c"; } +.bi-journal-bookmark::before { content: "\f43d"; } +.bi-journal-check::before { content: "\f43e"; } +.bi-journal-code::before { content: "\f43f"; } +.bi-journal-medical::before { content: "\f440"; } +.bi-journal-minus::before { content: "\f441"; } +.bi-journal-plus::before { content: "\f442"; } +.bi-journal-richtext::before { content: "\f443"; } +.bi-journal-text::before { content: "\f444"; } +.bi-journal-x::before { content: "\f445"; } +.bi-journal::before { content: "\f446"; } +.bi-journals::before { content: "\f447"; } +.bi-joystick::before { content: "\f448"; } +.bi-justify-left::before { content: "\f449"; } +.bi-justify-right::before { content: "\f44a"; } +.bi-justify::before { content: "\f44b"; } +.bi-kanban-fill::before { content: "\f44c"; } +.bi-kanban::before { content: "\f44d"; } +.bi-key-fill::before { content: "\f44e"; } +.bi-key::before { content: "\f44f"; } +.bi-keyboard-fill::before { content: "\f450"; } +.bi-keyboard::before { content: "\f451"; } +.bi-ladder::before { content: "\f452"; } +.bi-lamp-fill::before { content: "\f453"; } +.bi-lamp::before { content: "\f454"; } +.bi-laptop-fill::before { content: "\f455"; } +.bi-laptop::before { content: "\f456"; } +.bi-layer-backward::before { content: "\f457"; } +.bi-layer-forward::before { content: "\f458"; } +.bi-layers-fill::before { content: "\f459"; } +.bi-layers-half::before { content: "\f45a"; } +.bi-layers::before { content: "\f45b"; } +.bi-layout-sidebar-inset-reverse::before { content: "\f45c"; } +.bi-layout-sidebar-inset::before { content: "\f45d"; } +.bi-layout-sidebar-reverse::before { content: "\f45e"; } +.bi-layout-sidebar::before { content: "\f45f"; } +.bi-layout-split::before { content: "\f460"; } +.bi-layout-text-sidebar-reverse::before { content: "\f461"; } +.bi-layout-text-sidebar::before { content: "\f462"; } +.bi-layout-text-window-reverse::before { content: "\f463"; } +.bi-layout-text-window::before { content: "\f464"; } +.bi-layout-three-columns::before { content: "\f465"; } +.bi-layout-wtf::before { content: "\f466"; } +.bi-life-preserver::before { content: "\f467"; } +.bi-lightbulb-fill::before { content: "\f468"; } +.bi-lightbulb-off-fill::before { content: "\f469"; } +.bi-lightbulb-off::before { content: "\f46a"; } +.bi-lightbulb::before { content: "\f46b"; } +.bi-lightning-charge-fill::before { content: "\f46c"; } +.bi-lightning-charge::before { content: "\f46d"; } +.bi-lightning-fill::before { content: "\f46e"; } +.bi-lightning::before { content: "\f46f"; } +.bi-link-45deg::before { content: "\f470"; } +.bi-link::before { content: "\f471"; } +.bi-linkedin::before { content: "\f472"; } +.bi-list-check::before { content: "\f473"; } +.bi-list-nested::before { content: "\f474"; } +.bi-list-ol::before { content: "\f475"; } +.bi-list-stars::before { content: "\f476"; } +.bi-list-task::before { content: "\f477"; } +.bi-list-ul::before { content: "\f478"; } +.bi-list::before { content: "\f479"; } +.bi-lock-fill::before { content: "\f47a"; } +.bi-lock::before { content: "\f47b"; } +.bi-mailbox::before { content: "\f47c"; } +.bi-mailbox2::before { content: "\f47d"; } +.bi-map-fill::before { content: "\f47e"; } +.bi-map::before { content: "\f47f"; } +.bi-markdown-fill::before { content: "\f480"; } +.bi-markdown::before { content: "\f481"; } +.bi-mask::before { content: "\f482"; } +.bi-megaphone-fill::before { content: "\f483"; } +.bi-megaphone::before { content: "\f484"; } +.bi-menu-app-fill::before { content: "\f485"; } +.bi-menu-app::before { content: "\f486"; } +.bi-menu-button-fill::before { content: "\f487"; } +.bi-menu-button-wide-fill::before { content: "\f488"; } +.bi-menu-button-wide::before { content: "\f489"; } +.bi-menu-button::before { content: "\f48a"; } +.bi-menu-down::before { content: "\f48b"; } +.bi-menu-up::before { content: "\f48c"; } +.bi-mic-fill::before { content: "\f48d"; } +.bi-mic-mute-fill::before { content: "\f48e"; } +.bi-mic-mute::before { content: "\f48f"; } +.bi-mic::before { content: "\f490"; } +.bi-minecart-loaded::before { content: "\f491"; } +.bi-minecart::before { content: "\f492"; } +.bi-moisture::before { content: "\f493"; } +.bi-moon-fill::before { content: "\f494"; } +.bi-moon-stars-fill::before { content: "\f495"; } +.bi-moon-stars::before { content: "\f496"; } +.bi-moon::before { content: "\f497"; } +.bi-mouse-fill::before { content: "\f498"; } +.bi-mouse::before { content: "\f499"; } +.bi-mouse2-fill::before { content: "\f49a"; } +.bi-mouse2::before { content: "\f49b"; } +.bi-mouse3-fill::before { content: "\f49c"; } +.bi-mouse3::before { content: "\f49d"; } +.bi-music-note-beamed::before { content: "\f49e"; } +.bi-music-note-list::before { content: "\f49f"; } +.bi-music-note::before { content: "\f4a0"; } +.bi-music-player-fill::before { content: "\f4a1"; } +.bi-music-player::before { content: "\f4a2"; } +.bi-newspaper::before { content: "\f4a3"; } +.bi-node-minus-fill::before { content: "\f4a4"; } +.bi-node-minus::before { content: "\f4a5"; } +.bi-node-plus-fill::before { content: "\f4a6"; } +.bi-node-plus::before { content: "\f4a7"; } +.bi-nut-fill::before { content: "\f4a8"; } +.bi-nut::before { content: "\f4a9"; } +.bi-octagon-fill::before { content: "\f4aa"; } +.bi-octagon-half::before { content: "\f4ab"; } +.bi-octagon::before { content: "\f4ac"; } +.bi-option::before { content: "\f4ad"; } +.bi-outlet::before { content: "\f4ae"; } +.bi-paint-bucket::before { content: "\f4af"; } +.bi-palette-fill::before { content: "\f4b0"; } +.bi-palette::before { content: "\f4b1"; } +.bi-palette2::before { content: "\f4b2"; } +.bi-paperclip::before { content: "\f4b3"; } +.bi-paragraph::before { content: "\f4b4"; } +.bi-patch-check-fill::before { content: "\f4b5"; } +.bi-patch-check::before { content: "\f4b6"; } +.bi-patch-exclamation-fill::before { content: "\f4b7"; } +.bi-patch-exclamation::before { content: "\f4b8"; } +.bi-patch-minus-fill::before { content: "\f4b9"; } +.bi-patch-minus::before { content: "\f4ba"; } +.bi-patch-plus-fill::before { content: "\f4bb"; } +.bi-patch-plus::before { content: "\f4bc"; } +.bi-patch-question-fill::before { content: "\f4bd"; } +.bi-patch-question::before { content: "\f4be"; } +.bi-pause-btn-fill::before { content: "\f4bf"; } +.bi-pause-btn::before { content: "\f4c0"; } +.bi-pause-circle-fill::before { content: "\f4c1"; } +.bi-pause-circle::before { content: "\f4c2"; } +.bi-pause-fill::before { content: "\f4c3"; } +.bi-pause::before { content: "\f4c4"; } +.bi-peace-fill::before { content: "\f4c5"; } +.bi-peace::before { content: "\f4c6"; } +.bi-pen-fill::before { content: "\f4c7"; } +.bi-pen::before { content: "\f4c8"; } +.bi-pencil-fill::before { content: "\f4c9"; } +.bi-pencil-square::before { content: "\f4ca"; } +.bi-pencil::before { content: "\f4cb"; } +.bi-pentagon-fill::before { content: "\f4cc"; } +.bi-pentagon-half::before { content: "\f4cd"; } +.bi-pentagon::before { content: "\f4ce"; } +.bi-people-fill::before { content: "\f4cf"; } +.bi-people::before { content: "\f4d0"; } +.bi-percent::before { content: "\f4d1"; } +.bi-person-badge-fill::before { content: "\f4d2"; } +.bi-person-badge::before { content: "\f4d3"; } +.bi-person-bounding-box::before { content: "\f4d4"; } +.bi-person-check-fill::before { content: "\f4d5"; } +.bi-person-check::before { content: "\f4d6"; } +.bi-person-circle::before { content: "\f4d7"; } +.bi-person-dash-fill::before { content: "\f4d8"; } +.bi-person-dash::before { content: "\f4d9"; } +.bi-person-fill::before { content: "\f4da"; } +.bi-person-lines-fill::before { content: "\f4db"; } +.bi-person-plus-fill::before { content: "\f4dc"; } +.bi-person-plus::before { content: "\f4dd"; } +.bi-person-square::before { content: "\f4de"; } +.bi-person-x-fill::before { content: "\f4df"; } +.bi-person-x::before { content: "\f4e0"; } +.bi-person::before { content: "\f4e1"; } +.bi-phone-fill::before { content: "\f4e2"; } +.bi-phone-landscape-fill::before { content: "\f4e3"; } +.bi-phone-landscape::before { content: "\f4e4"; } +.bi-phone-vibrate-fill::before { content: "\f4e5"; } +.bi-phone-vibrate::before { content: "\f4e6"; } +.bi-phone::before { content: "\f4e7"; } +.bi-pie-chart-fill::before { content: "\f4e8"; } +.bi-pie-chart::before { content: "\f4e9"; } +.bi-pin-angle-fill::before { content: "\f4ea"; } +.bi-pin-angle::before { content: "\f4eb"; } +.bi-pin-fill::before { content: "\f4ec"; } +.bi-pin::before { content: "\f4ed"; } +.bi-pip-fill::before { content: "\f4ee"; } +.bi-pip::before { content: "\f4ef"; } +.bi-play-btn-fill::before { content: "\f4f0"; } +.bi-play-btn::before { content: "\f4f1"; } +.bi-play-circle-fill::before { content: "\f4f2"; } +.bi-play-circle::before { content: "\f4f3"; } +.bi-play-fill::before { content: "\f4f4"; } +.bi-play::before { content: "\f4f5"; } +.bi-plug-fill::before { content: "\f4f6"; } +.bi-plug::before { content: "\f4f7"; } +.bi-plus-circle-dotted::before { content: "\f4f8"; } +.bi-plus-circle-fill::before { content: "\f4f9"; } +.bi-plus-circle::before { content: "\f4fa"; } +.bi-plus-square-dotted::before { content: "\f4fb"; } +.bi-plus-square-fill::before { content: "\f4fc"; } +.bi-plus-square::before { content: "\f4fd"; } +.bi-plus::before { content: "\f4fe"; } +.bi-power::before { content: "\f4ff"; } +.bi-printer-fill::before { content: "\f500"; } +.bi-printer::before { content: "\f501"; } +.bi-puzzle-fill::before { content: "\f502"; } +.bi-puzzle::before { content: "\f503"; } +.bi-question-circle-fill::before { content: "\f504"; } +.bi-question-circle::before { content: "\f505"; } +.bi-question-diamond-fill::before { content: "\f506"; } +.bi-question-diamond::before { content: "\f507"; } +.bi-question-octagon-fill::before { content: "\f508"; } +.bi-question-octagon::before { content: "\f509"; } +.bi-question-square-fill::before { content: "\f50a"; } +.bi-question-square::before { content: "\f50b"; } +.bi-question::before { content: "\f50c"; } +.bi-rainbow::before { content: "\f50d"; } +.bi-receipt-cutoff::before { content: "\f50e"; } +.bi-receipt::before { content: "\f50f"; } +.bi-reception-0::before { content: "\f510"; } +.bi-reception-1::before { content: "\f511"; } +.bi-reception-2::before { content: "\f512"; } +.bi-reception-3::before { content: "\f513"; } +.bi-reception-4::before { content: "\f514"; } +.bi-record-btn-fill::before { content: "\f515"; } +.bi-record-btn::before { content: "\f516"; } +.bi-record-circle-fill::before { content: "\f517"; } +.bi-record-circle::before { content: "\f518"; } +.bi-record-fill::before { content: "\f519"; } +.bi-record::before { content: "\f51a"; } +.bi-record2-fill::before { content: "\f51b"; } +.bi-record2::before { content: "\f51c"; } +.bi-reply-all-fill::before { content: "\f51d"; } +.bi-reply-all::before { content: "\f51e"; } +.bi-reply-fill::before { content: "\f51f"; } +.bi-reply::before { content: "\f520"; } +.bi-rss-fill::before { content: "\f521"; } +.bi-rss::before { content: "\f522"; } +.bi-rulers::before { content: "\f523"; } +.bi-save-fill::before { content: "\f524"; } +.bi-save::before { content: "\f525"; } +.bi-save2-fill::before { content: "\f526"; } +.bi-save2::before { content: "\f527"; } +.bi-scissors::before { content: "\f528"; } +.bi-screwdriver::before { content: "\f529"; } +.bi-search::before { content: "\f52a"; } +.bi-segmented-nav::before { content: "\f52b"; } +.bi-server::before { content: "\f52c"; } +.bi-share-fill::before { content: "\f52d"; } +.bi-share::before { content: "\f52e"; } +.bi-shield-check::before { content: "\f52f"; } +.bi-shield-exclamation::before { content: "\f530"; } +.bi-shield-fill-check::before { content: "\f531"; } +.bi-shield-fill-exclamation::before { content: "\f532"; } +.bi-shield-fill-minus::before { content: "\f533"; } +.bi-shield-fill-plus::before { content: "\f534"; } +.bi-shield-fill-x::before { content: "\f535"; } +.bi-shield-fill::before { content: "\f536"; } +.bi-shield-lock-fill::before { content: "\f537"; } +.bi-shield-lock::before { content: "\f538"; } +.bi-shield-minus::before { content: "\f539"; } +.bi-shield-plus::before { content: "\f53a"; } +.bi-shield-shaded::before { content: "\f53b"; } +.bi-shield-slash-fill::before { content: "\f53c"; } +.bi-shield-slash::before { content: "\f53d"; } +.bi-shield-x::before { content: "\f53e"; } +.bi-shield::before { content: "\f53f"; } +.bi-shift-fill::before { content: "\f540"; } +.bi-shift::before { content: "\f541"; } +.bi-shop-window::before { content: "\f542"; } +.bi-shop::before { content: "\f543"; } +.bi-shuffle::before { content: "\f544"; } +.bi-signpost-2-fill::before { content: "\f545"; } +.bi-signpost-2::before { content: "\f546"; } +.bi-signpost-fill::before { content: "\f547"; } +.bi-signpost-split-fill::before { content: "\f548"; } +.bi-signpost-split::before { content: "\f549"; } +.bi-signpost::before { content: "\f54a"; } +.bi-sim-fill::before { content: "\f54b"; } +.bi-sim::before { content: "\f54c"; } +.bi-skip-backward-btn-fill::before { content: "\f54d"; } +.bi-skip-backward-btn::before { content: "\f54e"; } +.bi-skip-backward-circle-fill::before { content: "\f54f"; } +.bi-skip-backward-circle::before { content: "\f550"; } +.bi-skip-backward-fill::before { content: "\f551"; } +.bi-skip-backward::before { content: "\f552"; } +.bi-skip-end-btn-fill::before { content: "\f553"; } +.bi-skip-end-btn::before { content: "\f554"; } +.bi-skip-end-circle-fill::before { content: "\f555"; } +.bi-skip-end-circle::before { content: "\f556"; } +.bi-skip-end-fill::before { content: "\f557"; } +.bi-skip-end::before { content: "\f558"; } +.bi-skip-forward-btn-fill::before { content: "\f559"; } +.bi-skip-forward-btn::before { content: "\f55a"; } +.bi-skip-forward-circle-fill::before { content: "\f55b"; } +.bi-skip-forward-circle::before { content: "\f55c"; } +.bi-skip-forward-fill::before { content: "\f55d"; } +.bi-skip-forward::before { content: "\f55e"; } +.bi-skip-start-btn-fill::before { content: "\f55f"; } +.bi-skip-start-btn::before { content: "\f560"; } +.bi-skip-start-circle-fill::before { content: "\f561"; } +.bi-skip-start-circle::before { content: "\f562"; } +.bi-skip-start-fill::before { content: "\f563"; } +.bi-skip-start::before { content: "\f564"; } +.bi-slack::before { content: "\f565"; } +.bi-slash-circle-fill::before { content: "\f566"; } +.bi-slash-circle::before { content: "\f567"; } +.bi-slash-square-fill::before { content: "\f568"; } +.bi-slash-square::before { content: "\f569"; } +.bi-slash::before { content: "\f56a"; } +.bi-sliders::before { content: "\f56b"; } +.bi-smartwatch::before { content: "\f56c"; } +.bi-snow::before { content: "\f56d"; } +.bi-snow2::before { content: "\f56e"; } +.bi-snow3::before { content: "\f56f"; } +.bi-sort-alpha-down-alt::before { content: "\f570"; } +.bi-sort-alpha-down::before { content: "\f571"; } +.bi-sort-alpha-up-alt::before { content: "\f572"; } +.bi-sort-alpha-up::before { content: "\f573"; } +.bi-sort-down-alt::before { content: "\f574"; } +.bi-sort-down::before { content: "\f575"; } +.bi-sort-numeric-down-alt::before { content: "\f576"; } +.bi-sort-numeric-down::before { content: "\f577"; } +.bi-sort-numeric-up-alt::before { content: "\f578"; } +.bi-sort-numeric-up::before { content: "\f579"; } +.bi-sort-up-alt::before { content: "\f57a"; } +.bi-sort-up::before { content: "\f57b"; } +.bi-soundwave::before { content: "\f57c"; } +.bi-speaker-fill::before { content: "\f57d"; } +.bi-speaker::before { content: "\f57e"; } +.bi-speedometer::before { content: "\f57f"; } +.bi-speedometer2::before { content: "\f580"; } +.bi-spellcheck::before { content: "\f581"; } +.bi-square-fill::before { content: "\f582"; } +.bi-square-half::before { content: "\f583"; } +.bi-square::before { content: "\f584"; } +.bi-stack::before { content: "\f585"; } +.bi-star-fill::before { content: "\f586"; } +.bi-star-half::before { content: "\f587"; } +.bi-star::before { content: "\f588"; } +.bi-stars::before { content: "\f589"; } +.bi-stickies-fill::before { content: "\f58a"; } +.bi-stickies::before { content: "\f58b"; } +.bi-sticky-fill::before { content: "\f58c"; } +.bi-sticky::before { content: "\f58d"; } +.bi-stop-btn-fill::before { content: "\f58e"; } +.bi-stop-btn::before { content: "\f58f"; } +.bi-stop-circle-fill::before { content: "\f590"; } +.bi-stop-circle::before { content: "\f591"; } +.bi-stop-fill::before { content: "\f592"; } +.bi-stop::before { content: "\f593"; } +.bi-stoplights-fill::before { content: "\f594"; } +.bi-stoplights::before { content: "\f595"; } +.bi-stopwatch-fill::before { content: "\f596"; } +.bi-stopwatch::before { content: "\f597"; } +.bi-subtract::before { content: "\f598"; } +.bi-suit-club-fill::before { content: "\f599"; } +.bi-suit-club::before { content: "\f59a"; } +.bi-suit-diamond-fill::before { content: "\f59b"; } +.bi-suit-diamond::before { content: "\f59c"; } +.bi-suit-heart-fill::before { content: "\f59d"; } +.bi-suit-heart::before { content: "\f59e"; } +.bi-suit-spade-fill::before { content: "\f59f"; } +.bi-suit-spade::before { content: "\f5a0"; } +.bi-sun-fill::before { content: "\f5a1"; } +.bi-sun::before { content: "\f5a2"; } +.bi-sunglasses::before { content: "\f5a3"; } +.bi-sunrise-fill::before { content: "\f5a4"; } +.bi-sunrise::before { content: "\f5a5"; } +.bi-sunset-fill::before { content: "\f5a6"; } +.bi-sunset::before { content: "\f5a7"; } +.bi-symmetry-horizontal::before { content: "\f5a8"; } +.bi-symmetry-vertical::before { content: "\f5a9"; } +.bi-table::before { content: "\f5aa"; } +.bi-tablet-fill::before { content: "\f5ab"; } +.bi-tablet-landscape-fill::before { content: "\f5ac"; } +.bi-tablet-landscape::before { content: "\f5ad"; } +.bi-tablet::before { content: "\f5ae"; } +.bi-tag-fill::before { content: "\f5af"; } +.bi-tag::before { content: "\f5b0"; } +.bi-tags-fill::before { content: "\f5b1"; } +.bi-tags::before { content: "\f5b2"; } +.bi-telegram::before { content: "\f5b3"; } +.bi-telephone-fill::before { content: "\f5b4"; } +.bi-telephone-forward-fill::before { content: "\f5b5"; } +.bi-telephone-forward::before { content: "\f5b6"; } +.bi-telephone-inbound-fill::before { content: "\f5b7"; } +.bi-telephone-inbound::before { content: "\f5b8"; } +.bi-telephone-minus-fill::before { content: "\f5b9"; } +.bi-telephone-minus::before { content: "\f5ba"; } +.bi-telephone-outbound-fill::before { content: "\f5bb"; } +.bi-telephone-outbound::before { content: "\f5bc"; } +.bi-telephone-plus-fill::before { content: "\f5bd"; } +.bi-telephone-plus::before { content: "\f5be"; } +.bi-telephone-x-fill::before { content: "\f5bf"; } +.bi-telephone-x::before { content: "\f5c0"; } +.bi-telephone::before { content: "\f5c1"; } +.bi-terminal-fill::before { content: "\f5c2"; } +.bi-terminal::before { content: "\f5c3"; } +.bi-text-center::before { content: "\f5c4"; } +.bi-text-indent-left::before { content: "\f5c5"; } +.bi-text-indent-right::before { content: "\f5c6"; } +.bi-text-left::before { content: "\f5c7"; } +.bi-text-paragraph::before { content: "\f5c8"; } +.bi-text-right::before { content: "\f5c9"; } +.bi-textarea-resize::before { content: "\f5ca"; } +.bi-textarea-t::before { content: "\f5cb"; } +.bi-textarea::before { content: "\f5cc"; } +.bi-thermometer-half::before { content: "\f5cd"; } +.bi-thermometer-high::before { content: "\f5ce"; } +.bi-thermometer-low::before { content: "\f5cf"; } +.bi-thermometer-snow::before { content: "\f5d0"; } +.bi-thermometer-sun::before { content: "\f5d1"; } +.bi-thermometer::before { content: "\f5d2"; } +.bi-three-dots-vertical::before { content: "\f5d3"; } +.bi-three-dots::before { content: "\f5d4"; } +.bi-toggle-off::before { content: "\f5d5"; } +.bi-toggle-on::before { content: "\f5d6"; } +.bi-toggle2-off::before { content: "\f5d7"; } +.bi-toggle2-on::before { content: "\f5d8"; } +.bi-toggles::before { content: "\f5d9"; } +.bi-toggles2::before { content: "\f5da"; } +.bi-tools::before { content: "\f5db"; } +.bi-tornado::before { content: "\f5dc"; } +.bi-trash-fill::before { content: "\f5dd"; } +.bi-trash::before { content: "\f5de"; } +.bi-trash2-fill::before { content: "\f5df"; } +.bi-trash2::before { content: "\f5e0"; } +.bi-tree-fill::before { content: "\f5e1"; } +.bi-tree::before { content: "\f5e2"; } +.bi-triangle-fill::before { content: "\f5e3"; } +.bi-triangle-half::before { content: "\f5e4"; } +.bi-triangle::before { content: "\f5e5"; } +.bi-trophy-fill::before { content: "\f5e6"; } +.bi-trophy::before { content: "\f5e7"; } +.bi-tropical-storm::before { content: "\f5e8"; } +.bi-truck-flatbed::before { content: "\f5e9"; } +.bi-truck::before { content: "\f5ea"; } +.bi-tsunami::before { content: "\f5eb"; } +.bi-tv-fill::before { content: "\f5ec"; } +.bi-tv::before { content: "\f5ed"; } +.bi-twitch::before { content: "\f5ee"; } +.bi-twitter::before { content: "\f5ef"; } +.bi-type-bold::before { content: "\f5f0"; } +.bi-type-h1::before { content: "\f5f1"; } +.bi-type-h2::before { content: "\f5f2"; } +.bi-type-h3::before { content: "\f5f3"; } +.bi-type-italic::before { content: "\f5f4"; } +.bi-type-strikethrough::before { content: "\f5f5"; } +.bi-type-underline::before { content: "\f5f6"; } +.bi-type::before { content: "\f5f7"; } +.bi-ui-checks-grid::before { content: "\f5f8"; } +.bi-ui-checks::before { content: "\f5f9"; } +.bi-ui-radios-grid::before { content: "\f5fa"; } +.bi-ui-radios::before { content: "\f5fb"; } +.bi-umbrella-fill::before { content: "\f5fc"; } +.bi-umbrella::before { content: "\f5fd"; } +.bi-union::before { content: "\f5fe"; } +.bi-unlock-fill::before { content: "\f5ff"; } +.bi-unlock::before { content: "\f600"; } +.bi-upc-scan::before { content: "\f601"; } +.bi-upc::before { content: "\f602"; } +.bi-upload::before { content: "\f603"; } +.bi-vector-pen::before { content: "\f604"; } +.bi-view-list::before { content: "\f605"; } +.bi-view-stacked::before { content: "\f606"; } +.bi-vinyl-fill::before { content: "\f607"; } +.bi-vinyl::before { content: "\f608"; } +.bi-voicemail::before { content: "\f609"; } +.bi-volume-down-fill::before { content: "\f60a"; } +.bi-volume-down::before { content: "\f60b"; } +.bi-volume-mute-fill::before { content: "\f60c"; } +.bi-volume-mute::before { content: "\f60d"; } +.bi-volume-off-fill::before { content: "\f60e"; } +.bi-volume-off::before { content: "\f60f"; } +.bi-volume-up-fill::before { content: "\f610"; } +.bi-volume-up::before { content: "\f611"; } +.bi-vr::before { content: "\f612"; } +.bi-wallet-fill::before { content: "\f613"; } +.bi-wallet::before { content: "\f614"; } +.bi-wallet2::before { content: "\f615"; } +.bi-watch::before { content: "\f616"; } +.bi-water::before { content: "\f617"; } +.bi-whatsapp::before { content: "\f618"; } +.bi-wifi-1::before { content: "\f619"; } +.bi-wifi-2::before { content: "\f61a"; } +.bi-wifi-off::before { content: "\f61b"; } +.bi-wifi::before { content: "\f61c"; } +.bi-wind::before { content: "\f61d"; } +.bi-window-dock::before { content: "\f61e"; } +.bi-window-sidebar::before { content: "\f61f"; } +.bi-window::before { content: "\f620"; } +.bi-wrench::before { content: "\f621"; } +.bi-x-circle-fill::before { content: "\f622"; } +.bi-x-circle::before { content: "\f623"; } +.bi-x-diamond-fill::before { content: "\f624"; } +.bi-x-diamond::before { content: "\f625"; } +.bi-x-octagon-fill::before { content: "\f626"; } +.bi-x-octagon::before { content: "\f627"; } +.bi-x-square-fill::before { content: "\f628"; } +.bi-x-square::before { content: "\f629"; } +.bi-x::before { content: "\f62a"; } +.bi-youtube::before { content: "\f62b"; } +.bi-zoom-in::before { content: "\f62c"; } +.bi-zoom-out::before { content: "\f62d"; } +.bi-bank::before { content: "\f62e"; } +.bi-bank2::before { content: "\f62f"; } +.bi-bell-slash-fill::before { content: "\f630"; } +.bi-bell-slash::before { content: "\f631"; } +.bi-cash-coin::before { content: "\f632"; } +.bi-check-lg::before { content: "\f633"; } +.bi-coin::before { content: "\f634"; } +.bi-currency-bitcoin::before { content: "\f635"; } +.bi-currency-dollar::before { content: "\f636"; } +.bi-currency-euro::before { content: "\f637"; } +.bi-currency-exchange::before { content: "\f638"; } +.bi-currency-pound::before { content: "\f639"; } +.bi-currency-yen::before { content: "\f63a"; } +.bi-dash-lg::before { content: "\f63b"; } +.bi-exclamation-lg::before { content: "\f63c"; } +.bi-file-earmark-pdf-fill::before { content: "\f63d"; } +.bi-file-earmark-pdf::before { content: "\f63e"; } +.bi-file-pdf-fill::before { content: "\f63f"; } +.bi-file-pdf::before { content: "\f640"; } +.bi-gender-ambiguous::before { content: "\f641"; } +.bi-gender-female::before { content: "\f642"; } +.bi-gender-male::before { content: "\f643"; } +.bi-gender-trans::before { content: "\f644"; } +.bi-headset-vr::before { content: "\f645"; } +.bi-info-lg::before { content: "\f646"; } +.bi-mastodon::before { content: "\f647"; } +.bi-messenger::before { content: "\f648"; } +.bi-piggy-bank-fill::before { content: "\f649"; } +.bi-piggy-bank::before { content: "\f64a"; } +.bi-pin-map-fill::before { content: "\f64b"; } +.bi-pin-map::before { content: "\f64c"; } +.bi-plus-lg::before { content: "\f64d"; } +.bi-question-lg::before { content: "\f64e"; } +.bi-recycle::before { content: "\f64f"; } +.bi-reddit::before { content: "\f650"; } +.bi-safe-fill::before { content: "\f651"; } +.bi-safe2-fill::before { content: "\f652"; } +.bi-safe2::before { content: "\f653"; } +.bi-sd-card-fill::before { content: "\f654"; } +.bi-sd-card::before { content: "\f655"; } +.bi-skype::before { content: "\f656"; } +.bi-slash-lg::before { content: "\f657"; } +.bi-translate::before { content: "\f658"; } +.bi-x-lg::before { content: "\f659"; } +.bi-safe::before { content: "\f65a"; } +.bi-apple::before { content: "\f65b"; } +.bi-microsoft::before { content: "\f65d"; } +.bi-windows::before { content: "\f65e"; } +.bi-behance::before { content: "\f65c"; } +.bi-dribbble::before { content: "\f65f"; } +.bi-line::before { content: "\f660"; } +.bi-medium::before { content: "\f661"; } +.bi-paypal::before { content: "\f662"; } +.bi-pinterest::before { content: "\f663"; } +.bi-signal::before { content: "\f664"; } +.bi-snapchat::before { content: "\f665"; } +.bi-spotify::before { content: "\f666"; } +.bi-stack-overflow::before { content: "\f667"; } +.bi-strava::before { content: "\f668"; } +.bi-wordpress::before { content: "\f669"; } +.bi-vimeo::before { content: "\f66a"; } +.bi-activity::before { content: "\f66b"; } +.bi-easel2-fill::before { content: "\f66c"; } +.bi-easel2::before { content: "\f66d"; } +.bi-easel3-fill::before { content: "\f66e"; } +.bi-easel3::before { content: "\f66f"; } +.bi-fan::before { content: "\f670"; } +.bi-fingerprint::before { content: "\f671"; } +.bi-graph-down-arrow::before { content: "\f672"; } +.bi-graph-up-arrow::before { content: "\f673"; } +.bi-hypnotize::before { content: "\f674"; } +.bi-magic::before { content: "\f675"; } +.bi-person-rolodex::before { content: "\f676"; } +.bi-person-video::before { content: "\f677"; } +.bi-person-video2::before { content: "\f678"; } +.bi-person-video3::before { content: "\f679"; } +.bi-person-workspace::before { content: "\f67a"; } +.bi-radioactive::before { content: "\f67b"; } +.bi-webcam-fill::before { content: "\f67c"; } +.bi-webcam::before { content: "\f67d"; } +.bi-yin-yang::before { content: "\f67e"; } +.bi-bandaid-fill::before { content: "\f680"; } +.bi-bandaid::before { content: "\f681"; } +.bi-bluetooth::before { content: "\f682"; } +.bi-body-text::before { content: "\f683"; } +.bi-boombox::before { content: "\f684"; } +.bi-boxes::before { content: "\f685"; } +.bi-dpad-fill::before { content: "\f686"; } +.bi-dpad::before { content: "\f687"; } +.bi-ear-fill::before { content: "\f688"; } +.bi-ear::before { content: "\f689"; } +.bi-envelope-check-1::before { content: "\f68a"; } +.bi-envelope-check-fill::before { content: "\f68b"; } +.bi-envelope-check::before { content: "\f68c"; } +.bi-envelope-dash-1::before { content: "\f68d"; } +.bi-envelope-dash-fill::before { content: "\f68e"; } +.bi-envelope-dash::before { content: "\f68f"; } +.bi-envelope-exclamation-1::before { content: "\f690"; } +.bi-envelope-exclamation-fill::before { content: "\f691"; } +.bi-envelope-exclamation::before { content: "\f692"; } +.bi-envelope-plus-fill::before { content: "\f693"; } +.bi-envelope-plus::before { content: "\f694"; } +.bi-envelope-slash-1::before { content: "\f695"; } +.bi-envelope-slash-fill::before { content: "\f696"; } +.bi-envelope-slash::before { content: "\f697"; } +.bi-envelope-x-1::before { content: "\f698"; } +.bi-envelope-x-fill::before { content: "\f699"; } +.bi-envelope-x::before { content: "\f69a"; } +.bi-explicit-fill::before { content: "\f69b"; } +.bi-explicit::before { content: "\f69c"; } +.bi-git::before { content: "\f69d"; } +.bi-infinity::before { content: "\f69e"; } +.bi-list-columns-reverse::before { content: "\f69f"; } +.bi-list-columns::before { content: "\f6a0"; } +.bi-meta::before { content: "\f6a1"; } +.bi-mortorboard-fill::before { content: "\f6a2"; } +.bi-mortorboard::before { content: "\f6a3"; } +.bi-nintendo-switch::before { content: "\f6a4"; } +.bi-pc-display-horizontal::before { content: "\f6a5"; } +.bi-pc-display::before { content: "\f6a6"; } +.bi-pc-horizontal::before { content: "\f6a7"; } +.bi-pc::before { content: "\f6a8"; } +.bi-playstation::before { content: "\f6a9"; } +.bi-plus-slash-minus::before { content: "\f6aa"; } +.bi-projector-fill::before { content: "\f6ab"; } +.bi-projector::before { content: "\f6ac"; } +.bi-qr-code-scan::before { content: "\f6ad"; } +.bi-qr-code::before { content: "\f6ae"; } +.bi-quora::before { content: "\f6af"; } +.bi-quote::before { content: "\f6b0"; } +.bi-robot::before { content: "\f6b1"; } +.bi-send-check-fill::before { content: "\f6b2"; } +.bi-send-check::before { content: "\f6b3"; } +.bi-send-dash-fill::before { content: "\f6b4"; } +.bi-send-dash::before { content: "\f6b5"; } +.bi-send-exclamation-1::before { content: "\f6b6"; } +.bi-send-exclamation-fill::before { content: "\f6b7"; } +.bi-send-exclamation::before { content: "\f6b8"; } +.bi-send-fill::before { content: "\f6b9"; } +.bi-send-plus-fill::before { content: "\f6ba"; } +.bi-send-plus::before { content: "\f6bb"; } +.bi-send-slash-fill::before { content: "\f6bc"; } +.bi-send-slash::before { content: "\f6bd"; } +.bi-send-x-fill::before { content: "\f6be"; } +.bi-send-x::before { content: "\f6bf"; } +.bi-send::before { content: "\f6c0"; } +.bi-steam::before { content: "\f6c1"; } +.bi-terminal-dash-1::before { content: "\f6c2"; } +.bi-terminal-dash::before { content: "\f6c3"; } +.bi-terminal-plus::before { content: "\f6c4"; } +.bi-terminal-split::before { content: "\f6c5"; } +.bi-ticket-detailed-fill::before { content: "\f6c6"; } +.bi-ticket-detailed::before { content: "\f6c7"; } +.bi-ticket-fill::before { content: "\f6c8"; } +.bi-ticket-perforated-fill::before { content: "\f6c9"; } +.bi-ticket-perforated::before { content: "\f6ca"; } +.bi-ticket::before { content: "\f6cb"; } +.bi-tiktok::before { content: "\f6cc"; } +.bi-window-dash::before { content: "\f6cd"; } +.bi-window-desktop::before { content: "\f6ce"; } +.bi-window-fullscreen::before { content: "\f6cf"; } +.bi-window-plus::before { content: "\f6d0"; } +.bi-window-split::before { content: "\f6d1"; } +.bi-window-stack::before { content: "\f6d2"; } +.bi-window-x::before { content: "\f6d3"; } +.bi-xbox::before { content: "\f6d4"; } +.bi-ethernet::before { content: "\f6d5"; } +.bi-hdmi-fill::before { content: "\f6d6"; } +.bi-hdmi::before { content: "\f6d7"; } +.bi-usb-c-fill::before { content: "\f6d8"; } +.bi-usb-c::before { content: "\f6d9"; } +.bi-usb-fill::before { content: "\f6da"; } +.bi-usb-plug-fill::before { content: "\f6db"; } +.bi-usb-plug::before { content: "\f6dc"; } +.bi-usb-symbol::before { content: "\f6dd"; } +.bi-usb::before { content: "\f6de"; } +.bi-boombox-fill::before { content: "\f6df"; } +.bi-displayport-1::before { content: "\f6e0"; } +.bi-displayport::before { content: "\f6e1"; } +.bi-gpu-card::before { content: "\f6e2"; } +.bi-memory::before { content: "\f6e3"; } +.bi-modem-fill::before { content: "\f6e4"; } +.bi-modem::before { content: "\f6e5"; } +.bi-motherboard-fill::before { content: "\f6e6"; } +.bi-motherboard::before { content: "\f6e7"; } +.bi-optical-audio-fill::before { content: "\f6e8"; } +.bi-optical-audio::before { content: "\f6e9"; } +.bi-pci-card::before { content: "\f6ea"; } +.bi-router-fill::before { content: "\f6eb"; } +.bi-router::before { content: "\f6ec"; } +.bi-ssd-fill::before { content: "\f6ed"; } +.bi-ssd::before { content: "\f6ee"; } +.bi-thunderbolt-fill::before { content: "\f6ef"; } +.bi-thunderbolt::before { content: "\f6f0"; } +.bi-usb-drive-fill::before { content: "\f6f1"; } +.bi-usb-drive::before { content: "\f6f2"; } +.bi-usb-micro-fill::before { content: "\f6f3"; } +.bi-usb-micro::before { content: "\f6f4"; } +.bi-usb-mini-fill::before { content: "\f6f5"; } +.bi-usb-mini::before { content: "\f6f6"; } +.bi-cloud-haze2::before { content: "\f6f7"; } +.bi-device-hdd-fill::before { content: "\f6f8"; } +.bi-device-hdd::before { content: "\f6f9"; } +.bi-device-ssd-fill::before { content: "\f6fa"; } +.bi-device-ssd::before { content: "\f6fb"; } +.bi-displayport-fill::before { content: "\f6fc"; } +.bi-mortarboard-fill::before { content: "\f6fd"; } +.bi-mortarboard::before { content: "\f6fe"; } +.bi-terminal-x::before { content: "\f6ff"; } diff --git a/internal/server/assets/css/custom.css b/internal/server/assets/css/custom.css new file mode 100644 index 000000000..39c4626d2 --- /dev/null +++ b/internal/server/assets/css/custom.css @@ -0,0 +1,315 @@ +:root { + --pico-typography-spacing-vertical: 0.3rem; + --pico-form-element-spacing-vertical: 0.3rem; + --pico-form-element-spacing-horizontal: 0.5rem; + --pico-font-size: 100%; +} + +.error-message { + background-color: #f8d7da; + border: 1px solid #f5c6cb; + color: #721c24; + padding: 10px 15px; + border-radius: 5px; + margin-bottom: 20px; +} + +.inline-label { + display: flex; + align-items: center; + gap: 30px; +} + +.inline-label span { + white-space: nowrap; + flex-shrink: 0; +} + +.inline-label input { + flex-grow: 1; +} + +.run-list { + padding: 0; +} + +.run-list li { + list-style: none; + padding-left: 0; + margin-left: 0; + margin-bottom: 0.5rem; /* Add some space between items */ +} + +#scheduler-settings { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1rem; + max-width: 1200px; + margin: 0 auto; +} + +/* Add this new rule */ +#scheduler-settings[style*="display: none"] { + display: none !important; +} + +.scheduler-day { + background-color: #1c212c; + padding: 1rem; + border-radius: 8px; + border: 1px solid #2a3140; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); + transition: box-shadow 0.3s ease; +} + +.scheduler-day:hover { + box-shadow: 0 6px 8px rgba(0,0,0,0.15); +} + +.scheduler-day h4 { + margin-top: 0; + margin-bottom: 0.75rem; + color: #ffffff; + font-weight: bold; +} + +.time-range { + display: flex; + align-items: center; + margin-bottom: 0.75rem; +} + +.time-range input[type="time"] { + width: 15rem; + margin-right: 0.1rem; + padding: 0.15rem; + border: 1px solid #2a3140; + border-radius: 3px; + font-size: 0.90rem; +} + +.time-range span { + margin: 0 0.5rem; + margin-bottom: 5%; + color: #ffffff; + font-weight: bold; +} + +.remove-time-range { + background-color: #dc3545; + color: white; + border: none; + border-radius: 50%; + width: 2rem; + height: 2rem; + font-size: 1rem; + cursor: pointer; + margin-left: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s; +} + +.remove-time-range:hover { + background-color: #c82333; +} + +.add-time-range { + margin-top: 0.75rem; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + padding: 0.5rem 0.75rem; + font-size: 0.95rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + transition: background-color 0.2s; +} + +.add-time-range:hover { + background-color: #0056b3; +} + +.add-time-range i { + margin-right: 0.5rem; +} + +.recipe-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 10px; + margin-top: 10px; +} + +.recipe-item { + display: flex; + align-items: center; + background-color: #1c212c; + padding: 5px 10px; + border-radius: 4px; + transition: background-color 0.2s; +} + +.recipe-item:hover { + background-color: #2a3140; +} + +.recipe-item input[type="checkbox"] { + margin-right: 10px; +} + +.recipe-item span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Add these styles to your existing CSS file */ + +.run-list li summary { + display: flex; + justify-content: space-between; + align-items: center; + background-color: #1c212c; + transition: background-color 0.2s; + border: none; /* Remove border from summary */ +} + +.run-list li summary:hover { + background-color: #2a3140; +} + +.run-list li summary:focus { + outline: none; /* Remove the annoying outline */ +} + +.run-list li summary span { + flex-grow: 1; +} + +.run-list li summary button { + border: none; + cursor: pointer; + padding: 2.5px 5px; /* 50% smaller padding */ + margin-left: 10px; + font-size: 0.8rem; /* 50% smaller font size */ + color: #ffffff; + border-radius: 1px solid #2a3140; + transition: background-color 0.2s, transform 0.1s; + align-items: center; + justify-content: center; + outline: none; +} + +.run-list li summary button.remove-run { + margin-top: 15px; + background-color: #dc3545; /* Red for remove button */ +} + +.run-list li summary button.add-run { + margin-top: 15px; + background-color: #28a745; /* Green for add button */ +} + +.run-list li summary button:hover { + transform: scale(1.05); +} + +.run-list li summary span { + padding-left: 10px; +} + +.run-list li summary button.remove-run:hover { + background-color: #c82333; /* Darker red on hover */ +} + +.run-list li summary button.add-run:hover { + background-color: #218838; /* Darker green on hover */ +} + +.run-list li summary button i { + font-size: 0.9rem; /* 50% smaller icon size */ +} + +.run-list li details[open] summary { + border-bottom: 1px solid #2a3140; +} + +.run-list li details p { + margin: 0; + padding: 0.5rem 1rem; + background-color: #1c212c; +} + +/* New styles for the summary content */ +.summary-content { + display: flex; + align-items: center; + width: 100%; +} + +.summary-text { + flex-grow: 1; +} + +.summary-icons { + display: flex; + align-items: center; +} +.options-group { + margin-bottom: 20px; + padding: 10px; + border: 1px solid #444; + border-radius: 5px; + } + +.options-group label { + display: flex; + align-items: center; + margin-bottom: 5px; +} + +.options-group legend { + font-weight: bold; + padding: 0 5px; +} + +.options-group input[type="checkbox"] { + margin-right: 8px; +} + +.options-group label:hover { + background-color: #2d2d2d; + border-radius: 4px; + padding: 2px; +} + +#class-specific-settings { + margin: 1rem 0; + padding: 1rem; + border-radius: 8px; + border: 1px solid #2a3140; +} + +#class-specific-settings h4 { + margin-top: 0; + margin-bottom: 1rem; +} + +#no-settings-message { + text-align: center; + padding: 1rem; + color: #666; +} + +#class-specific-settings small { + display: block; + text-align: center; + color: #666; + margin-top: 0.5rem; +} \ No newline at end of file diff --git a/internal/server/assets/css/dashboard.css b/internal/server/assets/css/dashboard.css new file mode 100644 index 000000000..ecedf48ed --- /dev/null +++ b/internal/server/assets/css/dashboard.css @@ -0,0 +1,528 @@ +:root { + --primary: #4a9df8; + --primary-hover: #3a7dc8; + --background-color: #1a1d24; + --card-background-color: #22262e; + --card-hover-background-color: #2a2f38; +} +body { + padding: 20px; + background-color: var(--background-color); +} +.dashboard-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + background-color: var(--card-background-color); + padding: 20px; + border-radius: 6px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} +.dashboard-title-container { + display: flex; + align-items: center; +} +.dashboard-title { + font-size: 2em; + margin: 0; + color: var(--primary); +} +.dashboard-controls { + display: flex; + gap: 10px; +} +.version-badge { + font-size: 0.8em; + padding: 4px 8px; + border-radius: 4px; + background-color: #dc3545; + color: #fff; + margin-left: 10px; +} +.character-card { + background-color: var(--card-background-color); + border-radius: 6px; + padding: 15px; + margin-bottom: 15px; + transition: all 0.3s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} +.character-card:hover { + background-color: var(--card-hover-background-color); + transform: translateY(-2px); +} +.character-header { + display: flex; + justify-content: space-between; + align-items: center; +} +.character-name-status { + display: flex; + align-items: center; + gap: 10px; +} +.character-name { + font-size: 1.2em; + font-weight: bold; + color: var(--primary); +} +.character-controls { + display: flex; + gap: 8px; +} +.character-details { + margin-top: 15px; + display: none; + animation: fadeIn 0.3s ease; +} +.character-card.expanded .character-details { + display: block; +} +.toggle-details { + background: none; + border: none; + color: var(--primary); + cursor: pointer; + font-size: 1.2em; + padding: 0; + transition: transform 0.3s ease; + outline: none; /* Remove the outline */ +} +.toggle-details:hover { + color: var(--primary-hover); +} +.toggle-details:focus { + outline: none; /* Remove the outline on focus */ + box-shadow: none; /* Remove any box shadow on focus */ +} +.toggle-details i { + transition: transform 0.3s ease; + display: inline-block; +} +.character-card.expanded .toggle-details i { + transform: rotate(180deg); +} +.status-badge { + display: inline-flex; + align-items: center; + font-size: 0.9em; + background-color: transparent; + color: #a0a0a0; +} +.status-label { + margin-right: 5px; +} +.status-value { + font-weight: bold; + padding: 2px 6px; + border-radius: 3px; +} +.status-ingame .status-value { background-color: #28a745; color: white; } +.status-paused { background-color: #ffc107; color: black; } +.status-stopped { background-color: #dc3545; color: white; } +.status-paused .status-value { background-color: #ffc107; color: black; } +.status-notstarted .status-value { background-color: #dc3545; color: white; } +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 10px; + margin-top: 10px; +} +.stat-item { + background-color: rgba(255, 255, 255, 0.05); + padding: 10px; + border-radius: 4px; + text-align: center; +} +.stat-label { + font-size: 0.8em; + color: #a0a0a0; +} +.stat-value { + color: #ffffff; + font-weight: bold; +} +.btn { + padding: 6px 12px; + border-radius: 4px; + font-weight: bold; + transition: all 0.2s ease; + border: none; + cursor: pointer; +} +.btn:hover { + transform: translateY(-2px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} +.btn-start { background-color: #28a745; color: white; } +.btn-start:hover { background-color: #218838; } +.btn-stop { background-color: #dc3545; color: white; } +.btn-stop:hover { background-color: #c82333; } +.btn-pause { background-color: #ffc107; color: black; } +.btn-pause:hover { background-color: #e0a800; } +.btn-outline { + background-color: transparent; + border: 1px solid var(--primary); + color: var(--primary); +} +.btn-outline:hover { + background-color: var(--primary); + color: white; +} +.btn-icon { + margin-right: 5px; +} +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} +.loading { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; +} +.loading::after { + content: ""; + width: 50px; + height: 50px; + border: 10px solid #f3f3f3; + border-top: 10px solid #3498db; + border-radius: 50%; + animation: spin 1s linear infinite; +} +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} +.run-stats { + margin-top: 20px; +} +.run-stat { + background-color: rgba(255, 255, 255, 0.05); + padding: 10px; + border-radius: 4px; + margin-bottom: 10px; +} +.run-stat h4 { + margin-top: 0; + color: var(--primary); +} +.status-details { + margin-bottom: 10px; +} +.run-stats h3 { +margin-top: 20px; +color: var(--primary); +} +.run-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; + margin-top: 20px; +} +.run-stat { + background: linear-gradient(145deg, #2a2f38, #252a32); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 15px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08); + transition: all 0.3s ease; +} +.run-stat:hover { + transform: translateY(-3px); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.12); +} +.run-stat h4 { + margin-top: 0; + margin-bottom: 15px; + color: #4a9df8; + font-size: 1.2em; + font-weight: bold; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + display: flex; + justify-content: space-between; + align-items: center; +} +.run-stat p { + margin: 5px 0; +} +.stat-label { + color: #a0a0a0; +} +.run-stat-content { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} +.stat-label { + color: #a0a0a0; + font-size: 0.9em; +} + +.stat-value { + font-weight: bold; + color: #ffffff; +} +.current-run-indicator { + font-size: 0.7em; + background-color: #4a9df8; + color: white; + padding: 3px 8px; + border-radius: 12px; + box-shadow: 0 2px 4px rgba(74, 157, 248, 0.3); + animation: pulse 2s infinite; +} +@keyframes pulse { + 0% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 157, 248, 0.4); } + 70% { opacity: 0.7; box-shadow: 0 0 0 10px rgba(74, 157, 248, 0); } + 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 157, 248, 0); } +} +stat-value { + color: #ffffff; + font-weight: bold; +} +.run-stat h4 { + margin-top: 0; + margin-bottom: 10px; + color: var(--primary); +} +.running-for { + margin-top: 5px; + font-size: 0.9em; + color: #a0a0a0; +} +.run-stat-item { + font-size: 0.95em; + display: flex; + justify-content: space-between; + align-items: center; +} + +.stat-label { + color: #a0a0a0; + font-weight: 500; + margin-right: 5px; +} +.stat-short { color: #28a745; } +.stat-long { color: #ffc107; } +.stat-error { color: #dc3545; } +.character-name { + font-size: 1.2em; + font-weight: bold; + color: var(--primary); + display: flex; + align-items: center; +} + +.status-indicator { + width: 10px; + height: 10px; + border-radius: 50%; + margin-left: 10px; + animation: breathe 2s ease-in-out infinite; +} + +.status-indicator.in-game { + background-color: #28a745; +} + +.status-indicator.paused { + background-color: #ffc107; +} + +.status-indicator.stopped { + background-color: #dc3545; +} + +@keyframes breathe { + 0%, 100% { + opacity: 0.4; + transform: scale(0.9); + } + 50% { + opacity: 1; + transform: scale(1.1); + } +} + +.attach-popup { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: var(--card-background-color); + color: #ffffff; + padding: 20px; + border-radius: 8px; + box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); + z-index: 1000; + width: 80%; + max-width: 800px; +} + +.attach-popup h3 { + color: var(--primary); + margin-top: 0; + margin-bottom: 15px; +} + +.process-search { + width: 100%; + padding: 8px; + margin-bottom: 15px; + border: 1px solid #444; + border-radius: 4px; + background-color: var(--background-color); + color: #ffffff; +} + +.process-list { + max-height: 300px; + overflow-y: auto; + margin-bottom: 20px; + border: 1px solid #444; + border-radius: 4px; +} + +.process-list table { + width: 100%; + border-collapse: collapse; + color: #ffffff; + table-layout: fixed; /* Add this line */ +} + +.process-list th, .process-list td { + padding: 10px; + text-align: left; + border-bottom: 1px solid #444; + overflow: hidden; /* Add this line */ + text-overflow: ellipsis; /* Add this line */ + white-space: nowrap; /* Add this line */ +} + +/* Add these new rules */ +.process-list th:first-child, +.process-list td:first-child { + width: 40%; /* Adjust this value as needed */ +} + +.process-list th:nth-child(2), +.process-list td:nth-child(2) { + width: 40%; /* Adjust this value as needed */ +} + +.process-list th:last-child, +.process-list td:last-child { + width: 20%; /* Adjust this value as needed */ +} + +.process-list th { + background-color: var(--card-hover-background-color); + font-weight: bold; + color: var(--primary); +} + +.process-list tr:hover { + background-color: var(--card-hover-background-color); + cursor: pointer; +} + +.process-list tr.selected { + background-color: rgba(74, 157, 248, 0.2); +} + +.selected-process { + margin-bottom: 15px; + font-weight: bold; + color: var(--primary); +} + +.popup-buttons { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.btn { + padding: 8px 16px; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: bold; + transition: all 0.3s ease; +} + +.btn-primary { + background-color: var(--primary); + color: white; +} + +.btn-primary:hover { + background-color: var(--primary-hover); + transform: translateY(-2px); +} + +.btn-primary:disabled { + background-color: #555; + color: #888; + cursor: not-allowed; +} + +.btn-outline { + background-color: transparent; + border: 1px solid var(--primary); + color: var(--primary); +} + +.btn-outline:hover { + background-color: var(--primary); + color: white; +} + +/* Scrollbar styling */ +.process-list::-webkit-scrollbar { + width: 8px; +} + +.process-list::-webkit-scrollbar-track { + background: var(--background-color); +} + +.process-list::-webkit-scrollbar-thumb { + background-color: var(--primary); + border-radius: 4px; +} + +.process-list::-webkit-scrollbar-thumb:hover { + background-color: var(--primary-hover); +} + +.loading-spinner { + border: 4px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top: 4px solid var(--primary); + width: 40px; + height: 40px; + animation: spin 1s linear infinite; + margin: 20px auto; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.rotate { + animation: rotation 1s infinite linear; +} + +@keyframes rotation { + from { + transform: rotate(0deg); + } + to { + transform: rotate(359deg); + } +} \ No newline at end of file diff --git a/internal/server/assets/css/debug.css b/internal/server/assets/css/debug.css new file mode 100644 index 000000000..54e6d55d0 --- /dev/null +++ b/internal/server/assets/css/debug.css @@ -0,0 +1,220 @@ +:root { + --bg-color: #1e2124; + --secondary-bg: #2c2f33; + --text-color: #ffffff; + --accent-color: #5865F2; + --accent-light: #99aab5; + --success-color: #57F287; + --null-color: #ED4245; + --border-color: #424549; + --hover-color: #32363a; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: var(--bg-color); + color: var(--text-color); + margin: 0; + padding: 20px; + line-height: 1.6; +} + +.container { + max-width: 1200px; + margin: 0 auto; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding: 15px; + background-color: var(--secondary-bg); + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); +} + +h1 { + margin: 0; + font-size: 24px; + color: var(--accent-color); +} + +.version-tag { + background-color: #ED4245; + color: var(--text-color); + padding: 5px 10px; + border-radius: 4px; + font-size: 14px; + font-weight: bold; +} + +#supervisor-name { + font-size: 24px; + font-weight: bold; + color: var(--accent-color); + margin: 20px 0; + text-align: center; +} + +#sticky-controls { + position: sticky; + top: 0; + background-color: var(--secondary-bg); + padding: 15px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + z-index: 1000; + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +#left-controls, #right-controls { + display: flex; + align-items: center; +} + +#left-controls { + flex-grow: 1; +} + +#refresh-controls, #search-container { + display: flex; + align-items: center; + margin-right: 20px; +} + +#refresh-interval, #search-input { + width: 60px; + padding: 8px; + margin-right: 10px; + background-color: var(--bg-color); + border: 1px solid var(--border-color); + color: var(--text-color); + border-radius: 4px; +} + +#search-input { + width: 200px; +} + +button { + background-color: var(--accent-color); + color: var(--text-color); + border: none; + padding: 8px 15px; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.3s; + font-weight: bold; + display: flex; + align-items: center; + margin-right: 10px; +} + +button:hover { + background-color: #4752c4; +} + +#search-prev-btn, #search-next-btn { + margin: 0 5px; +} + +#search-results { + margin-left: 10px; + font-weight: bold; +} + +#right-controls button:last-child { + margin-right: 0; +} + +#copy-data-btn svg { + margin-right: 5px; +} + +#debug-container { + background-color: var(--secondary-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: auto; + max-height: calc(100vh - 200px); + padding: 15px; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); +} + +.tree-view { + font-family: 'Consolas', 'Courier New', monospace; + font-size: 14px; +} + +.tree-node { + margin: 5px 0; + padding: 5px; + border: 1px solid var(--border-color); + border-radius: 4px; + background-color: var(--bg-color); +} + +.tree-label { + cursor: pointer; + display: flex; + align-items: center; + padding: 2px 5px; + border-radius: 3px; + transition: background-color 0.2s; +} + +.tree-label:hover { + background-color: var(--hover-color); +} + +.tree-key { + color: var(--accent-light); + margin-right: 5px; +} + +.tree-value { + color: var(--success-color); +} + +.tree-value.null { + color: var(--null-color); +} + +.tree-toggle { + cursor: pointer; + color: var(--accent-color); + margin-right: 5px; + font-size: 12px; +} + +.large-dataset-notice { + color: var(--accent-color); + font-style: italic; + margin-top: 5px; +} + +.copy-btn { + background-color: var(--accent-light); + color: var(--bg-color); + border: none; + padding: 2px 5px; + border-radius: 3px; + cursor: pointer; + font-size: 12px; + margin-left: 5px; + opacity: 0; + transition: opacity 0.3s; +} + +.tree-node:hover > .tree-label .copy-btn { + opacity: 1; +} + +.highlight { + background-color: rgba(255, 255, 0, 0.3); +} \ No newline at end of file diff --git a/internal/server/assets/css/fonts/bootstrap-icons.woff b/internal/server/assets/css/fonts/bootstrap-icons.woff new file mode 100644 index 000000000..51204d27d Binary files /dev/null and b/internal/server/assets/css/fonts/bootstrap-icons.woff differ diff --git a/internal/server/assets/css/fonts/bootstrap-icons.woff2 b/internal/server/assets/css/fonts/bootstrap-icons.woff2 new file mode 100644 index 000000000..92c483021 Binary files /dev/null and b/internal/server/assets/css/fonts/bootstrap-icons.woff2 differ diff --git a/internal/server/assets/css/img/jsoneditor-icons.svg b/internal/server/assets/css/img/jsoneditor-icons.svg new file mode 100644 index 000000000..c2c27658e --- /dev/null +++ b/internal/server/assets/css/img/jsoneditor-icons.svg @@ -0,0 +1,749 @@ + + + JSON Editor Icons + + + + image/svg+xml + + JSON Editor Icons + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/internal/server/assets/css/jsoneditor.min.css b/internal/server/assets/css/jsoneditor.min.css new file mode 100644 index 000000000..defbd1512 --- /dev/null +++ b/internal/server/assets/css/jsoneditor.min.css @@ -0,0 +1,6 @@ +.jsoneditor input,.jsoneditor input:not([type]),.jsoneditor input[type=search],.jsoneditor input[type=text],.jsoneditor-modal input,.jsoneditor-modal input:not([type]),.jsoneditor-modal input[type=search],.jsoneditor-modal input[type=text]{height:auto;border:inherit;box-shadow:none;font-size:inherit;box-sizing:inherit;padding:inherit;font-family:inherit;transition:none;line-height:inherit}.jsoneditor input:focus,.jsoneditor input:not([type]):focus,.jsoneditor input[type=search]:focus,.jsoneditor input[type=text]:focus,.jsoneditor-modal input:focus,.jsoneditor-modal input:not([type]):focus,.jsoneditor-modal input[type=search]:focus,.jsoneditor-modal input[type=text]:focus{border:inherit;box-shadow:inherit}.jsoneditor textarea,.jsoneditor-modal textarea{height:inherit}.jsoneditor select,.jsoneditor-modal select{display:inherit;height:inherit}.jsoneditor label,.jsoneditor-modal label{font-size:inherit;font-weight:inherit;color:inherit}.jsoneditor table,.jsoneditor-modal table{border-collapse:collapse;width:auto}.jsoneditor td,.jsoneditor th,.jsoneditor-modal td,.jsoneditor-modal th{padding:0;display:table-cell;text-align:left;vertical-align:inherit;border-radius:inherit}.jsoneditor .autocomplete.dropdown{position:absolute;background:#fff;box-shadow:2px 2px 12px rgba(128,128,128,.3);border:1px solid #d3d3d3;overflow-x:hidden;overflow-y:auto;cursor:default;margin:0;padding:5px;text-align:left;outline:0;font-family:"dejavu sans mono","droid sans mono",consolas,monaco,"lucida console","courier new",courier,monospace,sans-serif;font-size:10pt}.jsoneditor .autocomplete.dropdown .item{color:#1a1a1a}.jsoneditor .autocomplete.dropdown .item.hover{background-color:#ebebeb}.jsoneditor .autocomplete.hint{color:#a1a1a1;top:4px;left:4px}.jsoneditor-contextmenu-root{position:relative;width:0;height:0}.jsoneditor-contextmenu{position:absolute;box-sizing:content-box;z-index:2}.jsoneditor-contextmenu .jsoneditor-menu{position:relative;left:0;top:0;width:128px;height:auto;background:#fff;border:1px solid #d3d3d3;box-shadow:2px 2px 12px rgba(128,128,128,.3);list-style:none;margin:0;padding:0}.jsoneditor-contextmenu .jsoneditor-menu button{position:relative;padding:0 8px 0 0;margin:0;width:128px;height:auto;border:none;cursor:pointer;color:#4d4d4d;background:0 0;font-size:10pt;font-family:arial,sans-serif;box-sizing:border-box;text-align:left}.jsoneditor-contextmenu .jsoneditor-menu button::-moz-focus-inner{padding:0;border:0}.jsoneditor-contextmenu .jsoneditor-menu button.jsoneditor-default{width:96px}.jsoneditor-contextmenu .jsoneditor-menu button.jsoneditor-expand{float:right;width:32px;height:24px;border-left:1px solid #e5e5e5}.jsoneditor-contextmenu .jsoneditor-menu li{overflow:hidden}.jsoneditor-contextmenu .jsoneditor-menu li ul{display:none;position:relative;left:-10px;top:0;border:none;box-shadow:inset 0 0 10px rgba(128,128,128,.5);padding:0 10px;-webkit-transition:all .3s ease-out;-moz-transition:all .3s ease-out;-o-transition:all .3s ease-out;transition:all .3s ease-out}.jsoneditor-contextmenu .jsoneditor-menu li ul .jsoneditor-icon{margin-left:24px}.jsoneditor-contextmenu .jsoneditor-menu li ul li button{padding-left:24px;animation:all ease-in-out 1s}.jsoneditor-contextmenu .jsoneditor-menu li button .jsoneditor-expand{position:absolute;top:0;right:0;width:24px;height:24px;padding:0;margin:0 4px 0 0;background-image:url(./img/jsoneditor-icons.svg);background-position:0 -72px}.jsoneditor-contextmenu .jsoneditor-icon{position:absolute;top:0;left:0;width:24px;height:24px;border:none;padding:0;margin:0;background-image:url(./img/jsoneditor-icons.svg)}.jsoneditor-contextmenu .jsoneditor-text{padding:4px 0 4px 24px;word-wrap:break-word}.jsoneditor-contextmenu .jsoneditor-text.jsoneditor-right-margin{padding-right:24px}.jsoneditor-contextmenu .jsoneditor-separator{height:0;border-top:1px solid #e5e5e5;padding-top:5px;margin-top:5px}.jsoneditor-contextmenu button.jsoneditor-remove .jsoneditor-icon{background-position:-24px 0}.jsoneditor-contextmenu button.jsoneditor-append .jsoneditor-icon{background-position:0 0}.jsoneditor-contextmenu button.jsoneditor-insert .jsoneditor-icon{background-position:0 0}.jsoneditor-contextmenu button.jsoneditor-duplicate .jsoneditor-icon{background-position:-48px 0}.jsoneditor-contextmenu button.jsoneditor-sort-asc .jsoneditor-icon{background-position:-168px 0}.jsoneditor-contextmenu button.jsoneditor-sort-desc .jsoneditor-icon{background-position:-192px 0}.jsoneditor-contextmenu button.jsoneditor-transform .jsoneditor-icon{background-position:-216px 0}.jsoneditor-contextmenu button.jsoneditor-extract .jsoneditor-icon{background-position:0 -24px}.jsoneditor-contextmenu button.jsoneditor-type-string .jsoneditor-icon{background-position:-144px 0}.jsoneditor-contextmenu button.jsoneditor-type-auto .jsoneditor-icon{background-position:-120px 0}.jsoneditor-contextmenu button.jsoneditor-type-object .jsoneditor-icon{background-position:-72px 0}.jsoneditor-contextmenu button.jsoneditor-type-array .jsoneditor-icon{background-position:-96px 0}.jsoneditor-contextmenu button.jsoneditor-type-modes .jsoneditor-icon{background-image:none;width:6px}.jsoneditor-contextmenu li,.jsoneditor-contextmenu ul{box-sizing:content-box;position:relative}.jsoneditor-contextmenu .jsoneditor-menu button:focus,.jsoneditor-contextmenu .jsoneditor-menu button:hover{color:#1a1a1a;background-color:#f5f5f5;outline:0}.jsoneditor-contextmenu .jsoneditor-menu li button.jsoneditor-selected,.jsoneditor-contextmenu .jsoneditor-menu li button.jsoneditor-selected:focus,.jsoneditor-contextmenu .jsoneditor-menu li button.jsoneditor-selected:hover{color:#fff;background-color:#ee422e}.jsoneditor-contextmenu .jsoneditor-menu li ul li button:focus,.jsoneditor-contextmenu .jsoneditor-menu li ul li button:hover{background-color:#f5f5f5}.jsoneditor-modal{max-width:95%;border-radius:2px!important;padding:45px 15px 15px 15px!important;box-shadow:2px 2px 12px rgba(128,128,128,.3);color:#4d4d4d;line-height:1.3em}.jsoneditor-modal.jsoneditor-modal-transform{width:600px!important}.jsoneditor-modal .pico-modal-header{position:absolute;box-sizing:border-box;top:0;left:0;width:100%;padding:0 10px;height:30px;line-height:30px;font-family:arial,sans-serif;font-size:11pt;background:#3883fa;color:#fff}.jsoneditor-modal table{width:100%}.jsoneditor-modal table td{padding:3px 0}.jsoneditor-modal table td.jsoneditor-modal-input{text-align:right;padding-right:0;white-space:nowrap}.jsoneditor-modal table td.jsoneditor-modal-actions{padding-top:15px}.jsoneditor-modal table th{vertical-align:middle}.jsoneditor-modal p:first-child{margin-top:0}.jsoneditor-modal a{color:#3883fa}.jsoneditor-modal .jsoneditor-jmespath-block{margin-bottom:10px}.jsoneditor-modal .pico-close{background:0 0!important;font-size:24px!important;top:7px!important;right:7px!important;color:#fff}.jsoneditor-modal input{padding:4px}.jsoneditor-modal input[type=text]{cursor:inherit}.jsoneditor-modal input[disabled]{background:#d3d3d3;color:grey}.jsoneditor-modal .jsoneditor-select-wrapper{position:relative;display:inline-block}.jsoneditor-modal .jsoneditor-select-wrapper:after{content:"";width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-top:6px solid #666;position:absolute;right:8px;top:14px;pointer-events:none}.jsoneditor-modal select{padding:3px 24px 3px 10px;min-width:180px;max-width:350px;-webkit-appearance:none;-moz-appearance:none;appearance:none;text-indent:0;text-overflow:"";font-size:10pt;line-height:1.5em}.jsoneditor-modal select::-ms-expand{display:none}.jsoneditor-modal .jsoneditor-button-group input{padding:4px 10px;margin:0;border-radius:0;border-left-style:none}.jsoneditor-modal .jsoneditor-button-group input.jsoneditor-button-first{border-top-left-radius:3px;border-bottom-left-radius:3px;border-left-style:solid}.jsoneditor-modal .jsoneditor-button-group input.jsoneditor-button-last{border-top-right-radius:3px;border-bottom-right-radius:3px}.jsoneditor-modal .jsoneditor-transform-preview{background:#f5f5f5;height:200px}.jsoneditor-modal .jsoneditor-transform-preview.jsoneditor-error{color:#ee422e}.jsoneditor-modal .jsoneditor-jmespath-wizard{line-height:1.2em;width:100%;padding:0;border-radius:3px}.jsoneditor-modal .jsoneditor-jmespath-label{font-weight:700;color:#1e90ff;margin-top:20px;margin-bottom:5px}.jsoneditor-modal .jsoneditor-jmespath-wizard-table{width:100%;border-collapse:collapse}.jsoneditor-modal .jsoneditor-jmespath-wizard-label{font-style:italic;margin:4px 0 2px 0}.jsoneditor-modal .jsoneditor-inline{position:relative;display:inline-block;width:100%;padding-top:2px;padding-bottom:2px}.jsoneditor-modal .jsoneditor-inline:not(:last-child){padding-right:2px}.jsoneditor-modal .jsoneditor-jmespath-filter{display:flex;flex-wrap:wrap}.jsoneditor-modal .jsoneditor-jmespath-filter-field{width:180px}.jsoneditor-modal .jsoneditor-jmespath-filter-relation{width:100px}.jsoneditor-modal .jsoneditor-jmespath-filter-value{min-width:180px;flex:1}.jsoneditor-modal .jsoneditor-jmespath-sort-field{width:170px}.jsoneditor-modal .jsoneditor-jmespath-sort-order{width:150px}.jsoneditor-modal .jsoneditor-jmespath-select-fields{width:100%}.jsoneditor-modal .selectr-selected{border-color:#d3d3d3;padding:4px 28px 4px 8px}.jsoneditor-modal .selectr-selected .selectr-tag{background-color:#3883fa;border-radius:5px}.jsoneditor-modal table td,.jsoneditor-modal table th{text-align:left;vertical-align:middle;font-weight:400;color:#4d4d4d;border-spacing:0;border-collapse:collapse}.jsoneditor-modal #query,.jsoneditor-modal input,.jsoneditor-modal input[type=text],.jsoneditor-modal input[type=text]:focus,.jsoneditor-modal select,.jsoneditor-modal textarea{background:#fff;border:1px solid #d3d3d3;color:#4d4d4d;border-radius:3px;padding:4px}.jsoneditor-modal,.jsoneditor-modal #query,.jsoneditor-modal input,.jsoneditor-modal input[type=text],.jsoneditor-modal option,.jsoneditor-modal select,.jsoneditor-modal table td,.jsoneditor-modal table th,.jsoneditor-modal textarea{font-size:10.5pt;font-family:arial,sans-serif}.jsoneditor-modal #query,.jsoneditor-modal .jsoneditor-transform-preview{font-family:"dejavu sans mono","droid sans mono",consolas,monaco,"lucida console","courier new",courier,monospace,sans-serif;font-size:10pt;width:100%;box-sizing:border-box}.jsoneditor-modal input[type=button],.jsoneditor-modal input[type=submit]{background:#f5f5f5;padding:4px 20px}.jsoneditor-modal input,.jsoneditor-modal select{cursor:pointer}.jsoneditor-modal .jsoneditor-button-group.jsoneditor-button-group-value-asc input.jsoneditor-button-asc,.jsoneditor-modal .jsoneditor-button-group.jsoneditor-button-group-value-desc input.jsoneditor-button-desc{background:#3883fa;border-color:#3883fa;color:#fff}.jsoneditor{color:#1a1a1a;border:thin solid #3883fa;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box;width:100%;height:100%;position:relative;padding:0;line-height:100%}div.jsoneditor-default,div.jsoneditor-field,div.jsoneditor-readonly,div.jsoneditor-value{border:1px solid transparent;min-height:16px;min-width:32px;padding:2px;margin:1px;word-wrap:break-word;float:left}div.jsoneditor-field p,div.jsoneditor-value p{margin:0}div.jsoneditor-value{word-break:break-word}div.jsoneditor-value.jsoneditor-empty::after{content:"value"}div.jsoneditor-value.jsoneditor-string{color:#006000}div.jsoneditor-value.jsoneditor-number{color:#ee422e}div.jsoneditor-value.jsoneditor-boolean{color:#ff8c00}div.jsoneditor-value.jsoneditor-null{color:#004ed0}div.jsoneditor-value.jsoneditor-color-value{color:#1a1a1a}div.jsoneditor-value.jsoneditor-invalid{color:#1a1a1a}div.jsoneditor-readonly{min-width:16px;color:grey}div.jsoneditor-empty{border-color:#d3d3d3;border-style:dashed;border-radius:2px}div.jsoneditor-field.jsoneditor-empty::after{content:"field"}div.jsoneditor td{vertical-align:top}div.jsoneditor td.jsoneditor-separator{padding:3px 0;vertical-align:top;color:grey}div.jsoneditor td.jsoneditor-tree{vertical-align:top}div.jsoneditor.busy pre.jsoneditor-preview{background:#f5f5f5;color:grey}div.jsoneditor.busy div.jsoneditor-busy{display:inherit}div.jsoneditor code.jsoneditor-preview{background:0 0}div.jsoneditor.jsoneditor-mode-preview pre.jsoneditor-preview{width:100%;height:100%;box-sizing:border-box;overflow:auto;padding:2px;margin:0;white-space:pre-wrap;word-break:break-all}div.jsoneditor-default{color:grey;padding-left:10px}div.jsoneditor-tree{width:100%;height:100%;position:relative;overflow:auto;background:#fff}div.jsoneditor-tree button.jsoneditor-button{width:24px;height:24px;padding:0;margin:0;border:none;cursor:pointer;background-color:transparent;background-image:url(./img/jsoneditor-icons.svg)}div.jsoneditor-tree button.jsoneditor-button:focus{background-color:#f5f5f5;outline:#e5e5e5 solid 1px}div.jsoneditor-tree button.jsoneditor-collapsed{background-position:0 -48px}div.jsoneditor-tree button.jsoneditor-expanded{background-position:0 -72px}div.jsoneditor-tree button.jsoneditor-contextmenu-button{background-position:-48px -72px}div.jsoneditor-tree button.jsoneditor-invisible{visibility:hidden;background:0 0}div.jsoneditor-tree button.jsoneditor-dragarea{background-image:url(./img/jsoneditor-icons.svg);background-position:-72px -72px;cursor:move}div.jsoneditor-tree :focus{outline:0}div.jsoneditor-tree div.jsoneditor-show-more{display:inline-block;padding:3px 4px;margin:2px 0;background-color:#e5e5e5;border-radius:3px;color:grey;font-family:arial,sans-serif;font-size:10pt}div.jsoneditor-tree div.jsoneditor-show-more a{display:inline-block;color:grey}div.jsoneditor-tree div.jsoneditor-color{display:inline-block;width:12px;height:12px;margin:4px;border:1px solid grey;cursor:pointer}div.jsoneditor-tree div.jsoneditor-date{background:#a1a1a1;color:#fff;font-family:arial,sans-serif;border-radius:3px;display:inline-block;padding:3px;margin:0 3px}div.jsoneditor-tree table.jsoneditor-tree{border-collapse:collapse;border-spacing:0;width:100%}div.jsoneditor-tree .jsoneditor-button.jsoneditor-schema-error{width:24px;height:24px;padding:0;margin:0 4px 0 0;background-image:url(./img/jsoneditor-icons.svg);background-position:-168px -48px;background-color:transparent}div.jsoneditor-outer{position:static;width:100%;height:100%;margin:0;padding:0;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box}div.jsoneditor-outer.has-nav-bar{margin-top:-26px;padding-top:26px}div.jsoneditor-outer.has-nav-bar.has-main-menu-bar{margin-top:-61px;padding-top:61px}div.jsoneditor-outer.has-status-bar{margin-bottom:-26px;padding-bottom:26px}div.jsoneditor-outer.has-main-menu-bar{margin-top:-35px;padding-top:35px}div.jsoneditor-busy{position:absolute;top:15%;left:0;box-sizing:border-box;width:100%;text-align:center;display:none}div.jsoneditor-busy span{background-color:#ffffab;border:1px solid #fe0;border-radius:3px;padding:5px 15px;box-shadow:0 0 5px rgba(0,0,0,.4)}div.jsoneditor-field.jsoneditor-empty::after,div.jsoneditor-value.jsoneditor-empty::after{pointer-events:none;color:#d3d3d3;font-size:8pt}a.jsoneditor-value.jsoneditor-url,div.jsoneditor-value.jsoneditor-url{color:#006000;text-decoration:underline}a.jsoneditor-value.jsoneditor-url{display:inline-block;padding:2px;margin:2px}a.jsoneditor-value.jsoneditor-url:focus,a.jsoneditor-value.jsoneditor-url:hover{color:#ee422e}div.jsoneditor-field.jsoneditor-highlight,div.jsoneditor-field[contenteditable=true]:focus,div.jsoneditor-field[contenteditable=true]:hover,div.jsoneditor-value.jsoneditor-highlight,div.jsoneditor-value[contenteditable=true]:focus,div.jsoneditor-value[contenteditable=true]:hover{background-color:#ffffab;border:1px solid #fe0;border-radius:2px}div.jsoneditor-field.jsoneditor-highlight-active,div.jsoneditor-field.jsoneditor-highlight-active:focus,div.jsoneditor-field.jsoneditor-highlight-active:hover,div.jsoneditor-value.jsoneditor-highlight-active,div.jsoneditor-value.jsoneditor-highlight-active:focus,div.jsoneditor-value.jsoneditor-highlight-active:hover{background-color:#fe0;border:1px solid #ffc700;border-radius:2px}div.jsoneditor-value.jsoneditor-array,div.jsoneditor-value.jsoneditor-object{min-width:16px}div.jsoneditor-tree button.jsoneditor-contextmenu-button.jsoneditor-selected,div.jsoneditor-tree button.jsoneditor-contextmenu-button:focus,div.jsoneditor-tree button.jsoneditor-contextmenu-button:hover,tr.jsoneditor-selected.jsoneditor-first button.jsoneditor-contextmenu-button{background-position:-48px -48px}div.jsoneditor-tree div.jsoneditor-show-more a:focus,div.jsoneditor-tree div.jsoneditor-show-more a:hover{color:#ee422e}.ace-jsoneditor,textarea.jsoneditor-text{min-height:150px}.ace-jsoneditor *,textarea.jsoneditor-text *{font-family:"dejavu sans mono","droid sans mono",consolas,monaco,"lucida console","courier new",courier,monospace,sans-serif}textarea.jsoneditor-text{width:100%;height:100%;margin:0;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box;outline-width:0;border:none;background-color:#fff;resize:none}tr.jsoneditor-highlight,tr.jsoneditor-selected{background-color:#d3d3d3}tr.jsoneditor-selected button.jsoneditor-contextmenu-button,tr.jsoneditor-selected button.jsoneditor-dragarea{visibility:hidden}tr.jsoneditor-selected.jsoneditor-first button.jsoneditor-contextmenu-button,tr.jsoneditor-selected.jsoneditor-first button.jsoneditor-dragarea{visibility:visible}div.jsoneditor-tree button.jsoneditor-dragarea:focus,div.jsoneditor-tree button.jsoneditor-dragarea:hover,tr.jsoneditor-selected.jsoneditor-first button.jsoneditor-dragarea{background-position:-72px -48px}div.jsoneditor td,div.jsoneditor th,div.jsoneditor tr{padding:0;margin:0}.jsoneditor-popover,.jsoneditor-schema-error,div.jsoneditor td,div.jsoneditor textarea,div.jsoneditor th,div.jsoneditor-field,div.jsoneditor-value,pre.jsoneditor-preview{font-family:"dejavu sans mono","droid sans mono",consolas,monaco,"lucida console","courier new",courier,monospace,sans-serif;font-size:10pt;color:#1a1a1a}.jsoneditor-schema-error{cursor:default;display:inline-block;height:24px;line-height:24px;position:relative;text-align:center;width:24px}.jsoneditor-popover{background-color:#4c4c4c;border-radius:3px;box-shadow:0 0 5px rgba(0,0,0,.4);color:#fff;padding:7px 10px;position:absolute;cursor:auto;width:200px}.jsoneditor-popover.jsoneditor-above{bottom:32px;left:-98px}.jsoneditor-popover.jsoneditor-above:before{border-top:7px solid #4c4c4c;bottom:-7px}.jsoneditor-popover.jsoneditor-below{top:32px;left:-98px}.jsoneditor-popover.jsoneditor-below:before{border-bottom:7px solid #4c4c4c;top:-7px}.jsoneditor-popover.jsoneditor-left{top:-7px;right:32px}.jsoneditor-popover.jsoneditor-left:before{border-left:7px solid #4c4c4c;border-top:7px solid transparent;border-bottom:7px solid transparent;content:"";top:19px;right:-14px;left:inherit;margin-left:inherit;margin-top:-7px;position:absolute}.jsoneditor-popover.jsoneditor-right{top:-7px;left:32px}.jsoneditor-popover.jsoneditor-right:before{border-right:7px solid #4c4c4c;border-top:7px solid transparent;border-bottom:7px solid transparent;content:"";top:19px;left:-14px;margin-left:inherit;margin-top:-7px;position:absolute}.jsoneditor-popover:before{border-right:7px solid transparent;border-left:7px solid transparent;content:"";display:block;left:50%;margin-left:-7px;position:absolute}.jsoneditor-text-errors tr.jump-to-line:hover{text-decoration:underline;cursor:pointer}.jsoneditor-schema-error:focus .jsoneditor-popover,.jsoneditor-schema-error:hover .jsoneditor-popover{display:block;animation:fade-in .3s linear 1,move-up .3s linear 1}@keyframes fade-in{from{opacity:0}to{opacity:1}}.jsoneditor .jsoneditor-validation-errors-container{max-height:130px;overflow-y:auto}.jsoneditor .jsoneditor-validation-errors{width:100%;overflow:hidden}.jsoneditor .jsoneditor-additional-errors{position:absolute;margin:auto;bottom:31px;left:calc(50% - 92px);color:grey;background-color:#ebebeb;padding:7px 15px;border-radius:8px}.jsoneditor .jsoneditor-additional-errors.visible{visibility:visible;opacity:1;transition:opacity 2s linear}.jsoneditor .jsoneditor-additional-errors.hidden{visibility:hidden;opacity:0;transition:visibility 0s 2s,opacity 2s linear}.jsoneditor .jsoneditor-text-errors{width:100%;border-collapse:collapse;border-top:1px solid #ffc700}.jsoneditor .jsoneditor-text-errors td{padding:3px 6px;vertical-align:middle}.jsoneditor .jsoneditor-text-errors td pre{margin:0;white-space:pre-wrap}.jsoneditor .jsoneditor-text-errors tr{background-color:#ffffab}.jsoneditor .jsoneditor-text-errors tr.parse-error{background-color:#ee2e2e70}.jsoneditor-text-errors .jsoneditor-schema-error{border:none;width:24px;height:24px;padding:0;margin:0 4px 0 0;cursor:pointer}.jsoneditor-text-errors tr .jsoneditor-schema-error{background-image:url(./img/jsoneditor-icons.svg);background-position:-168px -48px;background-color:transparent}.jsoneditor-text-errors tr.parse-error .jsoneditor-schema-error{background-image:url(./img/jsoneditor-icons.svg);background-position:-25px 0;background-color:transparent}.jsoneditor-anchor{cursor:pointer}.jsoneditor-anchor .picker_wrapper.popup.popup_bottom{top:28px;left:-10px}.fadein{-webkit-animation:fadein .3s;animation:fadein .3s;-moz-animation:fadein .3s;-o-animation:fadein .3s}@keyframes fadein{0%{opacity:0}100%{opacity:1}}.jsoneditor-modal input[type=search].selectr-input{border:1px solid #d3d3d3;width:calc(100% - 4px);margin:2px;padding:4px;box-sizing:border-box}.jsoneditor-modal button.selectr-input-clear{right:8px}.jsoneditor-menu{width:100%;height:35px;padding:2px;margin:0;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box;color:#fff;background-color:#3883fa;border-bottom:1px solid #3883fa}.jsoneditor-menu>.jsoneditor-modes>button,.jsoneditor-menu>button{width:26px;height:26px;margin:2px;padding:0;border-radius:2px;border:1px solid transparent;background-color:transparent;background-image:url(./img/jsoneditor-icons.svg);color:#fff;opacity:.8;font-family:arial,sans-serif;font-size:10pt;float:left}.jsoneditor-menu>.jsoneditor-modes>button:hover,.jsoneditor-menu>button:hover{background-color:rgba(255,255,255,.2);border:1px solid rgba(255,255,255,.4)}.jsoneditor-menu>.jsoneditor-modes>button:active,.jsoneditor-menu>.jsoneditor-modes>button:focus,.jsoneditor-menu>button:active,.jsoneditor-menu>button:focus{background-color:rgba(255,255,255,.3)}.jsoneditor-menu>.jsoneditor-modes>button:disabled,.jsoneditor-menu>button:disabled{opacity:.5;background-color:transparent;border:none}.jsoneditor-menu>button.jsoneditor-collapse-all{background-position:0 -96px}.jsoneditor-menu>button.jsoneditor-expand-all{background-position:0 -120px}.jsoneditor-menu>button.jsoneditor-sort{background-position:-120px -96px}.jsoneditor-menu>button.jsoneditor-transform{background-position:-144px -96px}.jsoneditor.jsoneditor-mode-form>.jsoneditor-menu>button.jsoneditor-sort,.jsoneditor.jsoneditor-mode-form>.jsoneditor-menu>button.jsoneditor-transform,.jsoneditor.jsoneditor-mode-view>.jsoneditor-menu>button.jsoneditor-sort,.jsoneditor.jsoneditor-mode-view>.jsoneditor-menu>button.jsoneditor-transform{display:none}.jsoneditor-menu>button.jsoneditor-undo{background-position:-24px -96px}.jsoneditor-menu>button.jsoneditor-undo:disabled{background-position:-24px -120px}.jsoneditor-menu>button.jsoneditor-redo{background-position:-48px -96px}.jsoneditor-menu>button.jsoneditor-redo:disabled{background-position:-48px -120px}.jsoneditor-menu>button.jsoneditor-compact{background-position:-72px -96px}.jsoneditor-menu>button.jsoneditor-format{background-position:-72px -120px}.jsoneditor-menu>button.jsoneditor-repair{background-position:-96px -96px}.jsoneditor-menu>.jsoneditor-modes{display:inline-block;float:left}.jsoneditor-menu>.jsoneditor-modes>button{background-image:none;width:auto;padding-left:6px;padding-right:6px}.jsoneditor-menu>.jsoneditor-modes>button.jsoneditor-separator,.jsoneditor-menu>button.jsoneditor-separator{margin-left:10px}.jsoneditor-menu a{font-family:arial,sans-serif;font-size:10pt;color:#fff;opacity:.8;vertical-align:middle}.jsoneditor-menu a:hover{opacity:1}.jsoneditor-menu a.jsoneditor-poweredBy{font-size:8pt;position:absolute;right:0;top:0;padding:10px}.jsoneditor-navigation-bar{width:100%;height:26px;line-height:26px;padding:0;margin:0;border-bottom:1px solid #d3d3d3;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box;color:grey;background-color:#ebebeb;overflow:hidden;font-family:arial,sans-serif;font-size:10pt}.jsoneditor-search{font-family:arial,sans-serif;position:absolute;right:4px;top:4px;border-collapse:collapse;border-spacing:0;display:flex}.jsoneditor-search input{color:#1a1a1a;width:120px;border:none;outline:0;margin:1px;line-height:20px;font-family:arial,sans-serif}.jsoneditor-search button{width:16px;height:24px;padding:0;margin:0;border:none;background:url(./img/jsoneditor-icons.svg);vertical-align:top}.jsoneditor-search button:hover{background-color:transparent}.jsoneditor-search button.jsoneditor-refresh{width:18px;background-position:-99px -73px}.jsoneditor-search button.jsoneditor-next{cursor:pointer;background-position:-124px -73px}.jsoneditor-search button.jsoneditor-next:hover{background-position:-124px -49px}.jsoneditor-search button.jsoneditor-previous{cursor:pointer;background-position:-148px -73px;margin-right:2px}.jsoneditor-search button.jsoneditor-previous:hover{background-position:-148px -49px}.jsoneditor-results{font-family:arial,sans-serif;color:#fff;padding-right:5px;line-height:26px}.jsoneditor-frame{border:1px solid transparent;background-color:#fff;padding:0 2px;margin:0}.jsoneditor-statusbar{line-height:26px;height:26px;color:grey;background-color:#ebebeb;border-top:1px solid #d3d3d3;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box;font-size:10pt}.jsoneditor-statusbar>.jsoneditor-curserinfo-val{margin-right:12px}.jsoneditor-statusbar>.jsoneditor-curserinfo-count{margin-left:4px}.jsoneditor-statusbar>.jsoneditor-validation-error-icon{float:right;width:24px;height:24px;padding:0;margin-top:1px;background-image:url(./img/jsoneditor-icons.svg);background-position:-168px -48px;cursor:pointer}.jsoneditor-statusbar>.jsoneditor-validation-error-count{float:right;margin:0 4px 0 0;cursor:pointer}.jsoneditor-statusbar>.jsoneditor-parse-error-icon{float:right;width:24px;height:24px;padding:0;margin:1px;background-image:url(./img/jsoneditor-icons.svg);background-position:-25px 0}.jsoneditor-statusbar .jsoneditor-array-info a{color:inherit}div.jsoneditor-statusbar>.jsoneditor-curserinfo-label,div.jsoneditor-statusbar>.jsoneditor-size-info{margin:0 4px}.jsoneditor-treepath{padding:0 5px;overflow:hidden;white-space:nowrap;outline:0}.jsoneditor-treepath.show-all{word-wrap:break-word;white-space:normal;position:absolute;background-color:#ebebeb;z-index:1;box-shadow:2px 2px 12px rgba(128,128,128,.3)}.jsoneditor-treepath.show-all span.jsoneditor-treepath-show-all-btn{display:none}.jsoneditor-treepath div.jsoneditor-contextmenu-root{position:absolute;left:0}.jsoneditor-treepath .jsoneditor-treepath-show-all-btn{position:absolute;background-color:#ebebeb;left:0;height:20px;padding:0 3px;cursor:pointer}.jsoneditor-treepath .jsoneditor-treepath-element{margin:1px;font-family:arial,sans-serif;font-size:10pt}.jsoneditor-treepath .jsoneditor-treepath-seperator{margin:2px;font-size:9pt;font-family:arial,sans-serif}.jsoneditor-treepath span.jsoneditor-treepath-element:hover,.jsoneditor-treepath span.jsoneditor-treepath-seperator:hover{cursor:pointer;text-decoration:underline}/*! + * Selectr 2.4.0 + * https://github.com/Mobius1/Selectr + * + * Released under the MIT license + */.selectr-container{position:relative}.selectr-container li{list-style:none}.selectr-hidden{position:absolute;overflow:hidden;clip:rect(0,0,0,0);width:1px;height:1px;margin:-1px;padding:0;border:0 none}.selectr-visible{position:absolute;left:0;top:0;width:100%;height:100%;opacity:0;z-index:11}.selectr-desktop.multiple .selectr-visible{display:none}.selectr-desktop.multiple.native-open .selectr-visible{top:100%;min-height:200px!important;height:auto;opacity:1;display:block}.selectr-container.multiple.selectr-mobile .selectr-selected{z-index:0}.selectr-selected{position:relative;z-index:1;box-sizing:border-box;width:100%;padding:7px 28px 7px 14px;cursor:pointer;border:1px solid #999;border-radius:3px;background-color:#fff}.selectr-selected::before{position:absolute;top:50%;right:10px;width:0;height:0;content:"";-o-transform:rotate(0) translate3d(0,-50%,0);-ms-transform:rotate(0) translate3d(0,-50%,0);-moz-transform:rotate(0) translate3d(0,-50%,0);-webkit-transform:rotate(0) translate3d(0,-50%,0);transform:rotate(0) translate3d(0,-50%,0);border-width:4px 4px 0 4px;border-style:solid;border-color:#6c7a86 transparent transparent}.selectr-container.native-open .selectr-selected::before,.selectr-container.open .selectr-selected::before{border-width:0 4px 4px 4px;border-style:solid;border-color:transparent transparent #6c7a86}.selectr-label{display:none;overflow:hidden;width:100%;white-space:nowrap;text-overflow:ellipsis}.selectr-placeholder{color:#6c7a86}.selectr-tags{margin:0;padding:0;white-space:normal}.has-selected .selectr-tags{margin:0 0 -2px}.selectr-tag{list-style:none;position:relative;float:left;padding:2px 25px 2px 8px;margin:0 2px 2px 0;cursor:default;color:#fff;border:medium none;border-radius:10px;background:#acb7bf none repeat scroll 0 0}.selectr-container.multiple.has-selected .selectr-selected{padding:5px 28px 5px 5px}.selectr-options-container{position:absolute;z-index:10000;top:calc(100% - 1px);left:0;display:none;box-sizing:border-box;width:100%;border-width:0 1px 1px;border-style:solid;border-color:transparent #999 #999;border-radius:0 0 3px 3px;background-color:#fff}.selectr-container.open .selectr-options-container{display:block}.selectr-input-container{position:relative;display:none}.selectr-clear,.selectr-input-clear,.selectr-tag-remove{position:absolute;top:50%;right:22px;width:20px;height:20px;padding:0;cursor:pointer;-o-transform:translate3d(0,-50%,0);-ms-transform:translate3d(0,-50%,0);-moz-transform:translate3d(0,-50%,0);-webkit-transform:translate3d(0,-50%,0);transform:translate3d(0,-50%,0);border:medium none;background-color:transparent;z-index:11}.selectr-clear,.selectr-input-clear{display:none}.selectr-container.has-selected .selectr-clear,.selectr-input-container.active .selectr-input-clear{display:block}.selectr-selected .selectr-tag-remove{right:2px}.selectr-clear::after,.selectr-clear::before,.selectr-input-clear::after,.selectr-input-clear::before,.selectr-tag-remove::after,.selectr-tag-remove::before{position:absolute;top:5px;left:9px;width:2px;height:10px;content:" ";background-color:#6c7a86}.selectr-tag-remove::after,.selectr-tag-remove::before{top:4px;width:3px;height:12px;background-color:#fff}.selectr-clear:before,.selectr-input-clear::before,.selectr-tag-remove::before{-o-transform:rotate(45deg);-ms-transform:rotate(45deg);-moz-transform:rotate(45deg);-webkit-transform:rotate(45deg);transform:rotate(45deg)}.selectr-clear:after,.selectr-input-clear::after,.selectr-tag-remove::after{-o-transform:rotate(-45deg);-ms-transform:rotate(-45deg);-moz-transform:rotate(-45deg);-webkit-transform:rotate(-45deg);transform:rotate(-45deg)}.selectr-input-container.active,.selectr-input-container.active .selectr-clear{display:block}.selectr-input{top:5px;left:5px;box-sizing:border-box;width:calc(100% - 30px);margin:10px 15px;padding:7px 30px 7px 9px;border:1px solid #999;border-radius:3px}.selectr-notice{display:none;box-sizing:border-box;width:100%;padding:8px 16px;border-top:1px solid #999;border-radius:0 0 3px 3px;background-color:#fff}.selectr-container.notice .selectr-notice{display:block}.selectr-container.notice .selectr-selected{border-radius:3px 3px 0 0}.selectr-options{position:relative;top:calc(100% + 2px);display:none;overflow-x:auto;overflow-y:scroll;max-height:200px;margin:0;padding:0}.selectr-container.notice .selectr-options-container,.selectr-container.open .selectr-input-container,.selectr-container.open .selectr-options{display:block}.selectr-option{position:relative;display:block;padding:5px 20px;list-style:outside none none;cursor:pointer;font-weight:400}.selectr-options.optgroups>.selectr-option{padding-left:25px}.selectr-optgroup{font-weight:700;padding:0}.selectr-optgroup--label{font-weight:700;margin-top:10px;padding:5px 15px}.selectr-match{text-decoration:underline}.selectr-option.selected{background-color:#ddd}.selectr-option.active{color:#fff;background-color:#5897fb}.selectr-option.disabled{opacity:.4}.selectr-option.excluded{display:none}.selectr-container.open .selectr-selected{border-color:#999 #999 transparent #999;border-radius:3px 3px 0 0}.selectr-container.open .selectr-selected::after{-o-transform:rotate(180deg) translate3d(0,50%,0);-ms-transform:rotate(180deg) translate3d(0,50%,0);-moz-transform:rotate(180deg) translate3d(0,50%,0);-webkit-transform:rotate(180deg) translate3d(0,50%,0);transform:rotate(180deg) translate3d(0,50%,0)}.selectr-disabled{opacity:.6}.has-selected .selectr-placeholder,.selectr-empty{display:none}.has-selected .selectr-label{display:block}.taggable .selectr-selected{padding:4px 28px 4px 4px}.taggable .selectr-selected::after{display:table;content:" ";clear:both}.taggable .selectr-label{width:auto}.taggable .selectr-tags{float:left;display:block}.taggable .selectr-placeholder{display:none}.input-tag{float:left;min-width:90px;width:auto}.selectr-tag-input{border:medium none;padding:3px 10px;width:100%;font-family:inherit;font-weight:inherit;font-size:inherit}.selectr-input-container.loading::after{position:absolute;top:50%;right:20px;width:20px;height:20px;content:"";-o-transform:translate3d(0,-50%,0);-ms-transform:translate3d(0,-50%,0);-moz-transform:translate3d(0,-50%,0);-webkit-transform:translate3d(0,-50%,0);transform:translate3d(0,-50%,0);-o-transform-origin:50% 0 0;-ms-transform-origin:50% 0 0;-moz-transform-origin:50% 0 0;-webkit-transform-origin:50% 0 0;transform-origin:50% 0 0;-moz-animation:.5s linear 0s normal forwards infinite running selectr-spin;-webkit-animation:.5s linear 0s normal forwards infinite running selectr-spin;animation:.5s linear 0s normal forwards infinite running selectr-spin;border-width:3px;border-style:solid;border-color:#aaa #ddd #ddd;border-radius:50%}@-webkit-keyframes selectr-spin{0%{-webkit-transform:rotate(0) translate3d(0,-50%,0);transform:rotate(0) translate3d(0,-50%,0)}100%{-webkit-transform:rotate(360deg) translate3d(0,-50%,0);transform:rotate(360deg) translate3d(0,-50%,0)}}@keyframes selectr-spin{0%{-webkit-transform:rotate(0) translate3d(0,-50%,0);transform:rotate(0) translate3d(0,-50%,0)}100%{-webkit-transform:rotate(360deg) translate3d(0,-50%,0);transform:rotate(360deg) translate3d(0,-50%,0)}}.selectr-container.open.inverted .selectr-selected{border-color:transparent #999 #999;border-radius:0 0 3px 3px}.selectr-container.inverted .selectr-options-container{border-width:1px 1px 0;border-color:#999 #999 transparent;border-radius:3px 3px 0 0;background-color:#fff}.selectr-container.inverted .selectr-options-container{top:auto;bottom:calc(100% - 1px)}.selectr-container ::-webkit-input-placeholder{color:#6c7a86;opacity:1}.selectr-container ::-moz-placeholder{color:#6c7a86;opacity:1}.selectr-container :-ms-input-placeholder{color:#6c7a86;opacity:1}.selectr-container ::placeholder{color:#6c7a86;opacity:1} \ No newline at end of file diff --git a/internal/server/assets/css/pico.min.css b/internal/server/assets/css/pico.min.css new file mode 100644 index 000000000..5928ed788 --- /dev/null +++ b/internal/server/assets/css/pico.min.css @@ -0,0 +1,4 @@ +@charset "UTF-8";/*! + * Pico CSS ✨ v2.0.6 (https://picocss.com) + * Copyright 2019-2024 - Licensed under MIT + */:root{--pico-font-family-emoji:"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--pico-font-family-sans-serif:system-ui,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,Helvetica,Arial,"Helvetica Neue",sans-serif,var(--pico-font-family-emoji);--pico-font-family-monospace:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace,var(--pico-font-family-emoji);--pico-font-family:var(--pico-font-family-sans-serif);--pico-line-height:1.5;--pico-font-weight:400;--pico-font-size:100%;--pico-text-underline-offset:0.1rem;--pico-border-radius:0.25rem;--pico-border-width:0.0625rem;--pico-outline-width:0.125rem;--pico-transition:0.2s ease-in-out;--pico-spacing:1rem;--pico-typography-spacing-vertical:1rem;--pico-block-spacing-vertical:var(--pico-spacing);--pico-block-spacing-horizontal:var(--pico-spacing);--pico-grid-column-gap:var(--pico-spacing);--pico-grid-row-gap:var(--pico-spacing);--pico-form-element-spacing-vertical:0.75rem;--pico-form-element-spacing-horizontal:1rem;--pico-group-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-primary-focus);--pico-group-box-shadow-focus-with-input:0 0 0 0.0625rem var(--pico-form-element-border-color);--pico-modal-overlay-backdrop-filter:blur(0.375rem);--pico-nav-element-spacing-vertical:1rem;--pico-nav-element-spacing-horizontal:0.5rem;--pico-nav-link-spacing-vertical:0.5rem;--pico-nav-link-spacing-horizontal:0.5rem;--pico-nav-breadcrumb-divider:">";--pico-icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--pico-icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--pico-icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--pico-icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--pico-icon-loading:url("data:image/svg+xml,%3Csvg fill='none' height='24' width='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cstyle%3E g %7B animation: rotate 2s linear infinite; transform-origin: center center; %7D circle %7B stroke-dasharray: 75,100; stroke-dashoffset: -5; animation: dash 1.5s ease-in-out infinite; stroke-linecap: round; %7D @keyframes rotate %7B 0%25 %7B transform: rotate(0deg); %7D 100%25 %7B transform: rotate(360deg); %7D %7D @keyframes dash %7B 0%25 %7B stroke-dasharray: 1,100; stroke-dashoffset: 0; %7D 50%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -17.5; %7D 100%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -62; %7D %7D %3C/style%3E%3Cg%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='rgb(136, 145, 164)' stroke-width='4' /%3E%3C/g%3E%3C/svg%3E")}@media (min-width:576px){:root{--pico-font-size:106.25%}}@media (min-width:768px){:root{--pico-font-size:112.5%}}@media (min-width:1024px){:root{--pico-font-size:118.75%}}@media (min-width:1280px){:root{--pico-font-size:125%}}@media (min-width:1536px){:root{--pico-font-size:131.25%}}a{--pico-text-decoration:underline}a.contrast,a.secondary{--pico-text-decoration:underline}small{--pico-font-size:0.875em}h1,h2,h3,h4,h5,h6{--pico-font-weight:700}h1{--pico-font-size:2rem;--pico-line-height:1.125;--pico-typography-spacing-top:3rem}h2{--pico-font-size:1.75rem;--pico-line-height:1.15;--pico-typography-spacing-top:2.625rem}h3{--pico-font-size:1.5rem;--pico-line-height:1.175;--pico-typography-spacing-top:2.25rem}h4{--pico-font-size:1.25rem;--pico-line-height:1.2;--pico-typography-spacing-top:1.874rem}h5{--pico-font-size:1.125rem;--pico-line-height:1.225;--pico-typography-spacing-top:1.6875rem}h6{--pico-font-size:1rem;--pico-line-height:1.25;--pico-typography-spacing-top:1.5rem}tfoot td,tfoot th,thead td,thead th{--pico-font-weight:600;--pico-border-width:0.1875rem}code,kbd,pre,samp{--pico-font-family:var(--pico-font-family-monospace)}kbd{--pico-font-weight:bolder}:where(select,textarea),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-outline-width:0.0625rem}[type=search]{--pico-border-radius:5rem}[type=checkbox],[type=radio]{--pico-border-width:0.125rem}[type=checkbox][role=switch]{--pico-border-width:0.1875rem}details.dropdown summary:not([role=button]){--pico-outline-width:0.0625rem}nav details.dropdown summary:focus-visible{--pico-outline-width:0.125rem}[role=search]{--pico-border-radius:5rem}[role=group]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus),[role=search]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[role=group]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus),[role=search]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=submit],[role=search] button{--pico-form-element-spacing-horizontal:2rem}details summary[role=button]:not(.outline)::after{filter:brightness(0) invert(1)}[aria-busy=true]:not(input,select,textarea):is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0) invert(1)}:root:not([data-theme=dark]),[data-theme=light]{--pico-background-color:#fff;--pico-color:#373c44;--pico-text-selection-color:rgba(2, 154, 232, 0.25);--pico-muted-color:#646b79;--pico-muted-border-color:#e7eaf0;--pico-primary:#0172ad;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 114, 173, 0.5);--pico-primary-hover:#015887;--pico-primary-hover-background:#02659a;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(2, 154, 232, 0.5);--pico-primary-inverse:#fff;--pico-secondary:#5d6b89;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(93, 107, 137, 0.5);--pico-secondary-hover:#48536b;--pico-secondary-hover-background:#48536b;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(93, 107, 137, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#181c25;--pico-contrast-background:#181c25;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(24, 28, 37, 0.5);--pico-contrast-hover:#000;--pico-contrast-hover-background:#000;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-secondary-hover);--pico-contrast-focus:rgba(93, 107, 137, 0.25);--pico-contrast-inverse:#fff;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(129, 145, 181, 0.01698),0.0335rem 0.067rem 0.402rem rgba(129, 145, 181, 0.024),0.0625rem 0.125rem 0.75rem rgba(129, 145, 181, 0.03),0.1125rem 0.225rem 1.35rem rgba(129, 145, 181, 0.036),0.2085rem 0.417rem 2.502rem rgba(129, 145, 181, 0.04302),0.5rem 1rem 6rem rgba(129, 145, 181, 0.06),0 0 0 0.0625rem rgba(129, 145, 181, 0.015);--pico-h1-color:#2d3138;--pico-h2-color:#373c44;--pico-h3-color:#424751;--pico-h4-color:#4d535e;--pico-h5-color:#5c6370;--pico-h6-color:#646b79;--pico-mark-background-color:#fde7c0;--pico-mark-color:#0f1114;--pico-ins-color:#1d6a54;--pico-del-color:#883935;--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:#f3f5f7;--pico-code-color:#646b79;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:#fbfcfc;--pico-form-element-selected-background-color:#dfe3eb;--pico-form-element-border-color:#cfd5e2;--pico-form-element-color:#23262c;--pico-form-element-placeholder-color:var(--pico-muted-color);--pico-form-element-active-background-color:#fff;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:#b86a6b;--pico-form-element-invalid-active-border-color:#c84f48;--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#4c9b8a;--pico-form-element-valid-active-border-color:#279977;--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#bfc7d9;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#dfe3eb;--pico-range-active-border-color:#bfc7d9;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:var(--pico-background-color);--pico-card-border-color:var(--pico-muted-border-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:#fbfcfc;--pico-dropdown-background-color:#fff;--pico-dropdown-border-color:#eff1f4;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#eff1f4;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(232, 234, 237, 0.75);--pico-progress-background-color:#dfe3eb;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 155, 138)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(200, 79, 72)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");color-scheme:light}:root:not([data-theme=dark]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),[data-theme=light] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}@media only screen and (prefers-color-scheme:dark){:root:not([data-theme]){--pico-background-color:#13171f;--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 9, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 9, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 9, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 9, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 9, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 9, 12, 0.06),0 0 0 0.0625rem rgba(7, 9, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:#ce7e7b;--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:#1a1f28;--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:#1c212c;--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:#1a1f28;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:#964a50;--pico-form-element-invalid-active-border-color:#b7403b;--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:#16896a;--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:#1a1f28;--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(8, 9, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(150, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");color-scheme:dark}:root:not([data-theme]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}:root:not([data-theme]) details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}:root:not([data-theme]) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}}[data-theme=dark]{--pico-background-color:#13171f;--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 9, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 9, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 9, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 9, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 9, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 9, 12, 0.06),0 0 0 0.0625rem rgba(7, 9, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:#ce7e7b;--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:#1a1f28;--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:#1c212c;--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:#1a1f28;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:#964a50;--pico-form-element-invalid-active-border-color:#b7403b;--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:#16896a;--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:#1a1f28;--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(8, 9, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(150, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");color-scheme:dark}[data-theme=dark] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}[data-theme=dark] details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}[data-theme=dark] [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}[type=checkbox],[type=radio],[type=range],progress{accent-color:var(--pico-primary)}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family);text-underline-offset:var(--pico-text-underline-offset);text-rendering:optimizeLegibility;overflow-wrap:break-word;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{width:100%;margin:0}main{display:block}body>footer,body>header,body>main{padding-block:var(--pico-block-spacing-vertical)}section{margin-bottom:var(--pico-block-spacing-vertical)}.container,.container-fluid{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--pico-spacing);padding-left:var(--pico-spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:1024px){.container{max-width:950px}}@media (min-width:1280px){.container{max-width:1200px}}@media (min-width:1536px){.container{max-width:1450px}}.grid{grid-column-gap:var(--pico-grid-column-gap);grid-row-gap:var(--pico-grid-row-gap);display:grid;grid-template-columns:1fr}@media (min-width:768px){.grid{grid-template-columns:repeat(auto-fit,minmax(0%,1fr))}}.grid>*{min-width:0}.overflow-auto{overflow:auto}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-style:normal;font-weight:var(--pico-font-weight)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family)}h1{--pico-color:var(--pico-h1-color)}h2{--pico-color:var(--pico-h2-color)}h3{--pico-color:var(--pico-h3-color)}h4{--pico-color:var(--pico-h4-color)}h5{--pico-color:var(--pico-h5-color)}h6{--pico-color:var(--pico-h6-color)}:where(article,address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--pico-typography-spacing-top)}p{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup>*{margin-top:0;margin-bottom:0}hgroup>:not(:first-child):last-child{--pico-color:var(--pico-muted-color);--pico-font-weight:unset;font-size:1rem}:where(ol,ul) li{margin-bottom:calc(var(--pico-typography-spacing-vertical) * .25)}:where(dl,ol,ul) :where(dl,ol,ul){margin:0;margin-top:calc(var(--pico-typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--pico-mark-background-color);color:var(--pico-mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--pico-typography-spacing-vertical) 0;padding:var(--pico-spacing);border-right:none;border-left:.25rem solid var(--pico-blockquote-border-color);border-inline-start:0.25rem solid var(--pico-blockquote-border-color);border-inline-end:none}blockquote footer{margin-top:calc(var(--pico-typography-spacing-vertical) * .5);color:var(--pico-blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--pico-ins-color);text-decoration:none}del{color:var(--pico-del-color)}::-moz-selection{background-color:var(--pico-text-selection-color)}::selection{background-color:var(--pico-text-selection-color)}:where(a:not([role=button])),[role=link]{--pico-color:var(--pico-primary);--pico-background-color:transparent;--pico-underline:var(--pico-primary-underline);outline:0;background-color:var(--pico-background-color);color:var(--pico-color);-webkit-text-decoration:var(--pico-text-decoration);text-decoration:var(--pico-text-decoration);text-decoration-color:var(--pico-underline);text-underline-offset:0.125em;transition:background-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition)}:where(a:not([role=button])):is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-primary-hover);--pico-underline:var(--pico-primary-hover-underline);--pico-text-decoration:underline}:where(a:not([role=button])):focus-visible,[role=link]:focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}:where(a:not([role=button])).secondary,[role=link].secondary{--pico-color:var(--pico-secondary);--pico-underline:var(--pico-secondary-underline)}:where(a:not([role=button])).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-underline:var(--pico-secondary-hover-underline)}:where(a:not([role=button])).contrast,[role=link].contrast{--pico-color:var(--pico-contrast);--pico-underline:var(--pico-contrast-underline)}:where(a:not([role=button])).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-underline:var(--pico-contrast-hover-underline)}a[role=button]{display:inline-block}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[role=button],[type=button],[type=file]::file-selector-button,[type=reset],[type=submit],button{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);--pico-color:var(--pico-primary-inverse);--pico-box-shadow:var(--pico-button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:1rem;line-height:var(--pico-line-height);text-align:center;text-decoration:none;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}[role=button]:is(:hover,:active,:focus),[role=button]:is([aria-current]:not([aria-current=false])),[type=button]:is(:hover,:active,:focus),[type=button]:is([aria-current]:not([aria-current=false])),[type=file]::file-selector-button:is(:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])),[type=reset]:is(:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false])),[type=submit]:is(:hover,:active,:focus),[type=submit]:is([aria-current]:not([aria-current=false])),button:is(:hover,:active,:focus),button:is([aria-current]:not([aria-current=false])){--pico-background-color:var(--pico-primary-hover-background);--pico-border-color:var(--pico-primary-hover-border);--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--pico-color:var(--pico-primary-inverse)}[role=button]:focus,[role=button]:is([aria-current]:not([aria-current=false])):focus,[type=button]:focus,[type=button]:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus,[type=submit]:focus,[type=submit]:is([aria-current]:not([aria-current=false])):focus,button:focus,button:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}[type=button],[type=reset],[type=submit]{margin-bottom:var(--pico-spacing)}:is(button,[type=submit],[type=button],[role=button]).secondary,[type=file]::file-selector-button,[type=reset]{--pico-background-color:var(--pico-secondary-background);--pico-border-color:var(--pico-secondary-border);--pico-color:var(--pico-secondary-inverse);cursor:pointer}:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border);--pico-color:var(--pico-secondary-inverse)}:is(button,[type=submit],[type=button],[role=button]).secondary:focus,:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}:is(button,[type=submit],[type=button],[role=button]).contrast{--pico-background-color:var(--pico-contrast-background);--pico-border-color:var(--pico-contrast-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-contrast-hover-background);--pico-border-color:var(--pico-contrast-hover-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:focus,:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}:is(button,[type=submit],[type=button],[role=button]).outline,[type=reset].outline{--pico-background-color:transparent;--pico-color:var(--pico-primary);--pico-border-color:var(--pico-primary)}:is(button,[type=submit],[type=button],[role=button]).outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:transparent;--pico-color:var(--pico-primary-hover);--pico-border-color:var(--pico-primary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary,[type=reset].outline{--pico-color:var(--pico-secondary);--pico-border-color:var(--pico-secondary)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-border-color:var(--pico-secondary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast{--pico-color:var(--pico-contrast);--pico-border-color:var(--pico-contrast)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-border-color:var(--pico-contrast-hover)}:where(button,[type=submit],[type=reset],[type=button],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]){opacity:.5;pointer-events:none}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--pico-spacing)/ 2) var(--pico-spacing);border-bottom:var(--pico-border-width) solid var(--pico-table-border-color);background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--pico-border-width) solid var(--pico-table-border-color);border-bottom:0}table.striped tbody tr:nth-child(odd) td,table.striped tbody tr:nth-child(odd) th{background-color:var(--pico-table-row-stripped-background-color)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-size:.875em;font-family:var(--pico-font-family)}pre code{font-size:inherit;font-family:inherit}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre{border-radius:var(--pico-border-radius);background:var(--pico-code-background-color);color:var(--pico-code-color);font-weight:var(--pico-font-weight);line-height:initial}code,kbd{display:inline-block;padding:.375rem}pre{display:block;margin-bottom:var(--pico-spacing);overflow-x:auto}pre>code{display:block;padding:var(--pico-spacing);background:0 0;line-height:var(--pico-line-height)}kbd{background-color:var(--pico-code-kbd-background-color);color:var(--pico-code-kbd-color);vertical-align:baseline}figure{display:block;margin:0;padding:0}figure figcaption{padding:calc(var(--pico-spacing) * .5) 0;color:var(--pico-muted-color)}hr{height:0;margin:var(--pico-typography-spacing-vertical) 0;border:0;border-top:1px solid var(--pico-muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--pico-line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox],[type=radio],[type=range]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2)}fieldset{width:100%;margin:0;margin-bottom:var(--pico-spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--pico-spacing) * .375);color:var(--pico-color);font-weight:var(--pico-form-label-font-weight,var(--pico-font-weight))}fieldset legend{margin-bottom:calc(var(--pico-spacing) * .5)}button[type=submit],input:not([type=checkbox],[type=radio]),select,textarea{width:100%}input:not([type=checkbox],[type=radio],[type=range],[type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal)}input,select,textarea{--pico-background-color:var(--pico-form-element-background-color);--pico-border-color:var(--pico-form-element-border-color);--pico-color:var(--pico-form-element-color);--pico-box-shadow:none;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[readonly]):is(:active,:focus){--pico-background-color:var(--pico-form-element-active-background-color)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[role=switch],[readonly]):is(:active,:focus){--pico-border-color:var(--pico-form-element-active-border-color)}:where(select,textarea):not([readonly]):focus,input:not([type=submit],[type=button],[type=reset],[type=range],[type=file],[readonly]):focus{--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit],[type=button],[type=reset]),select,textarea),input:not([type=submit],[type=button],[type=reset])[disabled],label[aria-disabled=true],select[disabled],textarea[disabled]{opacity:var(--pico-form-element-disabled-opacity);pointer-events:none}label[aria-disabled=true] input[disabled]{opacity:1}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid]{padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal)!important;padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=false]:not(select){background-image:var(--pico-icon-valid)}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=true]:not(select){background-image:var(--pico-icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--pico-border-color:var(--pico-form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--pico-border-color:var(--pico-form-element-valid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--pico-border-color:var(--pico-form-element-invalid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox],[type=radio]):is([aria-invalid],[aria-invalid=true],[aria-invalid=false]){background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--pico-form-element-placeholder-color);opacity:1}input:not([type=checkbox],[type=radio]),select,textarea{margin-bottom:var(--pico-spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple],[size]){padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal);padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);background-image:var(--pico-icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}select[multiple] option:checked{background:var(--pico-form-element-selected-background-color);color:var(--pico-form-element-color)}[dir=rtl] select:not([multiple],[size]){background-position:center left .75rem}textarea{display:block;resize:vertical}textarea[aria-invalid]{--pico-icon-height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);background-position:top right .75rem!important;background-size:1rem var(--pico-icon-height)!important}:where(input,select,textarea,fieldset,.grid)+small{display:block;width:100%;margin-top:calc(var(--pico-spacing) * -.75);margin-bottom:var(--pico-spacing);color:var(--pico-muted-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=false]+small{color:var(--pico-ins-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=true]+small{color:var(--pico-del-color)}label>:where(input,select,textarea){margin-top:calc(var(--pico-spacing) * .25)}label:has([type=checkbox],[type=radio]){width:-moz-fit-content;width:fit-content;cursor:pointer}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-inline-end:.5em;border-width:var(--pico-border-width);vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-bottom:0;cursor:pointer}[type=checkbox]~label:not(:last-of-type),[type=radio]~label:not(:last-of-type){margin-inline-end:1em}[type=checkbox]:indeterminate{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--pico-background-color:var(--pico-switch-background-color);--pico-color:var(--pico-switch-color);width:2.25em;height:1.25em;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:1.25em;background-color:var(--pico-background-color);line-height:1.25em}[type=checkbox][role=switch]:not([aria-invalid]){--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:before{display:block;aspect-ratio:1;height:100%;border-radius:50%;background-color:var(--pico-color);box-shadow:var(--pico-switch-thumb-box-shadow);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:focus{--pico-background-color:var(--pico-switch-background-color);--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:checked{--pico-background-color:var(--pico-switch-checked-background-color);--pico-border-color:var(--pico-switch-checked-background-color);background-image:none}[type=checkbox][role=switch]:checked::before{margin-inline-start:calc(2.25em - 1.25em)}[type=checkbox][role=switch][disabled]{--pico-background-color:var(--pico-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus{--pico-background-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true]{--pico-background-color:var(--pico-form-element-invalid-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus,[type=radio][aria-invalid=false]:checked,[type=radio][aria-invalid=false]:checked:active,[type=radio][aria-invalid=false]:checked:focus{--pico-border-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=radio]:checked:active[aria-invalid=true],[type=radio]:checked:focus[aria-invalid=true],[type=radio]:checked[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}input:not([type=checkbox],[type=radio],[type=range],[type=file]):is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){--pico-icon-position:0.75rem;--pico-icon-width:1rem;padding-right:calc(var(--pico-icon-width) + var(--pico-icon-position));background-image:var(--pico-icon-date);background-position:center right var(--pico-icon-position);background-size:var(--pico-icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=time]{background-image:var(--pico-icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--pico-icon-width);margin-right:calc(var(--pico-icon-width) * -1);margin-left:var(--pico-icon-position);opacity:0}@-moz-document url-prefix(){[type=date],[type=datetime-local],[type=month],[type=time],[type=week]{padding-right:var(--pico-form-element-spacing-horizontal)!important;background-image:none!important}}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--pico-color:var(--pico-muted-color);margin-left:calc(var(--pico-outline-width) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) 0;padding-left:var(--pico-outline-width);border:0;border-radius:0;background:0 0}[type=file]::file-selector-button{margin-right:calc(var(--pico-spacing)/ 2);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal)}[type=file]:is(:hover,:active,:focus)::file-selector-button{--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border)}[type=file]:focus::file-selector-button{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-webkit-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-moz-range-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-moz-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-ms-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-ms-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-moz-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-ms-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]:active,[type=range]:focus-within{--pico-range-border-color:var(--pico-range-active-border-color);--pico-range-thumb-color:var(--pico-range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem);background-image:var(--pico-icon-search);background-position:center left calc(var(--pico-form-element-spacing-horizontal) + .125rem);background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=false]{background-image:var(--pico-icon-search),var(--pico-icon-valid)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=true]{background-image:var(--pico-icon-search),var(--pico-icon-invalid)}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}details{display:block;margin-bottom:var(--pico-spacing)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--pico-transition)}details summary:not([role]){color:var(--pico-accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;margin-inline-start:calc(var(--pico-spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--pico-transition)}details summary:focus{outline:0}details summary:focus:not([role]){color:var(--pico-accordion-active-summary-color)}details summary:focus-visible:not([role]){outline:var(--pico-outline-width) solid var(--pico-primary-focus);outline-offset:calc(var(--pico-spacing,1rem) * 0.5);color:var(--pico-primary)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--pico-line-height,1.5))}details[open]>summary{margin-bottom:var(--pico-spacing)}details[open]>summary:not([role]):not(:focus){color:var(--pico-accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin-bottom:var(--pico-block-spacing-vertical);padding:var(--pico-block-spacing-vertical) var(--pico-block-spacing-horizontal);border-radius:var(--pico-border-radius);background:var(--pico-card-background-color);box-shadow:var(--pico-card-box-shadow)}article>footer,article>header{margin-right:calc(var(--pico-block-spacing-horizontal) * -1);margin-left:calc(var(--pico-block-spacing-horizontal) * -1);padding:calc(var(--pico-block-spacing-vertical) * .66) var(--pico-block-spacing-horizontal);background-color:var(--pico-card-sectioning-background-color)}article>header{margin-top:calc(var(--pico-block-spacing-vertical) * -1);margin-bottom:var(--pico-block-spacing-vertical);border-bottom:var(--pico-border-width) solid var(--pico-card-border-color);border-top-right-radius:var(--pico-border-radius);border-top-left-radius:var(--pico-border-radius)}article>footer{margin-top:var(--pico-block-spacing-vertical);margin-bottom:calc(var(--pico-block-spacing-vertical) * -1);border-top:var(--pico-border-width) solid var(--pico-card-border-color);border-bottom-right-radius:var(--pico-border-radius);border-bottom-left-radius:var(--pico-border-radius)}details.dropdown{position:relative;border-bottom:none}details.dropdown summary::after,details.dropdown>a::after,details.dropdown>button::after{display:block;width:1rem;height:calc(1rem * var(--pico-line-height,1.5));margin-inline-start:.25rem;float:right;transform:rotate(0) translateX(.2rem);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}nav details.dropdown{margin-bottom:0}details.dropdown summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-form-element-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-form-element-background-color);color:var(--pico-form-element-placeholder-color);line-height:inherit;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}details.dropdown summary:not([role]):active,details.dropdown summary:not([role]):focus{border-color:var(--pico-form-element-active-border-color);background-color:var(--pico-form-element-active-background-color)}details.dropdown summary:not([role]):focus{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}details.dropdown summary:not([role]):focus-visible{outline:0}details.dropdown summary:not([role])[aria-invalid=false]{--pico-form-element-border-color:var(--pico-form-element-valid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-valid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-valid-focus-color)}details.dropdown summary:not([role])[aria-invalid=true]{--pico-form-element-border-color:var(--pico-form-element-invalid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-invalid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-invalid-focus-color)}nav details.dropdown{display:inline;margin:calc(var(--pico-nav-element-spacing-vertical) * -1) 0}nav details.dropdown summary::after{transform:rotate(0) translateX(0)}nav details.dropdown summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-nav-link-spacing-vertical) * 2);padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav details.dropdown summary:not([role]):focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}details.dropdown summary+ul{display:flex;z-index:99;position:absolute;left:0;flex-direction:column;width:100%;min-width:-moz-fit-content;min-width:fit-content;margin:0;margin-top:var(--pico-outline-width);padding:0;border:var(--pico-border-width) solid var(--pico-dropdown-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-dropdown-background-color);box-shadow:var(--pico-dropdown-box-shadow);color:var(--pico-dropdown-color);white-space:nowrap;opacity:0;transition:opacity var(--pico-transition),transform 0s ease-in-out 1s}details.dropdown summary+ul[dir=rtl]{right:0;left:auto}details.dropdown summary+ul li{width:100%;margin-bottom:0;padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);list-style:none}details.dropdown summary+ul li:first-of-type{margin-top:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown summary+ul li:last-of-type{margin-bottom:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown summary+ul li a{display:block;margin:calc(var(--pico-form-element-spacing-vertical) * -.5) calc(var(--pico-form-element-spacing-horizontal) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);overflow:hidden;border-radius:0;color:var(--pico-dropdown-color);text-decoration:none;text-overflow:ellipsis}details.dropdown summary+ul li a:active,details.dropdown summary+ul li a:focus,details.dropdown summary+ul li a:focus-visible,details.dropdown summary+ul li a:hover,details.dropdown summary+ul li a[aria-current]:not([aria-current=false]){background-color:var(--pico-dropdown-hover-background-color)}details.dropdown summary+ul li label{width:100%}details.dropdown summary+ul li:has(label):hover{background-color:var(--pico-dropdown-hover-background-color)}details.dropdown[open] summary{margin-bottom:0}details.dropdown[open] summary+ul{transform:scaleY(1);opacity:1;transition:opacity var(--pico-transition),transform 0s ease-in-out 0s}details.dropdown[open] summary::before{display:block;z-index:1;position:fixed;width:100vw;height:100vh;inset:0;background:0 0;content:"";cursor:default}label>details.dropdown{margin-top:calc(var(--pico-spacing) * .25)}[role=group],[role=search]{display:inline-flex;position:relative;width:100%;margin-bottom:var(--pico-spacing);border-radius:var(--pico-border-radius);box-shadow:var(--pico-group-box-shadow,0 0 0 transparent);vertical-align:middle;transition:box-shadow var(--pico-transition)}[role=group] input:not([type=checkbox],[type=radio]),[role=group] select,[role=group]>*,[role=search] input:not([type=checkbox],[type=radio]),[role=search] select,[role=search]>*{position:relative;flex:1 1 auto;margin-bottom:0}[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=group]>:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child),[role=search]>:not(:first-child){margin-left:0;border-top-left-radius:0;border-bottom-left-radius:0}[role=group] input:not([type=checkbox],[type=radio]):not(:last-child),[role=group] select:not(:last-child),[role=group]>:not(:last-child),[role=search] input:not([type=checkbox],[type=radio]):not(:last-child),[role=search] select:not(:last-child),[role=search]>:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}[role=group] input:not([type=checkbox],[type=radio]):focus,[role=group] select:focus,[role=group]>:focus,[role=search] input:not([type=checkbox],[type=radio]):focus,[role=search] select:focus,[role=search]>:focus{z-index:2}[role=group] [role=button]:not(:first-child),[role=group] [type=button]:not(:first-child),[role=group] [type=reset]:not(:first-child),[role=group] [type=submit]:not(:first-child),[role=group] button:not(:first-child),[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=search] [role=button]:not(:first-child),[role=search] [type=button]:not(:first-child),[role=search] [type=reset]:not(:first-child),[role=search] [type=submit]:not(:first-child),[role=search] button:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child){margin-left:calc(var(--pico-border-width) * -1)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=reset],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=reset],[role=search] [type=submit],[role=search] button{width:auto}@supports selector(:has(*)){[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-button)}[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select,[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select{border-color:transparent}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus),[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-input)}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) button,[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) button{--pico-button-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-border);--pico-button-hover-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-hover-border)}[role=group] [role=button]:focus,[role=group] [type=button]:focus,[role=group] [type=reset]:focus,[role=group] [type=submit]:focus,[role=group] button:focus,[role=search] [role=button]:focus,[role=search] [type=button]:focus,[role=search] [type=reset]:focus,[role=search] [type=submit]:focus,[role=search] button:focus{box-shadow:none}}[role=search]>:first-child{border-top-left-radius:5rem;border-bottom-left-radius:5rem}[role=search]>:last-child{border-top-right-radius:5rem;border-bottom-right-radius:5rem}[aria-busy=true]:not(input,select,textarea,html){white-space:nowrap}[aria-busy=true]:not(input,select,textarea,html)::before{display:inline-block;width:1em;height:1em;background-image:var(--pico-icon-loading);background-size:1em auto;background-repeat:no-repeat;content:"";vertical-align:-.125em}[aria-busy=true]:not(input,select,textarea,html):not(:empty)::before{margin-inline-end:calc(var(--pico-spacing) * .5)}[aria-busy=true]:not(input,select,textarea,html):empty{text-align:center}[role=button][aria-busy=true],[type=button][aria-busy=true],[type=reset][aria-busy=true],[type=submit][aria-busy=true],a[aria-busy=true],button[aria-busy=true]{pointer-events:none}:root{--pico-scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:0;border:0;-webkit-backdrop-filter:var(--pico-modal-overlay-backdrop-filter);backdrop-filter:var(--pico-modal-overlay-backdrop-filter);background-color:var(--pico-modal-overlay-background-color);color:var(--pico-color)}dialog article{width:100%;max-height:calc(100vh - var(--pico-spacing) * 2);margin:var(--pico-spacing);overflow:auto}@media (min-width:576px){dialog article{max-width:510px}}@media (min-width:768px){dialog article{max-width:700px}}dialog article>header>*{margin-bottom:0}dialog article>header .close,dialog article>header :is(a,button)[rel=prev]{margin:0;margin-left:var(--pico-spacing);padding:0;float:right}dialog article>footer{text-align:right}dialog article>footer [role=button],dialog article>footer button{margin-bottom:0}dialog article>footer [role=button]:not(:first-of-type),dialog article>footer button:not(:first-of-type){margin-left:calc(var(--pico-spacing) * .5)}dialog article .close,dialog article :is(a,button)[rel=prev]{display:block;width:1rem;height:1rem;margin-top:calc(var(--pico-spacing) * -1);margin-bottom:var(--pico-spacing);margin-left:auto;border:none;background-image:var(--pico-icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;background-color:transparent;opacity:.5;transition:opacity var(--pico-transition)}dialog article .close:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),dialog article :is(a,button)[rel=prev]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--pico-scrollbar-width,0);overflow:hidden;pointer-events:none;touch-action:none}.modal-is-open dialog{pointer-events:auto;touch-action:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-duration:.2s;animation-timing-function:ease-in-out;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{animation-duration:.8s;animation-name:modal-overlay}:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-delay:.2s;animation-name:modal}.modal-is-closing dialog,.modal-is-closing dialog>article{animation-delay:0s;animation-direction:reverse}@keyframes modal-overlay{from{-webkit-backdrop-filter:none;backdrop-filter:none;background-color:transparent}}@keyframes modal{from{transform:translateY(-100%);opacity:0}}:where(nav li)::before{float:left;content:"​"}nav,nav ul{display:flex}nav{justify-content:space-between;overflow:visible}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--pico-nav-element-spacing-vertical) var(--pico-nav-element-spacing-horizontal)}nav li :where(a,[role=link]){display:inline-block;margin:calc(var(--pico-nav-link-spacing-vertical) * -1) calc(var(--pico-nav-link-spacing-horizontal) * -1);padding:var(--pico-nav-link-spacing-vertical) var(--pico-nav-link-spacing-horizontal);border-radius:var(--pico-border-radius)}nav li :where(a,[role=link]):not(:hover){text-decoration:none}nav li [role=button],nav li [type=button],nav li button,nav li input:not([type=checkbox],[type=radio],[type=range],[type=file]),nav li select{height:auto;margin-right:inherit;margin-bottom:0;margin-left:inherit;padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb]{align-items:center;justify-content:start}nav[aria-label=breadcrumb] ul li:not(:first-child){margin-inline-start:var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb] ul li a{margin:calc(var(--pico-nav-link-spacing-vertical) * -1) 0;margin-inline-start:calc(var(--pico-nav-link-spacing-horizontal) * -1)}nav[aria-label=breadcrumb] ul li:not(:last-child)::after{display:inline-block;position:absolute;width:calc(var(--pico-nav-link-spacing-horizontal) * 4);margin:0 calc(var(--pico-nav-link-spacing-horizontal) * -1);content:var(--pico-nav-breadcrumb-divider);color:var(--pico-muted-color);text-align:center;text-decoration:none;white-space:nowrap}nav[aria-label=breadcrumb] a[aria-current]:not([aria-current=false]){background-color:transparent;color:inherit;text-decoration:none;pointer-events:none}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--pico-nav-element-spacing-vertical) * .5) var(--pico-nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}[dir=rtl] nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{content:"\\"}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--pico-spacing) * .5);overflow:hidden;border:0;border-radius:var(--pico-border-radius);background-color:var(--pico-progress-background-color);color:var(--pico-progress-color)}progress::-webkit-progress-bar{border-radius:var(--pico-border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--pico-progress-color);-webkit-transition:inline-size var(--pico-transition);transition:inline-size var(--pico-transition)}progress::-moz-progress-bar{background-color:var(--pico-progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--pico-progress-background-color) linear-gradient(to right,var(--pico-progress-color) 30%,var(--pico-progress-background-color) 30%) top left/150% 150% no-repeat;animation:progress-indeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}[data-tooltip]{position:relative}[data-tooltip]:not(a,button,input){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before,[data-tooltip][data-placement=top]::after,[data-tooltip][data-placement=top]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--pico-border-radius);background:var(--pico-tooltip-background-color);content:attr(data-tooltip);color:var(--pico-tooltip-color);font-style:normal;font-weight:var(--pico-font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after,[data-tooltip][data-placement=top]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--pico-tooltip-background-color)}[data-tooltip][data-placement=bottom]::after,[data-tooltip][data-placement=bottom]::before{top:100%;bottom:auto;transform:translate(-50%,.25rem)}[data-tooltip][data-placement=bottom]:after{transform:translate(-50%,-.3rem);border:.3rem solid transparent;border-bottom:.3rem solid}[data-tooltip][data-placement=left]::after,[data-tooltip][data-placement=left]::before{top:50%;right:100%;bottom:auto;left:auto;transform:translate(-.25rem,-50%)}[data-tooltip][data-placement=left]:after{transform:translate(.3rem,-50%);border:.3rem solid transparent;border-left:.3rem solid}[data-tooltip][data-placement=right]::after,[data-tooltip][data-placement=right]::before{top:50%;right:auto;bottom:auto;left:100%;transform:translate(.25rem,-50%)}[data-tooltip][data-placement=right]:after{transform:translate(-.3rem,-50%);border:.3rem solid transparent;border-right:.3rem solid}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{--pico-tooltip-slide-to:translate(-50%, -0.25rem);transform:translate(-50%,.75rem);animation-duration:.2s;animation-fill-mode:forwards;animation-name:tooltip-slide;opacity:0}[data-tooltip]:focus::after,[data-tooltip]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, 0rem);transform:translate(-50%,-.25rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover::after,[data-tooltip][data-placement=bottom]:hover::before{--pico-tooltip-slide-to:translate(-50%, 0.25rem);transform:translate(-50%,-.75rem);animation-name:tooltip-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, -0.3rem);transform:translate(-50%,-.5rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:focus::before,[data-tooltip][data-placement=left]:hover::after,[data-tooltip][data-placement=left]:hover::before{--pico-tooltip-slide-to:translate(-0.25rem, -50%);transform:translate(.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:hover::after{--pico-tooltip-caret-slide-to:translate(0.3rem, -50%);transform:translate(.05rem,-50%);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:focus::before,[data-tooltip][data-placement=right]:hover::after,[data-tooltip][data-placement=right]:hover::before{--pico-tooltip-slide-to:translate(0.25rem, -50%);transform:translate(-.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:hover::after{--pico-tooltip-caret-slide-to:translate(-0.3rem, -50%);transform:translate(-.05rem,-50%);animation-name:tooltip-caret-slide}}@keyframes tooltip-slide{to{transform:var(--pico-tooltip-slide-to);opacity:1}}@keyframes tooltip-caret-slide{50%{opacity:0}to{transform:var(--pico-tooltip-caret-slide-to);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}} \ No newline at end of file diff --git a/internal/server/assets/js/Sortable.min.js b/internal/server/assets/js/Sortable.min.js new file mode 100644 index 000000000..bb9953355 --- /dev/null +++ b/internal/server/assets/js/Sortable.min.js @@ -0,0 +1,2 @@ +/*! Sortable 1.15.2 - MIT | git://github.com/SortableJS/Sortable.git */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;tt.length)&&(e=t.length);for(var n=0,o=new Array(e);n"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&p(t,e)||o&&t===n)return t}while(t!==n&&(t=(i=t).host&&i!==document&&i.host.nodeType?i.host:i.parentNode))}var i;return null}var g,m=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(m," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(m," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function v(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function b(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function Bt(t){V&&V.parentNode[K]._isOutsideThisEl(t.target)}function Ft(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return Pt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==Ft.supportPointer&&"PointerEvent"in window&&!u,emptyInsertThreshold:5};for(n in W.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in kt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&Nt,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),Dt.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,x())}function jt(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Ht(t){t.draggable=!1}function Lt(){Tt=!1}function Kt(t){return setTimeout(t,0)}function Wt(t){return clearTimeout(t)}Ft.prototype={constructor:Ft,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(mt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,V):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){xt.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&xt.push(o)}}(o),!V&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||tt===l)){if(ot=j(l),rt=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return q({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),G("filter",n,{evt:e}),void(i&&e.cancelable&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return q({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),G("filter",n,{evt:e}),!0}))return void(i&&e.cancelable&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!V&&n.parentNode===r&&(o=X(n),Q=r,Z=(V=n).parentNode,J=V.nextSibling,tt=n,lt=a.group,ct={target:Ft.dragged=V,clientX:(e||t).clientX,clientY:(e||t).clientY},ft=ct.clientX-o.left,pt=ct.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,V.style["will-change"]="all",o=function(){G("delayEnded",i,{evt:t}),Ft.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(V.draggable=!0),i._triggerDragStart(t,e),q({sortable:i,name:"choose",originalEvent:t}),k(V,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){b(V,t.trim(),Ht)}),h(l,"dragover",Yt),h(l,"mousemove",Yt),h(l,"touchmove",Yt),h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,V.draggable=!0),G("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():Ft.eventCanceled?this._onDrop():(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){V&&Ht(V),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;f(t,"mouseup",this._disableDelayedDrag),f(t,"touchend",this._disableDelayedDrag),f(t,"touchcancel",this._disableDelayedDrag),f(t,"mousemove",this._delayedDragTouchMoveHandler),f(t,"touchmove",this._delayedDragTouchMoveHandler),f(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(V,"dragend",this),h(Q,"dragstart",this._onDragStart));try{document.selection?Kt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;wt=!1,Q&&V?(G("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Bt),n=this.options,t||k(V,n.dragClass,!1),k(V,n.ghostClass,!0),Ft.active=this,t&&this._appendGhost(),q({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(ut){this._lastX=ut.clientX,this._lastY=ut.clientY,Rt();for(var t=document.elementFromPoint(ut.clientX,ut.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(ut.clientX,ut.clientY))!==e;)e=t;if(V.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:ut.clientX,clientY:ut.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=(t=e).parentNode);Xt()}},_onTouchMove:function(t){if(ct){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=$&&v($,!0),a=$&&r&&r.a,l=$&&r&&r.d,e=Mt&&yt&&E(yt),a=(i.clientX-ct.clientX+o.x)/(a||1)+(e?e[0]-Ct[0]:0)/(a||1),l=(i.clientY-ct.clientY+o.y)/(l||1)+(e?e[1]-Ct[1]:0)/(l||1);if(!Ft.active&&!wt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))D.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>D.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,$),e?t.clientX<_.left-10||t.clientY { + button.addEventListener('click', function () { + const day = this.dataset.day; + const timeRangesDiv = this.previousElementSibling; + if (timeRangesDiv) { + const newTimeRange = document.createElement('div'); + newTimeRange.className = 'time-range'; + newTimeRange.innerHTML = ` + + to + + + `; + timeRangesDiv.appendChild(newTimeRange); + } + }); + }); + + document.addEventListener('click', function (e) { + if (e.target.closest('.remove-time-range')) { + e.target.closest('.time-range').remove(); + } + }); + + document.getElementById('tzTrackAll').addEventListener('change', function (e) { + document.querySelectorAll('.tzTrackCheckbox').forEach(checkbox => { + checkbox.checked = e.target.checked; + }); + }); +}); + +function handleBossStaticThresholdChange() { + const input = document.getElementById('novaBossStaticThreshold'); + const selectedDifficulty = document.getElementById('gameDifficulty').value; + let minValue; + switch(selectedDifficulty) { + case 'normal': + minValue = 1; + break; + case 'nightmare': + minValue = 33; + break; + case 'hell': + minValue = 50; + break; + default: + minValue = 65; + } + + let value = parseInt(input.value); + if (isNaN(value) || value < minValue) { + value = minValue; + } else if (value > 100) { + value = 100; + } + input.value = value; +} \ No newline at end of file diff --git a/internal/server/assets/js/dashboard.js b/internal/server/assets/js/dashboard.js new file mode 100644 index 000000000..b1d0845fb --- /dev/null +++ b/internal/server/assets/js/dashboard.js @@ -0,0 +1,670 @@ +let socket; + let reconnectAttempts = 0; + const maxReconnectAttempts = 5; + const reconnectDelay = 3000; + + function connectWebSocket() { + socket = new WebSocket('ws://' + window.location.host + '/ws'); + + socket.onopen = function() { + console.log('WebSocket connected'); + reconnectAttempts = 0; + }; + + socket.onmessage = function(event) { + const data = JSON.parse(event.data); + updateDashboard(data); + }; + + socket.onclose = function() { + console.log('WebSocket disconnected'); + if (reconnectAttempts < maxReconnectAttempts) { + setTimeout(connectWebSocket, reconnectDelay); + reconnectAttempts++; + } else { + console.error('Max reconnect attempts reached'); + } + }; + } + + function fetchInitialData() { + fetch('/initial-data') + .then(response => response.json()) + .then(data => { + updateDashboard(data); + document.getElementById('loading').style.display = 'none'; + document.getElementById('dashboard').style.display = 'block'; + }) + .catch(error => console.error('Error fetching initial data:', error)); + } + + function updateDashboard(data) { + const versionElement = document.getElementById('version'); + if (versionElement) { + versionElement.textContent = data.Version; + if (data.Version === "dev") { + versionElement.textContent = "Development Version"; + versionElement.style.backgroundColor = "#dc3545"; + } + } + + const container = document.getElementById('characters-container'); + if (!container) return; + + if (Object.keys(data.Status).length === 0) { + container.innerHTML = '

No characters found, start adding a new character.

'; + return; + } + + for (const [key, value] of Object.entries(data.Status)) { + let card = document.getElementById(`card-${key}`); + if (!card) { + card = createCharacterCard(key); + container.appendChild(card); + } + updateCharacterCard(card, key, value, data.DropCount[key]); + } + + // Remove cards for characters that no longer exist + Array.from(container.children).forEach(card => { + if (!data.Status.hasOwnProperty(card.id.replace('card-', ''))) { + container.removeChild(card); + } + }); + } + + + function createCharacterCard(key) { + const card = document.createElement('div'); + card.className = 'character-card'; + card.id = `card-${key}`; + + card.innerHTML = ` +
+
+ ${key} +
+
+
+ + + + + + +
+
+
+
+ +
+
+
+
Games
+
0
+
+
+
Drops
+
None
+
+
+
Chickens
+
0
+
+
+
Deaths
+
0
+
+
+
Errors
+
0
+
+
+
+
+ `; + + setupEventListeners(card, key); + return card; + } + + + function setupEventListeners(card, key) { + if (!card) return; + + const toggleDetailsBtn = card.querySelector('.toggle-details'); + const startPauseBtn = card.querySelector('.start-pause'); + const stopBtn = card.querySelector('.stop'); + + if (toggleDetailsBtn) { + toggleDetailsBtn.addEventListener('click', function() { + card.classList.toggle('expanded'); + this.querySelector('i').style.transform = card.classList.contains('expanded') ? 'rotate(180deg)' : 'rotate(0deg)'; + saveExpandedState(); + }); + } + + if (startPauseBtn) { + startPauseBtn.addEventListener('click', function() { + const currentStatus = this.className.includes('btn-start') ? 'Not Started' : + this.className.includes('btn-pause') ? 'In game' : + 'Paused'; + let action; + if (currentStatus === 'Not Started') { + action = 'start'; + } else if (currentStatus === 'In game') { + action = 'togglePause'; + } else { // Paused + action = 'togglePause'; + } + fetch(`/${action}?characterName=${key}`) + .then(response => response.json()) + .then(data => { + updateDashboard(data); + }) + .catch(error => console.error('Error:', error)); + }); + } + if (stopBtn) { + stopBtn.addEventListener('click', function() { + fetch(`/stop?characterName=${key}`).then(() => fetchInitialData()); + }); + } + } + + + function updateStatusPosition(card, isExpanded) { + if (!card) return; + + const statusBadge = card.querySelector('.status-badge'); + const headerStatusContainer = card.querySelector('.character-name-status'); + const detailsStatusContainer = card.querySelector('.status-details'); + + if (!statusBadge || !headerStatusContainer || !detailsStatusContainer) return; + + if (isExpanded) { + detailsStatusContainer.insertBefore(statusBadge, detailsStatusContainer.firstChild); + } else { + headerStatusContainer.appendChild(statusBadge); + } + } + + function updateCharacterCard(card, key, value, dropCount) { + if (!card) return; + + const startPauseBtn = card.querySelector('.start-pause'); + const stopBtn = card.querySelector('.stop'); + const attachBtn = card.querySelector('.attach-btn'); + const statusDetails = card.querySelector('.status-details'); + const statusBadge = statusDetails.querySelector('.status-badge'); + const statusIndicator = card.querySelector('.status-indicator'); + + if (statusBadge && statusDetails) { + updateStatus(statusBadge, statusDetails, value.SupervisorStatus); + } + + if (statusIndicator) { + updateStatusIndicator(statusIndicator, value.SupervisorStatus); + } + + if (startPauseBtn && stopBtn && attachBtn) { + updateButtons(startPauseBtn, stopBtn, attachBtn, value.SupervisorStatus); + } + + + + updateStats(card, key, value.Games, dropCount); + updateRunStats(card, value.Games); + + if (statusDetails) { + updateStartedTime(statusDetails, value.StartedAt); + } + } + + function updateStatusIndicator(statusIndicator, status) { + statusIndicator.classList.remove('in-game', 'paused', 'stopped'); + if (status === "In game") { + statusIndicator.classList.add('in-game'); + } else if (status === "Starting") { + statusIndicator.classList.add('paused'); + } else if (status === "Paused") { + statusIndicator.classList.add('paused'); + } else { + statusIndicator.classList.add('stopped'); + } + } + + function updateStatus(statusBadge, statusDetails, status) { + if (!statusBadge || !statusDetails) return; + + const statusText = status || 'Not started'; + statusBadge.innerHTML = `Status: ${statusText}`; + statusBadge.className = `status-badge status-${statusText.toLowerCase().replace(' ', '')}`; + } + + function updateStartedTime(statusDetails, startedAt) { + const startTime = new Date(startedAt); + const now = new Date(); + + let runningForElement = statusDetails.querySelector('.running-for'); + if (!runningForElement) { + runningForElement = document.createElement('div'); + runningForElement.className = 'running-for'; + statusDetails.appendChild(runningForElement); + } + + if (startTime.getFullYear() === 1) { + runningForElement.textContent = 'Running for: N/A'; + return; + } + + const timeDiff = now - startTime; + if (timeDiff < 0) { + runningForElement.textContent = 'Running for: N/A'; + return; + } + + const duration = formatDuration(timeDiff); + runningForElement.textContent = `Running for: ${duration}`; + } + +function updateButtons(startPauseBtn, stopBtn, attachBtn, status) { + if (status === "Paused") { + startPauseBtn.innerHTML = 'Resume'; + startPauseBtn.className = 'start-pause btn btn-resume'; + stopBtn.style.display = 'inline-block'; + attachBtn.style.display = 'none'; + } else if (status === "In game" || status === "Starting") { + startPauseBtn.innerHTML = 'Pause'; + startPauseBtn.className = 'start-pause btn btn-pause'; + stopBtn.style.display = 'inline-block'; + attachBtn.style.display = 'none'; + } else { + startPauseBtn.innerHTML = 'Start'; + startPauseBtn.className = 'start-pause btn btn-start'; + stopBtn.style.display = 'none'; + attachBtn.style.display = 'inline-block'; + } +} + + function updateStats(card, key, games, dropCount) { + const stats = calculateStats(games); + + card.querySelector('.runs').textContent = stats.totalGames; + card.querySelector('.drops').innerHTML = dropCount === undefined ? 'None' : + (dropCount === 0 ? 'None' : `${dropCount}`); + card.querySelector('.chickens').textContent = stats.totalChickens; + card.querySelector('.deaths').textContent = stats.totalDeaths; + card.querySelector('.errors').textContent = stats.totalErrors; + } + + + function updateRunStats(card, games) { + const runStats = calculateRunStats(games); + const runStatsElement = card.querySelector('.run-stats'); + runStatsElement.innerHTML = '

Run Statistics

'; + + if (Object.keys(runStats).length === 0) { + runStatsElement.innerHTML += '

No run data available yet.

'; + return; + } + + const runStatsGrid = document.createElement('div'); + runStatsGrid.className = 'run-stats-grid'; + + for (const [runName, stats] of Object.entries(runStats)) { + const runElement = document.createElement('div'); + runElement.className = 'run-stat'; + if (stats.isCurrentRun) { + runElement.classList.add('current-run'); + } + runElement.innerHTML = ` +

${runName}${stats.isCurrentRun ? ' Current' : ''}

+
+
+ Fastest: ${formatDuration(stats.shortestTime)} +
+
+ Slowest: ${formatDuration(stats.longestTime)} +
+
+ Average: ${formatDuration(stats.averageTime)} +
+
+ Total: ${stats.runCount} +
+
+ Errors: ${stats.errorCount} +
+
+ Chickens: ${stats.runChickens} +
+
+ Deaths: ${stats.runDeaths} +
+
+ `; + runStatsGrid.appendChild(runElement); + } + + runStatsElement.appendChild(runStatsGrid); + } + + + function calculateRunStats(games) { + if (!games || games.length === 0) { + return {}; + } + + const runStats = {}; + + games.forEach(game => { + if (game.Runs && Array.isArray(game.Runs)) { + game.Runs.forEach(run => { + if (!runStats[run.Name]) { + runStats[run.Name] = { + shortestTime: Infinity, + longestTime: 0, + totalTime: 0, + errorCount: 0, + runCount: 0, + runChickens: 0, + runDeaths: 0, + successfulRunCount: 0, + isCurrentRun: false + }; + } + + // Check if this is the current run + if (run.Reason === "") { + runStats[run.Name].isCurrentRun = true; + } + + const runTime = new Date(run.FinishedAt) - new Date(run.StartedAt); + if (run.FinishedAt !== "0001-01-01T00:00:00Z" && runTime > 0) { + runStats[run.Name].runCount++; + + if (run.Reason === 'ok') { + runStats[run.Name].shortestTime = Math.min(runStats[run.Name].shortestTime, runTime); + runStats[run.Name].longestTime = Math.max(runStats[run.Name].longestTime, runTime); + runStats[run.Name].totalTime += runTime; + runStats[run.Name].successfulRunCount++; + } + } + + if (run.Reason == 'error') { + runStats[run.Name].errorCount++; + } + + if (run.Reason == 'chicken') { + runStats[run.Name].runChickens++; + } + + if (run.Reason == 'death') { + runStats[run.Name].runDeaths++; + } + }); + } + }); + + // Calculate average time for successful runs + for (const stats of Object.values(runStats)) { + if (stats.successfulRunCount > 0) { + stats.averageTime = stats.totalTime / stats.successfulRunCount; + } else { + stats.shortestTime = 0; + stats.longestTime = 0; + stats.averageTime = 0; + } + } + + return runStats; + } + + function calculateStats(games) { + if (!games || games.length === 0) { + return { totalGames: 0, totalChickens: 0, totalDeaths: 0, totalErrors: 0 }; + } + + return games.reduce((acc, game) => { + acc.totalGames++; + if (game.Reason === 'chicken') acc.totalChickens++; + else if (game.Reason === 'death') acc.totalDeaths++; + else if (game.Reason === 'error') acc.totalErrors++; + return acc; + }, { totalGames: 0, totalChickens: 0, totalDeaths: 0, totalErrors: 0 }); + } + + function formatDuration(ms) { + if (!isFinite(ms) || ms < 0) { + return 'N/A'; + } + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days}d ${hours % 24}h`; + if (hours > 0) return `${hours}h ${minutes % 60}m`; + if (minutes > 0) return `${minutes}m ${seconds % 60}s`; + return `${seconds}s`; + } + + function saveExpandedState() { + const expandedCards = Array.from(document.querySelectorAll('.character-card.expanded')) + .map(card => card.id); + localStorage.setItem('expandedCards', JSON.stringify(expandedCards)); + } + + function restoreExpandedState() { + const expandedCards = JSON.parse(localStorage.getItem('expandedCards')) || []; + expandedCards.forEach(cardId => { + const card = document.getElementById(cardId); + if (card) { + card.classList.add('expanded'); + const toggleBtn = card.querySelector('.toggle-details i'); + if (toggleBtn) { + toggleBtn.style.transform = 'rotate(180deg)'; + } + } + }); + } + + function showAttachPopup(characterName) { + const popup = document.createElement('div'); + popup.className = 'attach-popup'; + popup.innerHTML = ` +

Attach to Process

+ + `; + + document.body.appendChild(popup); + + // Fetch and populate the process list + fetchProcessList(characterName); + } + + function fetchProcessList(characterName) { + fetch('/process-list') + .then(response => response.json()) + .then(processes => { + const popup = document.querySelector('.attach-popup'); + + if (!processes || processes.length === 0) { + popup.innerHTML = ` +

No D2R Processes Found

+

There are no Diablo II: Resurrected processes currently running.

+ + `; + } else { + popup.innerHTML = ` +

Attach to Process

+ + + + + + + + + + +
Window TitleProcess NamePID
+
+ Selected Process: + None +
+ + `; + + const tbody = document.getElementById('process-list-body'); + processes.forEach(process => { + const row = document.createElement('tr'); + row.innerHTML = ` + ${process.windowTitle} + ${process.processName} + ${process.pid} + `; + row.addEventListener('click', () => selectProcess(row, process.pid)); + tbody.appendChild(row); + }); + + // Add event listeners + document.getElementById('choose-process').addEventListener('click', () => chooseProcess(characterName)); + document.getElementById('cancel-attach').addEventListener('click', closeAttachPopup); + document.getElementById('process-search').addEventListener('input', filterProcessList); + } + }) + .catch(error => { + console.error('Error fetching process list:', error); + const popup = document.querySelector('.attach-popup'); + popup.innerHTML = ` +

Error

+

An error occurred while fetching the process list.

+ + `; + }); + } + + function selectProcess(row, pid) { + const allRows = document.querySelectorAll('#process-list-body tr'); + allRows.forEach(r => r.classList.remove('selected')); + row.classList.add('selected'); + document.getElementById('choose-process').disabled = false; + document.getElementById('choose-process').dataset.pid = pid; + document.getElementById('selected-pid').textContent = pid; + } + + function chooseProcess(characterName) { + const pid = document.getElementById('choose-process').dataset.pid; + if (pid) { + // Show loading animation + const popup = document.querySelector('.attach-popup'); + popup.innerHTML = ` +

Attaching to Process

+
+

Please wait...

+ `; + + fetch(`/attach-process?characterName=${characterName}&pid=${pid}`, { method: 'POST' }) + .then(response => response.json()) + .then(data => { + if (data.success) { + // Show success message + popup.innerHTML = ` +

Success

+

Successfully attached to process ${pid} for character ${characterName}

+ `; + // Close popup after 2 seconds + setTimeout(() => { + closeAttachPopup(); + fetchInitialData(); // Refresh the dashboard + }, 2000); + } else { + // Show error message + popup.innerHTML = ` +

Error

+

Failed to attach to process: ${data.error}

+ + `; + } + }) + .catch(error => { + console.error('Error attaching to process:', error); + // Show error message + popup.innerHTML = ` +

Error

+

An error occurred while attaching to the process.

+ + `; + }); + } + } + + async function reloadConfig() { + const btn = document.getElementById('reloadConfigBtn'); + const icon = btn.querySelector('i'); + + // Disable button and start rotation + btn.disabled = true; + icon.classList.add('rotate'); + + try { + const response = await fetch('/api/reload-config'); + if (!response.ok) { + throw new Error('Failed to reload config'); + } + } catch (error) { + console.error('Error reloading config:', error); + } finally { + // Re-enable button and stop rotation + btn.disabled = false; + icon.classList.remove('rotate'); + } + } + + function closeAttachPopup() { + const popup = document.querySelector('.attach-popup'); + if (popup) { + popup.remove(); + } + } + + function filterProcessList() { + const searchTerm = document.getElementById('process-search').value.toLowerCase(); + const rows = document.querySelectorAll('#process-list-body tr'); + + rows.forEach(row => { + const windowTitle = row.cells[0].textContent.toLowerCase(); + const processName = row.cells[1].textContent.toLowerCase(); + if (windowTitle.includes(searchTerm) || processName.includes(searchTerm)) { + row.style.display = ''; + } else { + row.style.display = 'none'; + } + }); + } + + document.addEventListener('DOMContentLoaded', function() { + fetchInitialData(); + connectWebSocket(); + restoreExpandedState(); + }); \ No newline at end of file diff --git a/internal/server/assets/js/debug.js b/internal/server/assets/js/debug.js new file mode 100644 index 000000000..116f866f4 --- /dev/null +++ b/internal/server/assets/js/debug.js @@ -0,0 +1,329 @@ +const debugContainer = document.getElementById('debug-container'); +const refreshIntervalInput = document.getElementById('refresh-interval'); +const setIntervalBtn = document.getElementById('set-interval-btn'); +const expandAllBtn = document.getElementById('expand-all-btn'); +const supervisorNameElement = document.getElementById('supervisor-name'); +const searchInput = document.getElementById('search-input'); +const searchPrevBtn = document.getElementById('search-prev-btn'); +const searchNextBtn = document.getElementById('search-next-btn'); +const searchResults = document.getElementById('search-results'); + +let refreshInterval = 1000; // Default to 1 second +let refreshIntervalId; +let previousData = null; +let isAllExpanded = true; // Start with all expanded +let searchMatches = []; +let currentSearchMatch = -1; +let lastSearchTerm = ''; +let expandedState = {}; +let currentSearchMatchPath = null; + +function createTreeView(data, path = '') { + const fragment = document.createDocumentFragment(); + + for (const [key, value] of Object.entries(data)) { + if (key === 'CollisionGrid') continue; // Skip CollisionGrid entirely + + const node = document.createElement('div'); + node.className = 'tree-node'; + const currentPath = path ? `${path}.${key}` : key; + node.dataset.path = currentPath; + + const label = document.createElement('span'); + label.className = 'tree-label'; + + if (typeof value === 'object' && value !== null) { + const toggle = document.createElement('span'); + toggle.className = 'tree-toggle'; + toggle.textContent = expandedState[currentPath] !== false ? '▼' : '▶'; + label.appendChild(toggle); + + const keySpan = document.createElement('span'); + keySpan.className = 'tree-key'; + keySpan.textContent = key; + label.appendChild(keySpan); + + const copyBtn = createCopyButton(currentPath); + label.appendChild(copyBtn); + + node.appendChild(label); + + if (Array.isArray(value) && value.length > 100) { + const arrayPreview = document.createElement('div'); + arrayPreview.className = 'large-dataset-notice'; + arrayPreview.textContent = `Array with ${value.length} items`; + node.appendChild(arrayPreview); + + const childrenContainer = document.createElement('div'); + childrenContainer.style.display = expandedState[currentPath] !== false ? 'block' : 'none'; + childrenContainer.appendChild(createTreeView(Object.fromEntries(value.slice(0, 100).entries()), currentPath)); + node.appendChild(childrenContainer); + } else { + const childrenContainer = document.createElement('div'); + childrenContainer.style.display = expandedState[currentPath] !== false ? 'block' : 'none'; + childrenContainer.appendChild(createTreeView(value, currentPath)); + node.appendChild(childrenContainer); + } + + label.addEventListener('click', (e) => { + e.stopPropagation(); + toggleNode(currentPath); + }); + } else { + const keySpan = document.createElement('span'); + keySpan.className = 'tree-key'; + keySpan.textContent = `${key}: `; + label.appendChild(keySpan); + + const valueSpan = document.createElement('span'); + valueSpan.className = `tree-value ${value === null ? 'null' : ''}`; + valueSpan.textContent = JSON.stringify(value); + label.appendChild(valueSpan); + + const copyBtn = createCopyButton(currentPath); + label.appendChild(copyBtn); + + node.appendChild(label); + } + + if (path === '' && fragment.children.length > 0) { + const divider = document.createElement('div'); + divider.className = 'section-divider'; + fragment.appendChild(divider); + } + + fragment.appendChild(node); + } + + return fragment; +} + +function toggleNode(path) { + expandedState[path] = !expandedState[path]; + const node = debugContainer.querySelector(`[data-path="${path}"]`); + if (node) { + const label = node.querySelector('.tree-label'); + const toggle = label.querySelector('.tree-toggle'); + const childContainer = label.nextElementSibling; + if (toggle && childContainer) { + toggle.textContent = expandedState[path] ? '▼' : '▶'; + childContainer.style.display = expandedState[path] ? 'block' : 'none'; + } + } +} + +function createCopyButton(path) { + const copyBtn = document.createElement('button'); + copyBtn.className = 'copy-btn'; + copyBtn.textContent = 'Copy'; + copyBtn.dataset.clipboardPath = path; + copyBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const textToCopy = JSON.stringify(getValueByPath(previousData, path), null, 2); + navigator.clipboard.writeText(textToCopy).then(() => { + copyBtn.textContent = 'Copied!'; + setTimeout(() => { + copyBtn.textContent = 'Copy'; + }, 2000); + }); + }); + return copyBtn; +} + +function updateDebugContainer(data) { + const newTree = createTreeView(data); + debugContainer.innerHTML = ''; + debugContainer.appendChild(newTree); + previousData = JSON.parse(JSON.stringify(data)); + updateExpandAllButton(); + if (lastSearchTerm) { + performSearch(lastSearchTerm, false); + } +} + +function updateExpandAllButton() { + expandAllBtn.querySelector('span').textContent = isAllExpanded ? 'Collapse All' : 'Expand All'; +} + +function fetchDebugData() { + const urlParams = new URLSearchParams(window.location.search); + const characterName = urlParams.get('characterName') || 'nullref'; + fetch(`/debug-data?characterName=${characterName}`) + .then(response => response.json()) + .then(data => { + delete data.CollisionGrid; + updateDebugContainer(data); + if (data.PlayerUnit && data.PlayerUnit.Name) { + supervisorNameElement.textContent = `Supervisor: ${data.PlayerUnit.Name}`; + } else { + supervisorNameElement.textContent = `Supervisor: ${characterName}`; + } + }) + .catch(error => { + console.error('Error:', error); + debugContainer.innerHTML = '

Error fetching debug data

'; + }); +} + +function setRefreshInterval() { + const newInterval = parseInt(refreshIntervalInput.value, 10) * 1000; + if (newInterval && newInterval > 0) { + refreshInterval = newInterval; + clearInterval(refreshIntervalId); + refreshIntervalId = setInterval(fetchDebugData, refreshInterval); + } +} + +function toggleExpandAll() { + isAllExpanded = !isAllExpanded; + const allNodes = debugContainer.querySelectorAll('.tree-node'); + allNodes.forEach(node => { + const path = node.dataset.path; + expandedState[path] = isAllExpanded; + const label = node.querySelector('.tree-label'); + const toggle = label.querySelector('.tree-toggle'); + const childContainer = label.nextElementSibling; + if (toggle && childContainer) { + toggle.textContent = isAllExpanded ? '▼' : '▶'; + childContainer.style.display = isAllExpanded ? 'block' : 'none'; + } + }); + updateExpandAllButton(); +} + +function getValueByPath(obj, path) { + return path.split('.').reduce((acc, part) => acc && acc[part], obj); +} + +function performSearch(searchTerm = searchInput.value, shouldScroll = true) { + searchTerm = searchTerm.toLowerCase(); + lastSearchTerm = searchTerm; + searchMatches = []; + currentSearchMatch = -1; + + if (searchTerm) { + searchRecursive(debugContainer, searchTerm); + updateSearchResults(); + if (searchMatches.length > 0) { + if (currentSearchMatchPath) { + // Try to find the previous match in the new set of matches + currentSearchMatch = searchMatches.findIndex(node => node.dataset.path === currentSearchMatchPath); + if (currentSearchMatch === -1) { + currentSearchMatch = 0; + } + } else { + currentSearchMatch = 0; + } + highlightAndScrollToCurrentSearchResult(shouldScroll); + } + } else { + clearSearchHighlights(); + currentSearchMatchPath = null; + updateSearchResults(); + } +} + +function searchRecursive(node, searchTerm) { + if (node.classList && node.classList.contains('tree-node')) { + const label = node.querySelector('.tree-label'); + const text = label.textContent.toLowerCase(); + if (text.includes(searchTerm)) { + searchMatches.push(node); + } + } + for (const child of node.children) { + searchRecursive(child, searchTerm); + } +} + +function updateSearchResults() { + searchResults.textContent = searchMatches.length > 0 + ? `${currentSearchMatch + 1}/${searchMatches.length}` + : '0/0'; +} + +function goToNextSearchResult() { + if (searchMatches.length > 0) { + currentSearchMatch = (currentSearchMatch + 1) % searchMatches.length; + highlightAndScrollToCurrentSearchResult(true); + } +} + +function goToPreviousSearchResult() { + if (searchMatches.length > 0) { + currentSearchMatch = (currentSearchMatch - 1 + searchMatches.length) % searchMatches.length; + highlightAndScrollToCurrentSearchResult(true); + } +} + +function highlightAndScrollToCurrentSearchResult(shouldScroll = true) { + clearSearchHighlights(); + if (currentSearchMatch >= 0 && currentSearchMatch < searchMatches.length) { + const node = searchMatches[currentSearchMatch]; + if (node) { + const label = node.querySelector('.tree-label'); + if (label) { + label.classList.add('highlight'); + expandToNode(node); + currentSearchMatchPath = node.dataset.path; + if (shouldScroll) { + setTimeout(() => { + label.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 100); + } + } + } + } + updateSearchResults(); +} + +function clearSearchHighlights() { + const highlightedElements = debugContainer.querySelectorAll('.highlight'); + highlightedElements.forEach(el => el.classList.remove('highlight')); +} + +function expandToNode(node) { + let current = node; + while (current && current !== debugContainer) { + if (current.classList && current.classList.contains('tree-node')) { + const path = current.dataset.path; + if (path) { + expandedState[path] = true; + const label = current.querySelector('.tree-label'); + const toggle = label && label.querySelector('.tree-toggle'); + const childContainer = label && label.nextElementSibling; + if (toggle && childContainer) { + toggle.textContent = '▼'; + childContainer.style.display = 'block'; + } + } + } + current = current.parentElement; + } +} + +function createCopyDataButton() { + const copyDataBtn = document.getElementById('copy-data-btn'); + copyDataBtn.addEventListener('click', () => { + const textToCopy = JSON.stringify(previousData, null, 2); + navigator.clipboard.writeText(textToCopy).then(() => { + const originalText = copyDataBtn.innerHTML; + copyDataBtn.innerHTML = 'Copied!'; + setTimeout(() => { + copyDataBtn.innerHTML = originalText; + }, 2000); + }); + }); +} + +// Event Listeners +setIntervalBtn.addEventListener('click', setRefreshInterval); +expandAllBtn.addEventListener('click', toggleExpandAll); +searchInput.addEventListener('input', () => performSearch()); +searchNextBtn.addEventListener('click', goToNextSearchResult); +searchPrevBtn.addEventListener('click', goToPreviousSearchResult); + +// Initialize +createCopyDataButton(); +fetchDebugData(); +refreshIntervalId = setInterval(fetchDebugData, refreshInterval); \ No newline at end of file diff --git a/internal/server/http_server.go b/internal/server/http_server.go new file mode 100644 index 000000000..00f605ede --- /dev/null +++ b/internal/server/http_server.go @@ -0,0 +1,1034 @@ +package server + +import ( + "bytes" + "context" + "embed" + "encoding/json" + "errors" + "fmt" + "html/template" + "io/fs" + "log/slog" + "net/http" + "slices" + "sort" + "strconv" + "strings" + "syscall" + "time" + "unsafe" + + "github.com/gorilla/websocket" + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" + "github.com/hectorgimenez/d2go/pkg/data/difficulty" + "github.com/hectorgimenez/d2go/pkg/data/stat" + "github.com/hectorgimenez/koolo/internal/bot" + "github.com/hectorgimenez/koolo/internal/config" + ctx "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/utils" + "github.com/hectorgimenez/koolo/internal/utils/winproc" + "github.com/lxn/win" + "golang.org/x/sys/windows" +) + +type HttpServer struct { + logger *slog.Logger + server *http.Server + manager *bot.SupervisorManager + templates *template.Template + wsServer *WebSocketServer +} + +var ( + //go:embed all:assets + assetsFS embed.FS + //go:embed all:templates + templatesFS embed.FS + + upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, + } +) + +type Client struct { + conn *websocket.Conn + send chan []byte +} + +type WebSocketServer struct { + clients map[*Client]bool + broadcast chan []byte + register chan *Client + unregister chan *Client +} + +func NewWebSocketServer() *WebSocketServer { + return &WebSocketServer{ + clients: make(map[*Client]bool), + broadcast: make(chan []byte), + register: make(chan *Client), + unregister: make(chan *Client), + } +} + +type Process struct { + WindowTitle string `json:"windowTitle"` + ProcessName string `json:"processName"` + PID uint32 `json:"pid"` +} + +func (s *WebSocketServer) Run() { + for { + select { + case client := <-s.register: + s.clients[client] = true + case client := <-s.unregister: + if _, ok := s.clients[client]; ok { + delete(s.clients, client) + close(client.send) + } + case message := <-s.broadcast: + for client := range s.clients { + select { + case client.send <- message: + default: + close(client.send) + delete(s.clients, client) + } + } + } + } +} + +func (s *WebSocketServer) HandleWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + slog.Error("Failed to upgrade connection to WebSocket", "error", err) + return + } + + client := &Client{conn: conn, send: make(chan []byte, 256)} + s.register <- client + + go s.writePump(client) + go s.readPump(client) +} + +func (s *WebSocketServer) writePump(client *Client) { + defer func() { + client.conn.Close() + }() + + for { + select { + case message, ok := <-client.send: + if !ok { + client.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + + w, err := client.conn.NextWriter(websocket.TextMessage) + if err != nil { + return + } + w.Write(message) + + if err := w.Close(); err != nil { + return + } + } + } +} + +func (s *WebSocketServer) readPump(client *Client) { + defer func() { + s.unregister <- client + client.conn.Close() + }() + + for { + _, _, err := client.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + slog.Error("WebSocket read error", "error", err) + } + break + } + } +} + +func (s *HttpServer) BroadcastStatus() { + for { + data := s.getStatusData() + jsonData, err := json.Marshal(data) + if err != nil { + slog.Error("Failed to marshal status data", "error", err) + continue + } + + s.wsServer.broadcast <- jsonData + time.Sleep(1 * time.Second) + } +} + +func New(logger *slog.Logger, manager *bot.SupervisorManager) (*HttpServer, error) { + var templates *template.Template + helperFuncs := template.FuncMap{ + "isInSlice": func(slice []stat.Resist, value string) bool { + return slices.Contains(slice, stat.Resist(value)) + }, + "isTZSelected": func(slice []area.ID, value int) bool { + return slices.Contains(slice, area.ID(value)) + }, + "executeTemplateByName": func(name string, data interface{}) template.HTML { + tmpl := templates.Lookup(name) + var buf bytes.Buffer + if tmpl == nil { + return "This run is not configurable." + } + + tmpl.Execute(&buf, data) + return template.HTML(buf.String()) + }, + "qualityClass": qualityClass, + "statIDToText": statIDToText, + "contains": containss, + "seq": func(start, end int) []int { + var result []int + for i := start; i <= end; i++ { + result = append(result, i) + } + return result + }, + } + templates, err := template.New("").Funcs(helperFuncs).ParseFS(templatesFS, "templates/*.gohtml") + if err != nil { + return nil, err + } + + return &HttpServer{ + logger: logger, + manager: manager, + templates: templates, + }, nil +} + +func (s *HttpServer) getProcessList(w http.ResponseWriter, r *http.Request) { + processes, err := getRunningProcesses() + if err != nil { + http.Error(w, "Failed to get process list", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(processes) +} + +func (s *HttpServer) attachProcess(w http.ResponseWriter, r *http.Request) { + characterName := r.URL.Query().Get("characterName") + pidStr := r.URL.Query().Get("pid") + + pid, err := strconv.ParseUint(pidStr, 10, 32) + if err != nil { + s.logger.Error("Invalid PID", "error", err) + return + } + + // Find the main window handle (HWND) for the process + var hwnd win.HWND + enumWindowsCallback := func(h win.HWND, param uintptr) uintptr { + var processID uint32 + win.GetWindowThreadProcessId(h, &processID) + if processID == uint32(pid) { + hwnd = h + return 0 // Stop enumeration + } + return 1 // Continue enumeration + } + + windows.EnumWindows(syscall.NewCallback(enumWindowsCallback), nil) + + if hwnd == 0 { + s.logger.Error("Failed to find window handle for process", "pid", pid) + return + } + + // Call manager.Start with the correct arguments, including the HWND + go s.manager.Start(characterName, true, uint32(pid), uint32(hwnd)) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"success": true}) +} + +// Add this helper function +func getRunningProcesses() ([]Process, error) { + var processes []Process + + snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil, err + } + defer windows.CloseHandle(snapshot) + + var entry windows.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + + err = windows.Process32First(snapshot, &entry) + if err != nil { + return nil, err + } + + for { + windowTitle, _ := getWindowTitle(entry.ProcessID) + + if strings.ToLower(syscall.UTF16ToString(entry.ExeFile[:])) == "d2r.exe" { + processes = append(processes, Process{ + WindowTitle: windowTitle, + ProcessName: syscall.UTF16ToString(entry.ExeFile[:]), + PID: entry.ProcessID, + }) + } + + err = windows.Process32Next(snapshot, &entry) + if err != nil { + if err == windows.ERROR_NO_MORE_FILES { + break + } + return nil, err + } + } + + return processes, nil +} + +func getWindowTitle(pid uint32) (string, error) { + var windowTitle string + var hwnd windows.HWND + + cb := syscall.NewCallback(func(h win.HWND, param uintptr) uintptr { + var currentPID uint32 + _ = win.GetWindowThreadProcessId(h, ¤tPID) + + if currentPID == pid { + hwnd = windows.HWND(h) + return 0 // stop enumeration + } + return 1 // continue enumeration + }) + + // Enumerate all windows + windows.EnumWindows(cb, nil) + + if hwnd == 0 { + return "", fmt.Errorf("no window found for process ID %d", pid) + } + + // Get window title + var title [256]uint16 + _, _, _ = winproc.GetWindowText.Call( + uintptr(hwnd), + uintptr(unsafe.Pointer(&title[0])), + uintptr(len(title)), + ) + + windowTitle = syscall.UTF16ToString(title[:]) + return windowTitle, nil + +} + +func qualityClass(quality string) string { + switch quality { + case "LowQuality": + return "low-quality" + case "Normal": + return "normal-quality" + case "Superior": + return "superior-quality" + case "Magic": + return "magic-quality" + case "Set": + return "set-quality" + case "Rare": + return "rare-quality" + case "Unique": + return "unique-quality" + case "Crafted": + return "crafted-quality" + default: + return "unknown-quality" + } +} + +func statIDToText(id stat.ID) string { + return stat.StringStats[id] +} + +func containss(slice []string, item string) bool { + for _, v := range slice { + if v == item { + return true + } + } + return false +} + +func (s *HttpServer) initialData(w http.ResponseWriter, r *http.Request) { + data := s.getStatusData() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(data) +} + +func (s *HttpServer) getStatusData() IndexData { + status := make(map[string]bot.Stats) + drops := make(map[string]int) + + for _, supervisorName := range s.manager.AvailableSupervisors() { + status[supervisorName] = s.manager.Status(supervisorName) + if s.manager.GetSupervisorStats(supervisorName).Drops != nil { + drops[supervisorName] = len(s.manager.GetSupervisorStats(supervisorName).Drops) + } else { + drops[supervisorName] = 0 + } + } + + return IndexData{ + Version: config.Version, + Status: status, + DropCount: drops, + } +} + +func (s *HttpServer) Listen(port int) error { + s.wsServer = NewWebSocketServer() + go s.wsServer.Run() + go s.BroadcastStatus() + + http.HandleFunc("/", s.getRoot) + http.HandleFunc("/config", s.config) + http.HandleFunc("/supervisorSettings", s.characterSettings) + http.HandleFunc("/start", s.startSupervisor) + http.HandleFunc("/stop", s.stopSupervisor) + http.HandleFunc("/togglePause", s.togglePause) + http.HandleFunc("/debug", s.debugHandler) + http.HandleFunc("/debug-data", s.debugData) + http.HandleFunc("/drops", s.drops) + http.HandleFunc("/process-list", s.getProcessList) + http.HandleFunc("/attach-process", s.attachProcess) + http.HandleFunc("/ws", s.wsServer.HandleWebSocket) // Web socket + http.HandleFunc("/initial-data", s.initialData) // Web socket data + http.HandleFunc("/api/reload-config", s.reloadConfig) // New handler + + assets, _ := fs.Sub(assetsFS, "assets") + http.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(assets)))) + + s.server = &http.Server{ + Addr: fmt.Sprintf(":%d", port), + } + + if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + + return nil +} + +func (s *HttpServer) reloadConfig(w http.ResponseWriter, r *http.Request) { + result := s.manager.ReloadConfig() + if result != nil { + http.Error(w, result.Error(), http.StatusInternalServerError) + return + } + + s.logger.Info("Config reloaded") + w.WriteHeader(http.StatusOK) +} + +func (s *HttpServer) Stop() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + return s.server.Shutdown(ctx) +} + +func (s *HttpServer) getRoot(w http.ResponseWriter, r *http.Request) { + if !utils.HasAdminPermission() { + s.templates.ExecuteTemplate(w, "templates/admin_required.gohtml", nil) + return + } + + if config.Koolo.FirstRun { + http.Redirect(w, r, "/config", http.StatusSeeOther) + return + } + + s.index(w) +} + +func (s *HttpServer) debugData(w http.ResponseWriter, r *http.Request) { + characterName := r.URL.Query().Get("characterName") + if characterName == "" { + http.Error(w, "Character name is required", http.StatusBadRequest) + return + } + + type DebugData struct { + DebugData map[ctx.Priority]*ctx.Debug + GameData *game.Data + } + + context := s.manager.GetContext(characterName) + + debugData := DebugData{ + DebugData: context.ContextDebug, + GameData: context.Data, + } + + jsonData, err := json.Marshal(debugData) + if err != nil { + http.Error(w, "Failed to serialize game data", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(jsonData) +} + +func (s *HttpServer) debugHandler(w http.ResponseWriter, r *http.Request) { + s.templates.ExecuteTemplate(w, "debug.gohtml", nil) +} + +func (s *HttpServer) startSupervisor(w http.ResponseWriter, r *http.Request) { + supervisorList := s.manager.AvailableSupervisors() + Supervisor := r.URL.Query().Get("characterName") + + // Get the current auth method for the supervisor we wanna start + supCfg, currFound := config.Characters[Supervisor] + if !currFound { + // There's no config for the current supervisor. THIS SHOULDN'T HAPPEN + return + } + + // Prevent launching of other clients while there's a client with TokenAuth still starting + for _, sup := range supervisorList { + + // If the current don't check against the one we're trying to launch + if sup == Supervisor { + continue + } + + if s.manager.GetSupervisorStats(sup).SupervisorStatus == bot.Starting { + + // Prevent launching if we're using token auth & another client is starting (no matter what auth method) + if supCfg.AuthMethod == "TokenAuth" { + return + } + + // Prevent launching if another client that is using token auth is starting + sCfg, found := config.Characters[sup] + if found { + if sCfg.AuthMethod == "TokenAuth" { + return + } + } + } + } + + s.manager.Start(Supervisor, false) + s.initialData(w, r) +} + +func (s *HttpServer) stopSupervisor(w http.ResponseWriter, r *http.Request) { + s.manager.Stop(r.URL.Query().Get("characterName")) + s.initialData(w, r) +} + +func (s *HttpServer) togglePause(w http.ResponseWriter, r *http.Request) { + s.manager.TogglePause(r.URL.Query().Get("characterName")) + s.initialData(w, r) +} + +func (s *HttpServer) index(w http.ResponseWriter) { + status := make(map[string]bot.Stats) + drops := make(map[string]int) + + for _, supervisorName := range s.manager.AvailableSupervisors() { + status[supervisorName] = bot.Stats{ + SupervisorStatus: bot.NotStarted, + } + + status[supervisorName] = s.manager.Status(supervisorName) + + if s.manager.GetSupervisorStats(supervisorName).Drops != nil { + drops[supervisorName] = len(s.manager.GetSupervisorStats(supervisorName).Drops) + } else { + drops[supervisorName] = 0 + } + + } + + s.templates.ExecuteTemplate(w, "index.gohtml", IndexData{ + Version: config.Version, + Status: status, + DropCount: drops, + }) +} + +func (s *HttpServer) drops(w http.ResponseWriter, r *http.Request) { + sup := r.URL.Query().Get("supervisor") + cfg, found := config.Characters[sup] + if !found { + http.Error(w, "Can't fetch drop data because the configuration "+sup+" wasn't found", http.StatusNotFound) + return + } + + var Drops []data.Drop + + if s.manager.GetSupervisorStats(sup).Drops == nil { + Drops = make([]data.Drop, 0) + } else { + Drops = s.manager.GetSupervisorStats(sup).Drops + } + + s.templates.ExecuteTemplate(w, "drops.gohtml", DropData{ + NumberOfDrops: len(Drops), + Character: cfg.CharacterName, + Drops: Drops, + }) +} + +func validateSchedulerData(cfg *config.CharacterCfg) error { + for day := 0; day < 7; day++ { + + cfg.Scheduler.Days[day].DayOfWeek = day + + // Sort time ranges + sort.Slice(cfg.Scheduler.Days[day].TimeRanges, func(i, j int) bool { + return cfg.Scheduler.Days[day].TimeRanges[i].Start.Before(cfg.Scheduler.Days[day].TimeRanges[j].Start) + }) + + daysOfWeek := []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"} + + // Check for overlapping time ranges + for i := 0; i < len(cfg.Scheduler.Days[day].TimeRanges); i++ { + if !cfg.Scheduler.Days[day].TimeRanges[i].End.After(cfg.Scheduler.Days[day].TimeRanges[i].Start) { + return fmt.Errorf("end time must be after start time for day %s", daysOfWeek[day]) + } + + if i > 0 { + if !cfg.Scheduler.Days[day].TimeRanges[i].Start.After(cfg.Scheduler.Days[day].TimeRanges[i-1].End) { + return fmt.Errorf("overlapping time ranges for day %s", daysOfWeek[day]) + } + } + } + } + + return nil +} + +func (s *HttpServer) config(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + err := r.ParseForm() + if err != nil { + s.templates.ExecuteTemplate(w, "config.gohtml", ConfigData{KooloCfg: config.Koolo, ErrorMessage: "Error parsing form"}) + return + } + + newConfig := *config.Koolo + newConfig.FirstRun = false // Disable the welcome assistant + newConfig.D2RPath = r.Form.Get("d2rpath") + newConfig.D2LoDPath = r.Form.Get("d2lodpath") + newConfig.CentralizedPickitPath = r.Form.Get("centralized_pickit_path") + newConfig.UseCustomSettings = r.Form.Get("use_custom_settings") == "true" + newConfig.GameWindowArrangement = r.Form.Get("game_window_arrangement") == "true" + // Debug + newConfig.Debug.Log = r.Form.Get("debug_log") == "true" + newConfig.Debug.Screenshots = r.Form.Get("debug_screenshots") == "true" + // Discord + newConfig.Discord.Enabled = r.Form.Get("discord_enabled") == "true" + newConfig.Discord.EnableGameCreatedMessages = r.Form.Has("enable_game_created_messages") + newConfig.Discord.EnableNewRunMessages = r.Form.Has("enable_new_run_messages") + newConfig.Discord.EnableRunFinishMessages = r.Form.Has("enable_run_finish_messages") + newConfig.Discord.EnableDiscordChickenMessages = r.Form.Has("enable_discord_chicken_messages") + newConfig.Discord.EnableDiscordErrorMessages = r.Form.Has("enable_discord_error_messages") + + // Discord admins who can use bot commands + discordAdmins := r.Form.Get("discord_admins") + cleanedAdmins := strings.Map(func(r rune) rune { + if (r >= '0' && r <= '9') || r == ',' { + return r + } + return -1 + }, discordAdmins) + newConfig.Discord.BotAdmins = strings.Split(cleanedAdmins, ",") + newConfig.Discord.Token = r.Form.Get("discord_token") + newConfig.Discord.ChannelID = r.Form.Get("discord_channel_id") + // Telegram + newConfig.Telegram.Enabled = r.Form.Get("telegram_enabled") == "true" + newConfig.Telegram.Token = r.Form.Get("telegram_token") + telegramChatId, err := strconv.ParseInt(r.Form.Get("telegram_chat_id"), 10, 64) + if err != nil { + s.templates.ExecuteTemplate(w, "config.gohtml", ConfigData{KooloCfg: &newConfig, ErrorMessage: "Invalid Telegram Chat ID"}) + return + } + newConfig.Telegram.ChatID = telegramChatId + + err = config.ValidateAndSaveConfig(newConfig) + if err != nil { + s.templates.ExecuteTemplate(w, "config.gohtml", ConfigData{KooloCfg: &newConfig, ErrorMessage: err.Error()}) + return + } + + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + s.templates.ExecuteTemplate(w, "config.gohtml", ConfigData{KooloCfg: config.Koolo, ErrorMessage: ""}) +} + +func (s *HttpServer) characterSettings(w http.ResponseWriter, r *http.Request) { + var err error + if r.Method == http.MethodPost { + err = r.ParseForm() + if err != nil { + s.templates.ExecuteTemplate(w, "character_settings.gohtml", CharacterSettings{ + ErrorMessage: err.Error(), + }) + + return + } + + supervisorName := r.Form.Get("name") + cfg, found := config.Characters[supervisorName] + if !found { + err = config.CreateFromTemplate(supervisorName) + if err != nil { + s.templates.ExecuteTemplate(w, "character_settings.gohtml", CharacterSettings{ + ErrorMessage: err.Error(), + Supervisor: supervisorName, + }) + + return + } + cfg = config.Characters["template"] + } + + cfg.MaxGameLength, _ = strconv.Atoi(r.Form.Get("maxGameLength")) + cfg.CharacterName = r.Form.Get("characterName") + cfg.CommandLineArgs = r.Form.Get("commandLineArgs") + cfg.KillD2OnStop = r.Form.Has("kill_d2_process") + cfg.ClassicMode = r.Form.Has("classic_mode") + cfg.CloseMiniPanel = r.Form.Has("close_mini_panel") + cfg.HidePortraits = r.Form.Has("hide_portraits") + + // Bnet config + cfg.Username = r.Form.Get("username") + cfg.Password = r.Form.Get("password") + cfg.Realm = r.Form.Get("realm") + cfg.AuthMethod = r.Form.Get("authmethod") + cfg.AuthToken = r.Form.Get("AuthToken") + + // Scheduler config + cfg.Scheduler.Enabled = r.Form.Has("schedulerEnabled") + + for day := 0; day < 7; day++ { + + starts := r.Form[fmt.Sprintf("scheduler[%d][start][]", day)] + ends := r.Form[fmt.Sprintf("scheduler[%d][end][]", day)] + + cfg.Scheduler.Days[day].DayOfWeek = day + cfg.Scheduler.Days[day].TimeRanges = make([]config.TimeRange, 0) + + for i := 0; i < len(starts); i++ { + start, err := time.Parse("15:04", starts[i]) + if err != nil { + s.templates.ExecuteTemplate(w, "character_settings.gohtml", CharacterSettings{ + ErrorMessage: fmt.Sprintf("Invalid start time format for day %d: %s", day, starts[i]), + // ... (other fields) + }) + return + } + + end, err := time.Parse("15:04", ends[i]) + if err != nil { + s.templates.ExecuteTemplate(w, "character_settings.gohtml", CharacterSettings{ + ErrorMessage: fmt.Sprintf("Invalid end time format for day %d: %s", day, ends[i]), + }) + return + } + + cfg.Scheduler.Days[day].TimeRanges = append(cfg.Scheduler.Days[day].TimeRanges, struct { + Start time.Time "yaml:\"start\"" + End time.Time "yaml:\"end\"" + }{ + Start: start, + End: end, + }) + } + } + + // Validate scheduler data + err := validateSchedulerData(cfg) + if err != nil { + s.templates.ExecuteTemplate(w, "character_settings.gohtml", CharacterSettings{ + ErrorMessage: err.Error(), + // ... (other fields) + }) + return + } + + // Health config + cfg.Health.HealingPotionAt, _ = strconv.Atoi(r.Form.Get("healingPotionAt")) + cfg.Health.ManaPotionAt, _ = strconv.Atoi(r.Form.Get("manaPotionAt")) + cfg.Health.RejuvPotionAtLife, _ = strconv.Atoi(r.Form.Get("rejuvPotionAtLife")) + cfg.Health.RejuvPotionAtMana, _ = strconv.Atoi(r.Form.Get("rejuvPotionAtMana")) + cfg.Health.ChickenAt, _ = strconv.Atoi(r.Form.Get("chickenAt")) + cfg.Character.UseMerc = r.Form.Has("useMerc") + cfg.Health.MercHealingPotionAt, _ = strconv.Atoi(r.Form.Get("mercHealingPotionAt")) + cfg.Health.MercRejuvPotionAt, _ = strconv.Atoi(r.Form.Get("mercRejuvPotionAt")) + cfg.Health.MercChickenAt, _ = strconv.Atoi(r.Form.Get("mercChickenAt")) + + // Character + cfg.Character.Class = r.Form.Get("characterClass") + cfg.Character.StashToShared = r.Form.Has("characterStashToShared") + cfg.Character.UseTeleport = r.Form.Has("characterUseTeleport") + + // Berserker Barb specific options + if cfg.Character.Class == "berserker" { + cfg.Character.BerserkerBarb.SkipPotionPickupInTravincal = r.Form.Has("barbSkipPotionPickupInTravincal") + cfg.Character.BerserkerBarb.FindItemSwitch = r.Form.Has("characterFindItemSwitch") + } + + // Nova Sorceress specific options + if cfg.Character.Class == "nova" || cfg.Character.Class == "lightsorc" { + bossStaticThreshold, err := strconv.Atoi(r.Form.Get("novaBossStaticThreshold")) + if err == nil { + minThreshold := 65 // Default + switch cfg.Game.Difficulty { + case difficulty.Normal: + minThreshold = 1 + case difficulty.Nightmare: + minThreshold = 33 + case difficulty.Hell: + minThreshold = 50 + } + if bossStaticThreshold >= minThreshold && bossStaticThreshold <= 100 { + cfg.Character.NovaSorceress.BossStaticThreshold = bossStaticThreshold + } else { + cfg.Character.NovaSorceress.BossStaticThreshold = minThreshold + s.logger.Warn("Invalid Boss Static Threshold, setting to minimum for difficulty", + slog.Int("min", minThreshold), + slog.String("difficulty", string(cfg.Game.Difficulty))) + } + } else { + cfg.Character.NovaSorceress.BossStaticThreshold = 65 // Default value + s.logger.Warn("Invalid Boss Static Threshold input, setting to default", slog.Int("default", 65)) + } + } + + // Mosaic specific options + if cfg.Character.Class == "mosaic" { + cfg.Character.MosaicSin.UseTigerStrike = r.Form.Has("mosaicUseTigerStrike") + cfg.Character.MosaicSin.UseCobraStrike = r.Form.Has("mosaicUseCobraStrike") + cfg.Character.MosaicSin.UseClawsOfThunder = r.Form.Has("mosaicUseClawsOfThunder") + cfg.Character.MosaicSin.UseBladesOfIce = r.Form.Has("mosaicUseBladesOfIce") + cfg.Character.MosaicSin.UseFistsOfFire = r.Form.Has("mosaicUseFistsOfFire") + } + + for y, row := range cfg.Inventory.InventoryLock { + for x := range row { + if r.Form.Has(fmt.Sprintf("inventoryLock[%d][%d]", y, x)) { + cfg.Inventory.InventoryLock[y][x] = 0 + } else { + cfg.Inventory.InventoryLock[y][x] = 1 + } + } + } + + for x, value := range r.Form["inventoryBeltColumns[]"] { + cfg.Inventory.BeltColumns[x] = value + } + + // Game + cfg.Game.CreateLobbyGames = r.Form.Has("createLobbyGames") + cfg.Game.MinGoldPickupThreshold, _ = strconv.Atoi(r.Form.Get("gameMinGoldPickupThreshold")) + cfg.UseCentralizedPickit = r.Form.Has("useCentralizedPickit") + cfg.Game.UseCainIdentify = r.Form.Has("useCainIdentify") + cfg.Game.Difficulty = difficulty.Difficulty(r.Form.Get("gameDifficulty")) + cfg.Game.RandomizeRuns = r.Form.Has("gameRandomizeRuns") + + // Runs specific config + enabledRuns := make([]config.Run, 0) + + // we don't like errors, so we ignore them + json.Unmarshal([]byte(r.FormValue("gameRuns")), &enabledRuns) + cfg.Game.Runs = enabledRuns + + cfg.Game.Cows.OpenChests = r.Form.Has("gameCowsOpenChests") + + cfg.Game.Pit.MoveThroughBlackMarsh = r.Form.Has("gamePitMoveThroughBlackMarsh") + cfg.Game.Pit.OpenChests = r.Form.Has("gamePitOpenChests") + cfg.Game.Pit.FocusOnElitePacks = r.Form.Has("gamePitFocusOnElitePacks") + cfg.Game.Pit.OnlyClearLevel2 = r.Form.Has("gamePitOnlyClearLevel2") + + cfg.Game.Andariel.ClearRoom = r.Form.Has("gameAndarielClearRoom") + + cfg.Game.Pindleskin.SkipOnImmunities = []stat.Resist{} + for _, i := range r.Form["gamePindleskinSkipOnImmunities[]"] { + cfg.Game.Pindleskin.SkipOnImmunities = append(cfg.Game.Pindleskin.SkipOnImmunities, stat.Resist(i)) + } + + cfg.Game.StonyTomb.OpenChests = r.Form.Has("gameStonytombOpenChests") + cfg.Game.StonyTomb.FocusOnElitePacks = r.Form.Has("gameStonytombFocusOnElitePacks") + cfg.Game.AncientTunnels.OpenChests = r.Form.Has("gameAncientTunnelsOpenChests") + cfg.Game.AncientTunnels.FocusOnElitePacks = r.Form.Has("gameAncientTunnelsFocusOnElitePacks") + cfg.Game.Mausoleum.OpenChests = r.Form.Has("gameMausoleumOpenChests") + cfg.Game.Mausoleum.FocusOnElitePacks = r.Form.Has("gameMausoleumFocusOnElitePacks") + cfg.Game.DrifterCavern.OpenChests = r.Form.Has("gameDrifterCavernOpenChests") + cfg.Game.DrifterCavern.FocusOnElitePacks = r.Form.Has("gameDrifterCavernFocusOnElitePacks") + cfg.Game.SpiderCavern.OpenChests = r.Form.Has("gameSpiderCavernOpenChests") + cfg.Game.SpiderCavern.FocusOnElitePacks = r.Form.Has("gameSpiderCavernFocusOnElitePacks") + cfg.Game.ArachnidLair.OpenChests = r.Form.Has("gameArachnidLairOpenChests") + cfg.Game.ArachnidLair.FocusOnElitePacks = r.Form.Has("gameArachnidLairFocusOnElitePacks") + cfg.Game.Mephisto.KillCouncilMembers = r.Form.Has("gameMephistoKillCouncilMembers") + cfg.Game.Mephisto.OpenChests = r.Form.Has("gameMephistoOpenChests") + cfg.Game.Mephisto.ExitToA4 = r.Form.Has("gameMephistoExitToA4") + cfg.Game.Tristram.ClearPortal = r.Form.Has("gameTristramClearPortal") + cfg.Game.Tristram.FocusOnElitePacks = r.Form.Has("gameTristramFocusOnElitePacks") + cfg.Game.Nihlathak.ClearArea = r.Form.Has("gameNihlathakClearArea") + + cfg.Game.Baal.KillBaal = r.Form.Has("gameBaalKillBaal") + cfg.Game.Baal.DollQuit = r.Form.Has("gameBaalDollQuit") + cfg.Game.Baal.SoulQuit = r.Form.Has("gameBaalSoulQuit") + cfg.Game.Baal.ClearFloors = r.Form.Has("gameBaalClearFloors") + cfg.Game.Baal.OnlyElites = r.Form.Has("gameBaalOnlyElites") + + cfg.Game.Eldritch.KillShenk = r.Form.Has("gameEldritchKillShenk") + cfg.Game.LowerKurastChest.OpenRacks = r.Form.Has("gameLowerKurastChestOpenRacks") + cfg.Game.Diablo.StartFromStar = r.Form.Has("gameDiabloStartFromStar") + cfg.Game.Diablo.KillDiablo = r.Form.Has("gameDiabloKillDiablo") + cfg.Game.Diablo.FocusOnElitePacks = r.Form.Has("gameDiabloFocusOnElitePacks") + cfg.Game.Diablo.DisableItemPickupDuringBosses = r.Form.Has("gameDiabloDisableItemPickupDuringBosses") + attackFromDistance, err := strconv.Atoi(r.Form.Get("gameDiabloAttackFromDistance")) + if err != nil { + s.logger.Warn("Invalid Attack From Distance value, setting to default", + slog.String("error", err.Error()), + slog.Int("default", 0)) + cfg.Game.Diablo.AttackFromDistance = 0 // 0 will not reposition + } else { + if attackFromDistance > 25 { + attackFromDistance = 25 + } + cfg.Game.Diablo.AttackFromDistance = attackFromDistance + } + cfg.Game.Leveling.EnsurePointsAllocation = r.Form.Has("gameLevelingEnsurePointsAllocation") + cfg.Game.Leveling.EnsureKeyBinding = r.Form.Has("gameLevelingEnsureKeyBinding") + + // Quests options for Act 1 + cfg.Game.Quests.ClearDen = r.Form.Has("gameQuestsClearDen") + cfg.Game.Quests.RescueCain = r.Form.Has("gameQuestsRescueCain") + cfg.Game.Quests.RetrieveHammer = r.Form.Has("gameQuestsRetrieveHammer") + // Quests options for Act 2 + cfg.Game.Quests.KillRadament = r.Form.Has("gameQuestsKillRadament") + cfg.Game.Quests.GetCube = r.Form.Has("gameQuestsGetCube") + // Quests options for Act 3 + cfg.Game.Quests.RetrieveBook = r.Form.Has("gameQuestsRetrieveBook") + // Quests options for Act 4 + cfg.Game.Quests.KillIzual = r.Form.Has("gameQuestsKillIzual") + // Quests options for Act 5 + cfg.Game.Quests.KillShenk = r.Form.Has("gameQuestsKillShenk") + cfg.Game.Quests.RescueAnya = r.Form.Has("gameQuestsRescueAnya") + cfg.Game.Quests.KillAncients = r.Form.Has("gameQuestsKillAncients") + + cfg.Game.TerrorZone.FocusOnElitePacks = r.Form.Has("gameTerrorZoneFocusOnElitePacks") + cfg.Game.TerrorZone.SkipOtherRuns = r.Form.Has("gameTerrorZoneSkipOtherRuns") + cfg.Game.TerrorZone.OpenChests = r.Form.Has("gameTerrorZoneOpenChests") + + cfg.Game.TerrorZone.SkipOnImmunities = []stat.Resist{} + for _, i := range r.Form["gameTerrorZoneSkipOnImmunities[]"] { + cfg.Game.TerrorZone.SkipOnImmunities = append(cfg.Game.TerrorZone.SkipOnImmunities, stat.Resist(i)) + } + + tzAreas := make([]area.ID, 0) + for _, a := range r.Form["gameTerrorZoneAreas[]"] { + ID, _ := strconv.Atoi(a) + tzAreas = append(tzAreas, area.ID(ID)) + } + cfg.Game.TerrorZone.Areas = tzAreas + + // Gambling + cfg.Gambling.Enabled = r.Form.Has("gamblingEnabled") + + // Cube Recipes + cfg.CubeRecipes.Enabled = r.Form.Has("enableCubeRecipes") + enabledRecipes := r.Form["enabledRecipes"] + cfg.CubeRecipes.EnabledRecipes = enabledRecipes + cfg.CubeRecipes.SkipPerfectAmethysts = r.Form.Has("skipPerfectAmethysts") + cfg.CubeRecipes.SkipPerfectRubies = r.Form.Has("skipPerfectRubies") + // Companion + + // Companion config + cfg.Companion.Leader = r.Form.Has("companionLeader") + cfg.Companion.LeaderName = r.Form.Get("companionLeaderName") + cfg.Companion.GameNameTemplate = r.Form.Get("companionGameNameTemplate") + cfg.Companion.GamePassword = r.Form.Get("companionGamePassword") + + // Back to town config + cfg.BackToTown.NoHpPotions = r.Form.Has("noHpPotions") + cfg.BackToTown.NoMpPotions = r.Form.Has("noMpPotions") + cfg.BackToTown.MercDied = r.Form.Has("mercDied") + cfg.BackToTown.EquipmentBroken = r.Form.Has("equipmentBroken") + + config.SaveSupervisorConfig(supervisorName, cfg) + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + supervisor := r.URL.Query().Get("supervisor") + cfg := config.Characters["template"] + if supervisor != "" { + cfg = config.Characters[supervisor] + } + + enabledRuns := make([]string, 0) + // Let's iterate cfg.Game.Runs to preserve current order + for _, run := range cfg.Game.Runs { + enabledRuns = append(enabledRuns, string(run)) + } + disabledRuns := make([]string, 0) + for run := range config.AvailableRuns { + if !slices.Contains(cfg.Game.Runs, run) { + disabledRuns = append(disabledRuns, string(run)) + } + } + sort.Strings(disabledRuns) + + availableTZs := make(map[int]string) + for _, tz := range area.Areas { + if tz.CanBeTerrorized() { + availableTZs[int(tz.ID)] = tz.Name + } + } + + if cfg.Scheduler.Days == nil || len(cfg.Scheduler.Days) == 0 { + cfg.Scheduler.Days = make([]config.Day, 7) + for i := 0; i < 7; i++ { + cfg.Scheduler.Days[i] = config.Day{DayOfWeek: i} + } + } + + dayNames := []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"} + + s.templates.ExecuteTemplate(w, "character_settings.gohtml", CharacterSettings{ + Supervisor: supervisor, + Config: cfg, + DayNames: dayNames, + EnabledRuns: enabledRuns, + DisabledRuns: disabledRuns, + AvailableTZs: availableTZs, + RecipeList: config.AvailableRecipes, + }) +} diff --git a/internal/server/template_parameters.go b/internal/server/template_parameters.go new file mode 100644 index 000000000..9202b5e85 --- /dev/null +++ b/internal/server/template_parameters.go @@ -0,0 +1,40 @@ +package server + +import ( + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/koolo/internal/bot" + "github.com/hectorgimenez/koolo/internal/config" +) + +type IndexData struct { + ErrorMessage string + Version string + Status map[string]bot.Stats + DropCount map[string]int +} + +type DropData struct { + NumberOfDrops int + Character string + Drops []data.Drop +} + +type CharacterSettings struct { + ErrorMessage string + Supervisor string + Config *config.CharacterCfg + DayNames []string + EnabledRuns []string + DisabledRuns []string + AvailableTZs map[int]string + RecipeList []string +} + +type ConfigData struct { + ErrorMessage string + *config.KooloCfg +} + +type AutoSettings struct { + ErrorMessage string +} diff --git a/internal/server/templates/admin_required.gohtml b/internal/server/templates/admin_required.gohtml new file mode 100644 index 000000000..dc354784e --- /dev/null +++ b/internal/server/templates/admin_required.gohtml @@ -0,0 +1,18 @@ + + + + + + + + + Koolo Dashboard + + +
+
+

Windows administrator permissions are required to run Koolo, please execute koolo.exe as administrator.

+
+
+ + \ No newline at end of file diff --git a/internal/server/templates/character_settings.gohtml b/internal/server/templates/character_settings.gohtml new file mode 100644 index 000000000..69fbeef52 --- /dev/null +++ b/internal/server/templates/character_settings.gohtml @@ -0,0 +1,502 @@ +{{$topLevelContext := .}} + + + + + + + + + + + + Koolo Settings + + +
+ {{ if ne .ErrorMessage "" }} +
+
+
+
+ {{ .ErrorMessage }} +
+
+
+
+ {{ end }} +
+

General Settings


+
+ +
+ + +
+ +
Class-Specific Settings
+
+ + + + + + + + +
+ +
+ + +
+
+ + +
+ +

Client Settings


+
+ +
+
+ + + + + +
+

Battle.net settings


+
+ + + + +
+
+ +
+ +

Scheduler


+
+
+ +
+ +
+ {{ range $dayIndex := seq 0 6 }} +
+

{{ index $.DayNames $dayIndex }}

+
+ {{ $day := index $.Config.Scheduler.Days $dayIndex }} + {{ range $timeRange := $day.TimeRanges }} +
+ + to + + +
+ {{ end }} +
+ +
+ {{ end }} +
+ +

Health settings


+
+ + + + + +
+

Belt Layout


+
+ {{ range $index, $potionType := .Config.Inventory.BeltColumns }} + + {{ end }} +
+

Merc Settings


+ +
+ + + +
+

Inventory (Checked means locked)

+ + {{ range $rowIndex, $row := .Config.Inventory.InventoryLock }} + + {{ range $columnIndex, $unlocked := $row }} + + {{ end }} + + {{ end }} +
+ +
+

Game Settings


+
+
+ + +
+
+ + +
+

Run Settings


+
+
+ +
+
+
Enabled Runs:
+
    + {{ range $index, $run := .EnabledRuns }} +
  • +
    + + {{ $run }} + + + {{ executeTemplateByName $run $topLevelContext }} +
    +
  • + {{ end }} +
+
+
+
Disabled Runs:
+ +
    + {{ range $index, $run := .DisabledRuns }} +
  • +
    + + {{ $run }} + + + {{ executeTemplateByName $run $topLevelContext }} +
    +
  • + {{ end }} +
+
+
+

Gambling

+ +

Cube Recipes

+
+
+
+ +
+ {{ range $index, $recipe := .RecipeList }} + + {{ end }} +
+

Leader mode

+ + +

Back to Town Settings:

+
+ + + + +
+
+ + +
+
+
+
+ + diff --git a/internal/server/templates/config.gohtml b/internal/server/templates/config.gohtml new file mode 100644 index 000000000..7f92e394d --- /dev/null +++ b/internal/server/templates/config.gohtml @@ -0,0 +1,181 @@ + + + + + + + + + Koolo Settings + + +
+ {{ if ne .ErrorMessage "" }} +
+
+
+
+ {{ .ErrorMessage }} +
+
+
+
+ {{ end }} +
+

Settings

+
+
+ + + + + +

Debug

+
+ + +
+

Discord integration

+ + + + +
+ + + + + +
+

Telegram integration

+ + + +
+
+ {{ if not .FirstRun }} + + {{ end }} + +
+
+
+
+ + \ No newline at end of file diff --git a/internal/server/templates/debug.gohtml b/internal/server/templates/debug.gohtml new file mode 100644 index 000000000..4ec3428d8 --- /dev/null +++ b/internal/server/templates/debug.gohtml @@ -0,0 +1,47 @@ + + + + + + Koolo Debug Screen + + + +
+
+

Koolo Debug Screen

+ Development Version +
+
+
+
+
+ + +
+
+ + + + 0/0 +
+
+
+ + +
+
+
+
+ + + + \ No newline at end of file diff --git a/internal/server/templates/drops.gohtml b/internal/server/templates/drops.gohtml new file mode 100644 index 000000000..4466c29a9 --- /dev/null +++ b/internal/server/templates/drops.gohtml @@ -0,0 +1,341 @@ + + + + + + + + Drops for {{.Character}} + + + + +
+ + +
+ +
+

Drops for {{.Character}}

+

Total Drops: {{.NumberOfDrops}}

+
+
+
+ + +
+ +
+ + +
+ {{ range .Drops }} +
+
+
+ {{ if .Item.IdentifiedName }} + {{ .Item.IdentifiedName }} + {{ else }} + {{ .Item.Name }} + {{ end }} +
+
+
+ + {{ if not .Item.Identified }} +
+
Unidentified
+
+ {{ else }} + +
+ {{ range .Item.Stats }} + {{ if eq .ID 31 }} +
Defense: {{ .Value }}
+ {{ end }} + {{ end }} + + {{ $currentDur := 0 }} + {{ $maxDur := 0 }} + {{ range .Item.Stats }} + {{ if eq .ID 72 }} + {{ $currentDur = .Value }} + {{ end }} + {{ if eq .ID 73 }} + {{ $maxDur = .Value }} + {{ end }} + {{ end }} + {{ if and (ne $currentDur 0) (ne $maxDur 0) }} +
Durability: {{ $currentDur }} of {{ $maxDur }}
+ {{ end }} +
+ + +
+ + {{ range .Item.Stats }} + {{ if eq .ID 92 }} + {{ if ne .Value 0 }} +
{{ .String }}
+ {{ end }} + {{ end }} + {{ end }} + + + {{ range .Item.Stats }} + {{ if and (ne .ID 31) (ne .ID 72) (ne .ID 73) (ne .ID 21) (ne .ID 22) (ne .ID 23) (ne .ID 24) (ne .ID 68) (ne .ID 92) }} +
{{ .String }}
+ {{ end }} + {{ end }} + + {{ if .Item.Ethereal }} +
Ethereal (Cannot be Repaired)
+ {{ end }} +
+ {{ end }} + + {{ if .DropLocation }} +
+ Dropped in: {{ .DropLocation }} +
+ {{ end }} + + {{ if .Rule }} +
+ Stashed due to rule: {{ .Rule }} +
Rule location: {{ .RuleFile }} +
+ {{ end }} + +
+ {{ end }} +
+
+ + diff --git a/internal/server/templates/index.gohtml b/internal/server/templates/index.gohtml new file mode 100644 index 000000000..55ee87d16 --- /dev/null +++ b/internal/server/templates/index.gohtml @@ -0,0 +1,42 @@ + + + + + + + + + + Koolo Dashboard + + + +
+
+ +
+ + + \ No newline at end of file diff --git a/internal/server/templates/run_settings_components.gohtml b/internal/server/templates/run_settings_components.gohtml new file mode 100644 index 000000000..639dce0c1 --- /dev/null +++ b/internal/server/templates/run_settings_components.gohtml @@ -0,0 +1,182 @@ +{{ define "pit" }} +
+ + + + +
+{{ end }} + +{{ define "andariel" }} +
+ +
+{{ end }} + +{{ define "cows" }} +
+ +
+{{ end }} + +{{ define "pindleskin" }} +
+ +
+ + + + +
+
+{{ end }} + +{{ define "stony_tomb" }} +
+ + +
+{{ end }} + +{{ define "mausoleum" }} +
+ + +
+{{ end }} + +{{ define "ancient_tunnels" }} +
+ + +
+{{ end }} + +{{ define "drifter_cavern" }} +
+ + +
+{{ end }} + +{{ define "spider_cavern" }} +
+ + +
+{{ end }} + +{{ define "arachnid_lair" }} +
+ + +
+{{ end }} + +{{ define "mephisto" }} +
+ + + +
+{{ end }} + +{{ define "tristram" }} +
+ + +
+{{ end }} + +{{ define "nihlathak" }} +
+ +
+{{ end }} + +{{ define "baal" }} +
+ + + + + +
+{{ end }} + +{{ define "eldritch" }} +
+ +
+{{ end }} + +{{ define "lower_kurast_chest" }} +
+ +
+{{ end }} + +{{ define "diablo" }} +
+ Boss Options + + + +
+ +
+ Clear Options + + +
+{{ end }} + +{{ define "leveling" }} +
+ + +
+{{ end }} + +{{ define "quests" }} +
+ + + + + + + + + + + + + + + +
+{{ end }} + +{{ define "terror_zone" }} + {{$topLevelContext := .}} +
+ + + + +
+ + + + +
+ + {{ range $id, $name := .AvailableTZs }} + + {{ end }} +
+{{ end }} diff --git a/internal/single_supervisor.go b/internal/single_supervisor.go deleted file mode 100644 index 0cf01a2b8..000000000 --- a/internal/single_supervisor.go +++ /dev/null @@ -1,82 +0,0 @@ -package koolo - -import ( - "context" - "errors" - "fmt" - "math/rand" - "time" - - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/event" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/reader" - "github.com/hectorgimenez/koolo/internal/run" - "go.uber.org/zap" -) - -type Supervisor interface { - Start(ctx context.Context, factory *run.Factory) error - Stop() - TogglePause() -} - -type SinglePlayerSupervisor struct { - baseSupervisor -} - -func NewSinglePlayerSupervisor(logger *zap.Logger, bot *Bot, gr *reader.GameReader, gm *helper.GameManager) *SinglePlayerSupervisor { - return &SinglePlayerSupervisor{ - baseSupervisor: baseSupervisor{ - logger: logger, - bot: bot, - gr: gr, - gm: gm, - }, - } -} - -// Start will stay running during the application lifecycle, it will orchestrate all the required bot pieces -func (s *SinglePlayerSupervisor) Start(ctx context.Context, factory *run.Factory) error { - err := s.ensureProcessIsRunningAndPrepare() - if err != nil { - return fmt.Errorf("error preparing game: %w", err) - } - - firstRun := true - for { - select { - case <-ctx.Done(): - return nil - default: - if !s.gm.InGame() { - if err = s.gm.NewGame(); err != nil { - s.logger.Error(fmt.Sprintf("Error creating new game: %s", err.Error())) - continue - } - } - - runs := factory.BuildRuns() - gameStart := time.Now() - if config.Config.Game.RandomizeRuns { - rand.Shuffle(len(runs), func(i, j int) { runs[i], runs[j] = runs[j], runs[i] }) - } - s.logGameStart(runs) - err = s.bot.Run(ctx, firstRun, runs) - if err != nil { - if errors.Is(context.Canceled, ctx.Err()) { - return nil - } - errorMsg := fmt.Sprintf("Game finished with errors, reason: %s. Game total time: %0.2fs", err.Error(), time.Since(gameStart).Seconds()) - event.Events <- event.WithScreenshot(errorMsg) - s.logger.Warn(errorMsg) - } - if exitErr := s.gm.ExitGame(); exitErr != nil { - return fmt.Errorf("error exiting game: %s", exitErr) - } - firstRun = false - - s.updateGameStats() - } - } -} diff --git a/internal/supervisor.go b/internal/supervisor.go deleted file mode 100644 index eb6f77dd1..000000000 --- a/internal/supervisor.go +++ /dev/null @@ -1,134 +0,0 @@ -package koolo - -import ( - "bufio" - "errors" - "fmt" - "os" - "time" - - "github.com/go-vgo/robotgo" - "github.com/hectorgimenez/koolo/internal/event/stat" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" - "github.com/hectorgimenez/koolo/internal/reader" - "github.com/hectorgimenez/koolo/internal/run" - "github.com/lxn/win" - "go.uber.org/zap" -) - -type baseSupervisor struct { - logger *zap.Logger - bot *Bot - gr *reader.GameReader - gm *helper.GameManager -} - -func (s *baseSupervisor) TogglePause() { - s.bot.TogglePause() -} - -func (s *baseSupervisor) Stop() { - s.logger.Info("Shutting down NOW") - os.Exit(0) -} - -func (s *baseSupervisor) updateGameStats() { - if _, err := os.Stat("stats"); os.IsNotExist(err) { - err = os.MkdirAll("stats", 0700) - if err != nil { - s.logger.Error("Error creating stats directory", zap.Error(err)) - return - } - } - - fileName := fmt.Sprintf("stats/stats_%s.txt", stat.Status.ApplicationStartedAt.Format("2006-02-01-15_04_05")) - f, err := os.OpenFile(fileName, os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - s.logger.Error("Error writing game stats", zap.Error(err)) - return - } - w := bufio.NewWriter(f) - - for runName, rs := range stat.Status.RunStats { - var items = "" - for _, item := range rs.ItemsFound { - items += fmt.Sprintf("%s [%d]\n", item.Name, item.Quality) - } - avgRunTime := rs.TotalRunsTime.Seconds() / float64(rs.Errors+rs.Kills+rs.Deaths+rs.Chickens+rs.MerChicken) - statsRun := fmt.Sprintf("Stats for: %s\n"+ - " Run time: %0.2fs (Total) %0.2fs (Average)\n"+ - " Kills: %d\n"+ - " Deaths: %d\n"+ - " Chickens: %d\n"+ - " Merc Chickens: %d\n"+ - " Errors: %d\n"+ - " Used HP Potions: %d\n"+ - " Used MP Potions: %d\n"+ - " Used Rejuv Potions: %d\n"+ - " Used Merc HP Potions: %d\n"+ - " Used Merc Rejuv Potions: %d\n"+ - " Items: \n"+ - " %s", - runName, - rs.TotalRunsTime.Seconds(), avgRunTime, - rs.Kills, - rs.Deaths, - rs.Chickens, - rs.MerChicken, - rs.Errors, - rs.HealingPotionsUsed, - rs.ManaPotionsUsed, - rs.RejuvPotionsUsed, - rs.MercHealingPotionsUsed, - rs.MercRejuvPotionsUsed, - items, - ) - _, err = w.WriteString(statsRun + "\n") - if err != nil { - s.logger.Error("Error writing stats file", zap.Error(err)) - } - } - - w.Flush() - f.Close() -} - -func (s *baseSupervisor) ensureProcessIsRunningAndPrepare() error { - window := robotgo.FindWindow("Diablo II: Resurrected") - if window == win.HWND_TOP { - return errors.New("diablo II: Resurrected window can not be found! Ensure game is open") - } - win.SetForegroundWindow(window) - - pos := win.WINDOWPLACEMENT{} - point := win.POINT{} - win.ClientToScreen(window, &point) - win.GetWindowPlacement(window, &pos) - - hid.WindowLeftX = int(point.X + 1) - hid.WindowTopY = int(point.Y) - hid.GameAreaSizeX = int(pos.RcNormalPosition.Right) - hid.WindowLeftX - 9 - hid.GameAreaSizeY = int(pos.RcNormalPosition.Bottom) - hid.WindowTopY - 9 - helper.Sleep(1000) - - s.logger.Info(fmt.Sprintf( - "Diablo II: Resurrected window detected, offsetX: %d offsetY: %d. Game Area Size X: %d Y: %d", - hid.WindowLeftX, - hid.WindowTopY, - hid.GameAreaSizeX, - hid.GameAreaSizeY, - )) - - stat.Status.ApplicationStartedAt = time.Now() - return nil -} - -func (s *baseSupervisor) logGameStart(runs []run.Run) { - runNames := "" - for _, r := range runs { - runNames += r.Name() + ", " - } - stat.Status.TotalGames++ - s.logger.Info(fmt.Sprintf("Starting Game #%d. Run list: %s", stat.Status.TotalGames, runNames[:len(runNames)-2])) -} diff --git a/internal/town/A1.go b/internal/town/A1.go index 181bc6703..85d9fe6eb 100644 --- a/internal/town/A1.go +++ b/internal/town/A1.go @@ -2,7 +2,10 @@ package town import ( "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/d2go/pkg/data/object" + "github.com/hectorgimenez/koolo/internal/game" ) type A1 struct { @@ -28,8 +31,24 @@ func (a A1) RepairNPC() npc.ID { return npc.Charsi } -func (a A1) TPWaitingArea(d data.Data) data.Position { +func (a A1) IdentifyNPC() npc.ID { + return npc.DeckardCain5 +} + +func (a A1) TPWaitingArea(d game.Data) data.Position { + rogueBonfire, found := d.Objects.FindOne(object.RogueBonfire) + if found { + return data.Position{ + X: rogueBonfire.Position.X + 10, + Y: rogueBonfire.Position.Y + 10, + } + } + cain, _ := d.NPCs.FindOne(npc.Kashya) return cain.Positions[0] } + +func (a A1) TownArea() area.ID { + return area.RogueEncampment +} diff --git a/internal/town/A2.go b/internal/town/A2.go index d7a05c885..051f270fe 100644 --- a/internal/town/A2.go +++ b/internal/town/A2.go @@ -2,7 +2,9 @@ package town import ( "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/koolo/internal/game" ) type A2 struct { @@ -28,9 +30,17 @@ func (a A2) RepairNPC() npc.ID { return npc.Fara } -func (a A2) TPWaitingArea(_ data.Data) data.Position { +func (a A2) TPWaitingArea(_ game.Data) data.Position { return data.Position{ X: 5161, Y: 5059, } } + +func (a A2) IdentifyNPC() npc.ID { + return npc.DeckardCain2 +} + +func (a A2) TownArea() area.ID { + return area.LutGholein +} diff --git a/internal/town/A3.go b/internal/town/A3.go index 8a42315d6..7973560b3 100644 --- a/internal/town/A3.go +++ b/internal/town/A3.go @@ -2,7 +2,9 @@ package town import ( "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/koolo/internal/game" ) type A3 struct { @@ -28,9 +30,17 @@ func (a A3) RepairNPC() npc.ID { return npc.Hratli } -func (a A3) TPWaitingArea(_ data.Data) data.Position { +func (a A3) IdentifyNPC() npc.ID { + return npc.DeckardCain3 +} + +func (a A3) TPWaitingArea(_ game.Data) data.Position { return data.Position{ X: 5151, Y: 5068, } } + +func (a A3) TownArea() area.ID { + return area.KurastDocks +} diff --git a/internal/town/A4.go b/internal/town/A4.go index 23bd6d759..76b409b15 100644 --- a/internal/town/A4.go +++ b/internal/town/A4.go @@ -2,7 +2,9 @@ package town import ( "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/koolo/internal/game" ) type A4 struct { @@ -28,9 +30,17 @@ func (a A4) RepairNPC() npc.ID { return npc.Halbu } -func (a A4) TPWaitingArea(_ data.Data) data.Position { +func (a A4) IdentifyNPC() npc.ID { + return npc.DeckardCain4 +} + +func (a A4) TPWaitingArea(_ game.Data) data.Position { return data.Position{ X: 5047, Y: 5033, } } + +func (a A4) TownArea() area.ID { + return area.ThePandemoniumFortress +} diff --git a/internal/town/A5.go b/internal/town/A5.go index cd939febe..c7674afed 100644 --- a/internal/town/A5.go +++ b/internal/town/A5.go @@ -2,7 +2,9 @@ package town import ( "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/koolo/internal/game" ) type A5 struct { @@ -28,9 +30,17 @@ func (a A5) RepairNPC() npc.ID { return npc.Larzuk } -func (a A5) TPWaitingArea(_ data.Data) data.Position { +func (a A5) IdentifyNPC() npc.ID { + return npc.DeckardCain6 +} + +func (a A5) TPWaitingArea(_ game.Data) data.Position { return data.Position{ X: 5104, Y: 5019, } } + +func (a A5) TownArea() area.ID { + return area.Harrogath +} diff --git a/internal/town/shop_manager.go b/internal/town/shop_manager.go index b9d8072d2..df6dda278 100644 --- a/internal/town/shop_manager.go +++ b/internal/town/shop_manager.go @@ -2,91 +2,85 @@ package town import ( "fmt" - "math/rand" + "time" "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/item" "github.com/hectorgimenez/d2go/pkg/data/stat" - "github.com/hectorgimenez/koolo/internal/config" - "github.com/hectorgimenez/koolo/internal/health" - "github.com/hectorgimenez/koolo/internal/helper" - "github.com/hectorgimenez/koolo/internal/hid" + "github.com/hectorgimenez/d2go/pkg/nip" + "github.com/hectorgimenez/koolo/internal/context" + "github.com/hectorgimenez/koolo/internal/game" "github.com/hectorgimenez/koolo/internal/ui" - "go.uber.org/zap" ) -type ShopManager struct { - logger *zap.Logger - bm health.BeltManager -} - -func NewShopManager(logger *zap.Logger, bm health.BeltManager) ShopManager { - return ShopManager{ - logger: logger, - bm: bm, - } -} +func BuyConsumables(forceRefill bool) { + ctx := context.Get() -func (sm ShopManager) BuyConsumables(d data.Data, forceRefill bool) { - missingHealingPots := sm.bm.GetMissingCount(d, data.HealingPotion) - missingManaPots := sm.bm.GetMissingCount(d, data.ManaPotion) + missingHealingPots := ctx.BeltManager.GetMissingCount(data.HealingPotion) + missingManaPots := ctx.BeltManager.GetMissingCount(data.ManaPotion) - sm.logger.Debug(fmt.Sprintf("Buying: %d Healing potions and %d Mana potions", missingHealingPots, missingManaPots)) + ctx.Logger.Debug(fmt.Sprintf("Buying: %d Healing potions and %d Mana potions", missingHealingPots, missingManaPots)) // We traverse the items in reverse order because vendor has the best potions at the end - pot, found := sm.findFirstMatch(d, "superhealingpotion", "greaterhealingpotion", "healingpotion", "lighthealingpotion", "minorhealingpotion") + pot, found := findFirstMatch("superhealingpotion", "greaterhealingpotion", "healingpotion", "lighthealingpotion", "minorhealingpotion") if found && missingHealingPots > 0 { - sm.BuyItem(pot, missingHealingPots) + BuyItem(pot, missingHealingPots) missingHealingPots = 0 } - pot, found = sm.findFirstMatch(d, "supermanapotion", "greatermanapotion", "manapotion", "lightmanapotion", "minormanapotion") + pot, found = findFirstMatch("supermanapotion", "greatermanapotion", "manapotion", "lightmanapotion", "minormanapotion") // In Normal greater potions are expensive as we are low level, let's keep with cheap ones - if config.Config.Game.Difficulty == "normal" { - pot, found = sm.findFirstMatch(d, "manapotion", "lightmanapotion", "minormanapotion") + if ctx.CharacterCfg.Game.Difficulty == "normal" { + pot, found = findFirstMatch("manapotion", "lightmanapotion", "minormanapotion") } if found && missingManaPots > 0 { - sm.BuyItem(pot, missingManaPots) + BuyItem(pot, missingManaPots) missingManaPots = 0 } - if sm.ShouldBuyTPs(d) || forceRefill { - if _, found := d.Items.Find(item.TomeOfTownPortal, item.LocationInventory); !found { - sm.logger.Info("TP Tome not found, buying one...") - if itm, itmFound := d.Items.Find(item.TomeOfTownPortal, item.LocationVendor); itmFound { - sm.BuyItem(itm, 1) + if ShouldBuyTPs() || forceRefill { + if _, found := ctx.Data.Inventory.Find(item.TomeOfTownPortal, item.LocationInventory); !found { + ctx.Logger.Info("TP Tome not found, buying one...") + if itm, itmFound := ctx.Data.Inventory.Find(item.TomeOfTownPortal, item.LocationVendor); itmFound { + BuyItem(itm, 1) } } - sm.logger.Debug("Filling TP Tome...") - if itm, found := d.Items.Find(item.ScrollOfTownPortal, item.LocationVendor); found { - sm.buyFullStack(itm) + ctx.Logger.Debug("Filling TP Tome...") + if itm, found := ctx.Data.Inventory.Find(item.ScrollOfTownPortal, item.LocationVendor); found { + buyFullStack(itm) } } - if sm.ShouldBuyIDs(d) || forceRefill { - if _, found := d.Items.Find(item.TomeOfIdentify, item.LocationInventory); !found { - sm.logger.Info("ID Tome not found, buying one...") - if itm, itmFound := d.Items.Find(item.TomeOfIdentify, item.LocationVendor); itmFound { - sm.BuyItem(itm, 1) + if ShouldBuyIDs() || forceRefill { + if _, found := ctx.Data.Inventory.Find(item.TomeOfIdentify, item.LocationInventory); !found { + ctx.Logger.Info("ID Tome not found, buying one...") + if itm, itmFound := ctx.Data.Inventory.Find(item.TomeOfIdentify, item.LocationVendor); itmFound { + BuyItem(itm, 1) } } - sm.logger.Debug("Filling IDs Tome...") - if itm, found := d.Items.Find(item.ScrollOfIdentify, item.LocationVendor); found { - sm.buyFullStack(itm) + ctx.Logger.Debug("Filling IDs Tome...") + if itm, found := ctx.Data.Inventory.Find(item.ScrollOfIdentify, item.LocationVendor); found { + buyFullStack(itm) } } - if sm.ShouldBuyKeys(d) || forceRefill { - if itm, found := d.Items.Find(item.Key, item.LocationVendor); found { - sm.logger.Debug("Vendor with keys detected, provisioning...") - sm.buyFullStack(itm) + keyQuantity, shouldBuyKeys := ShouldBuyKeys() + if ctx.Data.PlayerUnit.Class != data.Assassin && (shouldBuyKeys || forceRefill) { + if itm, found := ctx.Data.Inventory.Find(item.Key, item.LocationVendor); found { + ctx.Logger.Debug("Vendor with keys detected, provisioning...") + + qty, _ := itm.FindStat(stat.Quantity, 0) + if (qty.Value + keyQuantity) <= 12 { + buyFullStack(itm) + } } } } -func (sm ShopManager) findFirstMatch(d data.Data, itemNames ...string) (data.Item, bool) { +func findFirstMatch(itemNames ...string) (data.Item, bool) { + ctx := context.Get() for _, name := range itemNames { - if itm, found := d.Items.Find(item.Name(name), item.LocationVendor); found { + if itm, found := ctx.Data.Inventory.Find(item.Name(name), item.LocationVendor); found { return itm, true } } @@ -94,93 +88,99 @@ func (sm ShopManager) findFirstMatch(d data.Data, itemNames ...string) (data.Ite return data.Item{}, false } -func (sm ShopManager) ShouldBuyTPs(d data.Data) bool { - portalTome, found := d.Items.Find(item.TomeOfTownPortal, item.LocationInventory) +func ShouldBuyTPs() bool { + portalTome, found := context.Get().Data.Inventory.Find(item.TomeOfTownPortal, item.LocationInventory) if !found { return true } - qty, found := portalTome.Stats[stat.Quantity] + qty, found := portalTome.FindStat(stat.Quantity, 0) - return qty.Value <= rand.Intn(5-1)+1 || !found + return qty.Value < 5 || !found } -func (sm ShopManager) ShouldBuyIDs(d data.Data) bool { - idTome, found := d.Items.Find(item.TomeOfIdentify, item.LocationInventory) +func ShouldBuyIDs() bool { + idTome, found := context.Get().Data.Inventory.Find(item.TomeOfIdentify, item.LocationInventory) if !found { return true } - qty, found := idTome.Stats[stat.Quantity] + qty, found := idTome.FindStat(stat.Quantity, 0) - return qty.Value <= rand.Intn(7-3)+1 || !found + return qty.Value < 10 || !found } -func (sm ShopManager) ShouldBuyKeys(d data.Data) bool { - keys, found := d.Items.Find(item.Key, item.LocationInventory) +func ShouldBuyKeys() (int, bool) { + keys, found := context.Get().Data.Inventory.Find(item.Key, item.LocationInventory) if !found { - return false + return 12, false } - qty, found := keys.Stats[stat.Quantity] - if found && qty.Value == 12 { - return false + qty, found := keys.FindStat(stat.Quantity, 0) + if found && qty.Value >= 12 { + return 12, false } - return true + return qty.Value, true } -func (sm ShopManager) SellJunk(d data.Data) { - for _, i := range ItemsToBeSold(d) { - if config.Config.Inventory.InventoryLock[i.Position.Y][i.Position.X] == 1 { - sm.SellItem(i) +func SellJunk() { + for _, i := range ItemsToBeSold() { + if context.Get().Data.CharacterCfg.Inventory.InventoryLock[i.Position.Y][i.Position.X] == 1 { + SellItem(i) } } } -func (sm ShopManager) SellItem(i data.Item) { +func SellItem(i data.Item) { + ctx := context.Get() screenPos := ui.GetScreenCoordsForItem(i) - hid.MovePointer(screenPos.X, screenPos.Y) - helper.Sleep(100) - hid.KeyDown("control") - helper.Sleep(50) - hid.Click(hid.LeftButton) - helper.Sleep(150) - hid.KeyUp("control") - helper.Sleep(500) - sm.logger.Debug(fmt.Sprintf("Item %s [%d] sold", i.Name, i.Quality)) + + time.Sleep(500 * time.Millisecond) + ctx.HID.ClickWithModifier(game.LeftButton, screenPos.X, screenPos.Y, game.CtrlKey) + time.Sleep(500 * time.Millisecond) + ctx.Logger.Debug(fmt.Sprintf("Item %s [%s] sold", i.Desc().Name, i.Quality.ToString())) } -func (sm ShopManager) BuyItem(i data.Item, quantity int) { +func BuyItem(i data.Item, quantity int) { + ctx := context.Get() screenPos := ui.GetScreenCoordsForItem(i) - hid.MovePointer(screenPos.X, screenPos.Y) - helper.Sleep(250) + + time.Sleep(250 * time.Millisecond) for k := 0; k < quantity; k++ { - hid.Click(hid.RightButton) - helper.Sleep(900) - sm.logger.Debug(fmt.Sprintf("Purchased %s [X:%d Y:%d]", i.Name, i.Position.X, i.Position.Y)) + ctx.HID.Click(game.RightButton, screenPos.X, screenPos.Y) + time.Sleep(900 * time.Millisecond) + ctx.Logger.Debug(fmt.Sprintf("Purchased %s [X:%d Y:%d]", i.Desc().Name, i.Position.X, i.Position.Y)) } } -func (sm ShopManager) buyFullStack(i data.Item) { +func buyFullStack(i data.Item) { screenPos := ui.GetScreenCoordsForItem(i) - hid.MovePointer(screenPos.X, screenPos.Y) - helper.Sleep(250) - hid.KeyDown("shift") - helper.Sleep(100) - hid.Click(hid.RightButton) - helper.Sleep(300) - hid.KeyUp("shift") - helper.Sleep(500) + + context.Get().HID.ClickWithModifier(game.RightButton, screenPos.X, screenPos.Y, game.ShiftKey) + time.Sleep(500 * time.Millisecond) } -func ItemsToBeSold(d data.Data) (items []data.Item) { - for _, itm := range d.Items.ByLocation(item.LocationInventory) { +func ItemsToBeSold() (items []data.Item) { + ctx := context.Get() + for _, itm := range ctx.Data.Inventory.ByLocation(item.LocationInventory) { if itm.IsFromQuest() { continue } - if config.Config.Inventory.InventoryLock[itm.Position.Y][itm.Position.X] == 1 { + if itm.Name == item.TomeOfTownPortal || itm.Name == item.TomeOfIdentify || itm.Name == item.Key || itm.Name == "WirtsLeg" { + continue + } + + if itm.IsRuneword { + continue + } + + if ctx.Data.CharacterCfg.Inventory.InventoryLock[itm.Position.Y][itm.Position.X] == 1 { + // If item is a full match will be stashed, we don't want to sell it + if _, result := ctx.Data.CharacterCfg.Runtime.Rules.EvaluateAll(itm); result == nip.RuleResultFullMatch && !itm.IsPotion() { + continue + } items = append(items, itm) } } diff --git a/internal/town/town.go b/internal/town/town.go index dbe8a8ca6..31e356923 100644 --- a/internal/town/town.go +++ b/internal/town/town.go @@ -4,6 +4,7 @@ import ( "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/d2go/pkg/data/npc" + "github.com/hectorgimenez/koolo/internal/game" ) type Town interface { @@ -12,17 +13,22 @@ type Town interface { RepairNPC() npc.ID MercContractorNPC() npc.ID GamblingNPC() npc.ID - TPWaitingArea(d data.Data) data.Position + IdentifyNPC() npc.ID + TPWaitingArea(d game.Data) data.Position + TownArea() area.ID } -func GetTownByArea(a area.Area) Town { - towns := map[area.Area]Town{ - area.RogueEncampment: A1{}, - area.LutGholein: A2{}, - area.KurastDocks: A3{}, - area.ThePandemoniumFortress: A4{}, - area.Harrogath: A5{}, +func GetTownByArea(a area.ID) Town { + switch a.Act() { + case 1: + return A1{} + case 2: + return A2{} + case 3: + return A3{} + case 4: + return A4{} } - return towns[a] + return A5{} } diff --git a/internal/ui/coordinates.go b/internal/ui/coordinates.go index 8dfb7314e..7ef609ac8 100644 --- a/internal/ui/coordinates.go +++ b/internal/ui/coordinates.go @@ -1,52 +1,115 @@ package ui -import ( - "github.com/hectorgimenez/d2go/pkg/data" - "github.com/hectorgimenez/d2go/pkg/data/item" -) - const ( - itemBoxSize = 33 - inventoryTopLeftX = 846 - inventoryTopLeftY = 369 - topCornerVendorWindowX = 109 - topCornerVendorWindowY = 147 - MercAvatarPositionX = 36 - MercAvatarPositionY = 39 + itemBoxSize = 33 + itemBoxSizeClassic = 35 + + inventoryTopLeftX = 846 + inventoryTopLeftXClassic = 663 + + inventoryTopLeftY = 369 + inventoryTopLeftYClassic = 379 + + topCornerVendorWindowX = 109 + topCornerVendorWindowXClassic = 275 + + topCornerVendorWindowY = 147 + topCornerVendorWindowYClassic = 149 + + topCornerCubeWindowX = 222 + topCornerCubeWindowXClassic = 398 + + topCornerCubeWindowY = 247 + topCornerCubeWindowYClassic = 239 + + MercAvatarPositionX = 36 + MercAvatarPositionXClassic = 208 + + MercAvatarPositionY = 39 + MercAvatarPositionYClassic = 53 + + CubeTransmuteBtnX = 273 + CubeTransmuteBtnXClassic = 451 + + CubeTransmuteBtnY = 411 + CubeTransmuteBtnYClassic = 405 + + CubeTakeItemX = 306 + CubeTakeItemXClassic = 484 + + CubeTakeItemY = 365 + CubeTakeItemYClassic = 358 + + WpTabStartX = 131 + WpTabStartXClassic = 266 + + WpTabStartY = 148 + WpTabStartYClassic = 95 - CubeTransmuteBtnX = 273 - CubeTransmuteBtnY = 411 + WpTabSizeX = 57 + WpTabSizeXClassic = 75 + + WpListPositionX = 200 + WpListPositionXClassic = 357 + + WpListStartY = 158 + WpListStartYClassic = 145 + + WpAreaBtnHeight = 41 + WpAreaBtnHeightClassic = 43 + + RepairButtonX = 390 + RepairButtonXClassic = 602 + + RepairButtonY = 515 + RepairButtonYClassic = 557 AnvilCenterX = 272 AnvilCenterY = 333 AnvilBtnX = 272 AnvilBtnY = 450 - QuestFirstTabX = 138 - QuestFirstTabY = 130 - QuestTabXInterval = 68 - MainSkillButtonX = 596 MainSkillButtonY = 693 SecondarySkillButtonX = 686 SecondarySkillButtonY = 693 - GambleRefreshButtonX = 390 - GambleRefreshButtonY = 515 -) + GambleRefreshButtonX = 390 + GambleRefreshButtonXClassic = 540 + + GambleRefreshButtonY = 515 + GambleRefreshButtonYClassic = 553 -func GetScreenCoordsForItem(itm data.Item) data.Position { - switch itm.Location { - case item.LocationVendor, item.LocationStash, item.LocationSharedStash1, item.LocationSharedStash2, item.LocationSharedStash3: - x := topCornerVendorWindowX + itm.Position.X*itemBoxSize + (itemBoxSize / 2) - y := topCornerVendorWindowY + itm.Position.Y*itemBoxSize + (itemBoxSize / 2) + SecondarySkillListFirstSkillX = 687 + MainSkillListFirstSkillX = 592 + SkillListFirstSkillY = 590 + SkillListSkillOffset = 45 - return data.Position{X: x, Y: y} - } + FirstMercFromContractorListX = 175 + FirstMercFromContractorListY = 142 - x := inventoryTopLeftX + itm.Position.X*itemBoxSize + (itemBoxSize / 2) - y := inventoryTopLeftY + itm.Position.Y*itemBoxSize + (itemBoxSize / 2) + StashGoldBtnX = 966 + StashGoldBtnXClassic = 754 - return data.Position{X: x, Y: y} -} + StashGoldBtnY = 526 + StashGoldBtnYClassic = 552 + + StashGoldBtnConfirmX = 547 + StashGoldBtnConfirmXClassic = 579 + + StashGoldBtnConfirmY = 388 + StashGoldBtnConfirmYClassic = 423 + + SwitchStashTabBtnX = 107 + SwitchStashTabBtnXClassic = 258 + + SwitchStashTabBtnY = 128 + SwitchStashTabBtnYClassic = 84 + + SwitchStashTabBtnTabSize = 82 + SwitchStashTabBtnTabSizeClassic = 96 + + CloseMiniPanelClassicX = 639 + CloseMiniPanelClassicY = 686 +) diff --git a/internal/ui/screen_cords.go b/internal/ui/screen_cords.go new file mode 100644 index 000000000..9cf879b22 --- /dev/null +++ b/internal/ui/screen_cords.go @@ -0,0 +1,19 @@ +package ui + +import ( + "github.com/hectorgimenez/koolo/internal/context" +) + +func GameCoordsToScreenCords(destinationX, destinationY int) (int, int) { + ctx := context.Get() + // Calculate diff between current player position and destination + diffX := destinationX - ctx.Data.PlayerUnit.Position.X + diffY := destinationY - ctx.Data.PlayerUnit.Position.Y + + // Transform cartesian movement (World) to isometric (screen) + // Helpful documentation: https://clintbellanger.net/articles/isometric_math/ + screenX := int((float32(diffX-diffY) * 19.8) + float32(ctx.GameReader.GameAreaSizeX/2)) + screenY := int((float32(diffX+diffY) * 9.9) + float32(ctx.GameReader.GameAreaSizeY/2)) + + return screenX, screenY +} diff --git a/internal/ui/template_finder.go b/internal/ui/template_finder.go deleted file mode 100644 index 4fafbf894..000000000 --- a/internal/ui/template_finder.go +++ /dev/null @@ -1,203 +0,0 @@ -package ui - -import ( - "fmt" - "image" - "io/fs" - "math" - "path/filepath" - "strings" - "time" - - "go.uber.org/zap" - - "gocv.io/x/gocv" -) - -type TemplateFinder struct { - templates map[string]Template - logger *zap.Logger -} - -type Template struct { - RGB gocv.Mat - GrayScale gocv.Mat - Mask gocv.Mat -} - -type TemplateMatch struct { - Score float32 - PositionX int - PositionY int - Found bool -} - -func NewTemplateFinder(logger *zap.Logger, templatesPath string) (*TemplateFinder, error) { - templates := map[string]Template{} - logger.Debug("Loading templates...") - start := time.Now() - err := filepath.Walk(templatesPath, func(path string, info fs.FileInfo, err error) error { - if err != nil { - return err - } - fileName := info.Name() - if !strings.Contains(fileName, ".webp") { - return nil - } - - mat := gocv.IMRead(path, gocv.IMReadUnchanged) - if mat.Empty() { - return nil - } - rgb := mat.Clone() - grayScale := mat.Clone() - gocv.CvtColor(mat, &rgb, gocv.ColorBGRAToBGR) - gocv.CvtColor(mat, &grayScale, gocv.ColorBGRAToGray) - - filename := strings.ReplaceAll(path, "assets\\", "") - filename = strings.ReplaceAll(filename, "\\", "_") - sanitizedName := strings.ReplaceAll(filename, ".webp", "") - - templates[sanitizedName] = Template{ - RGB: rgb, - GrayScale: grayScale, - Mask: createMask(mat), - } - - return nil - }) - if err != nil { - return nil, fmt.Errorf("error loading templates: %w", err) - } - - logger.Debug(fmt.Sprintf( - "Found a total of %d templates, loaded in %dms", - len(templates), - time.Since(start).Milliseconds()), - ) - - return &TemplateFinder{ - templates: templates, - logger: logger, - }, nil -} - -func createMask(tpl gocv.Mat) gocv.Mat { - mask := gocv.NewMat() - if tpl.Channels() > 3 { - imgChannel := gocv.Split(tpl) - gocv.Threshold(imgChannel[3], &mask, 1, 255, gocv.ThresholdBinary) - } - - return mask -} - -func (tf *TemplateFinder) MatchColorInArea(img image.Image, lowerColor, upperColor gocv.Scalar, x0, y0, x1, y1 int) TemplateMatch { - bigmat, err := gocv.ImageToMatRGB(img) - if err != nil { - return TemplateMatch{} - } - - mat := gocv.NewMat() - gocv.Resize(bigmat, &mat, image.Point{X: 1280, Y: 720}, 0, 0, gocv.InterpolationLinear) - croppedMat := mat.Region(image.Rect(x0, y0, x1, y1)) - mat = croppedMat.Clone() - gocv.CvtColor(mat, &mat, gocv.ColorBGRToHSV) - - lower := gocv.NewMatFromScalar(lowerColor, gocv.MatTypeCV8UC3) - upper := gocv.NewMatFromScalar(upperColor, gocv.MatTypeCV8UC3) - - //img2, _ := mat.ToImage() - //f, _ := os.Create("sc_hsv.png") - //png.Encode(f, img2) - //f.Close() - //lowerImg, _ := lower.ToImage() - //f, _ = os.Create("lower.png") - //png.Encode(f, lowerImg) - //f.Close() - // - //upperImg, _ := upper.ToImage() - //f, _ = os.Create("upper.png") - //png.Encode(f, upperImg) - //f.Close() - - mask := gocv.NewMat() - gocv.InRange(mat, lower, upper, &mask) - - coloredPixels := gocv.CountNonZero(mask) - percentageColored := (float64(coloredPixels) / float64(mask.Total())) * 100.0 - - tf.logger.Debug("Color matching operation", zap.Float64("score", percentageColored)) - if percentageColored > 75 { - return TemplateMatch{ - Score: float32(percentageColored), - Found: true, - } - } - - return TemplateMatch{} -} - -func (tf *TemplateFinder) FindInArea(tplName string, img image.Image, x0, y0, x1, y1 int) TemplateMatch { - t := time.Now() - threshold := float32(0.80) - colorDiffThreshold := float64(75) - - bigmat, err := gocv.ImageToMatRGB(img) - if err != nil { - return TemplateMatch{} - } - - mat := gocv.NewMat() - gocv.Resize(bigmat, &mat, image.Point{X: 1280, Y: 720}, 0, 0, gocv.InterpolationLinear) - - if x0 > 0 && y0 > 0 && x1 > 0 && y1 > 0 { - croppedMat := mat.Region(image.Rect(x0, y0, x1, y1)) - mat = croppedMat.Clone() - //croppedImg, _ := mat.ToImage() - //f, _ := os.Create("sc.png") - //png.Encode(f, croppedImg) - //f.Close() - } - - tpl, ok := tf.templates[tplName] - if !ok { - tf.logger.Error("Template not found", zap.String("template", tplName)) - return TemplateMatch{ - Score: 0, - Found: false, - } - } - - res := gocv.NewMat() - gocv.MatchTemplate(mat, tpl.RGB, &res, gocv.TmCcoeffNormed, tpl.Mask) - _, maxVal, _, maxPos := gocv.MinMaxLoc(res) - if maxVal > threshold { - region := mat.Region(image.Rect(maxPos.X, maxPos.Y, maxPos.X+tpl.RGB.Cols(), maxPos.Y+tpl.RGB.Rows())) - regionMean := region.Mean() - tplMean := tpl.RGB.Mean() - absDiff := math.Abs((regionMean.Val1 - tplMean.Val1) + (regionMean.Val2 - tplMean.Val2) + (regionMean.Val3 - tplMean.Val3)) - if absDiff < colorDiffThreshold { - tf.logger.Debug(fmt.Sprintf( - "Found Template (%dms): %s Score: %f, ColorDiff: %f", - time.Since(t).Milliseconds(), - tplName, - maxVal, - absDiff, - )) - - return TemplateMatch{ - Score: maxVal, - PositionX: maxPos.X, - PositionY: maxPos.Y, - Found: true, - } - } - } - - return TemplateMatch{} -} - -func (tf *TemplateFinder) Find(tplName string, img image.Image) TemplateMatch { - return tf.FindInArea(tplName, img, 0, 0, 0, 0) -} diff --git a/internal/ui/ui_manager.go b/internal/ui/ui_manager.go new file mode 100644 index 000000000..de6cc0d43 --- /dev/null +++ b/internal/ui/ui_manager.go @@ -0,0 +1,56 @@ +package ui + +import ( + "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/item" + "github.com/hectorgimenez/koolo/internal/context" +) + +func GetScreenCoordsForItem(itm data.Item) data.Position { + ctx := context.Get() + if ctx.GameReader.LegacyGraphics() { + return getScreenCoordsForItemClassic(itm) + } + + return getScreenCoordsForItem(itm) +} + +func getScreenCoordsForItem(itm data.Item) data.Position { + switch itm.Location.LocationType { + case item.LocationVendor, item.LocationStash, item.LocationSharedStash: + x := topCornerVendorWindowX + itm.Position.X*itemBoxSize + (itemBoxSize / 2) + y := topCornerVendorWindowY + itm.Position.Y*itemBoxSize + (itemBoxSize / 2) + + return data.Position{X: x, Y: y} + case item.LocationCube: + x := topCornerCubeWindowX + itm.Position.X*itemBoxSize + (itemBoxSize / 2) + y := topCornerCubeWindowY + itm.Position.Y*itemBoxSize + (itemBoxSize / 2) + + return data.Position{X: x, Y: y} + } + + x := inventoryTopLeftX + itm.Position.X*itemBoxSize + (itemBoxSize / 2) + y := inventoryTopLeftY + itm.Position.Y*itemBoxSize + (itemBoxSize / 2) + + return data.Position{X: x, Y: y} +} + +func getScreenCoordsForItemClassic(itm data.Item) data.Position { + switch itm.Location.LocationType { + case item.LocationVendor, item.LocationStash, item.LocationSharedStash: + x := topCornerVendorWindowXClassic + itm.Position.X*itemBoxSizeClassic + (itemBoxSizeClassic / 2) + y := topCornerVendorWindowYClassic + itm.Position.Y*itemBoxSizeClassic + (itemBoxSizeClassic / 2) + + return data.Position{X: x, Y: y} + case item.LocationCube: + x := topCornerCubeWindowXClassic + itm.Position.X*itemBoxSizeClassic + (itemBoxSizeClassic / 2) + y := topCornerCubeWindowYClassic + itm.Position.Y*itemBoxSizeClassic + (itemBoxSizeClassic / 2) + + return data.Position{X: x, Y: y} + } + + x := inventoryTopLeftXClassic + itm.Position.X*itemBoxSizeClassic + (itemBoxSizeClassic / 2) + y := inventoryTopLeftYClassic + itm.Position.Y*itemBoxSizeClassic + (itemBoxSizeClassic / 2) + + return data.Position{X: x, Y: y} +} diff --git a/internal/utils/image.go b/internal/utils/image.go new file mode 100644 index 000000000..02dfeca89 --- /dev/null +++ b/internal/utils/image.go @@ -0,0 +1,17 @@ +package utils + +import ( + "image" + "image/jpeg" + "os" +) + +func SaveImageJPEG(img image.Image, path string) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + + return jpeg.Encode(f, img, &jpeg.Options{Quality: 80}) +} diff --git a/internal/helper/randomness.go b/internal/utils/randomness.go similarity index 93% rename from internal/helper/randomness.go rename to internal/utils/randomness.go index 1b3e178f5..50c92c63c 100644 --- a/internal/helper/randomness.go +++ b/internal/utils/randomness.go @@ -1,4 +1,4 @@ -package helper +package utils import ( "math/rand" diff --git a/internal/helper/sleep.go b/internal/utils/sleep.go similarity index 95% rename from internal/helper/sleep.go rename to internal/utils/sleep.go index 7f26e36c6..d7d62e70f 100644 --- a/internal/helper/sleep.go +++ b/internal/utils/sleep.go @@ -1,4 +1,4 @@ -package helper +package utils import ( "time" diff --git a/internal/utils/spiral.go b/internal/utils/spiral.go new file mode 100644 index 000000000..908b19188 --- /dev/null +++ b/internal/utils/spiral.go @@ -0,0 +1,31 @@ +package utils + +import ( + "math" +) + +func Spiral(position int) (int, int) { + t := position * 40 + a := 4.0 + b := -2.0 + trad := float64(t) * math.Pi / 180.0 + x := (a + b*trad) * math.Cos(trad) + y := (a + b*trad) * math.Sin(trad) + + return int(x), int(y) +} + +func ItemSpiral(position int) (int, int) { + t := position * 25 + + a := 3.0 // - a controls the starting radius + b := -1.5 // - b controls how quickly the spiral expands + + // Convert to radians and calculate position + trad := float64(t) * math.Pi / 180.0 + + x := (a + b*trad) * math.Cos(trad) + y := (a + b*trad) * math.Sin(trad) + + return int(x), int(y) +} diff --git a/internal/utils/windows.go b/internal/utils/windows.go new file mode 100644 index 000000000..e7071300e --- /dev/null +++ b/internal/utils/windows.go @@ -0,0 +1,21 @@ +package utils + +import ( + "os" + "syscall" + + "golang.org/x/sys/windows" +) + +func HasAdminPermission() bool { + _, err := os.Open("\\\\.\\PHYSICALDRIVE0") + + return err == nil +} + +func ShowDialog(title, message string) { + t, _ := syscall.UTF16PtrFromString(title) + txt, _ := syscall.UTF16PtrFromString(message) + + windows.MessageBox(0, txt, t, 0) +} diff --git a/internal/utils/winproc/gdi32.go b/internal/utils/winproc/gdi32.go new file mode 100644 index 000000000..b614df608 --- /dev/null +++ b/internal/utils/winproc/gdi32.go @@ -0,0 +1,13 @@ +package winproc + +import "golang.org/x/sys/windows" + +var ( + GDI32 = windows.NewLazySystemDLL("gdi32.dll") + CreateCompatibleDC = GDI32.NewProc("CreateCompatibleDC") + CreateCompatibleBitmap = GDI32.NewProc("CreateCompatibleBitmap") + SelectObject = GDI32.NewProc("SelectObject") + DeleteObject = GDI32.NewProc("DeleteObject") + DeleteDC = GDI32.NewProc("DeleteDC") + GetDIBits = GDI32.NewProc("GetDIBits") +) diff --git a/internal/utils/winproc/kernel32.go b/internal/utils/winproc/kernel32.go new file mode 100644 index 000000000..24dad312d --- /dev/null +++ b/internal/utils/winproc/kernel32.go @@ -0,0 +1,13 @@ +package winproc + +import "golang.org/x/sys/windows" + +const ( + EXECUTION_STATE_ES_DISPLAY_REQUIRED = 0x00000002 + EXECUTION_STATE_ES_CONTINUOUS = 0x80000000 +) + +var ( + KERNEL32 = windows.NewLazySystemDLL("kernel32.dll") + SetThreadExecutionState = KERNEL32.NewProc("SetThreadExecutionState") +) diff --git a/internal/utils/winproc/user32.go b/internal/utils/winproc/user32.go new file mode 100644 index 000000000..dba8e8987 --- /dev/null +++ b/internal/utils/winproc/user32.go @@ -0,0 +1,14 @@ +package winproc + +import "golang.org/x/sys/windows" + +var ( + USER32 = windows.NewLazySystemDLL("user32.dll") + PrintWindow = USER32.NewProc("PrintWindow") + GetWindowDC = USER32.NewProc("GetWindowDC") + SetProcessDpiAware = USER32.NewProc("SetProcessDPIAware") + SetWindowText = USER32.NewProc("SetWindowTextW") + GetKeyState = USER32.NewProc("GetKeyState") + GetWindowText = USER32.NewProc("GetWindowTextW") + MapVirtualKey = USER32.NewProc("MapVirtualKeyW") +) diff --git a/prepare_env.bat b/prepare_env.bat deleted file mode 100644 index 28b782c93..000000000 --- a/prepare_env.bat +++ /dev/null @@ -1,7 +0,0 @@ -@echo off - -echo Installing dependencies... -go mod download - -echo Building OpenCV... (this will take a while) -%GOPATH%\pkg\mod\gocv.io\x\gocv@v0.35.0\win_build_opencv.cmd static diff --git a/tools/handle64.exe b/tools/handle64.exe new file mode 100644 index 000000000..c822dc9bd Binary files /dev/null and b/tools/handle64.exe differ diff --git a/koolo-map.exe b/tools/koolo-map.exe similarity index 100% rename from koolo-map.exe rename to tools/koolo-map.exe