diff --git a/Readme.md b/Readme.md index 573d7b10..95e844b1 100644 --- a/Readme.md +++ b/Readme.md @@ -16,561 +16,20 @@ https://github.com/40Cakes/pokebot-gen3/assets/16377135/e6cea062-895e-411a-86fb- |![image](https://github.com/40Cakes/pokebot-gen3/assets/16377135/69230b70-24f2-46b3-bb7e-54241785a932)|![image](https://github.com/40Cakes/pokebot-gen3/assets/16377135/613e73b8-bc20-46aa-92c1-168d566f4e66)|![image](https://github.com/40Cakes/pokebot-gen3/assets/16377135/a8c0f5be-9b81-4be6-8a71-cdf909ef0df0)| # 📖 Preamble -- This is still in development, as such, functionality is subject to change without warning - always make sure you back up your `profiles//` folder before updating your bot! +- This is still in development, as such, functionality is subject to change - always make sure you back up your `profiles` folders before updating your bot! - Reach out in Discord [#bot-support-libmgba❔](https://discord.com/channels/1057088810950860850/1139190426834833528) if you have any issues -The bot is frame perfect and can cheat by reading data from any point in memory. By default it will attempt to perform most actions as if a human were playing to make gameplay as representative as possible, some examples: +The bot is frame perfect and can _technically_ cheat by reading data from any point in memory. By default it will attempt to perform actions as if a human were playing to make gameplay as representative as possible, some examples: - Starter Pokémon are generated just _1 frame_ after confirming the starter selection, the bot will wait until the battle begins, and the starter Pokémon sprite is visible before resetting - It's possible to peek inside un-hatched eggs to view stats and shininess as soon as they're received from the daycare, the bot will wait until the eggs are fully hatched before checking and logging -- These are intentional design decisions, bot [cheats](#cheatsyml---cheats-config) can be used to bypass them (in most cases) +- These are intentional design decisions, bot [cheats](https://github.com/40Cakes/pokebot-gen3/wiki/%F0%9F%92%8E-Cheats) can be used to bypass them (in most cases) *** -# ⚠ Photosensitivity Warning -- Running mGBA at unbound speeds, will cause **very fast and bright flashing**! -- Any unbounded video examples on this page will be hidden by default, and marked with **⚠ photosensitivity warning** - -*** - -# 🔒 Prerequisites -### Operating Systems - -- Windows (**64-bit**) -- Linux (**64-bit**) - - Note: only tested and confirmed working on **Ubuntu 23.04, 23.10** and **Debian 12** - -### Download the Bot -To download the latest bot from GitHub, go to the top of the page > click the green **Code** button > **Download ZIP**. - -Alternatively, if you'd like to be able to easily pull the latest updates without re-downloading the entire ZIP: -- Install [GitHub Desktop](https://desktop.github.com/) (you don't need an account) -- Click **Clone a repository from the Internet...** -- Use repository URL `https://github.com/40Cakes/pokebot-gen3.git` and choose a save location on your PC -- Click **Clone** -- Any time there's a new update, you can pull the latest changes by clicking **Fetch origin**, then **Pull origin** - -### Requirements -- [Python 3.12](https://www.python.org/downloads/release/python-3120/) (**64-bit**) -- **Linux** only: Install the following packages with `apt` or appropriate package manager: `sudo apt install python3-tk libmgba0.10 portaudio19-dev` -- **Note**: running the bot will **automatically** install required Python packages and download + extract [libmgba](https://github.com/hanzi/libmgba-py) - if you're using Python for any other projects, consider using a [venv](https://docs.python.org/3/library/venv.html) to isolate these packages from your base environment - -### Optional -- [Windows Terminal](https://github.com/microsoft/terminal/releases) - recommended for full 🌈colour🌈 and ✨emoji support✨ in the console output -- [Notepad++](https://notepad-plus-plus.org/) - recommended for syntax highlighting while editing `.yml` config files - -*** - -# ❓ How To Run -- Place some **official** Pokémon .gba ROMs into the `roms/` folder -- Double click `pokebot.py` or run `python pokebot.py` in a terminal and follow the on-screen steps to create and/or select a profile - -The bot ships with the default mGBA input mapping, see [`profiles/keys.yml`](#keysyml---emulator-input-mapping) to view the default mapping, or customise them to your preference. - -The bot will pause once a shiny is encountered. You **must** ensure you are able to escape battle **100% of the time**, otherwise the bot will get stuck. Auto-catching and other features will be added in due time. - -*** - -# 💾 Import a Save -If you have a save from mGBA that you'd like to import and use with the bot, then you will need to import the save state. - -- In mGBA, run a game and load into the save file -- **File** > **Save State File...** > **Save** -- Double click `pokebot.py` or run `python pokebot.py` in a terminal > type a profile **name** > click **Load Existing Save** -- Open the save state file you just saved -- A new bot profile will be created in the `profiles/` folder and set up all required files - -*** - -# 🌍 Supported Games and Languages -Variations of games, languages and revisions may have different memory offsets, there will be a table of supported/tested variations under each bot mode listed below. - -- ✅ Supported (tested) -- 🟨 Supported (not tested) -- ❌ Not supported - -ROM hacks will likely not work, and are ❌ **not supported** or planned to be supported! - -The ROMs in the `roms/` folder are checked and verified against a list of all known official gen3 game hashes. If you **really** want to test a ROM hack with the bot, you must add the SHA1 hash of the ROM to `modules/Roms.py`. - -The SHA1 hash of a ROM can be calculated with any of the following methods: -- [ROM Hasher](https://www.romhacking.net/utilities/1002/) -- Windows Powershell: `Get-FileHash 'rom_name.gba' -Algorithm SHA1` -- Linux: `sha1sum 'rom_name.gba'` - -Please do not seek support or complain if you find that your ROM hack does not work with the bot. - -*** - -# 🤖 Bot Modes -- The bot mode can be changed at any time while the bot is running by using the menu on the UI -- `Manual` mode is the default mode -- Press `Tab` to toggle between `Manual` mode and a previously selected mode - -*** -## 🔧 Manual -Manual mode simply disables all bot inputs, allowing you to track encounters and stats on your own shiny hunts as you play the game normally. - -## 🔄 Spin -Spin clockwise on a single tile, useful for Safari Zone and [repel tricking](https://bulbapedia.bulbagarden.net/wiki/Appendix:Repel_trick) as it doesn't count steps - -Start the mode while in the overworld, in any patch of grass/water/cave. - -
-🎥 Click here to show a video example - -https://github.com/40Cakes/pokebot-gen3/assets/16377135/32ced886-062b-483b-86c4-11be8ce55943 - -
- -
-✅🟨❌ Click here for support information - -| | 🟥 Ruby | 🔷 Sapphire | 🟢 Emerald | 🔥 FireRed | 🌿 LeafGreen | -|:---------|:----:|:--------:|:-------:|:-------:|:---------:| -| English | ✅ | ✅ | ✅ | ✅ | ✅ | -| Japanese | 🟨 | 🟨 | 🟨 | 🟨 | 🟨 | -| German | 🟨 | 🟨 | 🟨 | 🟨 | 🟨 | -| Spanish | 🟨 | 🟨 | 🟨 | 🟨 | 🟨 | -| French | 🟨 | 🟨 | 🟨 | 🟨 | 🟨 | -| Italian | 🟨 | 🟨 | 🟨 | 🟨 | 🟨 | -
- -## 💼 Starters -Soft reset for starter Pokémon. - -
-🎥 Click here to show a video example - -https://github.com/40Cakes/pokebot-gen3/assets/16377135/54f7f774-8cc1-4c6e-a6f7-b8474b66637b - -
- -- For modes that use soft resets such as starters, the bot will track RNG to ensure a unique frame is hit after every reset, this is to prevent repeatedly generating an identical Pokémon, this will cause soft resets to take progressively longer over time -- If resets begin to take too long, it is recommended to start a new save file with a different TID to reset this delay or check out [`profiles/cheats.yml`](#cheatsyml---cheats-config) -- **Note**: Even though you set the trainer to face the desired PokéBall, it is still important to set the correct `starter` in the config! This option is used by the bot to track frames to ensure a unique starter is generated every time -- **Note**: For the time being, Johto starters will automatically enable the `starters` option in [`profiles/cheats.yml`](#cheatsyml---cheats-config), the shininess of the starter is checked via memhacks as start menu navigation is WIP (in future, shininess will be checked via the party summary menu) - -### FireRed and LeafGreen (Kanto) -1. Select the `starter` in `profiles/general.yml` - `Bulbasaur`, `Charmander` or `Squirtle` -2. Face the desired PokéBall in Oak's lab, save the game (**in-game, not a save state**) -3. Start the bot - -### Emerald (Johto) -1. Select the `starter` in `profiles/general.yml` - `Chikorita`, `Cyndaquil` or `Totodile` -2. Face the desired PokéBall in Birch's lab, save the game (**in-game, not a save state**) -3. Start the bot - -### Ruby, Sapphire and Emerald (Hoenn) -1. Select the `starter` in `profiles/general.yml` - `Treecko`, `Torchic` or `Mudkip` -2. Face the starters bag, and save the game (**in-game, not a save state**) -3. Start the bot - -
-✅🟨❌ Click here for support information - -| | 🟥 Ruby | 🔷 Sapphire | 🟢 Emerald | 🔥 FireRed | 🌿 LeafGreen | -|:---------|:-------:|:-----------:|:----------:|:----------:|:------------:| -| English | ✅ | ✅ | ✅ | ✅ | ✅ | -| Japanese | - | - | - | - | - | -| German | - | - | - | - | - | -| Spanish | - | - | - | - | - | -| French | - | - | - | - | - | -| Italian | - | - | - | - | - | -
- -## 🎣 Fishing -Start the mode while facing the water, with any fishing rod registered. -
-🎥 Click here to show a video example - -https://github.com/40Cakes/pokebot-gen3/assets/16377135/4317ba99-8854-4ce5-b054-d6bf652c7b28 - -
- -
-✅🟨❌ Click here for support information - -| | 🟥 Ruby | 🔷 Sapphire | 🟢 Emerald | 🔥 FireRed | 🌿 LeafGreen | -|:---------|:----:|:--------:|:-------:|:-------:|:---------:| -| English | ✅ | ✅ | ✅ | ✅ | ✅ | -| Japanese | - | - | - | - | - | -| German | - | - | - | - | - | -| Spanish | - | - | - | - | - | -| French | - | - | - | - | - | -| Italian | - | - | - | - | - | -
- -## 🚲 Bunny Hop -Bunny hop on the spot with the [Acro Bike](https://bulbapedia.bulbagarden.net/wiki/Acro_Bike), useful for Safari Zone and [repel tricking](https://bulbapedia.bulbagarden.net/wiki/Appendix:Repel_trick) as it doesn't count steps. - -Start the mode while in the overworld, in any patch of grass/cave, with the Acro Bike registered. -- **Note**: `Bunny Hop` is ~10% slower encounters/h on average than `spin` mode - -
-🎥 Click here to show a video example - -https://github.com/40Cakes/pokebot-gen3/assets/16377135/bedbd712-c57c-4d26-923b-ee3fd314afe3 - -
- -
-✅🟨❌ Click here for support information - -| | 🟥 Ruby | 🔷 Sapphire | 🟢 Emerald | -|:---------|:----:|:--------:|:-------:| -| English | ✅ | ✅ | ✅ | -| Japanese | - | - | - | -| German | - | - | - | -| Spanish | - | - | - | -| French | - | - | - | -| Italian | - | - | - | -
- -*** - -# 🛠 Configuration -Configuration files are loaded and validated against a schema, once at bot launch. Any changes made while the bot is running will not take effect until the bot is stopped and restarted. - -## 🚧 Work in progress 🚧 -A lot of the config in `.yml` files is placeholder for future/planned features. - -## Multi-instance botting -The bot stores all profile information, such as save games, screenshots, statistics, etc. in the profile `profiles//`) folder, which is automatically created once you create a new profile in the GUI. - -Running multiple instances of the bot is as easy as starting the bot multiple times and loading a different profile each time. You should **not** run multiple instances of the bot with the same profile simultaneously! - -Statistics are saved into a subfolder of your profile `profiles//stats/`. - -The bot will first attempt to load individual config files from your profile folder (`profiles//`), if that folder does not exist or any of the configuration files are missing, it will load the default config file in the `profiles/` folder. This allows you to selectively override specific config files on a per-profile basis. - -Example: -``` -├── /profiles - │ - ├── /emerald-profile - │ current_save.sav - │ current_state.ss1 - │ discord.yml <-- config loaded for 'emerald-profile' - │ general.yml <-- config loaded for 'emerald-profile' - │ - ├── /firered-profile - │ current_save.sav - │ current_state.ss1 - │ general.yml <-- config loaded for 'firered-profile' - │ - │ catch_block.yml <-- config loaded for all profiles - │ cheats.yml <-- config loaded for all profiles - │ customcatchfilters.py <-- config loaded for all profiles - │ customhooks.py <-- config loaded for all profiles - │ discord.yml <-- config loaded for all profiles except 'emerald-profile' - │ general.yml <-- config loaded for all profiles except 'emerald-profile' and 'firered-profile' - │ logging.yml <-- config loaded for all profiles - │ obs.yml <-- config loaded for all profiles -``` - -## `keys.yml` - Emulator input mapping -This file controls keyboard to GBA button mappings. - -- For a full list of available key codes, see [here](https://www.tcl.tk/man/tcl8.4/TkCmd/keysyms.html) or [here](https://anzeljg.github.io/rin2/book2/2405/docs/tkinter/key-names.html) (column `.keysym`) - -### Default Input Mapping -- A button: `X` -- B button: `Z` -- D-Pad: Arrow keys (`Up`, `Down`, `Left`, `Right`) -- Start button: `Enter` -- Select button: `Backspace` -- Toggle manual bot mode on/off: `Tab` -- Toggle video output on/off: `V` -- Toggle audio output on/off: `B` -- Zoom window scaling in/out: `+`, `-` -- Create save state: `Ctrl + S` -- Load save state menu: `Ctrl + L` -- Reset emulator/reboot game: `Ctrl + R` -- Exit the bot and emulator: `Ctrl + Q` -- Emulator speed: - - 1x speed: `1` - - 2x speed: `2` - - 3x speed: `3` - - 4x speed: `4` - - Unbound: `0` - **⚠ Photosensitivity warning**: this will run the emulator as fast as possible! - -## `general.yml` - General config -
-Click to expand - -### General -`starter` - choose which starter Pokémon to hunt for, used when bot mode is set to `starters` (see [💼 starters](#-starters)) - -
- -## `logging.yml` - Logging and console output config -
-Click to expand - -### Logging -`log_encounters` - log all encounters to .csv (`stats/encounters/` folder), each phase is logged to a separate file - -### Console output -The following `console` options will control how much data is displayed in the Python terminal/console, valid options are `verbose`, `basic` or `disable` -- `encounter_data` -- `encounter_ivs` -- `encounter_moves` -- `statistics` - -### Save raw Pokémon data (.pk3) -The bot can dump individual Pokémon files (.pk3 format) to be managed/transferred in the [PKHeX save editor](https://github.com/kwsch/PKHeX). - -The Pokémon are dumped to the `pokemon/` folder in your profile, in the following format: - -`273 ★ - SEEDOT - Modest [180] - C88CF14B19C6.pk3` (` - - [] - .pk3`) - -`save_pk3`: -- `all` - dump all encounters -- `shiny` - dump shiny encounters -- `custom` - dump custom catch filter encounters - -Feel free to share any rare/interesting .pk3 files in [#pkhexchange💱](https://discord.com/channels/1057088810950860850/1123523909745135616)! - -### Automatically add Pokémon to PC storage (.pk3) -While auto-catch is currently still a work in progress, the following option automatically import encountered Pokémon into your PC storage. - -Imported Pokémon will be placed into the first available PC slot, in a regular PokéBall. - -If space is available in the PC, and the Pokémon was successfully imported, the bot will run from the encounter and continue to hunt. - -`import_pk3` - enable automatic .pk3 import to PC storage +# ❓ Getting Started +Visit the [wiki](https://github.com/40Cakes/pokebot-gen3/wiki) for information on running the bot. -
- -## `discord.yml` - Discord integration config - -
-Click to expand - -### Discord -For privacy reasons, rich presence and webhooks are **disabled** by default. - -### Discord rich presence -`rich_presence` - Rich presence will display information on your Discord profile such as game, route, total encounters, total shinies and encounter rate. - -![Discord_tC7ni4A9L4](https://github.com/40Cakes/pokebot-gen3/assets/16377135/ece7cc12-b97a-45cc-a06e-afd679860ce1) - -### Discord webhooks -`global_webhook_url` - global Discord webhook URL, default webhook for all Discord webhooks unless specified otherwise -- ⚠ **Warning**: this webhook is considered sensitive! If you leak your webhook, anyone will be able to post in your channel -- **Edit Channel** > **Integrations** > **Webhooks** > **New Webhook** > **Copy Webhook URL** to generate a new webhook - -`iv_format` - changes IV formatting displayed in messages, set to `basic` or `formatted` -- `basic`:
`HP: 31 | ATK: 31 | DEF: 31 | SPA: 31 | SPD: 31 | SPE: 31` - -- `formatted`: - ``` - ╔═══╤═══╤═══╤═══╤═══╤═══╗ - ║HP │ATK│DEF│SPA│SPD│SPE║ - ╠═══╪═══╪═══╪═══╪═══╪═══╣ - ║31 │31 │31 │31 │31 │31 ║ - ╚═══╧═══╧═══╧═══╧═══╧═══╝ - ``` - -`bot_id` - set to any string you want, this string is added to the footer of all Discord messages, it can be useful to identify bots if multiple are set to post in the same channel - -#### Webhook parameters -`enable` - toggle the webhook on/off - -`webhook_url` - set to post specific message types to different channels, defaults to `global_webhook_url` if not set -- Commented out in config file by default, remove the leading `#` to uncomment - -Each webhook type also supports pinging @users or @roles. - -`ping_mode` - set to `user` or `role` -- Leave blank to disable pings - -`ping_id` - set to user/role ID -- **Settings** > **Advanced** > Enable **Developer Mode** to enable Discord developer mode -- Right click **user/role** > **Copy ID** - -#### Webhook types -`shiny_pokemon_encounter` - Shiny Pokémon encounters - -![Discord_c0jrjiKGRE](https://github.com/40Cakes/pokebot-gen3/assets/16377135/e1706b41-5f89-40b4-918d-30d6e8fa92c2) - -`pokemon_encounter_milestones` - Pokémon encounter milestones messages every `interval` encounters - -![Discord_ObO28tVrPk](https://github.com/40Cakes/pokebot-gen3/assets/16377135/5c4698f0-07cf-4289-aa4e-6398f56422e0) - -`shiny_pokemon_encounter_milestones` - Shiny Pokémon encounter milestones every `interval` encounters - -![Discord_w7UfnPxlJZ](https://github.com/40Cakes/pokebot-gen3/assets/16377135/6d6e9b85-c8b4-4c15-8970-eb86e3b712ab) - -`total_encounter_milestones` - Total encounter milestones every `interval` encounters - -![Discord_ual6ZrsLNm](https://github.com/40Cakes/pokebot-gen3/assets/16377135/f6a82866-fbb3-4192-a771-f0b298bc12ec) - -`phase_summary` - Phase summary, first summary at `first_interval`, then every `consequent_interval` after that - -![Discord_plUyXtjnQt](https://github.com/40Cakes/pokebot-gen3/assets/16377135/573a638b-fe4e-4f16-95dd-31f0f750a517) - -`anti_shiny_pokemon_encounter` - Anti-shiny Pokémon encounters -- Anti-shinies are just a bit of fun, they are mathematically, the complete opposite of a shiny -- An [SV](https://bulbapedia.bulbagarden.net/wiki/Personality_value#Shininess) of `65,528 - 65,535` is considered anti-shiny - -![Discord_G2hvTZG21a](https://github.com/40Cakes/pokebot-gen3/assets/16377135/3f04d1cf-4040-4163-80d2-13cac84eed1f) - -`custom_filter_pokemon_encounter` - [Custom catch filter](#customcatchfilterspy---custom-catch-filters) encounters - -
- -## `catch_block.yml` - Catch block config -
-Click to expand - -### Block list -A list of shinies to skip catching, useful if you don't want to fill up your PC with very common encounters. - -`block_list` - list of Pokémon to skip catching, example: -``` -block_list: - - Poochyena - - Pidgey - - Rattata -``` - -- **Note**: phase stats will still be reset after encountering a shiny on the block list. -- The block list is reloaded by the bot after every shiny encounter, so you can modify this file while the bot is running! - -
- -## `cheats.yml` - Cheats config -
-Click to expand - -### Cheats -Perform actions not possible by a human, such as peeking into eggs to check shininess, knowing instantly which route a roamer is on, instantly locate Feebas tiles etc. - -RNG manipulation options may be added to the bot in the future, all cheats are disabled by default. - -`starters` - soft reset as soon as possible after receiving the starter Pokémon, this will bypass slow battle/menu animations, saving time - -`starters_rng` - inject a random value into `gRngValue` before selecting a starter Pokémon -- Removes all delays before selecting a starter Pokémon, preventing resets from progressively slowing down over time as the bot waits for unique frames -- Gen3 Pokémon games use predictable methods to seed RNG, this can cause the bot to find identical PID Pokémon repeatedly after every reset (which is why RNG manipulation is possible), see [here](https://blisy.net/g3/frlg-starter.html) and [here](https://www.smogon.com/forums/threads/rng-manipulation-in-firered-leafgreen-wild-pok%C3%A9mon-supported-in-rng-reporter-9-93.62357/) for more technical information -- Uses Python's built-in [`random`](https://docs.python.org/3/library/random.html) library to generate and inject a 'more random' (still pseudo-random) 32-bit integer into the `gRngValue` memory address, essentially re-seeding the game's RNG - -
- -## `obs.yml` - OBS config - -
-Click to expand - -### OBS -#### OBS WebSocket Server Settings -The `obs_websocket` config will allow the bot to send commands to OBS via WebSockets, -see [here](https://github.com/obsproject/obs-websocket) for more information on OBS WebSockets. - -Enable WebSockets in **OBS** > **Tools** > **Websocket Server Settings** > **Enable WebSocket Server** - -`host` - hostname/IP address OBS WebSockets is listening on - -`port` - TCP port OBS WebSockets is listening on - -`password` - password to authenticate to WebSocket server (**required**) - -#### OBS WebSocket Parameters -`shiny_delay` - delay catching a shiny encounter by `n` frames, useful to give you viewers some time to react before saving a replay - -`discord_delay` - delay Discord webhooks by `n` seconds, prevent spoilers if there is a stream delay - -`screenshot` - take OBS screenshot of shiny encounter -- **Note**: **OBS** > **Settings** > **Hotkeys** > **Screenshot Output** must be set to **Ctrl + F11** -- The bot does **not** emulate keystrokes, it simply sends a `TriggerHotkeyByKeySequence` (**Ctrl + F11**) WebSocket command -- Screenshot is taken after `shiny_delay` to allow stream overlays to update - -`replay_buffer` - save OBS replay buffer after `replay_buffer_delay` -- **Note**: **OBS** > **Settings** > **Hotkeys** > **Replay Buffer** > **Save Replay** must set to **Ctrl + F12** -- The bot does **not** emulate keystrokes, it simply sends a `TriggerHotkeyByKeySequence` (**Ctrl + F12**) WebSocket command - -`replay_buffer_delay` - delay saving OBS replay buffer by `n` seconds -- Runs in a separate thread and will not pause main bot thread -- If the replay buffer is long enough, it will also capture some encounters after the shiny encounter - -`discord_webhook_url` - Discord webhook URL to post OBS `screenshot`, after a shiny encounter - -`replay_dir` - OBS screenshot/replay buffer folder -- **OBS** > **Settings** > **Output** > **Recording** > **Recording Path** -- Relative folder to `pokebot.py`, this is used to post stream `screenshot` to Discord if `discord_webhook_url` is set - -### Web server -The `http_server` config will enable a Flask HTTP server, which can be used to retrieve data and drive stream overlays. - -`enable` - toggle web server on/off - -`ip` - IP address for server to listen on - -`port` - TCP port for server to listen on -- Port must be unique for each bot instance - -#### HTTP Endpoints -All HTTP responses are in JSON format. - -`GET /trainer` - returns trainer information such as name, TID, SID, map bank, map ID, X/Y coordinates etc. - -`GET /items` - returns all a list of all items in the bag and PC, and their quantities - -`GET /party` - returns a detailed list of all Pokémon in the party - -`GET /encounter_log` returns a detailed list of the recent 10 Pokémon encounters - -`GET /shiny_log` returns a detailed list of all shiny Pokémon encounters (`shiny_log.json`) - -`GET /stats` returns the phase and total statistics (`totals.json`) - -`GET /encounter_rate` returns the current encounter rate (encounters per hour) - -`GET /event_flags` returns all event flags for the current save file (optional parameter `?flag=FLAG_NAME` to get a specific flag) - -`GET /emulator` returns information about the emulator core + the current loaded game/profile - -`GET /fps` returns a list of emulator FPS (frames per second), in intervals of 1 second, for the previous 60 seconds - -
- -## `customcatchfilters.py` - Custom catch filters - -
-Click to expand - -All Pokémon encounters are checked by custom catch filters, use this file if you are after Pokémon that match very specific criteria, some examples are provided (most are disabled by default). - -These filters are checked *after* the catch block list, so if Wurmple is on your [catch block list](#catchblockyml---catch-block-config), the Wurmple evolution example below will still be checked. - -If you are not familiar with Python, it is highly recommended to use an IDE such as [PyCharm](https://www.jetbrains.com/pycharm/) to edit this file as any syntax errors will be highlighted, and the `pokemon` object will auto-complete and show available parameters for you to filter on. - -- `return "any message"` (string) - will command the bot to catch the current encounter, the string returned will be added to the Discord webhook if `custom_filter_pokemon_encounter` is enabled in [discord.yml](#discordyml---discord-integration-config) -- `pass` - will skip the check, and continue to check other criteria further down the file -- `save_pk3(pokemon)` instead of `return "any message"` will [dump a .pk3 file](#save-raw-pokémon-data-pk3) and continue without pausing the bot until auto-catch is ready - -The following example will catch any shiny Wurmple that will evolve into Silcoon/Beautifly, and ignore any that would evolve into Cascoon/Dustox: - -```py -# Shiny Wurmple evolving based on evolution -if pokemon.is_shiny and pokemon.species.name == "Wurmple": - if pokemon.wurmple_evolution == "silcoon": - return "Shiny Wurmple evolving into Silcoon/Beautifly" - if pokemon.wurmple_evolution == "cascoon": - pass -``` - -The following example will catch any Pokémon with all perfect IVs: -```py -# Pokémon with perfect IVs -if pokemon.ivs.sum() == (6 * 31): - return "Pokémon with perfect IVs" -``` - -- **Note**: you must restart the bot after editing this file for changes to take effect! - -
+The wiki contains information about the default emulator keybinds/inputs, bot modes, configuration files and more (use the side bar to navigate)! *** @@ -580,7 +39,7 @@ if pokemon.ivs.sum() == (6 * 31): - Set **TEXT SPEED** to **FAST** - Set **BATTLE SCENE** to **OFF** - Utilise [repel tricks](https://bulbapedia.bulbagarden.net/wiki/Appendix:Repel_trick) to boost encounter rates of target Pokémon -- Using modes `Spin` or `Bunny Hop` and repels will become effectively infinite + steps won't be counted in Safari Zone +- Using modes [Spin](https://github.com/40Cakes/pokebot-gen3/wiki/%F0%9F%94%84-Spin) or [Bunny Hop](https://github.com/40Cakes/pokebot-gen3/wiki/%F0%9F%9A%B2-Bunny-Hop) and repels will become effectively infinite + steps won't be counted in Safari Zone - Use a lead Pokémon with encounter rate boosting [abilities](https://bulbapedia.bulbagarden.net/wiki/Category:Abilities_that_affect_appearance_of_wild_Pok%C3%A9mon), such as **[Illuminate](https://bulbapedia.bulbagarden.net/wiki/Illuminate_(Ability))** - Use a lead Pokémon with a [short cry](https://docs.google.com/spreadsheets/d/1rmtNdlIXiif1Sz20i-9mfhFdoqb1VnAOIntlr3tnPeU) - Use a lead Pokémon with a single character nickname @@ -598,8 +57,6 @@ positional arguments: options: -h, --help show this help message and exit - -m {Manual,Spin,Starters,Fishing,Bunny Hop}, --bot-mode {Manual,Spin,Starters,Fishing,Bunny Hop} - Initial bot mode (default: Manual) -s {0,1,2,3,4}, --emulation-speed {0,1,2,3,4} Initial emulation speed (0 for unthrottled; default: 1) -nv, --no-video Turn off video output by default @@ -610,6 +67,12 @@ options: *** +# ⚠ Photosensitivity Warning +- Running mGBA at unbound speeds, will cause **very fast and bright flashing**! +- Any unbounded video examples on this page will be hidden by default, and marked with **⚠ photosensitivity warning** + +*** + # ❤ Attributions - [mGBA](https://github.com/mgba-emu/mgba) diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..75138242 --- /dev/null +++ b/conftest.py @@ -0,0 +1 @@ +"""Present to signal the module root to pytest.""" diff --git a/modules/config.py b/modules/config.py deleted file mode 100644 index ef7122b6..00000000 --- a/modules/config.py +++ /dev/null @@ -1,330 +0,0 @@ -import sys -from pathlib import Path - -from jsonschema import validate -from ruamel.yaml import YAML - -from modules.console import console - -yaml = YAML() - -available_bot_modes = ["Manual", "Spin", "Starters", "Fishing", "Bunny Hop", "Rayquaza"] - -general_schema = f""" -type: object -properties: - starter: - type: string - enum: - - Treecko - - Torchic - - Mudkip - - Bulbasaur - - Charmander - - Squirtle - - Chikorita - - Totodile - - Cyndaquil -""" - -logging_schema = """ - log_encounters: - type: boolean - console: - type: object - properties: - encounter_data: - type: string - enum: - - verbose - - basic - - disable - encounter_ivs: - type: string - enum: - - verbose - - basic - - disable - encounter_moves: - type: string - enum: - - verbose - - basic - - disable - statistics: - type: string - enum: - - verbose - - basic - - disable - save_pk3: - type: object - properties: - all: - type: boolean - shiny: - type: boolean - custom: - type: boolean - import_pk3: - type: boolean -""" - -discord_schema = """ -type: object -properties: - rich_presence: - type: boolean - iv_format: - type: string - enum: - - basic - - formatted - bot_id: - type: string - shiny_pokemon_encounter: - type: object - properties: - enable: - type: boolean - ping_mode: - enum: - - ~ - - user - - role - pokemon_encounter_milestones: - type: object - properties: - enable: - type: boolean - interval: - type: integer - minimum: 0 - ping_mode: - enum: - - ~ - - user - - role - total_encounter_milestones: - type: object - properties: - enable: - type: boolean - interval: - type: integer - minimum: 0 - ping_mode: - enum: - - ~ - - user - - role - phase_summary: - type: object - properties: - enable: - type: boolean - first_interval: - type: integer - minimum: 0 - consequent_interval: - type: integer - minimum: 0 - ping_mode: - enum: - - ~ - - user - - role - anti_shiny_pokemon_encounter: - type: object - properties: - enable: - type: boolean - ping_mode: - enum: - - ~ - - user - - role - custom_filter_pokemon_encounter: - type: object - properties: - enable: - type: boolean - ping_mode: - enum: - - ~ - - user - - role -""" - -obs_schema = """ -type: object -properties: - obs_websocket: - type: object - properties: - host: - type: string - port: - type: integer - password: - type: string - shiny_delay: - type: integer - minimum: 0 - discord_delay: - type: integer - minimum: 0 - screenshot: - type: boolean - replay_buffer: - type: boolean - replay_buffer_delay: - type: integer - minimum: 0 - replay_dir: - type: string - http_server: - type: object - properties: - enable: - type: boolean - ip: - type: string - port: - type: integer -""" - -cheats_schema = """ -type: object -properties: - starters: - type: boolean - starters_rng: - type: boolean -""" - -catch_block_schema = """ -type: object -properties: - block_list: - type: array -""" - -keys_schema = """ -type: object -properties: - gba: - type: object - properties: - Up: {type: string} - Down: {type: string} - Left: {type: string} - Right: {type: string} - A: {type: string} - B: {type: string} - L: {type: string} - R: {type: string} - Start: {type: string} - Select: {type: string} - - emulator: - type: object - properties: - zoom_in: {type: string} - zoom_out: {type: string} - toggle_manual: {type: string} - toggle_video: {type: string} - toggle_audio: {type: string} - set_speed_1x: {type: string} - set_speed_2x: {type: string} - set_speed_3x: {type: string} - set_speed_4x: {type: string} - toggle_unthrottled: {type: string} - reset: {type: string} - exit: {type: string} - save_state: {type: string} - toggle_stepping_mode: {type: string} -""" - -schemas = { - "general": general_schema, - "logging": logging_schema, - "discord": discord_schema, - "obs": obs_schema, - "cheats": cheats_schema, -} - -config = {"general": {}, "logging": {}, "discord": {}, "obs": {}, "cheats": {}} - -# Keeps a list of all configuration directories that should be searched whenever we are looking -# for a particular config file. -# In practice, this will contain the global `profiles/` directory, and the profile-specific config -# directory (`profiles//config/`) once a profile has been selected by the user. -config_dir_stack: list[Path] = [] - - -def load_config(file_name: str, schema: str) -> dict: - """ - Looks for and loads a single config file and returns its parsed contents. - - If the config file cannot be found, it stops the bot. - - :param file_name: File name (without path) of the config file - :param schema: JSON Schema string to validate the configuration dict against - :return: Parsed and validated contents of the configuration file - """ - result = None - for config_dir in config_dir_stack: - file_path = config_dir / file_name - if file_path.is_file(): - result = load_config_file(file_path, schema) - - if result is None: - console.print(f"[bold red]Could not find any config file named {file_name}.[/]") - sys.exit(1) - - return result - - -def load_config_file(file_path: Path, schema: str) -> dict: - """ - Loads and validates a single config file. This requires an exact path and therefore will not - fall back to the global config directory if the file could not be found. - - It will stop the bot if the file does not exist or contains invalid data. - - :param file_path: Path to the config file - :param schema: JSON Schema string to validate the configuration dict against - :return: Parsed and validated contents of the configuration file - """ - try: - with open(file_path, mode="r", encoding="utf-8") as f: - config = yaml.load(f) - validate(config, yaml.load(schema)) - return config - except: - console.print(f"[bold red]Config file {str(file_path)} is invalid![/]") - sys.exit(1) - - -def load_config_from_directory(path: Path, allow_missing_files=False) -> None: - """ - Loads all the 'default' configuration files into the `config` variable that can be accessed by other modules. - - :param path: Path to the config directory. - :param allow_missing_files: If this is False, the function will stop the bot if it cannot find a config file. - This should be used when loading the global configuration directory, but not when - loading the profile-specific config directory (so that we use the profile-specific - config if it exists, but keep using the global one if it doesn't.) - """ - global config_dir_stack, config - - config_dir_stack.append(path) - - for key in config: - file_path = path / (key + ".yml") - if file_path.is_file(): - config[key] = load_config_file(file_path, schemas[key]) - elif not allow_missing_files: - console.print(f"[bold red]Expected a config file {str(file_path)} could not be found.[/]") - sys.exit(1) diff --git a/modules/config/__init__.py b/modules/config/__init__.py new file mode 100644 index 00000000..a98e9b39 --- /dev/null +++ b/modules/config/__init__.py @@ -0,0 +1,143 @@ +"""Module for managing and accessing configuration.""" + +from pathlib import Path + +from confz import BaseConfig, FileSource +from ruamel.yaml import YAML + +from modules import exceptions +from modules.modes import available_bot_modes +from modules.runtime import get_base_path +from modules.config.schemas_v1 import CatchBlock, Cheats, Discord, General, Keys, Logging, OBS, ProfileMetadata + +# Defines which class attributes of the Config class are meant to hold required configuration data. +CONFIG_ATTRS = { + "catch_block", + "cheats", + "discord", + "general", + "keys", + "logging", + "obs", +} + + +class Config: + """Initializes a config directory and provides access to the different settings.""" + + available_bot_modes = available_bot_modes + + def __init__(self, config_dir: str | Path | None = None, is_profile: bool = False, strict: bool = False) -> None: + """Initialize the configuration folder, loading all config files. + + :param config_dir: Config directory to load during initialization. + :param is_profile: Whether profile files are expected in this directory. + :param strict: Whether to allow files to be missing. + """ + self.config_dir = get_base_path() / "profiles" if not config_dir else Path(config_dir) + self.catch_block: CatchBlock = CatchBlock() + self.cheats: Cheats = Cheats() + self.discord: Discord = Discord() + self.general: General = General() + self.is_profile = is_profile + self.keys: Keys = Keys() + self.loaded = False + self.logging: Logging = Logging() + self.metadata: ProfileMetadata | None = None + self.obs: OBS = OBS() + self.load(strict=strict) + + def load(self, config_dir: str | Path | None = None, strict: bool = True): + """Load the configuration files in the config_dir. + + :param config_dir: New config dir to load. + :param strict: Whether all files must be present in the directory. + """ + if config_dir: + self.config_dir = config_dir + + for attr in CONFIG_ATTRS: + self.reload_file(attr, strict=strict) + if self.is_profile: + file_path = self.config_dir / ProfileMetadata.filename + self.metadata = load_config_file(file_path, ProfileMetadata, strict=True) + self.loaded = True + + def save(self, config_dir: str | Path | None = None, strict: bool = True): + """Saves currently loaded configuration into files inside config_dir. + + :param config_dir: New config dir to save to. + :param strict: Whether to allow overwriting files or creating missing directories. + """ + if config_dir: + self.config_dir = config_dir + + for attr in CONFIG_ATTRS: + self.save_file(attr, strict=strict) + if self.is_profile: + self.save_file("metadata", strict=strict) + + def reload_file(self, attr: str, strict: bool = False) -> None: + """Reload a specific configuration file, using the same source. + + :param attr: The instance attribute that holds the config file to load. + :param strict: Whether all files must be present in the directory. + """ + + config_inst = getattr(self, attr, None) + if not isinstance(config_inst, BaseConfig): + raise exceptions.PrettyValueError(f"Config.{attr} is not a valid configuration to load.") + file_path = self.config_dir / config_inst.filename + config_inst = load_config_file(file_path, config_inst.__class__, strict=strict) + if config_inst: + setattr(self, attr, config_inst) + + def save_file(self, attr: str, strict: bool = False) -> None: + """Save a specific configuration file, using the same source. + + :param attr: The instance attribute that holds the config file to save. + :param strict: Whether all files must be present in the directory. + """ + + config_inst = getattr(self, attr, None) + if not isinstance(config_inst, BaseConfig): + raise exceptions.PrettyValueError(f"Config.{attr} is not a valid configuration to save.") + save_config_file(self.config_dir, config_inst, strict=strict) + + +def load_config_file(file_path: Path, config_cls: type[BaseConfig], strict: bool = False) -> BaseConfig | None: + """Helper to load files from a path without manually creating the sources. + + :param file_path: The path to the file to load. + :param config_cls: Class to instance from the specified path. + :param strict: Whether to raise an exception if the file is missing. + """ + if not file_path.is_file(): + if strict: + raise exceptions.CriticalFileMissing(file_path) + config_inst = None + else: + sources = [FileSource(file_path)] + config_inst = config_cls(config_sources=sources) + return config_inst + + +def save_config_file(config_dir: Path, config_inst: BaseConfig, strict: bool = False) -> None: + """Helper to save config data from a model into a config directory. + + :param config_dir: The directory to store the file into. + :param config_inst: Config instance to save. + :param strict: Whether to allow overwriting files or creating missing directories. + """ + if not config_dir.is_dir(): + if strict: + raise exceptions.CriticalDirectoryMissing(config_dir) + config_dir.mkdir() + if not isinstance(config_inst, BaseConfig): + raise exceptions.PrettyValueError(f"The provided config is not a valid config instance.") + config_file = config_dir / config_inst.filename + if strict and config_file.is_file(): + raise exceptions.PrettyValueError(f"The file {config_file} already exists. Refusing to overwrite it.") + yaml = YAML() + yaml.allow_unicode = False + yaml.dump(config_inst.model_dump(), config_dir / config_inst.filename) diff --git a/modules/config/schemas_v1.py b/modules/config/schemas_v1.py new file mode 100644 index 00000000..91d995cb --- /dev/null +++ b/modules/config/schemas_v1.py @@ -0,0 +1,200 @@ +"""Contains default schemas for configuration files.""" + +from __future__ import annotations + +from enum import Enum +from pathlib import Path +from typing import Literal + +from confz import BaseConfig +from pydantic import field_validator, Field +from pydantic.types import Annotated, ClassVar, NonNegativeInt, PositiveInt + + +class Starters(Enum): + TREECKO = "Treecko" + TORCHIC = "Torchic" + MUDKIP = "Mudkip" + BULBASAUR = "Bulbasaur" + CHARMANDER = "Charmander" + SQUIRTLE = "Squirtle" + CHIKORITA = "Chikorita" + TOTODILE = "Totodile" + CYNDAQUIL = "Cyndaquil" + + +class CatchBlock(BaseConfig): + """Schema for the catch_block configuration.""" + + filename: ClassVar = "catch_block.yml" + block_list: list[str] = [] + + +class Cheats(BaseConfig): + """Schema for the cheat configuration.""" + + filename: ClassVar = "cheats.yml" + starters: bool = False + starters_rng: bool = False + + +class Discord(BaseConfig): + """Schema for the discord configuration.""" + + filename: ClassVar = "discord.yml" + rich_presence: bool = False + iv_format: Literal["basic", "formatted"] = "formatted" + bot_id: str = "PokéBot" + global_webhook_url: str = "" + shiny_pokemon_encounter: DiscordWebhook = Field(default_factory=lambda: DiscordWebhook()) + pokemon_encounter_milestones: DiscordWebhook = Field(default_factory=lambda: DiscordWebhook(interval=10000)) + shiny_pokemon_encounter_milestones: DiscordWebhook = Field(default_factory=lambda: DiscordWebhook(interval=5)) + total_encounter_milestones: DiscordWebhook = Field(default_factory=lambda: DiscordWebhook(interval=25000)) + phase_summary: DiscordWebhook = Field(default_factory=lambda: DiscordWebhook()) + anti_shiny_pokemon_encounter: DiscordWebhook = Field(default_factory=lambda: DiscordWebhook()) + custom_filter_pokemon_encounter: DiscordWebhook = Field(default_factory=lambda: DiscordWebhook()) + + +class DiscordWebhook(BaseConfig): + """Schema for the different webhooks sections contained in the Discord config.""" + + enable: bool = False + first_interval: PositiveInt | None = 8192 # Only used by phase_summary. + consequent_interval: PositiveInt | None = 5000 # Only used by phase_summary. + interval: PositiveInt = 5 + ping_mode: Literal["user", "role", None] = None + ping_id: str | None = None + + +class General(BaseConfig): + """Schema for the general configuration.""" + + filename: ClassVar = "general.yml" + starter: Starters = Starters.MUDKIP + + +class Keys(BaseConfig): + """Schema for the keys configuration.""" + + filename: ClassVar = "keys.yml" + gba: KeysGBA = Field(default_factory=lambda: KeysGBA()) + emulator: KeysEmulator = Field(default_factory=lambda: KeysEmulator()) + + +class KeysEmulator(BaseConfig): + """Schema for the emulator keys section in the Keys config.""" + + zoom_in: str = "plus" + zoom_out: str = "minus" + toggle_manual: str = "Tab" + toggle_video: str = "v" + toggle_audio: str = "b" + set_speed_1x: str = "1" + set_speed_2x: str = "2" + set_speed_3x: str = "3" + set_speed_4x: str = "4" + set_speed_unthrottled: str = "0" + reset: str = "Ctrl+R" + reload_config: str = "Ctrl+C" + exit: str = "Ctrl+Q" + save_state: str = "Ctrl+S" + toggle_stepping_mode: str = "Ctrl+L" + + +class KeysGBA(BaseConfig): + """Schema for the GBA keys section in the Keys config.""" + + Up: str = "Up" + Down: str = "Down" + Left: str = "Left" + Right: str = "Right" + A: str = "x" + B: str = "z" + L: str = "a" + R: str = "s" + Start: str = "Return" + Select: str = "BackSpace" + + +class Logging(BaseConfig): + """Schema for the logging configuration.""" + + filename: ClassVar = "logging.yml" + console: LoggingConsole = Field(default_factory=lambda: LoggingConsole()) + save_pk3: LoggingSavePK3 = Field(default_factory=lambda: LoggingSavePK3()) + import_pk3: bool = False + log_encounters: bool = False + + +class LoggingConsole(BaseConfig): + """Schema for the console section in the Logging config.""" + + encounter_data: Literal["verbose", "basic", "disable"] = "verbose" + encounter_ivs: Literal["verbose", "basic", "disable"] = "verbose" + encounter_moves: Literal["verbose", "basic", "disable"] = "disable" + statistics: Literal["verbose", "basic", "disable"] = "verbose" + + +class LoggingSavePK3(BaseConfig): + """Schema for the save_pk3 section in the Logging config.""" + + all: bool = False + shiny: bool = False + custom: bool = False + + +class OBS(BaseConfig): + """Schema for the OBS configuration.""" + + filename: ClassVar = "obs.yml" + discord_delay: NonNegativeInt = 0 + discord_webhook_url: str | None = None + replay_dir: Path = "./stream/replays/" + replay_buffer: bool = False + replay_buffer_delay: NonNegativeInt = 0 + screenshot: bool = False + shiny_delay: NonNegativeInt = 0 + obs_websocket: OBSWebsocket = Field(default_factory=lambda: OBSWebsocket()) + http_server: OBSHTTPServer = Field(default_factory=lambda: OBSHTTPServer()) + + @field_validator("replay_dir") + def validate_dir(cls, value: str | Path, **kwargs) -> Path: + """Ensure the replay_dir field returns a path.""" + if isinstance(value, str): + value = Path(value) + if not isinstance(value, Path): + raise ValueError(f"Expected a Path or a string, got: {type(value)}.") + return value + + +class OBSWebsocket(BaseConfig): + """Schema for the obs_websocket section in the OBS config.""" + + host: str = "127.0.0.1" + password: str = "password" + port: Annotated[int, Field(gt=0, lt=65536)] = 4455 + + +class OBSHTTPServer(BaseConfig): + """Schema for the http_server section in the OBS config.""" + + enable: bool = False + ip: str = "127.0.0.1" + port: Annotated[int, Field(gt=0, lt=65536)] = 8888 + + +class ProfileMetadata(BaseConfig): + """Schema for the metadata configuration file part of profiles.""" + + filename: ClassVar = "metadata.yml" + version: PositiveInt = 1 + rom: ProfileMetadataROM = Field(default_factory=lambda: ProfileMetadataROM()) + + +class ProfileMetadataROM(BaseConfig): + """Schema for the rom section of the metadata config.""" + + file_name: str = "" + game_code: str = "" + revision: NonNegativeInt = 0 + language: Literal["E", "F", "D", "I", "J", "S"] = "" diff --git a/modules/console.py b/modules/console.py index 7af76778..76ef6240 100644 --- a/modules/console.py +++ b/modules/console.py @@ -2,6 +2,7 @@ from rich.table import Table from rich.theme import Theme +from modules.context import context from modules.pokemon import Pokemon theme = Theme( @@ -60,14 +61,12 @@ def sv_colour(value: int) -> str: def print_stats(total_stats: dict, pokemon: Pokemon, session_pokemon: list, encounter_rate: int) -> None: - from modules.config import config - type_colour = pokemon.species.types[0].name.lower() rich_name = f"[{type_colour}]{pokemon.species.name}[/]" console.print("\n") console.rule(f"{rich_name} encountered at {pokemon.location_met}", style=type_colour) - match config["logging"]["console"]["encounter_data"]: + match context.config.logging.console.encounter_data: case "verbose": pokemon_table = Table() pokemon_table.add_column("PID", justify="center", width=10) @@ -99,7 +98,7 @@ def print_stats(total_stats: dict, pokemon: Pokemon, session_pokemon: list, enco f"Shiny Value: {pokemon.shiny_value:,}" ) - match config["logging"]["console"]["encounter_ivs"]: + match context.config.logging.console.encounter_ivs: case "verbose": iv_table = Table(title=f"{pokemon.species.name} IVs") iv_table.add_column("HP", justify="center", style=iv_colour(pokemon.ivs.hp)) @@ -130,7 +129,7 @@ def print_stats(total_stats: dict, pokemon: Pokemon, session_pokemon: list, enco f"Sum: [{iv_sum_colour(pokemon.ivs.sum())}]{pokemon.ivs.sum()}[/]" ) - match config["logging"]["console"]["encounter_moves"]: + match context.config.logging.console.encounter_moves: case "verbose": move_table = Table(title=f"{pokemon.species.name} Moves") move_table.add_column("Name", justify="left", width=20) @@ -167,7 +166,7 @@ def print_stats(total_stats: dict, pokemon: Pokemon, session_pokemon: list, enco f"PP: {learned_move.pp}" ) - match config["logging"]["console"]["statistics"]: + match context.config.logging.console.statistics: case "verbose": stats_table = Table(title="Statistics") stats_table.add_column("", justify="left", width=10) diff --git a/modules/context.py b/modules/context.py index 62352ef2..ffcda410 100644 --- a/modules/context.py +++ b/modules/context.py @@ -6,18 +6,42 @@ from modules.profiles import Profile from modules.roms import ROM +from modules.config import Config + class BotContext: - def __init__(self, initial_bot_mode: str = 'Manual'): + def __init__(self, initial_bot_mode: str = "Manual"): + self.config = Config() + self.emulator: Optional["LibmgbaEmulator"] = None self.gui: Optional["PokebotGui"] = None self.profile: Optional["Profile"] = None self.debug: bool = False - self._current_message: str = '' + self._current_message: str = "" self._current_bot_mode: str = initial_bot_mode - self._previous_bot_mode: str = 'Manual' + self._previous_bot_mode: str = "Manual" + + def reload_config(self) -> str: + """Triggers a config reload, reload the global config then specific profile config. + + :return: A user-facing message + """ + try: + new_config = Config() + new_config.load(self.config.config_dir, strict=False) + self.config = new_config + message = "[cyan]Profile settings loaded.[/]" + except Exception as error: + if self.debug: + raise error + message = ( + "[bold red]The configuration could not be loaded, no changes have been made.[/]\n" + "[bold yellow]This is Probably due to a malformed file." + "For more information run the bot with the --debug flag.[/]" + ) + return message @property def message(self) -> str: @@ -113,4 +137,4 @@ def _update_gui(self) -> None: self.gui.on_settings_updated() -context = BotContext() +context: BotContext = BotContext() diff --git a/modules/discord.py b/modules/discord.py index acef35a4..8557fdba 100644 --- a/modules/discord.py +++ b/modules/discord.py @@ -2,7 +2,6 @@ from pathlib import Path from pypresence import Presence from discord_webhook import DiscordWebhook, DiscordEmbed -from modules.config import config from modules.context import context @@ -19,42 +18,42 @@ def discord_message( embed_footer: str = None, embed_color: str = "FFFFFF", ) -> None: - if not webhook_url: - webhook_url = config["discord"]["global_webhook_url"] - webhook, embed_obj = DiscordWebhook(url=webhook_url, content=content), None + webhook_url = webhook_url or context.config.discord.global_webhook_url + if webhook_url: + webhook, embed_obj = DiscordWebhook(url=webhook_url, content=content), None - if image: - with open(image, "rb") as f: - webhook.add_file(file=f.read(), filename="image.png") + if image: + with open(image, "rb") as f: + webhook.add_file(file=f.read(), filename="image.png") - if embed: - embed_obj = DiscordEmbed(title=embed_title, color=embed_color) + if embed: + embed_obj = DiscordEmbed(title=embed_title, color=embed_color) - if embed_description: - embed_obj.description = embed_description + if embed_description: + embed_obj.description = embed_description - if embed_fields: - for key, value in embed_fields.items(): - embed_obj.add_embed_field(name=key, value=value, inline=False) + if embed_fields: + for key, value in embed_fields.items(): + embed_obj.add_embed_field(name=key, value=value, inline=False) - if embed_thumbnail: - with open(embed_thumbnail, "rb") as f: - webhook.add_file(file=f.read(), filename="thumb.png") - embed_obj.set_thumbnail(url="attachment://thumb.png") + if embed_thumbnail: + with open(embed_thumbnail, "rb") as f: + webhook.add_file(file=f.read(), filename="thumb.png") + embed_obj.set_thumbnail(url="attachment://thumb.png") - if embed_image: - with open(embed_image, "rb") as f: - webhook.add_file(file=f.read(), filename="embed.png") - embed_obj.set_image(url="attachment://embed.png") + if embed_image: + with open(embed_image, "rb") as f: + webhook.add_file(file=f.read(), filename="embed.png") + embed_obj.set_image(url="attachment://embed.png") - if embed_footer: - embed_obj.set_footer(text=embed_footer) + if embed_footer: + embed_obj.set_footer(text=embed_footer) - embed_obj.set_timestamp() - webhook.add_embed(embed_obj) + embed_obj.set_timestamp() + webhook.add_embed(embed_obj) - time.sleep(config["obs"]["discord_delay"]) - webhook.execute() + time.sleep(context.config.obs.discord_delay) + webhook.execute() def discord_rich_presence() -> None: @@ -86,7 +85,7 @@ def discord_rich_presence() -> None: RPC.update( state=f"{location} | {context.rom.game_name}", details=( - f'{totals["totals"].get("encounters", 0):,} ({totals["totals"].get("shiny_encounters", 0):,}✨) |' + f'{totals.get("totals", {}).get("encounters", 0):,} ({totals.get("totals", {}).get("shiny_encounters", 0):,}✨) |' f" {total_stats.get_encounter_rate():,}/h" ), large_image=large_image, diff --git a/modules/encounter.py b/modules/encounter.py index 6f4c6c4d..03279f06 100644 --- a/modules/encounter.py +++ b/modules/encounter.py @@ -1,4 +1,3 @@ -from modules.config import config from modules.console import console from modules.context import context from modules.files import save_pk3 @@ -7,8 +6,6 @@ from modules.pokemon import Pokemon from modules.stats import total_stats -block_list: list = [] - def encounter_pokemon(pokemon: Pokemon) -> None: """ @@ -18,28 +15,24 @@ def encounter_pokemon(pokemon: Pokemon) -> None: :return: """ - global block_list - - if config["logging"]["save_pk3"]["all"]: + config = context.config + if config.logging.save_pk3.all: save_pk3(pokemon) - if pokemon.is_shiny or block_list == []: - # Load catch block config file - allows for editing while bot is running - from modules.config import catch_block_schema, load_config - - config_catch_block = load_config("catch_block.yml", catch_block_schema) - block_list = config_catch_block["block_list"] + if pokemon.is_shiny: + config.reload_file("catch_block") custom_filter_result = total_stats.custom_catch_filters(pokemon) custom_found = isinstance(custom_filter_result, str) - total_stats.log_encounter(pokemon, block_list, custom_filter_result) + total_stats.log_encounter(pokemon, config.catch_block.block_list, custom_filter_result) + context.message = f"Encountered a {pokemon.species.name} with a shiny value of {pokemon.shiny_value:,}!" # TODO temporary until auto-catch is ready if pokemon.is_shiny or custom_found: if pokemon.is_shiny: - if not config["logging"]["save_pk3"]["all"] and config["logging"]["save_pk3"]["shiny"]: + if not config.logging.save_pk3.all and config.logging.save_pk3.shiny: save_pk3(pokemon) state_tag = "shiny" console.print("[bold yellow]Shiny found!") @@ -49,7 +42,7 @@ def encounter_pokemon(pokemon: Pokemon) -> None: alert_message = f"Found a shiny {pokemon.species.name}. 🥳" elif custom_found: - if not config["logging"]["save_pk3"]["all"] and config["logging"]["save_pk3"]["custom"]: + if not config.logging.save_pk3.all and config.logging.save_pk3.custom: save_pk3(pokemon) state_tag = "customfilter" console.print("[bold green]Custom filter Pokemon found!") @@ -62,7 +55,7 @@ def encounter_pokemon(pokemon: Pokemon) -> None: alert_title = None alert_message = None - if not custom_found and pokemon.species.name in block_list: + if not custom_found and pokemon.species.name in config.catch_block.block_list: console.print(f"[bold yellow]{pokemon.species.name} is on the catch block list, skipping encounter...") else: filename_suffix = f"{state_tag}_{pokemon.species.safe_name}" @@ -70,7 +63,7 @@ def encounter_pokemon(pokemon: Pokemon) -> None: # TEMPORARY until auto-battle/auto-catch is done # if the mon is saved and imported, no need to catch it by hand - if config["logging"]["import_pk3"]: + if config.logging.import_pk3: if import_into_storage(pokemon.data): return diff --git a/modules/exceptions.py b/modules/exceptions.py new file mode 100644 index 00000000..1dd9a01f --- /dev/null +++ b/modules/exceptions.py @@ -0,0 +1,74 @@ +"""Custom exception handlers.""" + +from __future__ import annotations + +import sys + +from modules.console import console +from modules.context import context + + +class PrettyException(Exception): + """Base class for all exceptions with rich print methods.""" + + exit_code: int | None = 1 + message_template: str = "{}" + message_color = "[bold red]" + recommendation: str = "" + recommendation_color = "[bold yellow]" + + def bare_message(self) -> PrettyException: + """Create an exception to raise without pretty formatting.""" + message = self.message_template.format(self.args) + message = f"{message}\n{self.recommendation}" + return PrettyException(message) + + +class PrettyValueError(PrettyException): + """Exception to print a rich message whenever a ValueError would be raised.""" + + +class CriticalDirectoryMissing(PrettyException): + """Exception for whenever a core file is missing.""" + + message_template = "Could not load {}, the directory does not exist or is not readable." + recommendation = "Make sure the directory exists and the user has read access." + + +class CriticalFileMissing(PrettyException): + """Exception for whenever a core file is missing.""" + + message_template = "Could not load {}, file does not exist." + recommendation = "Please re-download the program or restore the missing file." + + +class InvalidConfigData(PrettyException): + """Exception for whenever config file validation fails.""" + + message_template = "Config file {} is invalid!" + recommendation = "Please re-download the program or restore/amend the file contents." + + +def exception_hook(exc_type: type[Exception], exc_instance: Exception, traceback) -> None: + """General handler for exceptions to remove tracebacks and highlight messages if debug is off. + + :param exc_type: Base Exception type, kept for parity with the overridden hook. + :param exc_instance: Instanced exception object being raised. + :param traceback: Traceback object, kept for parity with the overridden hook. + """ + if not isinstance(exc_instance, PrettyException): + raise exc_instance + if context.debug: + raise exc_instance.bare_message() + message = exc_instance.message_template.format(*exc_instance.args) + message = f"{exc_instance.message_color}{message}[/]" + if exc_instance.recommendation: + recommendation = f"{exc_instance.recommendation_color}{exc_instance.recommendation}[/]" + message = f"{message}\n{recommendation}" + console.print(message) + exit_code = exc_instance.exit_code + if exit_code is not None: + sys.exit(exit_code) + + +sys.excepthook = exception_hook diff --git a/modules/gui/__init__.py b/modules/gui/__init__.py index 4f22101e..0ef3e763 100644 --- a/modules/gui/__init__.py +++ b/modules/gui/__init__.py @@ -6,7 +6,6 @@ import PIL.Image import PIL.ImageTk -from modules.config import load_config, keys_schema from modules.console import console from modules.context import context from modules.game import set_rom @@ -32,7 +31,7 @@ def __init__(self, main_loop: callable, on_exit: callable): self._startup_settings: "StartupSettings | None" = None self.window.geometry("540x400") - self.window.resizable(False, True) + self.window.resizable(context.debug, True) self.window.protocol("WM_DELETE_WINDOW", self._close_window) self.window.bind("", self._handle_key_down_event) self.window.bind("", self._handle_key_up_event) @@ -45,13 +44,7 @@ def __init__(self, main_loop: callable, on_exit: callable): background=[("!active", "green"), ("active", "darkgreen"), ("pressed", "green")], ) - key_config = load_config("keys.yml", keys_schema) - self._gba_keys: dict[str, int] = {} - for key in input_map: - self._gba_keys[key_config["gba"][key].lower()] = input_map[key] - self._emulator_keys: dict[str, str] = {} - for action in key_config["emulator"]: - self._emulator_keys[key_config["emulator"][action].lower()] = action + self._apply_key_config() self._create_profile_screen = CreateProfileScreen( self.window, self._enable_select_profile_screen, self._run_profile @@ -62,6 +55,16 @@ def __init__(self, main_loop: callable, on_exit: callable): self._emulator_screen = EmulatorScreen(self.window) self._set_app_icon() + def _apply_key_config(self) -> None: + """Applies key settings from the configuration.""" + key_config = context.config.keys + self._gba_keys: dict[str, int] = {} + for key, val in dict(key_config.gba).items(): + self._gba_keys[val.lower()] = input_map[key] + self._emulator_keys: dict = {} + for action, value in dict(key_config.emulator).items(): + self._emulator_keys[value.lower()] = action + def run(self, startup_settings: "StartupSettings") -> None: self._startup_settings = startup_settings if startup_settings.always_on_top: @@ -129,6 +132,7 @@ def _enable_select_profile_screen(self) -> None: def _run_profile(self, profile: "Profile") -> None: self._reset_screen() context.profile = profile + context.config.load(profile.path, strict=False) set_rom(profile.rom) context.emulator = LibmgbaEmulator(profile, self._emulator_screen.update) @@ -173,6 +177,10 @@ def _handle_key_down_event(self, event): context.toggle_manual_mode() console.print(f"Now in [cyan]{context.bot_mode}[/] mode") context.emulator.set_inputs(0) + case "reload_config": + message = context.reload_config() + self._apply_key_config() + console.print(message) case "toggle_video": context.toggle_video() case "toggle_audio": diff --git a/modules/gui/emulator_controls.py b/modules/gui/emulator_controls.py index 3a44150c..32d55f12 100644 --- a/modules/gui/emulator_controls.py +++ b/modules/gui/emulator_controls.py @@ -2,7 +2,6 @@ from tkinter import Tk, ttk from typing import Union -from modules.config import available_bot_modes from modules.context import context from modules.libmgba import LibmgbaEmulator from modules.version import pokebot_name, pokebot_version @@ -57,7 +56,7 @@ def update(self) -> None: return if self.bot_mode_combobox.get() != context.bot_mode: - self.bot_mode_combobox.current(available_bot_modes.index(context.bot_mode)) + self.bot_mode_combobox.current(context.config.available_bot_modes.index(context.bot_mode)) self.last_known_bot_mode = context.bot_mode self._set_button_colour(self.speed_1x_button, active_condition=context.emulation_speed == 1) @@ -67,8 +66,9 @@ def update(self) -> None: self._set_button_colour(self.unthrottled_button, active_condition=context.emulation_speed == 0) self._set_button_colour(self.toggle_video_button, active_condition=context.video) - self._set_button_colour(self.toggle_audio_button, active_condition=context.audio, - disabled_condition=context.emulation_speed == 0) + self._set_button_colour( + self.toggle_audio_button, active_condition=context.audio, disabled_condition=context.emulation_speed == 0 + ) self.bot_message.config(text=context.message) @@ -90,7 +90,9 @@ def handle_bot_mode_selection(event) -> None: context.bot_mode = new_bot_mode ttk.Label(group, text="Bot Mode:", justify="left").grid(row=0, sticky="W") - self.bot_mode_combobox = ttk.Combobox(group, values=available_bot_modes, width=16, state="readonly") + self.bot_mode_combobox = ttk.Combobox( + group, values=context.config.available_bot_modes, width=16, state="readonly" + ) self.bot_mode_combobox.bind("<>", handle_bot_mode_selection) self.bot_mode_combobox.bind("", lambda e: self.window.focus()) self.bot_mode_combobox.grid(row=1, sticky="W", padx=0) @@ -146,8 +148,12 @@ def _add_stats_and_version_notice(self, row: int, column: int, columnspan: int = self.stats_label = ttk.Label(group, text="", foreground="grey", font=tkinter.font.Font(size=9)) self.stats_label.grid(row=0, column=0, sticky="W") - version_label = ttk.Label(group, text=f"{context.rom.short_game_name} - {pokebot_name} {pokebot_version}", - foreground="grey", font=tkinter.font.Font(size=9)) + version_label = ttk.Label( + group, + text=f"{context.rom.short_game_name} - {pokebot_name} {pokebot_version}", + foreground="grey", + font=tkinter.font.Font(size=9), + ) version_label.grid(row=0, column=1, sticky="E") def _set_button_colour(self, button: ttk.Button, active_condition: bool, disabled_condition: bool = False) -> None: @@ -166,6 +172,7 @@ def _update_stats(self): stats.append(f"{current_fps:,}fps ({current_fps / 59.73:0.2f}x)") if context.profile: from modules.stats import total_stats # TODO prevent instantiating TotalStats class before profile selected + stats.append(f"{total_stats.get_encounter_rate():,}/h") stats.append(f"{round(current_load * 100, 1)}%") self.stats_label.config(text=" | ".join(stats)) @@ -175,7 +182,7 @@ class DebugTab: def draw(self, root: ttk.Notebook): pass - def update(self, emulator: 'LibmgbaEmulator'): + def update(self, emulator: "LibmgbaEmulator"): pass def on_video_output_click(self, click_location: tuple[int, int], scale: int): diff --git a/modules/gui/emulator_screen.py b/modules/gui/emulator_screen.py index 27097bfa..060dbe15 100644 --- a/modules/gui/emulator_screen.py +++ b/modules/gui/emulator_screen.py @@ -45,7 +45,7 @@ def _initialise_controls(self, debug: bool = False) -> None: def enable(self) -> None: self.window.title(f"{context.profile.path.name} | {pokebot_name} {pokebot_version}") - self.window.resizable(False, False) + self.window.resizable(context.debug, context.debug) self.window.rowconfigure(0, weight=1) self.window.columnconfigure(0, weight=1) @@ -62,7 +62,7 @@ def disable(self) -> None: if self.frame: self.frame.destroy() self.window.geometry("540x400") - self.window.resizable(False, True) + self.window.resizable(context.debug, True) def update(self) -> None: if context.emulator._performance_tracker.time_since_last_render() >= (1 / 60) * 1_000_000_000: @@ -121,11 +121,13 @@ def scale(self, scale: int) -> None: def toggle_stepping_mode(self) -> None: self._stepping_mode = not self._stepping_mode if self._stepping_mode: + def next_step(): self._current_step += 1 - self._stepping_button = Button(self.window, text="⮞", padx=8, background="red", foreground="white", - command=next_step, cursor="hand2") + self._stepping_button = Button( + self.window, text="⮞", padx=8, background="red", foreground="white", command=next_step, cursor="hand2" + ) self._stepping_button.place(x=0, y=0) self._current_step = 0 else: @@ -139,7 +141,8 @@ def _generate_placeholder_image(self): def _update_image(self, image: PIL.Image): self.current_canvas_image = PIL.ImageTk.PhotoImage( - image=image.resize((self.width * self.scale, self.height * self.scale), resample=False)) + image=image.resize((self.width * self.scale, self.height * self.scale), resample=False) + ) self.canvas.create_image(self.center_of_canvas, image=self.current_canvas_image, state="normal") self._update_window() @@ -154,7 +157,9 @@ def _add_canvas(self) -> None: self.canvas = Canvas(self.window, width=480, height=320) self.canvas.grid(sticky="NW", row=0, column=0) if context.debug: + def handle_click_on_video_output(event): if context.video: self._controls.on_video_output_click((event.x // self.scale, event.y // self.scale), self.scale) + self.canvas.bind("", handle_click_on_video_output) diff --git a/modules/http.py b/modules/http.py index 4b7e2d30..39861ebe 100644 --- a/modules/http.py +++ b/modules/http.py @@ -1,7 +1,6 @@ from flask_cors import CORS from flask import Flask, jsonify, request -from modules.config import config from modules.context import context from modules.items import get_items from modules.pokemon import get_party @@ -110,6 +109,6 @@ def http_get_routes(): server.run( debug=False, threaded=True, - host=config["obs"]["http_server"]["ip"], - port=config["obs"]["http_server"]["port"], + host=context.config.obs.http_server.ip, + port=context.config.obs.http_server.port, ) diff --git a/modules/main.py b/modules/main.py index 1fc78da9..8e61c0a4 100644 --- a/modules/main.py +++ b/modules/main.py @@ -1,7 +1,6 @@ import sys from threading import Thread -from modules.config import config, load_config_from_directory from modules.console import console from modules.context import context from modules.memory import get_game_state, GameState @@ -17,14 +16,15 @@ def main_loop() -> None: try: mode = None - load_config_from_directory(context.profile.path, allow_missing_files=True) - if config["discord"]["rich_presence"]: + config = context.config + + if config.discord.rich_presence: from modules.discord import discord_rich_presence Thread(target=discord_rich_presence).start() - if config["obs"]["http_server"]["enable"]: + if config.obs.http_server.enable: from modules.http import http_server Thread(target=http_server).start() diff --git a/modules/modes/__init__.py b/modules/modes/__init__.py new file mode 100644 index 00000000..f68c4795 --- /dev/null +++ b/modules/modes/__init__.py @@ -0,0 +1,3 @@ +"""Contains modes of operation for the bot.""" + +available_bot_modes = ["Manual", "Spin", "Starters", "Fishing", "Bunny Hop", "Rayquaza"] diff --git a/modules/modes/starters.py b/modules/modes/starters.py index c2818535..35207c8b 100644 --- a/modules/modes/starters.py +++ b/modules/modes/starters.py @@ -1,7 +1,6 @@ import random from enum import Enum -from modules.config import config from modules.console import console from modules.context import context from modules.encounter import encounter_pokemon @@ -11,6 +10,8 @@ from modules.pokemon import get_party, opponent_changed from modules.trainer import trainer +config = context.config + class Regions(Enum): KANTO_STARTERS = 0 @@ -53,13 +54,13 @@ def __init__(self) -> None: self.johto_starters: list = ["Chikorita", "Totodile", "Cyndaquil"] self.hoenn_starters: list = ["Treecko", "Torchic", "Mudkip"] - if config["general"]["starter"] in self.kanto_starters and context.rom.game_title in [ + if config.general.starter.value in self.kanto_starters and context.rom.game_title in [ "POKEMON LEAF", "POKEMON FIRE", ]: self.region: Regions = Regions.KANTO_STARTERS - elif config["general"]["starter"] in self.johto_starters and context.rom.game_title == "POKEMON EMER": + elif config.general.starter.value in self.johto_starters and context.rom.game_title == "POKEMON EMER": self.region: Regions = Regions.JOHTO_STARTERS self.start_party_length: int = 0 console.print( @@ -70,8 +71,8 @@ def __init__(self) -> None: if len(get_party()) == 6: self.update_state(ModeStarterStates.PARTY_FULL) - elif config["general"]["starter"] in self.hoenn_starters: - self.bag_position: int = BagPositions[config["general"]["starter"].upper()].value + elif config.general.starter.value in self.hoenn_starters: + self.bag_position: int = BagPositions[config.general.starter.value.upper()].value if context.rom.game_title == "POKEMON EMER": self.region = Regions.HOENN_STARTERS self.task_bag_cursor: str = "TASK_HANDLESTARTERCHOOSEINPUT" @@ -89,8 +90,8 @@ def __init__(self) -> None: else: self.state = ModeStarterStates.INCOMPATIBLE - if not config["cheats"]["starters_rng"]: - self.rng_history: list = get_rng_state_history(config["general"]["starter"]) + if not config.cheats.starters_rng: + self.rng_history: list = get_rng_state_history(config.general.starter.value) def update_state(self, state: ModeStarterStates): self.state: ModeStarterStates = state @@ -98,7 +99,7 @@ def update_state(self, state: ModeStarterStates): def step(self): if self.state == ModeStarterStates.INCOMPATIBLE: message = ( - f"Starter `{config['general']['starter']}` is incompatible, update `starter` in config " + f"Starter `{config.general.starter.value}` is incompatible, update `starter` in config " f"file `general.yml` to a valid starter for {context.rom.game_name} and restart the bot!" ) console.print(f"[red bold]{message}") @@ -125,7 +126,7 @@ def step(self): continue case ModeStarterStates.RNG_CHECK: - if config["cheats"]["starters_rng"]: + if config.cheats.starters_rng: self.update_state(ModeStarterStates.OVERWORLD) else: rng = unpack_uint32(read_symbol("gRngValue")) @@ -133,7 +134,7 @@ def step(self): pass else: self.rng_history.append(rng) - save_rng_state_history(config["general"]["starter"], self.rng_history) + save_rng_state_history(config.general.starter.value, self.rng_history) self.update_state(ModeStarterStates.OVERWORLD) continue @@ -146,7 +147,7 @@ def step(self): continue case ModeStarterStates.INJECT_RNG: - if config["cheats"]["starters_rng"]: + if config.cheats.starters_rng: write_symbol("gRngValue", pack_uint32(random.randint(0, 2**32 - 1))) self.update_state(ModeStarterStates.SELECT_STARTER) @@ -167,7 +168,7 @@ def step(self): continue case ModeStarterStates.EXIT_MENUS: - if not config["cheats"]["starters"]: + if not config.cheats.starters: if trainer.get_facing_direction() != "Down": context.emulator.press_button("B") context.emulator.hold_button("Down") @@ -227,7 +228,7 @@ def step(self): continue case ModeStarterStates.INJECT_RNG: - if config["cheats"]["starters_rng"]: + if config.cheats.starters_rng: write_symbol("gRngValue", pack_uint32(random.randint(0, 2**32 - 1))) self.update_state(ModeStarterStates.YES_NO) @@ -240,7 +241,7 @@ def step(self): continue case ModeStarterStates.RNG_CHECK: - if config["cheats"]["starters_rng"]: + if config.cheats.starters_rng: self.update_state(ModeStarterStates.CONFIRM_STARTER) else: rng = unpack_uint32(read_symbol("gRngValue")) @@ -248,7 +249,7 @@ def step(self): pass else: self.rng_history.append(rng) - save_rng_state_history(config["general"]["starter"], self.rng_history) + save_rng_state_history(config.general.starter.value, self.rng_history) self.update_state(ModeStarterStates.CONFIRM_STARTER) continue @@ -260,7 +261,7 @@ def step(self): continue case ModeStarterStates.EXIT_MENUS: - if config["cheats"]["starters"]: + if config.cheats.starters: self.update_state(ModeStarterStates.CHECK_STARTER) continue else: @@ -273,10 +274,8 @@ def step(self): continue case ModeStarterStates.CHECK_STARTER: - config["cheats"]["starters"] = True # TODO temporary until menu navigation is ready - if config["cheats"][ - "starters" - ]: # TODO check Pokémon summary screen once menu navigation merged + config.cheats.starters = True # TODO temporary until menu navigation is ready + if config.cheats.starters: # TODO check Pokémon summary screen once menu navigation merged self.update_state(ModeStarterStates.LOG_STARTER) continue @@ -310,7 +309,7 @@ def step(self): continue case ModeStarterStates.INJECT_RNG: - if config["cheats"]["starters_rng"]: + if config.cheats.starters_rng: write_symbol("gRngValue", pack_uint32(random.randint(0, 2**32 - 1))) self.update_state(ModeStarterStates.BAG_MENU) @@ -336,7 +335,7 @@ def step(self): continue case ModeStarterStates.RNG_CHECK: - if config["cheats"]["starters_rng"]: + if config.cheats.starters_rng: self.update_state(ModeStarterStates.CONFIRM_STARTER) else: rng = unpack_uint32(read_symbol("gRngValue")) @@ -344,12 +343,12 @@ def step(self): pass else: self.rng_history.append(rng) - save_rng_state_history(config["general"]["starter"], self.rng_history) + save_rng_state_history(config.general.starter.value, self.rng_history) self.update_state(ModeStarterStates.CONFIRM_STARTER) continue case ModeStarterStates.CONFIRM_STARTER: - if config["cheats"]["starters"]: + if config.cheats.starters: if len(get_party()) > 0: self.update_state(ModeStarterStates.LOG_STARTER) context.emulator.press_button("A") diff --git a/modules/obs.py b/modules/obs.py index 919cc9af..11a4514b 100644 --- a/modules/obs.py +++ b/modules/obs.py @@ -1,15 +1,15 @@ import obsws_python as obs -from modules.config import config +from modules.context import context def obs_hot_key( obs_key: str, pressCtrl: bool = False, pressShift: bool = False, pressAlt: bool = False, pressCmd: bool = False ): with obs.ReqClient( - host=config["obs"]["obs_websocket"]["host"], - port=config["obs"]["obs_websocket"]["port"], - password=config["obs"]["obs_websocket"]["password"], + host=context.config.obs.obs_websocket.host, + port=context.config.obs.obs_websocket.port, + password=context.config.obs.obs_websocket.password, timeout=5, ) as client: client.trigger_hot_key_by_key_sequence( diff --git a/modules/profiles.py b/modules/profiles.py index ad505ae6..991a8921 100644 --- a/modules/profiles.py +++ b/modules/profiles.py @@ -3,42 +3,15 @@ from datetime import datetime from pathlib import Path -import jsonschema -from ruamel.yaml import YAML - +from modules import exceptions +from modules.config import load_config_file, save_config_file +from modules.config.schemas_v1 import ProfileMetadata, ProfileMetadataROM from modules.console import console from modules.roms import ROMS_DIRECTORY, ROM, list_available_roms, load_rom_data from modules.runtime import get_base_path PROFILES_DIRECTORY = get_base_path() / "profiles" -metadata_schema = """ -type: object -properties: - version: - type: integer - enum: - - 1 - rom: - type: object - properties: - file_name: - type: string - game_code: - type: string - revision: - type: integer - language: - type: string - enum: - - E - - F - - D - - I - - J - - S -""" - @dataclass class Profile: @@ -65,6 +38,8 @@ def list_available_profiles() -> list[Profile]: profiles = [] for entry in PROFILES_DIRECTORY.iterdir(): + if entry.name.startswith("_"): + continue try: profiles.append(load_profile(entry)) except RuntimeError: @@ -77,43 +52,34 @@ def load_profile_by_name(name: str) -> Profile: return load_profile(PROFILES_DIRECTORY / name) -def load_profile(path) -> Profile: +def load_profile(path: Path) -> Profile: if not path.is_dir(): raise RuntimeError("Path is not a valid profile directory.") - - metadata_file = path / "metadata.yml" - if not metadata_file.is_file(): - raise RuntimeError("Path is not a valid profile directory.") - - try: - metadata = YAML().load(metadata_file) - jsonschema.validate(metadata, YAML().load(metadata_schema)) - except: - console.print(f'[bold red]Metadata file for profile "{path.name}" is invalid![/]') - sys.exit(1) - + metadata = load_config_file(path / ProfileMetadata.filename, ProfileMetadata, strict=True) current_state = path / "current_state.ss1" if current_state.exists(): last_played = datetime.fromtimestamp(current_state.stat().st_mtime) else: last_played = None - rom_file = ROMS_DIRECTORY / metadata["rom"]["file_name"] + rom_file = ROMS_DIRECTORY / metadata.rom.file_name if rom_file.is_file(): rom = load_rom_data(rom_file) return Profile(rom, path, last_played) else: for rom in list_available_roms(): - if ( - rom.game_code == metadata["rom"]["game_code"] - and rom.revision == metadata["rom"]["revision"] - and rom.language == metadata["rom"]["language"] + if all( + [ + rom.game_code == metadata.rom.game_code, + rom.revision == metadata.rom.revision, + rom.language == metadata.rom.language, + ] ): return Profile(rom, path, last_played) console.print( - f"[bold red]Could not find ROM `{metadata['rom']['file_name']}` for profile `{path.name}`, " - f"please place `{metadata['rom']['file_name']}` into `{ROMS_DIRECTORY}`!" + f"[bold red]Could not find ROM `{metadata.rom.file_name}` for profile `{path.name}`, " + f"please place `{metadata.rom.file_name}` into `{ROMS_DIRECTORY}`!" ) sys.exit(1) @@ -123,24 +89,19 @@ def profile_directory_exists(name: str) -> bool: def create_profile(name: str, rom: ROM) -> Profile: + if name.startswith("_"): + raise exceptions.PrettyValueError(f'Profile names cannot start with the underscore "_" character.') profile_directory = PROFILES_DIRECTORY / name if profile_directory.exists(): raise RuntimeError(f'There already is a profile called "{name}", cannot create a new one with that name.') - profile_directory.mkdir() - yaml = YAML() - yaml.allow_unicode = False - yaml.dump( - { - "version": 1, - "rom": { - "file_name": rom.file.name, - "game_code": rom.game_code, - "revision": rom.revision, - "language": str(rom.language), - }, - }, - profile_directory / "metadata.yml", + rom_cfg = ProfileMetadataROM( + file_name=rom.file.name, + game_code=rom.game_code, + revision=rom.revision, + language=str(rom.language), ) + profile_metadata = ProfileMetadata(rom=rom_cfg) + save_config_file(profile_directory, profile_metadata, strict=False) return Profile(rom, profile_directory, None) diff --git a/modules/stats.py b/modules/stats.py index 865a5d18..70b10d14 100644 --- a/modules/stats.py +++ b/modules/stats.py @@ -7,7 +7,6 @@ from threading import Thread from datetime import datetime -from modules.config import config from modules.console import print_stats from modules.context import context from modules.csv import log_encounter_to_csv @@ -315,7 +314,7 @@ def log_encounter(self, pokemon: Pokemon, block_list: list, custom_filter_result self.update_sv_records(pokemon) self.update_iv_records(pokemon) - if config["logging"]["log_encounters"]: + if context.config.logging.log_encounters: log_encounter_to_csv(self.total_stats, pokemon.to_dict(), self.stats_dir_path) self.update_shiny_averages(pokemon) @@ -328,10 +327,10 @@ def log_encounter(self, pokemon: Pokemon, block_list: list, custom_filter_result self.update_shiny_incremental_stats(pokemon) # TODO fix all this OBS crap - for i in range(config["obs"].get("shiny_delay", 1)): + for i in range(context.config.obs.get("shiny_delay", 1)): context.emulator.run_single_frame() # TODO bad (needs to be refactored so main loop advances frame) - if config["obs"]["screenshot"]: + if context.config.obs.screenshot: from modules.obs import obs_hot_key while get_game_state() != GameState.BATTLE: @@ -348,7 +347,7 @@ def log_encounter(self, pokemon: Pokemon, block_list: list, custom_filter_result Pokemon(pokemon.data), copy.deepcopy(self.total_stats), copy.deepcopy(block_list), - copy.deepcopy(custom_filter_result) + copy.deepcopy(custom_filter_result), ) Thread(target=self.custom_hooks, args=(hook,)).start() diff --git a/pokebot.py b/pokebot.py index 8ad1ac26..d2864da1 100644 --- a/pokebot.py +++ b/pokebot.py @@ -2,10 +2,12 @@ import argparse import atexit +import pathlib import platform from dataclasses import dataclass -from modules.runtime import is_bundled_app, get_base_path +from modules.modes import available_bot_modes +from modules.runtime import is_bundled_app from modules.version import pokebot_name, pokebot_version OS_NAME = platform.system() @@ -43,6 +45,18 @@ class StartupSettings: no_audio: bool emulation_speed: int always_on_top: bool + config_path: str + + +def directory_arg(value: str) -> pathlib.Path: + """Determine if the value is a valid readable directory. + + :param value: Directory to verify. + """ + path_obj = pathlib.Path(value) + if not path_obj.is_dir() or not path_obj.exists(): + raise exceptions.CriticalDirectoryMissing(value) + return path_obj def parse_arguments() -> StartupSettings: @@ -53,19 +67,20 @@ def parse_arguments() -> StartupSettings: nargs="?", help="Profile to initialize. Otherwise, the profile selection menu will appear.", ) - parser.add_argument("-m", "--bot-mode", choices=available_bot_modes, help="Initial bot mode (default: Manual)") + parser.add_argument("-m", "--bot-mode", choices=available_bot_modes, help="Initial bot mode (default: Manual).") parser.add_argument( "-s", "--emulation-speed", choices=["0", "1", "2", "3", "4"], help="Initial emulation speed (0 for unthrottled; default: 1)", ) - parser.add_argument("-nv", "--no-video", action="store_true", help="Turn off video output by default") - parser.add_argument("-na", "--no-audio", action="store_true", help="Turn off audio output by default") + parser.add_argument("-nv", "--no-video", action="store_true", help="Turn off video output by default.") + parser.add_argument("-na", "--no-audio", action="store_true", help="Turn off audio output by default.") parser.add_argument( - "-t", "--always-on-top", action="store_true", help="Keep the bot window always on top of other windows" + "-t", "--always-on-top", action="store_true", help="Keep the bot window always on top of other windows." ) - parser.add_argument("-d", "--debug", action="store_true", help="Enable extra debug options and a debug menu") + parser.add_argument("-d", "--debug", action="store_true", help="Enable extra debug options and a debug menu.") + parser.add_argument("-c", "--config", type=directory_arg, dest="config_path", help=argparse.SUPPRESS) args = parser.parse_args() preselected_profile: Profile | None = None @@ -80,6 +95,7 @@ def parse_arguments() -> StartupSettings: no_audio=bool(args.no_audio), emulation_speed=int(args.emulation_speed or "1"), always_on_top=bool(args.always_on_top), + config_path=args.config_path, ) @@ -88,16 +104,13 @@ def parse_arguments() -> StartupSettings: from requirements import check_requirements check_requirements() - - from modules.config import load_config_from_directory, available_bot_modes + from modules import exceptions # Import base module to ensure the custom exception hook is applied. from modules.context import context from modules.console import console from modules.gui import PokebotGui from modules.main import main_loop from modules.profiles import Profile, profile_directory_exists, load_profile_by_name - load_config_from_directory(get_base_path() / "profiles") - # This catches the signal Windows emits when the underlying console window is closed # by the user. We still want to save the emulator state in that case, which would not # happen by default! diff --git a/profiles/catch_block.yml b/profiles/catch_block.yml index eaabb746..03e43f17 100644 --- a/profiles/catch_block.yml +++ b/profiles/catch_block.yml @@ -1,7 +1,4 @@ -# Catch block list config -# See readme for documentation: https://github.com/40Cakes/pokebot-gen3#catch_blockyml---catch-block-config +# See wiki for documentation: https://github.com/40Cakes/pokebot-gen3/wiki/%E2%9D%8C-Catch-Block-List block_list: - - PokemonName1 - - PokemonName2 - - PokemonName3 \ No newline at end of file + - MissingNo diff --git a/profiles/cheats.yml b/profiles/cheats.yml index 4e659c98..ff281e59 100644 --- a/profiles/cheats.yml +++ b/profiles/cheats.yml @@ -1,14 +1,4 @@ -# Cheats config -# See readme for documentation: https://github.com/40Cakes/pokebot-gen3#cheatsyml---cheats-config +# See wiki for documentation: https://github.com/40Cakes/pokebot-gen3/wiki/%F0%9F%92%8E-Cheats -starters: false # `true`, `false` -starters_rng: false # `true`, `false` - -# ----------------------------------------- -# Everything below is not implemented, yet™ -# ----------------------------------------- - -# TODO RNG manipulation -# TODO egg resets -# TODO instant find Feebas tile -# TODO instant find roamer map +starters: false +starters_rng: false diff --git a/profiles/customcatchfilters.py b/profiles/customcatchfilters.py index 5f9fa268..dc4f2d06 100644 --- a/profiles/customcatchfilters.py +++ b/profiles/customcatchfilters.py @@ -77,17 +77,17 @@ def custom_catch_filters(pokemon: Pokemon) -> str | bool: # Pokémon with perfect IVs if pokemon.ivs.sum() == (6 * 31): return "Pokémon with perfect IVs" - #pass + # pass # Pokémon with all 0 IVs if pokemon.ivs.sum() == 0: return "Pokémon with all 0 IVs" - #pass + # pass # Pokémon with 6 identical IVs of any value if all(v == ivs[0] for v in ivs): return "Pokémon with 6 identical IVs of any value" - #pass + # pass # Pokémon with 4 or more max IVs in any stat max_ivs = sum(1 for v in ivs if v == 31) diff --git a/profiles/customhooks.py b/profiles/customhooks.py index daeaad00..e289801c 100644 --- a/profiles/customhooks.py +++ b/profiles/customhooks.py @@ -3,7 +3,6 @@ import time import random from threading import Thread -from modules.config import config from modules.console import console from modules.context import context from modules.discord import discord_message @@ -11,6 +10,8 @@ from modules.runtime import get_sprites_path from modules.version import pokebot_version +config = context.config + def custom_hooks(hook) -> None: """ @@ -31,7 +32,7 @@ def custom_hooks(hook) -> None: # Discord messages def IVField() -> str: # Formatted IV table - if config["discord"]["iv_format"] == "formatted": + if config.discord.iv_format == "formatted": iv_field = ( "```" "╔═══╤═══╤═══╤═══╤═══╤═══╗\n" @@ -81,25 +82,25 @@ def PhaseSummary() -> dict: } def Footer() -> str: - return f"ID: {config['discord']['bot_id']} | {context.rom.game_name}\nPokéBot {pokebot_version}" + return f"ID: {config.discord.bot_id} | {context.rom.game_name}\nPokéBot {pokebot_version}" try: # Discord shiny Pokémon encountered - if config["discord"]["shiny_pokemon_encounter"]["enable"] and pokemon.is_shiny: + if config.discord.shiny_pokemon_encounter.enable and pokemon.is_shiny: # Discord pings discord_ping = "" - match config["discord"]["shiny_pokemon_encounter"]["ping_mode"]: + match config.discord.shiny_pokemon_encounter.ping_mode: case "role": - discord_ping = f"📢 <@&{config['discord']['shiny_pokemon_encounter']['ping_id']}>" + discord_ping = f"📢 <@&{config.discord.shiny_pokemon_encounter.ping_id}>" case "user": - discord_ping = f"📢 <@{config['discord']['shiny_pokemon_encounter']['ping_id']}>" + discord_ping = f"📢 <@{config.discord.shiny_pokemon_encounter.ping_id}>" block = ( "\n❌Skipping catching shiny (on catch block list)!" if pokemon.species.name in block_list else "" ) discord_message( - webhook_url=config["discord"]["shiny_pokemon_encounter"].get("webhook_url", None), + webhook_url=config.discord.shiny_pokemon_encounter.get("webhook_url", None), content=f"Encountered a shiny ✨ {pokemon.species.name} ✨! {block}\n{discord_ping}", embed=True, embed_title="Shiny encountered!", @@ -124,20 +125,20 @@ def Footer() -> str: try: # Discord Pokémon encounter milestones if ( - config["discord"]["pokemon_encounter_milestones"]["enable"] + config.discord.pokemon_encounter_milestones.enable and stats["pokemon"][pokemon.species.name].get("encounters", -1) - % config["discord"]["pokemon_encounter_milestones"].get("interval", 0) + % config.discord.pokemon_encounter_milestones.get("interval", 0) == 0 ): # Discord pings discord_ping = "" - match config["discord"]["pokemon_encounter_milestones"]["ping_mode"]: + match config.discord.pokemon_encounter_milestones.ping_mode: case "role": - discord_ping = f"📢 <@&{config['discord']['pokemon_encounter_milestones']['ping_id']}>" + discord_ping = f"📢 <@&{config.discord.pokemon_encounter_milestones.ping_id}>" case "user": - discord_ping = f"📢 <@{config['discord']['pokemon_encounter_milestones']['ping_id']}>" + discord_ping = f"📢 <@{config.discord.pokemon_encounter_milestones.ping_id}>" discord_message( - webhook_url=config["discord"]["pokemon_encounter_milestones"].get("webhook_url", None), + webhook_url=config.discord.pokemon_encounter_milestones.get("webhook_url", None), content=f"🎉 New milestone achieved!\n{discord_ping}", embed=True, embed_description=f"{stats['pokemon'][pokemon.species.name].get('encounters', 0):,} {pokemon.species.name} encounters!", @@ -151,21 +152,21 @@ def Footer() -> str: try: # Discord shiny Pokémon encounter milestones if ( - config["discord"]["shiny_pokemon_encounter_milestones"]["enable"] + config.discord.shiny_pokemon_encounter_milestones.enable and pokemon.is_shiny and stats["pokemon"][pokemon.species.name].get("shiny_encounters", -1) - % config["discord"]["shiny_pokemon_encounter_milestones"].get("interval", 0) + % config.discord.shiny_pokemon_encounter_milestones.get("interval", 0) == 0 ): # Discord pings discord_ping = "" - match config["discord"]["shiny_pokemon_encounter_milestones"]["ping_mode"]: + match config.discord.shiny_pokemon_encounter_milestones.ping_mode: case "role": - discord_ping = f"📢 <@&{config['discord']['shiny_pokemon_encounter_milestones']['ping_id']}>" + discord_ping = f"📢 <@&{config.discord.shiny_pokemon_encounter_milestones.ping_id}>" case "user": - discord_ping = f"📢 <@{config['discord']['shiny_pokemon_encounter_milestones']['ping_id']}>" + discord_ping = f"📢 <@{config.discord.shiny_pokemon_encounter_milestones.ping_id}>" discord_message( - webhook_url=config["discord"]["shiny_pokemon_encounter_milestones"].get("webhook_url", None), + webhook_url=config.discord.shiny_pokemon_encounter_milestones.get("webhook_url", None), content=f"🎉 New milestone achieved!\n{discord_ping}", embed=True, embed_description=f"{stats['pokemon'][pokemon.species.name].get('shiny_encounters', 0):,} shiny ✨ {pokemon.species.name} ✨ encounters!", @@ -179,18 +180,17 @@ def Footer() -> str: try: # Discord total encounter milestones if ( - config["discord"]["total_encounter_milestones"]["enable"] - and stats["totals"].get("encounters", -1) - % config["discord"]["total_encounter_milestones"].get("interval", 0) + config.discord.total_encounter_milestones.enable + and stats["totals"].get("encounters", -1) % config.discord.total_encounter_milestones.get("interval", 0) == 0 ): # Discord pings discord_ping = "" - match config["discord"]["total_encounter_milestones"]["ping_mode"]: + match config.discord.total_encounter_milestones.ping_mode: case "role": - discord_ping = f"📢 <@&{config['discord']['total_encounter_milestones']['ping_id']}>" + discord_ping = f"📢 <@&{config.discord.total_encounter_milestones.ping_id}>" case "user": - discord_ping = f"📢 <@{config['discord']['total_encounter_milestones']['ping_id']}>" + discord_ping = f"📢 <@{config.discord.total_encounter_milestones.ping_id}>" embed_thumbnail = random.choice( [ @@ -212,7 +212,7 @@ def Footer() -> str: ) discord_message( - webhook_url=config["discord"]["total_encounter_milestones"].get("webhook_url", None), + webhook_url=config.discord.total_encounter_milestones.get("webhook_url", None), content=f"🎉 New milestone achieved!\n{discord_ping}", embed=True, embed_description=f"{stats['totals'].get('encounters', 0):,} total encounters!", @@ -226,29 +226,28 @@ def Footer() -> str: try: # Discord phase encounter notifications if ( - config["discord"]["phase_summary"]["enable"] + config.discord.phase_summary.enable and not pokemon.is_shiny and ( - stats["totals"].get("phase_encounters", -1) - == config["discord"]["phase_summary"].get("first_interval", 0) + stats["totals"].get("phase_encounters", -1) == config.discord.phase_summary.get("first_interval", 0) or ( stats["totals"].get("phase_encounters", -1) - > config["discord"]["phase_summary"].get("first_interval", 0) + > config.discord.phase_summary.get("first_interval", 0) and stats["totals"].get("phase_encounters", -1) - % config["discord"]["phase_summary"].get("consequent_interval", 0) + % config.discord.phase_summary.get("consequent_interval", 0) == 0 ) ) ): # Discord pings discord_ping = "" - match config["discord"]["phase_summary"]["ping_mode"]: + match config.discord.phase_summary.ping_mode: case "role": - discord_ping = f"📢 <@&{config['discord']['phase_summary']['ping_id']}>" + discord_ping = f"📢 <@&{config.discord.phase_summary.ping_id}>" case "user": - discord_ping = f"📢 <@{config['discord']['phase_summary']['ping_id']}>" + discord_ping = f"📢 <@{config.discord.phase_summary.ping_id}>" discord_message( - webhook_url=config["discord"]["phase_summary"].get("webhook_url", None), + webhook_url=config.discord.phase_summary.get("webhook_url", None), content=f"💀 The current phase has reached {stats['totals'].get('phase_encounters', 0):,} encounters!\n{discord_ping}", embed=True, embed_fields=PhaseSummary(), @@ -260,16 +259,16 @@ def Footer() -> str: try: # Discord anti-shiny Pokémon encountered - if config["discord"]["anti_shiny_pokemon_encounter"]["enable"] and pokemon.is_anti_shiny: + if config.discord.anti_shiny_pokemon_encounter.enable and pokemon.is_anti_shiny: # Discord pings discord_ping = "" - match config["discord"]["anti_shiny_pokemon_encounter"]["ping_mode"]: + match config.discord.anti_shiny_pokemon_encounter.ping_mode: case "role": - discord_ping = f"📢 <@&{config['discord']['anti_shiny_pokemon_encounter']['ping_id']}>" + discord_ping = f"📢 <@&{config.discord.anti_shiny_pokemon_encounter.ping_id}>" case "user": - discord_ping = f"📢 <@{config['discord']['anti_shiny_pokemon_encounter']['ping_id']}>" + discord_ping = f"📢 <@{config.discord.anti_shiny_pokemon_encounter.ping_id}>" discord_message( - webhook_url=config["discord"]["anti_shiny_pokemon_encounter"].get("webhook_url", None), + webhook_url=config.discord.anti_shiny_pokemon_encounter.get("webhook_url", None), content=f"Encountered an anti-shiny 💀 {pokemon.species.name} 💀!\n{discord_ping}", embed=True, embed_title="Anti-Shiny encountered!", @@ -291,17 +290,17 @@ def Footer() -> str: try: # Discord Pokémon matching custom filter encountered - if config["discord"]["custom_filter_pokemon_encounter"]["enable"] and isinstance(custom_filter_result, str): + if config.discord.custom_filter_pokemon_encounter.enable and isinstance(custom_filter_result, str): # Discord pings discord_ping = "" - match config["discord"]["custom_filter_pokemon_encounter"]["ping_mode"]: + match config.discord.custom_filter_pokemon_encounter.ping_mode: case "role": - discord_ping = f"📢 <@&{config['discord']['custom_filter_pokemon_encounter']['ping_id']}>" + discord_ping = f"📢 <@&{config.discord.custom_filter_pokemon_encounter.ping_id}>" case "user": - discord_ping = f"📢 <@{config['discord']['custom_filter_pokemon_encounter']['ping_id']}>" + discord_ping = f"📢 <@{config.discord.custom_filter_pokemon_encounter.ping_id}>" discord_message( - webhook_url=config["discord"]["custom_filter_pokemon_encounter"].get("webhook_url", None), + webhook_url=config.discord.custom_filter_pokemon_encounter.et("webhook_url", None), content=f"Encountered a {pokemon.species.name} matching custom filter: `{custom_filter_result}`!\n{discord_ping}", embed=True, embed_title="Encountered Pokémon matching custom catch filter!", @@ -327,13 +326,13 @@ def Footer() -> str: try: # Post the most recent OBS stream screenshot to Discord # (screenshot is taken in stats.py before phase resets) - if config["obs"]["discord_webhook_url"] and pokemon.is_shiny: + if config.obs.discord_webhook_url and pokemon.is_shiny: def OBSDiscordScreenshot(): time.sleep(3) # Give the screenshot some time to save to disk - images = glob.glob(f"{config['obs']['replay_dir']}*.png") + images = glob.glob(f"{config.obs.replay_dir}*.png") image = max(images, key=os.path.getctime) - discord_message(webhook_url=config["obs"].get("discord_webhook_url", None), image=image) + discord_message(webhook_url=config.obs.get("discord_webhook_url", None), image=image) # Run in a thread to not hold up other hooks Thread(target=OBSDiscordScreenshot).start() @@ -342,12 +341,12 @@ def OBSDiscordScreenshot(): try: # Save OBS replay buffer n frames after encountering a shiny - if config["obs"]["replay_buffer"] and pokemon.is_shiny: + if config.obs.replay_buffer and pokemon.is_shiny: def OBSReplayBuffer(): from modules.obs import obs_hot_key - time.sleep(config["obs"].get("replay_buffer_delay", 0)) + time.sleep(config.obs.get("replay_buffer_delay", 0)) obs_hot_key("OBS_KEY_F12", pressCtrl=True) # Run in a thread to not hold up other hooks diff --git a/profiles/discord.yml b/profiles/discord.yml index 54d13b8c..39b75a26 100644 --- a/profiles/discord.yml +++ b/profiles/discord.yml @@ -1,10 +1,9 @@ -# Discord settings -# See readme for documentation: https://github.com/40Cakes/pokebot-gen3#discordyml---discord-integration-config +# See wiki for documentation: https://github.com/40Cakes/pokebot-gen3/wiki/%F0%9F%93%A2-Discord-Integration -rich_presence: false # `true`, `false` -global_webhook_url: https://discord.com/api/webhooks/ +rich_presence: false +global_webhook_url: "" iv_format: formatted # `basic`, `formatted` -bot_id: PokéBot # Any string of text +bot_id: PokéBot # Shiny Pokémon encounters shiny_pokemon_encounter: @@ -53,10 +52,9 @@ anti_shiny_pokemon_encounter: ping_id: #webhook_url: - # Custom filter Pokémon encountered custom_filter_pokemon_encounter: enable: false ping_mode: ping_id: - #webhook_url: \ No newline at end of file + #webhook_url: diff --git a/profiles/general.yml b/profiles/general.yml index 443e16ff..7a38e09d 100644 --- a/profiles/general.yml +++ b/profiles/general.yml @@ -1,6 +1,3 @@ -# General settings -# See readme for documentation: https://github.com/40Cakes/pokebot-gen3#generalyml---general-config +# To be deprecated shortly in PR #109 -# Starter - used when bot mode is set to `starters` -# `Treecko`, `Torchic`, `Mudkip`, `Bulbasaur`, `Charmander`, `Squirtle`, `Chikorita`, `Cyndaquil`, `Totodile` starter: Mudkip diff --git a/profiles/keys.yml b/profiles/keys.yml index 2be4d657..e84158a1 100644 --- a/profiles/keys.yml +++ b/profiles/keys.yml @@ -1,61 +1,57 @@ -# Emulator input mapping settings -# See readme for documentation: https://github.com/40Cakes/pokebot-gen3#keysyml---emulator-input-mapping +# See wiki for documentation: https://github.com/40Cakes/pokebot-gen3/wiki/%F0%9F%8E%AE-Emulator-Input-Mapping # Default mGBA mapping gba: - Up: 'Up' - Down: 'Down' - Left: 'Left' - Right: 'Right' - A: 'x' - B: 'z' - L: 'a' - R: 's' - Start: 'Return' - Select: 'BackSpace' + Up: Up + Down: Down + Left: Left + Right: Right + A: x + B: z + L: a + R: s + Start: Return + Select: BackSpace # Keys that trigger emulator features -# -# You can optionally prefix a key code with 'Ctrl+' so that it only works if the Ctrl -# modifier key is held at the same time. +# You can optionally prefix a key code with 'Ctrl+' so that it only works if the Ctrl modifier key is held at the same time. emulator: - # Increases the zoom level by 1 - zoom_in: 'plus' + # Increase zoom level by 1 + zoom_in: plus - # Decreases the zoom level by 1 - zoom_out: 'minus' + # Decrease zoom level by 1 + zoom_out: minus - # Switches between the 'manual' bot mode and whatever is configured in `general.yml` - toggle_manual: 'Tab' + # Toggle between the 'manual' bot mode and the previously selected mode + toggle_manual: Tab - # Disables or enables video output (disabling speeds up emulation) - toggle_video: 'v' + # Toggle video output (disabling video will boost FPS) + toggle_video: v - # Turns sound on or off - toggle_audio: 'b' + # Toggle sound output + toggle_audio: b - # Sets the emulation speed to 1×/2×/3×/4× + # Set emulation speed set_speed_1x: '1' set_speed_2x: '2' set_speed_3x: '3' set_speed_4x: '4' - # Disables throttling entirely, i.e. the emulator now runs as fast as your CPU allows + # ⚠ Photosensitivity warning: Disable throttling entirely, the emulator will run as fast as your CPU allows set_speed_unthrottled: '0' # Reset emulator core/reboot game - reset: 'Ctrl+R' + reset: Ctrl+R # Exit emulator and bot - exit: 'Ctrl+Q' + exit: Ctrl+Q # Create a save state - save_state: 'Ctrl+S' + save_state: Ctrl+S - # Open the load save state selection menu - load_state: 'Ctrl+L' + # Open load save state selection menu + load_state: Ctrl+L # -- Ignore this unless you want to do programming on the bot code -- - # Allows you to put the emulator into 'stepping mode', where frames need to be advanced - # manually using a button. Useful for analysing memory values. - toggle_stepping_mode: 'Ctrl+P' + # Allows you to put the emulator into 'stepping mode', where frames need to be advanced manually using a button, useful for analysing memory values + toggle_stepping_mode: Ctrl+P diff --git a/profiles/logging.yml b/profiles/logging.yml index e009c2bb..b3a7dd08 100644 --- a/profiles/logging.yml +++ b/profiles/logging.yml @@ -1,7 +1,6 @@ -# Logging/console output options -# See readme for documentation: https://github.com/40Cakes/pokebot-gen3#loggingyml---logging-and-console-output-config +# See wiki for documentation: https://github.com/40Cakes/pokebot-gen3/wiki/%F0%9F%93%84-Logging-and-Console-Output -# Log encounters to .csv files +# Log all encounters to .csv (`stats/encounters/` folder), each phase is logged to a separate file log_encounters: false # `true`, `false` # Console output @@ -13,12 +12,10 @@ console: statistics: verbose # Save .pk3 files -# `true`, `false` save_pk3: all: false shiny: true custom: true # Automatically load .pk3 file into PC storage -# `true`, `false` import_pk3: false diff --git a/profiles/obs.yml b/profiles/obs.yml index d040b51a..abb87d87 100644 --- a/profiles/obs.yml +++ b/profiles/obs.yml @@ -1,5 +1,4 @@ -# OBS settings -# See readme for documentation: https://github.com/40Cakes/pokebot-gen3#obsyml---obs-config +# See wiki for documentation: https://github.com/40Cakes/pokebot-gen3#obsyml---obs-config obs_websocket: host: 127.0.0.1 @@ -8,14 +7,13 @@ obs_websocket: shiny_delay: 0 discord_delay: 0 -screenshot: false # `true`, `false` -replay_buffer: false # `true`, `false` +screenshot: false +replay_buffer: false replay_buffer_delay: 0 discord_webhook_url: replay_dir: "./stream/replays/" -# Web server http_server: - enable: false # `true`, `false` + enable: false ip: 127.0.0.1 port: 8888 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..d87d1399 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +# Requirements exclusive for development/testing. +# These should not be installed for users. +pytest==7.4.3 diff --git a/requirements.py b/requirements.py index 474922e2..c0c853ba 100644 --- a/requirements.py +++ b/requirements.py @@ -12,6 +12,7 @@ # This is a list of requirements for `pip`, akin to `requirements.txt`. required_modules = [ + "confz==2.0.1", "numpy~=1.26.1", "Flask~=2.3.2", "Flask-Cors~=4.0.0", @@ -27,7 +28,7 @@ "sounddevice~=0.4.6", "requests~=2.31.0", "pyperclip3~=0.4.1", - "plyer~=2.1.0" + "plyer~=2.1.0", ] if platform.system() == "Windows": @@ -44,14 +45,17 @@ def get_requirements_hash() -> str: :return: A hash of all the current requirements, as well as this system's Python version. """ import hashlib - requirements_block = "\n".join([ - *required_modules, - platform.python_version(), - libmgba_ver, - libmgba_tag, - recommended_python_version, - *supported_python_versions, - ]) + + requirements_block = "\n".join( + [ + *required_modules, + platform.python_version(), + libmgba_ver, + libmgba_tag, + recommended_python_version, + *supported_python_versions, + ] + ) return hashlib.sha1(requirements_block.encode("utf-8")).hexdigest() @@ -103,8 +107,7 @@ def update_requirements(ask_for_confirmation: bool = True) -> bool: pip_flags = ["--disable-pip-version-check", "--no-python-version-warning"] for module in required_modules: subprocess.check_call( - [sys.executable, "-m", "pip", "install", *pip_flags, module], - stderr=sys.stderr, stdout=sys.stdout + [sys.executable, "-m", "pip", "install", *pip_flags, module], stderr=sys.stderr, stdout=sys.stdout ) # Make sure that `libmgba-py` is installed. @@ -194,5 +197,5 @@ def check_requirements() -> bool: return update_requirements() -if __name__ == '__main__': +if __name__ == "__main__": update_requirements() diff --git a/tests/Readme.md b/tests/Readme.md new file mode 100644 index 00000000..091310bb --- /dev/null +++ b/tests/Readme.md @@ -0,0 +1,53 @@ +# Unit Tests + +### Prerequisites + +Install the dependencies in `requirements-dev.txt`. These are not part of the normal dependencies to prevent installing +them for end users. + +``` +pip install -r requirements-dev.txt +``` + +### Running unit tests + +Pytest will detect pretty much all `test*` folders and python files and run functions that start with `test`. + +``` +pytest + +===================================================== test session starts ===================================================== +... +collected 5 items + +tests/test_config.py ..... [100%] + +====================================================== 5 passed in 0.33s ====================================================== +``` + +A specific file can also be run, instead of collecting and running all tests: + +``` +pytest test_config.py + +===================================================== test session starts ===================================================== +... +collected 5 items + +tests/test_config.py ..... [100%] + +====================================================== 5 passed in 0.33s ====================================================== +``` + +The `-v` flag provides additional information, and we can use `-k` to run tests matching a specific expression. + +``` +pytest -k defaults -vvv +===================================================== test session starts ===================================================== +... +collected 5 items / 4 deselected / 1 selected + +tests/test_config.py::test_config[defaults load correctly] PASSED [100%] + +=============================================== 1 passed, 4 deselected in 0.30s =============================================== +``` diff --git a/tests/config/cheats.yml b/tests/config/cheats.yml new file mode 100644 index 00000000..80b15f11 --- /dev/null +++ b/tests/config/cheats.yml @@ -0,0 +1,2 @@ +"starters": true +"starters_rng": true diff --git a/tests/config/profile/metadata.yml b/tests/config/profile/metadata.yml new file mode 100644 index 00000000..03986a53 --- /dev/null +++ b/tests/config/profile/metadata.yml @@ -0,0 +1,6 @@ +version: 1 +rom: + file_name: Pokemon - Emerald Version (U).gba + game_code: BPE + revision: 0 + language: E diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..98d258df --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,151 @@ +"""Unit tests to ensure config files are loaded properly.""" + +from pathlib import Path + +import pytest + +import pokebot as _ # We need this import here to ensure all modules are loaded in the right order. + +from modules import config +from modules import exceptions + +WEBHOOK_DEFAULTS = { + "consequent_interval": 5000, + "enable": False, + "first_interval": 8192, + "interval": 5, + "ping_id": None, + "ping_mode": None, +} +DEFAULT_CONFIG = { + "catch_block": {"block_list": []}, + "cheats": {"starters": False, "starters_rng": False}, + "discord": { + "anti_shiny_pokemon_encounter": WEBHOOK_DEFAULTS, + "bot_id": "PokéBot", + "global_webhook_url": "", + "iv_format": "formatted", + "phase_summary": WEBHOOK_DEFAULTS, + "pokemon_encounter_milestones": { + "consequent_interval": 5000, + "enable": False, + "first_interval": 8192, + "interval": 10000, + "ping_id": None, + "ping_mode": None, + }, + "rich_presence": False, + "shiny_pokemon_encounter": WEBHOOK_DEFAULTS, + "shiny_pokemon_encounter_milestones": WEBHOOK_DEFAULTS, + "custom_filter_pokemon_encounter": WEBHOOK_DEFAULTS, + "total_encounter_milestones": { + "consequent_interval": 5000, + "enable": False, + "first_interval": 8192, + "interval": 25000, + "ping_id": None, + "ping_mode": None, + }, + }, + "general": {"starter": config.schemas_v1.Starters.MUDKIP}, + "keys": { + "gba": { + "Up": "Up", + "Down": "Down", + "Left": "Left", + "Right": "Right", + "A": "x", + "B": "z", + "L": "a", + "R": "s", + "Start": "Return", + "Select": "BackSpace", + }, + "emulator": { + "zoom_in": "plus", + "zoom_out": "minus", + "toggle_manual": "Tab", + "toggle_video": "v", + "toggle_audio": "b", + "set_speed_1x": "1", + "set_speed_2x": "2", + "set_speed_3x": "3", + "set_speed_4x": "4", + "set_speed_unthrottled": "0", + "reload_config": "Ctrl+C", + "reset": "Ctrl+R", + "exit": "Ctrl+Q", + "save_state": "Ctrl+S", + "toggle_stepping_mode": "Ctrl+L", + }, + }, + "logging": { + "console": { + "encounter_data": "verbose", + "encounter_ivs": "verbose", + "encounter_moves": "disable", + "statistics": "verbose", + }, + "import_pk3": False, + "log_encounters": False, + "save_pk3": {"all": False, "custom": False, "shiny": False}, + }, + "obs": { + "discord_delay": 0, + "discord_webhook_url": None, + "http_server": {"enable": False, "ip": "127.0.0.1", "port": 8888}, + "obs_websocket": {"password": "password", "host": "127.0.0.1", "port": 4455}, + "replay_buffer": False, + "replay_buffer_delay": 0, + "replay_dir": "./stream/replays/", + "screenshot": False, + "shiny_delay": 0, + }, +} + +CONFIG_TESTS = { + "config_load": { + "defaults load correctly": {"kwargs": {"config_dir": Path("tests")}, "expected": DEFAULT_CONFIG}, + "folder loads correctly": { + "kwargs": {"config_dir": Path("tests") / "config"}, + "expected": DEFAULT_CONFIG.copy() | {"cheats": {"starters": True, "starters_rng": True}}, + }, + "profile loads correctly": { + "kwargs": {"config_dir": (Path("tests") / "config") / "profile", "is_profile": True}, + "expected": DEFAULT_CONFIG.copy() + | { + "metadata": { + "rom": { + "file_name": "Pokemon - Emerald Version (U).gba", + "game_code": "BPE", + "language": "E", + "revision": 0, + }, + "version": 1, + } + }, + }, + "is_profile but missing metadata": { + "kwargs": {"config_dir": Path("tests") / "config", "is_profile": True}, + "raises": exceptions.CriticalFileMissing, + }, + "missing file with strict = True": { + "kwargs": {"config_dir": Path("tests") / "config", "strict": True}, + "raises": exceptions.CriticalFileMissing, + }, + } +} + + +@pytest.mark.parametrize("tests", CONFIG_TESTS["config_load"].values(), ids=CONFIG_TESTS["config_load"].keys()) +def test_config(test: dict) -> None: + """Ensures the main config can be instanced and loads all children objects.""" + exception = test.get("raises") + if exception: + with pytest.raises(exception): + _ = config.Config(**test["kwargs"]) + else: + loaded_config = config.Config(**test["kwargs"]) + msg = "Attribute {} does not match the expected result: {}" + for attribute, expected in test["expected"].items(): + assert getattr(loaded_config, attribute).model_dump() == expected, msg.format(attribute, expected)