diff --git a/bot/exts/fun/adventure.py b/bot/exts/fun/adventure.py new file mode 100644 index 0000000000..a2d49562a1 --- /dev/null +++ b/bot/exts/fun/adventure.py @@ -0,0 +1,415 @@ +# Adventure command from Python bot. +import asyncio +import json +from contextlib import suppress +from pathlib import Path +from typing import Literal, NotRequired, TypedDict + +from discord import Embed, HTTPException, Message, Reaction, User +from discord.ext import commands +from discord.ext.commands import Cog as DiscordCog, Context, clean_content +from pydis_core.utils.logging import get_logger +from pydis_core.utils.scheduling import create_task + +from bot import constants +from bot.bot import Bot + +log = get_logger(__name__) + +class GameInfo(TypedDict): + """A dictionary containing the game information. Used in `available_games.json`.""" + + id: str + name: str + description: str + color: str + time: int + + +BASE_PATH = "bot/resources/fun/adventures" + +AVAILABLE_GAMES: list[GameInfo] = json.loads( + Path(f"{BASE_PATH}/available_games.json").read_text("utf8") +) + +AVAILABLE_GAMES_DICT = {game["id"]: game for game in AVAILABLE_GAMES} + + +class OptionData(TypedDict): + """A dictionary containing the options data of the game. Part of the RoomData dictionary.""" + + text: str + leads_to: str + emoji: str + requires_effect: NotRequired[str] + effect_restricts: NotRequired[str] + effect: NotRequired[str] + + +class RoomData(TypedDict): + """A dictionary containing the room data of the game. Part of the AdventureData dictionary.""" + + text: str + options: list[OptionData] + + +class EndRoomData(TypedDict): + """ + A dictionary containing the ending room data of the game. + + Variant of the RoomData dictionary, also part of the AdventureData dictionary. + """ + + text: str + type: Literal["end"] + emoji: str + + +class GameData(TypedDict): + """ + A dictionary containing the game data, serialized from a JSON file in `resources/fun/adventures`. + + The keys are the room names, and the values are dictionaries containing the room data, + which can be either a RoomData or an EndRoomData. + + There must exist only one "start" key in the dictionary. However, there can be multiple endings, i.e., EndRoomData. + """ + + start: RoomData + other_rooms: dict[str, RoomData | EndRoomData] + + +class GameCodeNotFoundError(ValueError): + """Raised when a GameSession code doesn't exist.""" + + def __init__( + self, + arg: str, + ) -> None: + super().__init__(arg) + + +class GameSession: + """An interactive session for the Adventure RPG game.""" + + def __init__( + self, + ctx: Context, + game_code_or_index: str | None = None, + ): + """Creates an instance of the GameSession class.""" + self._ctx = ctx + self._bot = ctx.bot + + # set the game details/ game codes required for the session + self.game_code = game_code_or_index + self.game_data = None + self.game_info = None + if game_code_or_index: + self.game_code = self._parse_game_code(game_code_or_index) + self.game_info = self._get_game_info() + self.game_data = self._get_game_data() + + # store relevant discord info + self.author = ctx.author + self.destination = ctx.channel + self.message = None + + # init session states + self._current_room: str = "start" + self._path: list[str] = [self._current_room] + self._effects: list[str] = [] + + # session settings + self._timeout_seconds = 30 if self.game_info is None else self.game_info["time"] + self.timeout_message = ( + f"⏳ Hint: time is running out! You must make a choice within {self._timeout_seconds} seconds." + ) + self._timeout_task = None + self.reset_timeout() + + def _parse_game_code(self, game_code_or_index: str) -> str: + """Returns the actual game code for the given index/ game code.""" + # sanitize the game code to prevent directory traversal attacks. + game_code = Path(game_code_or_index).name + + # convert index to game code if it's a valid number that is in range. + # otherwise, return the game code as is, assuming it's a valid game code. + # if game code is not valid, errors will be raised later when trying to load the game info. + try: + index = int(game_code_or_index) + if 1 <= index <= len(AVAILABLE_GAMES): + game_code = AVAILABLE_GAMES[index - 1]["id"] + except (ValueError, IndexError): + pass + + return game_code + + def _get_game_data(self) -> GameData | None: + """Returns the game data for the given game code.""" + game_code = self.game_code + + # load the game data from the JSON file + try: + game_data = json.loads( + Path(f"{BASE_PATH}/{game_code}.json").read_text("utf8") + ) + return game_data + except FileNotFoundError: + log.error( + "Game located in `available_games.json`, but game data not found. Game code: %s", + game_code + ) + raise GameCodeNotFoundError(f"Game code `{game_code}` not found.") + + def _get_game_info(self) -> GameInfo: + """Returns the game info for the given game code.""" + game_code = self.game_code + + try: + return AVAILABLE_GAMES_DICT[game_code] + except KeyError: + raise GameCodeNotFoundError(f"Game code `{game_code}` not found.") + + async def notify_timeout(self) -> None: + """Notifies the user that the session has timed out.""" + if self.message is None: + return + await self.message.edit(content="⏰ You took too long to make a choice! The game has ended. :(") + + async def timeout(self) -> None: + """Waits for a set number of seconds, then stops the game session.""" + await asyncio.sleep(self._timeout_seconds) + await self.notify_timeout() + await self.message.clear_reactions() + await self.stop() + + def cancel_timeout(self) -> None: + """Cancels the timeout task.""" + if self._timeout_task and not self._timeout_task.cancelled(): + self._timeout_task.cancel() + + def reset_timeout(self) -> None: + """Cancels the original timeout task and sets it again from the start.""" + self.cancel_timeout() + + # recreate the timeout task + self._timeout_task = create_task(self.timeout()) + + async def send_available_game_codes(self) -> None: + """Sends a list of all available game codes.""" + available_game_codes = "\n\n".join( + f"{index}. **{game['name']}** (`{game['id']}`)\n*{game['description']}*" + for index, game in enumerate(AVAILABLE_GAMES, start=1) + ) + + embed = Embed( + title="πŸ“‹ Available Games", + description=available_game_codes, + colour=constants.Colours.soft_red, + ) + + embed.set_footer(text="πŸ’‘ Hint: use `.adventure [game_code]` or `.adventure [index]` to start a game.") + + await self.destination.send(embed=embed) + + async def on_reaction_add(self, reaction: Reaction, user: User) -> None: + """Event handler for when reactions are added on the game message.""" + # ensure it was the relevant session message + if reaction.message.id != self.message.id: + return + + # ensure it was the session author who reacted + if user.id != self.author.id: + return + + emoji = str(reaction.emoji) + + # check if valid action + acceptable_emojis = [option["emoji"] for option in self.available_options] + if emoji not in acceptable_emojis: + return + + self.reset_timeout() + + # remove all the reactions to prep for re-use + with suppress(HTTPException): + await self.message.clear_reactions() + + # Run relevant action method + all_emojis = [option["emoji"] for option in self.all_options] + + # We technically don't need this, but it's here to mitigate race conditions. + if emoji not in all_emojis: + return + + await self.pick_option(all_emojis.index(emoji)) + + + async def on_message_delete(self, message: Message) -> None: + """Closes the game session when the game message is deleted.""" + if message.id == self.message.id: + await self.stop() + + async def prepare(self) -> None: + """Sets up the game events, message and reactions.""" + if self.game_data: + await self.update_message() + self._bot.add_listener(self.on_reaction_add) + self._bot.add_listener(self.on_message_delete) + else: + await self.send_available_game_codes() + + + async def add_reactions(self) -> None: + """Adds the relevant reactions to the message based on if options are available in the current room.""" + if self.is_in_ending_room: + return + + pickable_emojis = [option["emoji"] for option in self.available_options] + + for reaction in pickable_emojis: + await self.message.add_reaction(reaction) + + def _format_room_data(self, room_data: RoomData) -> str: + """Formats the room data into a string for the embed description.""" + text = room_data["text"] + + formatted_options = "\n".join( + f"{option["emoji"]} {option["text"]}" + if option in self.available_options + else "πŸ”’ ***This option is locked***" + for option in self.all_options + ) + + return f"{text}\n\n{formatted_options}" + + def embed_message(self, room_data: RoomData | EndRoomData) -> Embed: + """Returns an Embed with the requested room data formatted within.""" + embed = Embed() + embed.color = int(self.game_info["color"], base=16) + + current_game_name = AVAILABLE_GAMES_DICT[self.game_code]["name"] + + if self.is_in_ending_room: + embed.description = room_data["text"] + emoji = room_data["emoji"] + embed.set_author(name=f"Game ended! {emoji}") + embed.set_footer(text=f"✨ Thanks for playing {current_game_name}!") + else: + embed.description = self._format_room_data(room_data) + embed.set_author(name=current_game_name) + embed.set_footer(text=self.timeout_message) + + return embed + + async def update_message(self) -> None: + """Sends the initial message, or changes the existing one to the given room ID.""" + embed_message = self.embed_message(self.current_room_data) + + if not self.message: + self.message = await self.destination.send(embed=embed_message) + else: + await self.message.edit(embed=embed_message) + + if self.is_in_ending_room: + await self.stop() + else: + await self.add_reactions() + + @classmethod + async def start(cls, ctx: Context, game_code_or_index: str | None = None) -> "GameSession": + """Create and begin a game session based on the given game code.""" + session = cls(ctx, game_code_or_index) + await session.prepare() + + return session + + async def stop(self) -> None: + """Stops the game session, clean up by removing event listeners.""" + self.cancel_timeout() + self._bot.remove_listener(self.on_reaction_add) + self._bot.remove_listener(self.on_message_delete) + + @property + def is_in_ending_room(self) -> bool: + """Check if the game has ended.""" + return self.current_room_data.get("type") == "end" + + @property + def all_options(self) -> list[OptionData]: + """Get all options in the current room.""" + return self.current_room_data.get("options", []) + + @property + def available_options(self) -> bool: + """ + Get "available" options in the current room. + + This filters out options that require an effect that the user doesn't have or options that restrict an effect. + """ + filtered_options = filter( + lambda option: ( + "requires_effect" not in option or option.get("requires_effect") in self._effects + ) and ( + "effect_restricts" not in option or option.get("effect_restricts") not in self._effects + ), + self.all_options + ) + + return filtered_options + + @property + def current_room_data(self) -> RoomData | EndRoomData: + """Get the current room data.""" + current_room = self._current_room + + if current_room == "start": + return self.game_data[current_room] + + return self.game_data["other_rooms"][current_room] + + async def pick_option(self, index: int) -> None: + """Event that is called when the user picks an option.""" + chosen_option = self.all_options[index] + + next_room = chosen_option["leads_to"] + new_effect = chosen_option.get("effect") + + # update all the game states + if new_effect: + self._effects.append(new_effect) + self._path.append(next_room) + self._current_room = next_room + + # update the message with the new room + await self.update_message() + + +class Adventure(DiscordCog): + """Custom Embed for Adventure RPG games.""" + + @commands.command(name="adventure") + async def new_adventure(self, ctx: Context, game_code_or_index: str | None = None) -> None: + """Wanted to slay a dragon? Embark on an exciting journey through text-based RPG adventure.""" + if isinstance(game_code_or_index, str): + # prevent malicious pings and mentions + sanitiser = clean_content(fix_channel_mentions=True) + game_code_or_index = await sanitiser.convert(ctx, game_code_or_index) + + # quality of life: if the user accidentally wraps the game code in backticks, process it anyway + game_code_or_index = game_code_or_index.strip("`") + try: + await GameSession.start(ctx, game_code_or_index) + except GameCodeNotFoundError as error: + await ctx.send(str(error)) + + @commands.command(name="adventures") + async def list_adventures(self, ctx: Context) -> None: + """List all available adventure games.""" + await GameSession.start(ctx, None) + + +async def setup(bot: Bot) -> None: + """Load the Adventure cog.""" + await bot.add_cog(Adventure(bot)) diff --git a/bot/resources/fun/adventures/Gurfelts_haunted_mansion.json b/bot/resources/fun/adventures/Gurfelts_haunted_mansion.json new file mode 100644 index 0000000000..aaf7a030ab --- /dev/null +++ b/bot/resources/fun/adventures/Gurfelts_haunted_mansion.json @@ -0,0 +1,193 @@ +{ + "start": { + "text": "You are Gurfelt, a curious purple dog, standing outside a creepy haunted mansion. The wind howls, and the front door creaks ominously. What will you do?", + "options": [ + { + "text": "Walk around the mansion", + "leads_to": "grave", + "emoji": "🐾" + }, + { + "text": "Enter the mansion", + "leads_to": "lobby", + "emoji": "🏚️" + } + ] + }, + "other_rooms": { + "grave": { + "text": "You circle around the mansion and come across an old, neglected grave. Something glimmers in the moonlight. Gurfelt pricks up his ears, unsure if he should investigate.", + "options": [ + { + "text": "Dig up the grave", + "leads_to": "digged_grave", + "emoji": "⛏️", + "effect_restricts": "rusty_key" + }, + { + "text": "Leave the grave and go inside the mansion", + "leads_to": "lobby", + "emoji": "πŸƒ" + } + ] + }, + "digged_grave": { + "text": "You dig up the grave and find a rusty key! Gurfelt picks it up, should he take it with him?.", + "options": [ + { + "text": "Take the key", + "leads_to": "lobby", + "emoji": "πŸ”‘", + "effect": "rusty_key" + }, + { + "text": "Leave the grave and go inside the mansion", + "leads_to": "lobby", + "emoji": "πŸƒ" + } + ] + }, + "lobby": { + "text": "Stepping through the door, Gurfelt is immediately greeted by flickering lights and a sudden BANGβ€”something just slammed shut behind him! Gurfelt has the feeling that he is not alone here...", + "options": [ + { + "text": "Go upstairs", + "leads_to": "upstairs", + "emoji": "πŸšͺ" + }, + { + "text": "Explore the living room", + "leads_to": "living_room", + "emoji": "πŸ›‹οΈ" + } + ] + }, + "living_room": { + "text": "The living room is dimly lit. Old portraits line the walls, and a faint shimmer appears near the fireplace. Suddenly, a ghost emerges from the shadows!", + "options": [ + { + "text": "Talk to the ghost", + "leads_to": "ghost_info", + "emoji": "πŸ‘»" + }, + { + "text": "Proceed to the kitchen", + "leads_to": "kitchen", + "emoji": "🍽️" + }, + { + "text": "Return to the lobby", + "leads_to": "lobby", + "emoji": "🏚️" + } + ] + }, + "ghost_info": { + "text": "The ghost tells you a disturbing secret: 'the mansion once belonged to a reclusive inventor who vanished under mysterious circumstances. The inventor tried to create a potato which could feed thousands of people but something went wrong' Chills run down Gurfelt’s spine.", + "options": [ + { + "text": "Check out the kitchen", + "leads_to": "kitchen", + "emoji": "🍽️" + }, + { + "text": "Head back to the lobby", + "leads_to": "lobby", + "emoji": "🏚️" + } + ] + }, + "kitchen": { + "text": "You enter a dusty kitchen filled with rusty utensils and scattered knives. There is also a single potato masher on the counter...", + "options": [ + { + "text": "Take a knife and go to the lobby", + "leads_to": "lobby", + "emoji": "πŸ”ͺ", + "effect": "knife", + "effect_restricts": "knife" + }, + { + "text": "Take the potato masher and go to the lobby", + "leads_to": "lobby", + "emoji": "πŸ₯”", + "effect": "potato_masher", + "effect_restricts": "potato_masher" + }, + { + "text": "Return to the lobby empty-handed", + "leads_to": "lobby", + "emoji": "πŸšͺ" + } + ] + }, + "upstairs": { + "text": "You carefully climb the creaky stairs. A dusty corridor extends ahead with two doors: one leads to the attic, and another looks locked, possibly requiring a key.", + "options": [ + { + "text": "Go to the attic", + "leads_to": "attic", + "emoji": "πŸ”¦" + }, + { + "text": "Open the secret room", + "leads_to": "secret_room", + "emoji": "πŸ—οΈ", + "requires_effect": "rusty_key" + } + ] + }, + "secret_room": { + "text": "You unlock the door with the rusty key, revealing a trove of gold coins and... a copy of GTA 6?! Overjoyed, Gurfelt decides this is enough excitement (and wealth) for one day!", + "type": "end", + "emoji": "πŸŽ‰" + }, + "attic": { + "text": "The attic is dark and cluttered with old boxes. Suddenly, a giant potato monster lumbers out of the shadows, roaring at Gurfelt!", + "options": [ + { + "text": "Eat the monster", + "leads_to": "end_eat_monster", + "emoji": "πŸ˜–" + }, + { + "text": "Use the knife", + "leads_to": "end_knife_monster", + "emoji": "πŸ”ͺ", + "requires_effect": "knife" + }, + { + "text": "Try to charm the monster", + "leads_to": "end_charm_monster", + "emoji": "πŸͺ„" + }, + { + "text": "Mash the monster", + "leads_to": "end_mash_monster", + "emoji": "πŸ₯”", + "requires_effect": "potato_masher" + } + ] + }, + "end_eat_monster": { + "text": "Gurfelt tries to eat the potato monster. It tastes terrible! Horrified by the awful taste, Gurfelt bolts away in disgust. The adventure ends here.", + "type": "end", + "emoji": "🀒" + }, + "end_knife_monster": { + "text": "Gurfelt raises the knife, ready to strike, but hesitates. A question grips himβ€”does his life hold more value than the monster's? Doubt consumes him. He sinks to his knees, lost in uncertainty. The adventure ends here.", + "type": "end", + "emoji": "πŸ—Ώ" + }, + "end_charm_monster": { + "text": "Gurfelt tries to charm the potato monster with a blown kiss and a wagging tail, but it only angers the beast. Gurfelt flees, defeated and spooked. The adventure ends here.", + "type": "end", + "emoji": "😱" + }, + "end_mash_monster": { + "text": "Armed with the potato masher, Gurfelt reduces the monstrous spud to harmless mash! Victorious, Gurfelt claims the haunted attic as conquered. The adventure ends in triumph!", + "type": "end", + "emoji": "πŸ†" + } + } +} diff --git a/bot/resources/fun/adventures/available_games.json b/bot/resources/fun/adventures/available_games.json new file mode 100644 index 0000000000..fd31d5c432 --- /dev/null +++ b/bot/resources/fun/adventures/available_games.json @@ -0,0 +1,23 @@ +[ + { + "id": "three_little_pigs", + "name": "Three Little Pigs", + "description": "A wolf is on the prowl! You are one of the three little pigs. Try to survive by building a house.", + "color": "0x1DA1F2", + "time": 30 + }, + { + "id": "dragon_slayer", + "name": "Dragon Slayer", + "description": "A dragon is terrorizing the kingdom! You are a brave knight, tasked with rescuing the princess and defeating the dragon.", + "color": "0x1F8B4C", + "time": 60 + }, + { + "id": "Gurfelts_haunted_mansion", + "name": "Gurfelt's Haunted Mansion", + "description": "Explore a haunted mansion and uncover its secrets!", + "color": "0xB734EB", + "time": 60 + } +] diff --git a/bot/resources/fun/adventures/dragon_slayer.json b/bot/resources/fun/adventures/dragon_slayer.json new file mode 100644 index 0000000000..ae605cb0ab --- /dev/null +++ b/bot/resources/fun/adventures/dragon_slayer.json @@ -0,0 +1,194 @@ +{ + "start": { + "text": "The wind whips at your cloak as you stand at the foot of Mount Cinder, its peak shrouded in smoke. Princess Elara, known for her wisdom and kindness, has been snatched away by the fearsome dragon, Ignis, who makes his lair atop this treacherous mountain. The King has promised a handsome reward and the Princess's hand in marriage to the hero who returns her safely. Two paths lie before you:", + "options": [ + { + "text": "The Direct Assault", + "leads_to": "assault_gatekeepers", + "emoji": "βš”οΈ" + }, + { + "text": "The Stealthy Infiltration", + "leads_to": "woods_fork", + "emoji": "🌲" + } + ] + }, + "other_rooms": { + "assault_gatekeepers": { + "text": "A narrow, rocky path winds upwards, but it's blocked by two hulking ogres wielding crude clubs. They snarl menacingly, demanding passage.", + "options": [ + { + "text": "Bribe them with promises of gold", + "leads_to": "assault_bridge", + "emoji": "πŸ’°" + }, + { + "text": "Engage them in combat", + "leads_to": "assault_death", + "emoji": "πŸ’₯" + } + ] + }, + "woods_fork": { + "text": "The Whispering Woods loom before you, a dense tangle of ancient trees and shadowed paths. The air is thick with the smell of damp earth and decaying leaves. You come to a fork in the trail.", + "options": [ + { + "text": "Follow the well-worn trail", + "leads_to": "woods_bridge", + "emoji": "πŸ‘£" + }, + { + "text": "Venture off-trail, seeking a shortcut", + "leads_to": "woods_hermit", + "emoji": "🧭" + } + ] + }, + "assault_bridge": { + "text": "The path leads to a rickety rope bridge spanning a deep chasm. Closer inspection reveals that several ropes are frayed, and pressure plates glint ominously. It's clearly trapped.", + "options": [ + { + "text": "Carefully disarm the trap before crossing", + "leads_to": "lair_entrance", + "emoji": "πŸ› οΈ" + }, + { + "text": "Take the risk and sprint across quickly", + "leads_to": "assault_death", + "emoji": "πŸƒ" + } + ] + }, + "woods_bridge": { + "text": "The trail leads to a narrow wooden bridge swaying precariously over a deep ravine. The wood creaks ominously under your weight, and you notice several planks are rotten. It's clearly trapped.", + "options": [ + { + "text": "Carefully disarm the trap before crossing", + "leads_to": "lair_entrance", + "emoji": "πŸ› οΈ" + }, + { + "text": "Take the risk and sprint across quickly", + "leads_to": "woods_death", + "emoji": "πŸƒ" + } + ] + }, + "woods_hermit": { + "text": "Deep within the woods, you stumble upon a small, moss-covered hut. A wizened hermit emerges, his eyes twinkling with ancient knowledge. He offers cryptic advice: 'The dragon's weakness lies not in strength, but in sorrow.'", + "options": [ + { + "text": "Heed the hermit's words and remember his wisdom", + "leads_to": "lair_inner", + "emoji": "πŸ‘‚" + }, + { + "text": "Dismiss the hermit as a rambling madman", + "leads_to": "woods_death", + "emoji": "🀷" + } + ] + }, + "lair_entrance": { + "text": "You finally reach the mouth of Ignis's lair, a gaping maw in the mountainside. A shimmering magical barrier seals the entrance, pulsating with arcane energy.", + "options": [ + { + "text": "Search the surrounding area for a way to disable the barrier", + "leads_to": "lair_weakness", + "emoji": "πŸ”" + }, + { + "text": "Attempt to force your way through with brute strength", + "leads_to": "lair_inner", + "emoji": "πŸ’₯" + } + ] + }, + "lair_inner": { + "text": "You've bypassed the outer defenses, but the inner chamber is guarded by a swirling fire elemental, crackling with intense heat. It hisses and lunges, eager to incinerate you.", + "options": [ + { + "text": "Use water magic, if you possess it, to exploit the elemental's weakness", + "leads_to": "lair_weakness", + "emoji": "πŸ’§" + }, + { + "text": "Engage in direct combat, a battle of fire against fire", + "leads_to": "lair_final_battle", + "emoji": "πŸ”₯" + } + ] + }, + "lair_weakness": { + "text": "You find Princess Elara chained to a rock, looking pale but unharmed. Ignis, a magnificent beast wreathed in smoke and flame, descends before you. He is powerful, but you remember the hermit's words.", + "options": [ + { + "text": "Appeal to the dragon's sorrow, referencing a past loss you learned about during your exploration", + "leads_to": "rescue", + "emoji": "πŸ₯Ί" + }, + { + "text": "Prepare for combat, drawing your weapon", + "leads_to": "lair_final_battle", + "emoji": "βš”οΈ" + } + ] + }, + "lair_final_battle": { + "text": "Ignis is enraged! He unleashes a torrent of fire, bathing the chamber in searing heat.", + "options": [ + { + "text": "Dodge and weave through the flames, seeking an opening to attack", + "leads_to": "rescue", + "emoji": "➑️" + }, + { + "text": "Stand your ground and attempt to counter-attack with your shield and weapon", + "leads_to": "final_battle_death", + "emoji": "πŸ›‘οΈ" + } + ] + }, + "rescue": { + "text": "Ignis is defeated (or swayed by your understanding). Princess Elara is safe.", + "options": [ + { + "text": "Take the princess and escape the lair immediately, prioritizing her safety", + "leads_to": "ending_diplomat", + "emoji": "πŸƒβ€β™€οΈ" + }, + { + "text": "Loot the dragon's hoard before leaving, thinking of the reward", + "leads_to": "ending_greedy", + "emoji": "πŸ’Ž" + } + ] + }, + "assault_death": { + "text": "The ogres prove too strong, their clubs crushing your bones. Or perhaps you misjudged the bridge trap, falling into the chasm below. Your journey ends here, a grim tale whispered among adventurers.", + "type": "end", + "emoji": "πŸ’€" + }, + "woods_death": { + "text": "You become hopelessly lost in the labyrinthine woods, succumbing to starvation and exposure. Or perhaps you underestimated the dangers lurking in the shadows, falling prey to a wild beast. Your journey ends here, a cautionary tale for those who stray from the path.", + "type": "end", + "emoji": "πŸ’€" + }, + "final_battle_death": { + "text": "Ignis's fiery breath engulfs you, leaving nothing but ashes. Your journey ends here, a testament to the dragon's terrible power.", + "type": "end", + "emoji": "πŸ’€" + }, + "ending_diplomat": { + "text": "You return to the kingdom a hero, not only for rescuing the princess, but also for your wisdom and compassion in understanding the dragon's sorrow. You are celebrated as a true leader and hero.", + "type": "end", + "emoji": "πŸ•ŠοΈ" + }, + "ending_greedy": { + "text": "You return to the kingdom with the princess and a vast hoard of treasure. You are hailed as a hero, but whispers follow you about your greed and whether you truly deserved the reward. The princess, though grateful for her rescue, looks at you with a hint of disappointment. You have saved her, but at the cost of some of your honor. The kingdom will remember your name, but not all the stories told will be flattering.", + "type": "end", + "emoji": "😈" + } + } +} diff --git a/bot/resources/fun/adventures/three_little_pigs.json b/bot/resources/fun/adventures/three_little_pigs.json new file mode 100644 index 0000000000..53fbd3671b --- /dev/null +++ b/bot/resources/fun/adventures/three_little_pigs.json @@ -0,0 +1,99 @@ +{ + "start": { + "text": "A wolf is on the prowl! You are one of the three little pigs. Choose your starting action:", + "options": [ + { + "text": "Build a Straw House", + "leads_to": "straw_house_branch", + "emoji": "🌾" + }, + { + "text": "Build a Stick House", + "leads_to": "stick_house_branch", + "emoji": "πŸͺ΅" + }, + { + "text": "Build a Brick House", + "leads_to": "brick_house_branch", + "emoji": "🧱" + } + ] + }, + "other_rooms": { + "straw_house_branch": { + "text": "You've chosen to build a straw house. What do you do?", + "options": [ + { + "text": "Hurry and finish!", + "leads_to": "ending_1", + "emoji": "πŸ’¨" + }, + { + "text": "Invite a friend (the duck) for help.", + "leads_to": "ending_2", + "emoji": "πŸ¦†" + } + ] + }, + "stick_house_branch": { + "text": "You've chosen to build a stick house. What do you do?", + "options": [ + { + "text": "Focus on speed.", + "leads_to": "ending_3", + "emoji": "πŸƒ" + }, + { + "text": "Reinforce the frame with extra sticks.", + "leads_to": "ending_4", + "emoji": "πŸ’ͺ" + } + ] + }, + "brick_house_branch": { + "text": "You've chosen to build a brick house. What do you do?", + "options": [ + { + "text": "Finish quickly, no time for extras!", + "leads_to": "ending_5", + "emoji": "⏱️" + }, + { + "text": "Fortify the door with a steel lock and hire a boar as a guard.", + "leads_to": "ending_6", + "emoji": "πŸ”’" + } + ] + }, + "ending_1": { + "text": "The wolf huffs and puffs and blows your house down! You're captured!", + "type": "end", + "emoji": "🐺" + }, + "ending_2": { + "text": "The wolf still blows the house down, but you and the duck escape through the chimney!", + "type": "end", + "emoji": "πŸ’¨" + }, + "ending_3": { + "text": "The wolf huffs and puffs and *mostly* blows your house down. You narrowly escape, but your friend, the rabbit, gets caught!", + "type": "end", + "emoji": "πŸ‡" + }, + "ending_4": { + "text": "The wolf huffs and puffs, but the house holds! He tries to climb the roof, but you've prepared a trapdoor and he falls into a boiling pot of soup! You have wolf stew for dinner.", + "type": "end", + "emoji": "🍲" + }, + "ending_5": { + "text": "The wolf can't blow down the house. He tries the chimney, but you've blocked it! He gives up and goes hungry.", + "type": "end", + "emoji": "πŸ˜”" + }, + "ending_6": { + "text": "The wolf tries everything, but the house is impenetrable. The boar chases him away. You live happily ever after, with excellent security.", + "type": "end", + "emoji": "πŸŽ‰" + } + } +}