diff --git a/capabilities.md b/capabilities.md new file mode 100644 index 00000000..b1857d98 --- /dev/null +++ b/capabilities.md @@ -0,0 +1,28 @@ +## Capabilities +See (TODO link to tierlist video) for a general overview of capabilities and their current state. + +### Ironclad +- Missing cards: several, but also specifically: Infernal Blade, True Grit + +### Silent +- Missing cards: Distraction, All Out Attack, Well Laid Plans, Masterful Stab, Doppelganger, Setup, Nightmare, Alchemize, +- Strategies around poison use would require additional comparator adjustments: e.g. who to best target with Corpse Explosion? When do we want to apply Catalyst? + +### Defect +- Missing cards: Force Field, Hologram, Rebound, Seek, Static Discharge, White Noise + +### Watcher +- Missing cards: Conjure Blade, Foreign Influence, Meditate, Omniscience, Vault + +### General +- **Calculation limit:** The bot will stop thinking after it has evaluated 11,000 battle paths for one play. This will often result in it just playing the card that's all the way on the right. This is an intentional trade-off vs waiting a long time. Most likely to happen when you've got a LOT of cards in hand, the potential to play a lot of them, and 3+ enemies. +- **Heart**: The bot doesn't know how to find the keys +- **Bug**: Prayer Wheel card reward is only checked if first card reward is picked up +- **Missing colorless cards**: Chrysalis, Discovery, Forethought, Jack of All Trades, Madness, Metamorphosis, Panic Button, Purity, Secret Technique, Secret Weapon, The Bomb, Thinking Ahead, Transmutation, Violence + +### Misc improvement ideas +- Know that Writhing Mass will change intent after each hit. Could do: stop dealing damage when we can block the hit. Also avoid the curse. +- Know when Time Eater is going to heal, so don't waste resources on him. +- Add purchasing of potions (e.g. Ritual Potion) to the shop purchase handler. + +We maintain a big backlog of issues, let us know if you want inspiration. :D \ No newline at end of file diff --git a/how_to_make_your_own_bot.md b/how_to_make_your_own_bot.md new file mode 100644 index 00000000..e4f2b9fc --- /dev/null +++ b/how_to_make_your_own_bot.md @@ -0,0 +1,37 @@ +## How to: Make your own bot +In Bottled AI, a bot Strategy is a collection of configurations that make up its behavior. Some configurations are straightforward, like "what character do we play?" or "what cards do we pick up?". +Others are a little more complicated, like "how do we weigh damaging opponents vs powering ourselves up?". + +Changing a Strategy's configurations lets you customize your own bot. + +### Create a new bot Strategy +1) Go to `\rs\ai` +2) Copy the entire `_example` folder +3) Rename example.py and EXAMPLE_STRATEGY to match your new Strategy's name +4) Search for `_example` imports and rename them +5) (Change the Strategy that is run in `main.py`) + +### Basics +- Come up with an idea of how you want the bot to behave +- Adjust the lists in your Strategy's `config.py` file and your strategy's handlers. + +### State of Bottled AI +Important! For the current state of Bottled AI's capabilities, see [capabilities.py](capabilities.py). + + +## Advanced +### Handlers +Handlers contain sets of behavior to be applied when their `can_handle` conditions are applied. +There are a set of Common handler shared between Strategies, but you can create new handlers on a Strategy level. +Remember to adjust which handlers are used in your `[your_strategy's_name.py]`. + +### Battle +Battles are the most complicated part of the bot. In battle, the bot will simulate/calculate the outcome from playing all of its cards in all possible configurations. We call this part the 'calculator'. Secondly, the bot will then play cards based on which plays led to the most desirable outcome. + +There are therefore 2 ways to adjust in-battle behavior: +1) **Extending the simulation**: i.e. teaching the bot what happens when a card is played / relic triggered / etc. Place to start: `\rs\calculator\battle_state`. +2) **Changing the desirability of outcomes** to impact its decision-making. Place to start: `\rs\common\comparators\common_general_comparator.py` + +### Other +- If you're making deeper behavior adjustments, we recommend adding some tests covering your new functionality See `/tests` +- TODO probably more diff --git a/main.py b/main.py index 5a2a4aa3..53c5c5e4 100644 --- a/main.py +++ b/main.py @@ -4,6 +4,7 @@ from rs.ai.claw_is_law.claw_is_law import CLAW_IS_LAW from rs.ai.peaceful_pummeling.peaceful_pummeling import PEACEFUL_PUMMELING from rs.ai.pwnder_my_orbs.pwnder_my_orbs import PWNDER_MY_ORBS +from rs.ai.requested_strike.requested_strike import REQUESTED_STRIKE from rs.ai.shivs_and_giggles.shivs_and_giggles import SHIVS_AND_GIGGLES from rs.helper.seed import make_random_seed from rs.api.client import Client diff --git a/readme.md b/readme.md index 11aa12af..87356148 100644 --- a/readme.md +++ b/readme.md @@ -1,18 +1,18 @@ -## Setup Guidelines +## Setup -### Getting Python Setup: +### Python Setup 1) Have Python 3.11.8 or so installed - - Windows: you might need to add Python to the system path variable things + - Windows: you may need to add Python to the Path Environmental Variable - MacOS: you need Python 3.11+ within xcode. This requires xcode 14.0+ (this applied for python 3.9+, might need to be higher now), which in turn requires MacOS Monterey (lower versions won't work!) 2) Have PIP (python package manager) installed: https://pip.pypa.io/en/stable/installation/ -### Getting the Project Setup: -1) Checkout this repository, and clone it into the steam app folder in a new folder: `ai\requested_strike`. - - Windows ex: ` E:\Steam\steamapps\common\SlayTheSpire\ai\requested_strike` - - MacOS ex: Browse local files of StS via Steam -> Right click and Show Package Contents -> Resources -> ai -> requested_strike +### Project Setup +1) Clone this repository into the game's install folder, in a new folder: `ai\requested_strike`. + - Windows example: ` E:\Steam\steamapps\common\SlayTheSpire\ai\requested_strike` + - MacOS example: Browse local files of StS via Steam -> Right click and Show Package Contents -> Resources -> ai -> requested_strike 2) Run `python -m pip install -r requirements.txt` in the root folder of the project (installs python dependencies) -### Getting The Game Setup +### Game Setup 1) Through the steam workshop, make sure you have: - BaseMod https://steamcommunity.com/sharedfiles/filedetails/?id=1605833019 - StSLib https://steamcommunity.com/sharedfiles/filedetails/?id=1609158507 @@ -26,32 +26,44 @@ ### Running the Bot - Run the bot via the game's main menu - - -> Mods - - -> Communication Mod - - -> Config (next to "Return") - - -> Start external process + - Mods -> + - Communication Mod -> + - Config (next to "Return") -> + - Start external process +- You can configure some run settings in [main.py](main.py). Now it should all be able to run! The process has a timeout of 10s so if you simply see that delay but nothing's happening, then something isn't working. To debug, check the output in the ModTheSpire console, or the `communication_mod_errors.log` in the StS folder. -### Screenshots -- You'll need to adjust the display settings in game to be bordered window (not fullscreen, not borderless window) -- In the config parameters in main.py, turn "take_screenshots" to True -- Only the last run will have its screenshots saved, and they're in logs/screenshots (starting a new run will delete anything in there) -- Doesn't run on Mac, so also various previous steps about python dependencies may not be required +## Making your own bot +- See [how_to_make_your_own_bot.md](how_to_make_your_own_bot.md). + + +## Tools -### Testing -- All tests can be found in the /tests directory +### Bot Controls +- Adjust which bot strategy is used, the amount of runs, and the seed in [main.py](main.py). +- Pause the bot in [run_controller.txt](run_controller.txt). +- Adjust the speed of certain actions in [presentation_config.py](presentation_config.py). + +### Tests +- All tests can be found in the `/tests` directory +- VERY useful for checking bot behavior without needing to run the game - You can run coverage checks with: - `python -m coverage run -m unittest discover .\tests` - `python -m coverage report` - `python -m coverage html` -- We don't know if we trust them yet, but yeah + +### Screenshots +- You'll need to adjust the display settings in game to be bordered window (not fullscreen, not borderless window) +- In the config parameters in main.py, turn "take_screenshots" to True +- Only the last run will have its screenshots saved, and they're in logs/screenshots (starting a new run will delete anything in there) +- Doesn't run on Mac, so also various previous steps about python dependencies may not be required ### Analyzing -You can run `analyze.py` to do analysis on recent runs. Simply supply a list of seeds to the variable at the top. +You can run [analyze.py](analyze.py) to do analysis on recent runs. Simply supply a list of seeds to the variable at the top. It can be used for just seeing how a session went, but it will naturally get the last two instances of a seed and compare them. So, it's made for doing a set of runs, changing the logic and rerunning them, then comparing performance results. \ No newline at end of file diff --git a/rs/ai/_example/_README.md b/rs/ai/_example/_README.md deleted file mode 100644 index 49fb9bf5..00000000 --- a/rs/ai/_example/_README.md +++ /dev/null @@ -1,6 +0,0 @@ -This is an example ai strategy which you can copy/paste to start a new AI strategy. - -- Copy the entire folder -- Rename example.py and EXAMPLE_STRATEGY to match your new folder's name -- Search for `_example` imports and rename them the same -- Also add a potion handler apparently \ No newline at end of file diff --git a/rs/ai/_example/config.py b/rs/ai/_example/config.py index 9b1e76eb..56200c53 100644 --- a/rs/ai/_example/config.py +++ b/rs/ai/_example/config.py @@ -1,11 +1,18 @@ -CARD_REMOVAL_PRIORITY_LIST = ['strike', 'strike+', 'defend', 'defend+'] +# card id. 'strike+' is an upgraded strike +CARD_REMOVAL_PRIORITY_LIST = [ + 'strike', + 'strike+', + 'defend', + 'defend+'] +# [card display name]: [max amount to have in deck] DESIRED_CARDS_FOR_DECK: dict[str, int] = { 'perfected strike': 1337, 'twin strike': 2, 'wild strike': 2, } +# card display name: max amount to have in deck DESIRED_CARDS_FROM_POTIONS: dict[str, int] = { 'demon form': 1, 'apotheosis': 1, diff --git a/rs/ai/_example/example.py b/rs/ai/_example/example.py index 031231ed..ae18d39a 100644 --- a/rs/ai/_example/example.py +++ b/rs/ai/_example/example.py @@ -1,6 +1,9 @@ +from typing import List + from rs.ai._example.config import CARD_REMOVAL_PRIORITY_LIST, DESIRED_CARDS_FOR_DECK, HIGH_PRIORITY_UPGRADES, \ DESIRED_CARDS_FROM_POTIONS, DESIRED_POTIONS from rs.ai._example.handlers.event_handler import EventHandler +from rs.ai._example.handlers.potions_handler import PotionsBossHandler, PotionsEventFightHandler, PotionsEliteHandler from rs.ai._example.handlers.shop_purchase_handler import ShopPurchaseHandler from rs.ai._example.handlers.upgrade_handler import UpgradeHandler from rs.common.handlers.common_astrolabe_handler import CommonAstrolabeHandler @@ -19,6 +22,14 @@ from rs.common.handlers.common_transform_handler import CommonTransformHandler from rs.machine.ai_strategy import AiStrategy from rs.machine.character import Character +from rs.machine.handlers.handler import Handler + +example_battle_potion_handlers: List[Handler] = [ + # Potions Handlers First + PotionsBossHandler(), + PotionsEventFightHandler(), + PotionsEliteHandler(), +] EXAMPLE_STRATEGY: AiStrategy = AiStrategy( name='EXAMPLE_STRATEGY', diff --git a/rs/ai/_example/handlers/potions_handler.py b/rs/ai/_example/handlers/potions_handler.py new file mode 100644 index 00000000..e4814661 --- /dev/null +++ b/rs/ai/_example/handlers/potions_handler.py @@ -0,0 +1,90 @@ +from typing import List + +from rs.game.screen_type import ScreenType +from rs.machine.command import Command +from rs.machine.handlers.handler import Handler +from rs.machine.handlers.handler_action import HandlerAction +from rs.machine.state import GameState + +# see also common_combat_reward_handler.py for discarding potions +# these potions might still sneak into our slots with entropric brew +dont_play_potions = [ + 'Smoke Bomb', + 'Elixir Potion', + 'Liquid Memories', + 'Snecko Oil', + 'Stance Potion', + 'Ambrosia', + 'Distilled Chaos', +] + + +class PotionsBaseHandler(Handler): + + def can_handle(self, state: GameState) -> bool: + # must be implemented by children + pass + + def handle(self, state: GameState) -> HandlerAction: + pot = self.get_potions_to_play(state)[0] + wait_command = "wait 30" + if pot['requires_target']: + target = 0 + for m_index, monster in enumerate(state.get_monsters()): # Find the back-est monster that isn't dead + if monster['name'] == 'Reptomancer': # Special case since he might not be in the back + target = m_index + break + if not monster['is_gone']: + target = m_index + return HandlerAction(commands=[wait_command, "potion use " + str(pot['idx']) + " " + str(target), wait_command]) + return HandlerAction(commands=[wait_command, "potion use " + str(pot['idx']), wait_command]) + + def get_potions_to_play(self, state: GameState) -> List[dict]: + to_play = [] + for idx, pot in enumerate(state.get_potions()): + if pot['can_use'] and pot['name'] not in dont_play_potions: + pot['idx'] = idx + to_play.append(pot) + return to_play + + +class PotionsEliteHandler(PotionsBaseHandler): + def __int__(self): + super().__init__() + + def can_handle(self, state: GameState) -> bool: + hp_per = state.get_player_health_percentage() * 100 + return state.has_command(Command.POTION) \ + and state.combat_state() \ + and state.screen_type() == ScreenType.NONE.value \ + and state.game_state()['room_type'] == "MonsterRoomElite" \ + and (hp_per <= 50 and state.combat_state()['turn'] == 1) \ + and self.get_potions_to_play(state) + + +class PotionsEventFightHandler(PotionsBaseHandler): # Treat most Event Fights like Elites + def __int__(self): + super().__init__() + + def can_handle(self, state: GameState) -> bool: + hp_per = state.get_player_health_percentage() * 100 + return state.has_command(Command.POTION) \ + and state.combat_state() \ + and state.screen_type() == ScreenType.NONE.value \ + and state.game_state()['room_type'] == "EventRoom" \ + and not state.has_monster("Fungi Beast") \ + and (hp_per <= 50 and state.combat_state()['turn'] == 1) \ + and self.get_potions_to_play(state) + + +class PotionsBossHandler(PotionsBaseHandler): + def __int__(self): + super().__init__() + + def can_handle(self, state: GameState) -> bool: + return state.has_command(Command.POTION) \ + and state.combat_state() \ + and state.screen_type() == ScreenType.NONE.value \ + and state.game_state()['room_type'] == "MonsterRoomBoss" \ + and state.combat_state()['turn'] == 1 \ + and self.get_potions_to_play(state)