From 9dd363aa2691dc267dbc67d20984727d61a781c3 Mon Sep 17 00:00:00 2001 From: Tino Date: Fri, 18 Oct 2024 16:20:16 -0500 Subject: [PATCH] Add Wiki pages for plugins and the stats database This adds some basic information about how plugins are supposed to work, and also extends the base `BotPlugin` class with comments for each function. Until now, a plugin file was only allowed to contain _one_ plugin class -- but there's no real reason why it has to be this way, so I've removed that restriction. I've also added a very brief description of the contents of the stats database to the wiki. --- modules/plugin_interface.py | 128 ++++++++++++++++++ modules/plugins.py | 10 +- wiki/Readme.md | 5 + wiki/pages/Customisation - Plugins.md | 47 +++++++ .../Customisation - Statistics Database.md | 21 +++ 5 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 wiki/pages/Customisation - Plugins.md create mode 100644 wiki/pages/Customisation - Statistics Database.md diff --git a/modules/plugin_interface.py b/modules/plugin_interface.py index bada8916b..cd61d9f00 100644 --- a/modules/plugin_interface.py +++ b/modules/plugin_interface.py @@ -10,40 +10,168 @@ class BotPlugin: def get_additional_bot_modes(self) -> Iterable[type["BotMode"]]: + """ + This hook can return an iterable (i.e. a list, tuple, or generator) of bot modes + that should be added to the default ones. + + It can be used to add custom bot modes without modifying the regular bot code + (which would get replaced during updates.) + + :return: Iterable of bot mode _types_. Note that this must be a reference to the + class itself and not an instance of it. So do `yield MyMode` instead of + `yield MyMode()`. + """ return [] def get_additional_bot_listeners(self) -> Iterable["BotListener"]: + """ + This hook returns a list of bot listeners that should be loaded. + + Bot listeners are classes implementing `BotListener`, and these have a + `handle_frame()` method that gets called -- you guessed it -- every frame. + + This can be useful to wait for certain in-game events to happen and then + act upon it without having to do it within a bot mode. + + Bot listeners can also be added in other hooks (by calling + `context.bot_listeners.append(MyListener())`), in case they don't have to + run all the time. + + :return: List of _instances_ of bot listeners. + """ + return [] def on_profile_loaded(self, profile: "Profile") -> None: + """ + This is called after a profile has been selected via the GUI or a command-line + option and the emulation has started. + + :param profile: The profile selected by the user. + """ pass def on_battle_started(self, encounter: "EncounterInfo | None") -> Generator | None: + """ + This is called once the game entered battle mode, so when the screen has faded + to black. + + :param encounter: Information about the encounter if this is a wild Pokémon + encounter, otherwise (in trainer battles) None. + :return: This _may_ return a Generator (so you can use `yield` inside here), in + which case the current bot mode is suspended and this generator function + takes control. + """ + pass def on_wild_encounter_visible(self, encounter: "EncounterInfo") -> Generator | None: + """ + This is called once a wild encounter is fully visible, i.e. the sliding-in + animation has completed, the Pokémon has done it's cry and the 'Wild XYZ appeared!' + message is visible. + + :param encounter: Information about the wild encounter. + :return: This _may_ return a Generator (so you can use `yield` inside here), in + which case the current bot mode is suspended and this generator function + takes control. + """ pass def on_battle_ended(self, outcome: "BattleOutcome") -> Generator | None: + """ + This is called once a battle has ended. At this point, the game is still in battle + mode and not yet in the overworld. It's just the point at which the outcome of the + battle is known. + + :param outcome: How the battle ended, e.g. won, lost, ran away, ... + :return: This _may_ return a Generator (so you can use `yield` inside here), in + which case the current bot mode is suspended and this generator function + takes control. + """ pass def on_logging_encounter(self, encounter: "EncounterInfo") -> None: + """ + This is called whenever an encounter is being logged. This _may_ happen because of a + wild encounter battle, but it also gets triggered by hatching eggs. Bot modes can + trigger this too by calling `log_encounter()`, which is done for gift Pokémon. + + :param encounter: Information about the wild encounter. + """ pass def on_pokemon_evolved(self, evolved_pokemon: "Pokemon") -> Generator | None: + """ + This is called when a Pokémon has evolved. It is not called if an evolution has been + interrupted by pressing `B`. + + :param evolved_pokemon: Data of the Pokémon _after_ evolution. + :return: This _may_ return a Generator (so you can use `yield` inside here), in + which case the current bot mode is suspended and this generator function + takes control. + """ pass def on_egg_starting_to_hatch(self, hatching_pokemon: "EncounterInfo") -> Generator | None: + """ + This is called when the egg hatching cutscene starts. + + :param hatching_pokemon: Data of the egg that is about to hatch. + :return: This _may_ return a Generator (so you can use `yield` inside here), in + which case the current bot mode is suspended and this generator function + takes control. + """ + pass def on_egg_hatched(self, hatched_pokemon: "EncounterInfo") -> Generator | None: + """ + This is called during the egg-hatching cutscene once the egg has hatched and the + Pokémon is visible. + + :param hatched_pokemon: Data of the Pokémon that has hatched. + :return: This _may_ return a Generator (so you can use `yield` inside here), in + which case the current bot mode is suspended and this generator function + takes control. + """ pass def on_whiteout(self) -> Generator | None: + """ + This is called when the player has whited out (due to being defeated in battle, or + the last party Pokémon fainting due to poison.) + + When this is called, the white-out dialogue has already been completed and the player + is standing in front of the last Pokémon Center. + + :return: This _may_ return a Generator (so you can use `yield` inside here), in + which case the current bot mode is suspended and this generator function + takes control. + """ pass def on_judge_encounter(self, opponent: "Pokemon") -> str | bool: + """ + This is called during `judge_encounter()`, which is supposed to decide whether a + Pokémon is worth catching or not. + + Shiny and Roamer Pokémon are matched automatically, but this can be used to add custom + filter rules for Pokémon. + + :param opponent: Information about the encountered Pokémon. + :return: `False` is this Pokémon is considered to NOT be of interest, otherwise a string + describing why it has value. This string is displayed in some log messages. + """ return False def on_should_nickname_pokemon(self, pokemon: "Pokemon") -> str | None: + """ + This is called when the player is asked whether to give a nickname to a newly + acquired Pokémon. + + :param pokemon: The newly received Pokémon. + :return: The nickname (max. 10 characters) to give to the Pokémon, or `None` to + not give a nickname. + """ return None diff --git a/modules/plugins.py b/modules/plugins.py index de146bd61..fcd9e9919 100644 --- a/modules/plugins.py +++ b/modules/plugins.py @@ -50,14 +50,12 @@ def load_plugins(): inspect.getmembers(getattr(imported_module, module_name), inspect.isclass), ) ) + if len(classes) == 0: - raise RuntimeError(f"Could not load plugin `{file.name}`: It did not contain any class.") - if len(classes) > 1: - raise RuntimeError(f"Could not load plugin `{file.name}`: It contained more than one class.") - if not issubclass(classes[0][1], BotPlugin): - raise RuntimeError(f"Could not load plugin `{file.name}`: Class did not inherit from `BotPlugin`.") + raise RuntimeError(f"Could not load plugin `{file.name}`: It did not contain any plugin class.") - plugins.append(classes[0][1]()) + for class_name, plugin_class in classes: + plugins.append(plugin_class()) def plugin_get_additional_bot_modes() -> Iterable["BotMode"]: diff --git a/wiki/Readme.md b/wiki/Readme.md index 5d2d4dc3e..67efac184 100644 --- a/wiki/Readme.md +++ b/wiki/Readme.md @@ -51,3 +51,8 @@ For quick help and support, reach out in Discord [#pokebot-gen3-support❔](http - 🥅 [Custom Catch Filters](pages/Configuration%20-%20Custom%20Catch%20Filters.md) - 💎 [Cheats](pages/Configuration%20-%20Cheats.md) - 📡 [HTTP Server](pages/Configuration%20-%20HTTP%20Server.md) + +### Customisation + +- 🧩 [Bot Plugins](pages/Customisation%20-%20Plugins.md) +- 📊 [Statistics Database](pages/Customisation%20-%20Statistics%20Database.md) diff --git a/wiki/pages/Customisation - Plugins.md b/wiki/pages/Customisation - Plugins.md new file mode 100644 index 000000000..807886a1b --- /dev/null +++ b/wiki/pages/Customisation - Plugins.md @@ -0,0 +1,47 @@ +🏠 [`pokebot-gen3` Wiki Home](../Readme.md) + +# 🧩 Plugins + +If you know Python and want to customise the behaviour of the bot, you can create a plugin. + +Plugins are little scripts that get called for specific events during the game (such as a +battle starting, an egg hatching, etc.) Take a look at +[modules/plugin_interface.py](../../modules/plugin_interface.py) for a list of events that +a plugin may be called for. + + +## Creating a plugin + +To make a plugin, create a Python file inside the `plugins/` directory. This must have the +`.py` file extension and it must be placed directly in the `plugins/` directory, not in a +subdirectory. + +In this file, create a class that inherits from `BotPlugin`. So the most basic implementation +of a plugin would be: + +```python +from modules.plugin_interface import BotPlugin + +class MyFirstPlugin(BotPlugin): + pass +``` + +Of course, this doesn't do anything yet. You can choose some method from the parent `BotPlugin` +class to overwrite (see [modules/plugin_interface.py](../../modules/plugin_interface.py) for +a list.) + + +## Why write a plugin and not just edit the bot's code? + +The `plugins/` directory is excluded from the Git repository and will also not be touched by +the automatic updater. So code in that directory won't fall victim to future updates -- +whereas if you edit the bot's code directly, this might get removed again when the bot updates +and you're not careful. + + +## Example plugins + +While not meant to be just an example, there are some features that use the bot's plugin +infrastructure to work. + +You can find those 'default plugins' in [modules/built_in_plugins/](../../modules/built_in_plugins/). diff --git a/wiki/pages/Customisation - Statistics Database.md b/wiki/pages/Customisation - Statistics Database.md new file mode 100644 index 000000000..012c79e56 --- /dev/null +++ b/wiki/pages/Customisation - Statistics Database.md @@ -0,0 +1,21 @@ +🏠 [`pokebot-gen3` Wiki Home](../Readme.md) + +# 📊 Statistics Database + +The bot stores its statistics in an sqlite3 database. This file can be found in the +profile directory, at `profiles//stats.db`. + +This contains 4 main tables: + +- **encounters** contains information about encountered Pokémon. If `log_encounters` is + enabled (see [the Wiki page on logging](Console,%20Logging%20and%20Image%20Config.md)), + this will contain _all_ encountered Pokémon. Otherwise it just contains shinies, + roaming Pokémon as well as Pokémon that matched a custom catch filter. +- **shiny_phases** contains information about Shiny Phases, etc. the time periods between + two shiny encounters. +- **encounter_summaries** contains information for each species (and in case of Unown, for + each single letter) the bot has encountered in this profile and so can answer questions + like 'How many Seedot have we encountered in total?' By summing all those individual + species entries you get the total stats. +- **pickup_items** contains a list of items that have been acquired using the Pickup ability, + and how many of them have been picked up so far.