From b3d9e46546d78c242cee2dcfe59d745189a9e02d Mon Sep 17 00:00:00 2001 From: Strengthless Date: Wed, 26 Feb 2025 16:05:18 +0100 Subject: [PATCH 1/7] feat: preliminarily implement the `adventure` command --- bot/exts/fun/adventure.py | 333 ++++++++++++++++++ .../fun/adventures/available_games.json | 12 + .../fun/adventures/dragon_slayer.json | 192 ++++++++++ .../fun/adventures/three_little_pigs.json | 97 +++++ 4 files changed, 634 insertions(+) create mode 100644 bot/exts/fun/adventure.py create mode 100644 bot/resources/fun/adventures/available_games.json create mode 100644 bot/resources/fun/adventures/dragon_slayer.json create mode 100644 bot/resources/fun/adventures/three_little_pigs.json diff --git a/bot/exts/fun/adventure.py b/bot/exts/fun/adventure.py new file mode 100644 index 0000000000..b7550173a7 --- /dev/null +++ b/bot/exts/fun/adventure.py @@ -0,0 +1,333 @@ +# Adventure command from Python bot. +import asyncio +from contextlib import suppress +import json +from pathlib import Path +from typing import Literal, NamedTuple, TypedDict, Union + +from discord import Embed, HTTPException, Message, Reaction, User +from discord.ext import commands +from discord.ext.commands import Cog as DiscordCog, Command, Context +from pydis_core.utils.logging import get_logger + +from bot import constants +from bot.bot import Bot + + +class Cog(NamedTuple): + """Show information about a Cog's name, description and commands.""" + + name: str + description: str + commands: list[Command] + + +log = get_logger(__name__) + + +class GameInfo(TypedDict): + """ + A dictionary containing the game information. Used in `available_games.json`. + """ + + id: str + name: str + description: str + + +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 + + +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 AdventureData(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 + __annotations__: dict[str, Union[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: 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 + self.game_data = None + if game_code: + self.game_data = self._get_game_data(game_code) + + # store relevant discord info + self.author = ctx.author + self.destination = ctx.channel + self.message = None + + # init session states + self._current_room = "start" + self._path = [self._current_room] + + # session settings + self.timeout_message = ( + "Time is running out! You must make a choice within 60 seconds. ⏳" + ) + self._timeout_task = None + self.reset_timeout() + + def _get_game_data(self, game_code: str) -> AdventureData | None: + """Returns the game data for the given game code.""" + try: + # sanitize the game code to prevent directory traversal attacks. + game_code = Path(game_code).name + game_data = json.loads( + Path(f"{BASE_PATH}/{game_code}.json").read_text("utf8") + ) + return game_data + except FileNotFoundError: + raise GameCodeNotFoundError(f'Game code "{game_code}" not found.') + + async def notify_timeout(self) -> None: + """Notifies the user that the session has timed out.""" + await self.message.edit(content="⏰ You took too long to make a choice! The game has ended. :(") + + async def timeout(self, seconds: int = 60) -> None: + """Waits for a set number of seconds, then stops the game session.""" + await asyncio.sleep(seconds) + await self.notify_timeout() + 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 = self._bot.loop.create_task(self.timeout()) + + async def send_available_game_codes(self) -> None: + """Sends a list of all available game codes.""" + available_game_codes = "\n".join( + f"{game['id']} - {game['name']}" for game in AVAILABLE_GAMES + ) + + embed = Embed( + title="Available games", + description=available_game_codes, + colour=constants.Colours.soft_red, + ) + + 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 + current_room = self._current_room + available_options = self.game_data[current_room]["options"] + acceptable_emojis = [option["emoji"] for option in 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 + await self.pick_option(acceptable_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("start") + self._bot.add_listener(self.on_reaction_add) + self._bot.add_listener(self.on_message_delete) + else: + await self.send_available_game_codes() + + + 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 + + current_room = self._current_room + available_options = self.game_data[current_room]["options"] + reactions = [option["emoji"] for option in available_options] + + for reaction in reactions: + self._bot.loop.create_task(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"] + options = room_data["options"] + + formatted_options = "\n".join( + f"{option["emoji"]} {option["text"]}" for option in 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() + + 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 over! {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, room_id: str) -> None: + """Sends the initial message, or changes the existing one to the given room ID.""" + target_room_data = self.game_data[room_id] + embed_message = self.embed_message(target_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: + self.add_reactions() + + @classmethod + async def start(cls, ctx: Context, game_code: str | None = None) -> "GameSession": + """ + Create and begin a game session based on the given game code. + """ + session = cls(ctx, game_code) + 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.""" + current_room = self._current_room + + return self.game_data[current_room].get("type") == "end" + + async def pick_option(self, index: int) -> None: + """Event that is called when the user picks an option.""" + current_room = self._current_room + next_room = self.game_data[current_room]["options"][index]["leads_to"] + + # update the path and current room + self._path.append(next_room) + self._current_room = next_room + + # update the message with the new room + await self.update_message(next_room) + + +class Adventure(DiscordCog): + """Custom Embed for Adventure RPG games.""" + + @commands.command(name="adventure") + async def new_adventure(self, ctx: Context, game_code: str | None = None) -> None: + """Wanted to slay a dragon? Embark on an exciting journey through text-based RPG adventure.""" + try: + await GameSession.start(ctx, game_code) + except GameCodeNotFoundError as error: + await ctx.send(str(error)) + return + + +async def setup(bot: Bot) -> None: + await bot.add_cog(Adventure(bot)) diff --git a/bot/resources/fun/adventures/available_games.json b/bot/resources/fun/adventures/available_games.json new file mode 100644 index 0000000000..7dc250843e --- /dev/null +++ b/bot/resources/fun/adventures/available_games.json @@ -0,0 +1,12 @@ +[ + { + "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." + }, + { + "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." + } +] diff --git a/bot/resources/fun/adventures/dragon_slayer.json b/bot/resources/fun/adventures/dragon_slayer.json new file mode 100644 index 0000000000..fbd67ce9a3 --- /dev/null +++ b/bot/resources/fun/adventures/dragon_slayer.json @@ -0,0 +1,192 @@ +{ + "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": "🌲" + } + ] + }, + "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..18b93a5a87 --- /dev/null +++ b/bot/resources/fun/adventures/three_little_pigs.json @@ -0,0 +1,97 @@ +{ + "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": "🧱" + } + ] + }, + "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": "πŸŽ‰" + } +} From 5494f130cd57cdaa5883ff1aa334fceccb77c47d Mon Sep 17 00:00:00 2001 From: Strengthless Date: Wed, 26 Feb 2025 22:03:16 +0100 Subject: [PATCH 2/7] feat: support for `.adventures` and `.adventure [index]` --- bot/exts/fun/adventure.py | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/bot/exts/fun/adventure.py b/bot/exts/fun/adventure.py index b7550173a7..7a1f60f4b6 100644 --- a/bot/exts/fun/adventure.py +++ b/bot/exts/fun/adventure.py @@ -127,16 +127,26 @@ def __init__( # session settings self.timeout_message = ( - "Time is running out! You must make a choice within 60 seconds. ⏳" + "⏳ Hint: time is running out! You must make a choice within 60 seconds." ) self._timeout_task = None self.reset_timeout() def _get_game_data(self, game_code: str) -> AdventureData | None: """Returns the game data for the given game code.""" + # sanitize the game code to prevent directory traversal attacks. + game_code = Path(game_code).name + + # Convert index to game code if it's a number + try: + index = int(game_code) + game_code = AVAILABLE_GAMES[index - 1]["id"] + self.game_code = game_code + except (ValueError, IndexError): + pass + + # load the game data from the JSON file try: - # sanitize the game code to prevent directory traversal attacks. - game_code = Path(game_code).name game_data = json.loads( Path(f"{BASE_PATH}/{game_code}.json").read_text("utf8") ) @@ -168,16 +178,19 @@ def reset_timeout(self) -> None: async def send_available_game_codes(self) -> None: """Sends a list of all available game codes.""" - available_game_codes = "\n".join( - f"{game['id']} - {game['name']}" for game in AVAILABLE_GAMES + 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", + 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: @@ -250,14 +263,15 @@ def _format_room_data(self, room_data: RoomData) -> str: def embed_message(self, room_data: RoomData | EndRoomData) -> Embed: """Returns an Embed with the requested room data formatted within.""" embed = Embed() + embed.color = constants.Colours.soft_orange 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 over! {emoji}") - embed.set_footer(text=f"Thanks for playing - {current_game_name}") + 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) @@ -326,7 +340,11 @@ async def new_adventure(self, ctx: Context, game_code: str | None = None) -> Non await GameSession.start(ctx, game_code) except GameCodeNotFoundError as error: await ctx.send(str(error)) - return + + @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: From af2d8594d16f8216152be2e50c7ba59d69a750bc Mon Sep 17 00:00:00 2001 From: Strengthless Date: Thu, 27 Feb 2025 16:35:32 +0100 Subject: [PATCH 3/7] chore: lint all files --- bot/exts/fun/adventure.py | 44 +++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/bot/exts/fun/adventure.py b/bot/exts/fun/adventure.py index 7a1f60f4b6..6863c8cda5 100644 --- a/bot/exts/fun/adventure.py +++ b/bot/exts/fun/adventure.py @@ -1,9 +1,9 @@ -# Adventure command from Python bot. +# Adventure command from Python bot. import asyncio -from contextlib import suppress import json +from contextlib import suppress from pathlib import Path -from typing import Literal, NamedTuple, TypedDict, Union +from typing import Literal, NamedTuple, TypedDict from discord import Embed, HTTPException, Message, Reaction, User from discord.ext import commands @@ -26,9 +26,7 @@ class Cog(NamedTuple): class GameInfo(TypedDict): - """ - A dictionary containing the game information. Used in `available_games.json`. - """ + """A dictionary containing the game information. Used in `available_games.json`.""" id: str name: str @@ -75,19 +73,18 @@ class AdventureData(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. + 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 - __annotations__: dict[str, Union[RoomData, EndRoomData]] + __annotations__: dict[str, RoomData | EndRoomData] class GameCodeNotFoundError(ValueError): - """ - Raised when a GameSession code doesn't exist. - """ + """Raised when a GameSession code doesn't exist.""" def __init__( self, @@ -97,9 +94,7 @@ def __init__( class GameSession: - """ - An interactive session for the Adventure RPG game. - """ + """An interactive session for the Adventure RPG game.""" def __init__( self, @@ -144,7 +139,7 @@ def _get_game_data(self, game_code: str) -> AdventureData | None: self.game_code = game_code except (ValueError, IndexError): pass - + # load the game data from the JSON file try: game_data = json.loads( @@ -172,7 +167,7 @@ def cancel_timeout(self) -> None: 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 = self._bot.loop.create_task(self.timeout()) @@ -180,7 +175,7 @@ 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) + for index, game in enumerate(AVAILABLE_GAMES, start=1) ) embed = Embed( @@ -192,7 +187,7 @@ async def send_available_game_codes(self) -> None: 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 @@ -235,13 +230,13 @@ async def prepare(self) -> None: self._bot.add_listener(self.on_message_delete) else: await self.send_available_game_codes() - + 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 - + current_room = self._current_room available_options = self.game_data[current_room]["options"] reactions = [option["emoji"] for option in available_options] @@ -259,7 +254,7 @@ def _format_room_data(self, room_data: RoomData) -> str: ) 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() @@ -296,9 +291,7 @@ async def update_message(self, room_id: str) -> None: @classmethod async def start(cls, ctx: Context, game_code: str | None = None) -> "GameSession": - """ - Create and begin a game session based on the given game code. - """ + """Create and begin a game session based on the given game code.""" session = cls(ctx, game_code) await session.prepare() @@ -340,7 +333,7 @@ async def new_adventure(self, ctx: Context, game_code: str | None = None) -> Non await GameSession.start(ctx, game_code) 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.""" @@ -348,4 +341,5 @@ async def list_adventures(self, ctx: Context) -> None: async def setup(bot: Bot) -> None: + """Load the Adventure cog.""" await bot.add_cog(Adventure(bot)) From 1bd710fbbfe67670295fe2fe1b3fccd0b82f2766 Mon Sep 17 00:00:00 2001 From: Johan <104634655+joel90688@users.noreply.github.com> Date: Fri, 28 Feb 2025 16:57:27 +0100 Subject: [PATCH 4/7] feat: add "effect" mechanism for hidden paths (#8) * feat: added scary haunted mansion story and choices now affect the game later * refactor: refactored and cherry picked according to comments * refactor: refactored and cherry picked according to comments * feat: added effect_restricts * docs: changed comments describing effect_restrics --------- Co-authored-by: Johan Nilsson Co-authored-by: Strengthless --- bot/exts/fun/adventure.py | 60 ++++-- .../adventures/Gurfelts_haunted_mansion.json | 191 ++++++++++++++++++ .../fun/adventures/available_games.json | 5 + 3 files changed, 240 insertions(+), 16 deletions(-) create mode 100644 bot/resources/fun/adventures/Gurfelts_haunted_mansion.json diff --git a/bot/exts/fun/adventure.py b/bot/exts/fun/adventure.py index 6863c8cda5..5385bc893a 100644 --- a/bot/exts/fun/adventure.py +++ b/bot/exts/fun/adventure.py @@ -3,7 +3,7 @@ import json from contextlib import suppress from pathlib import Path -from typing import Literal, NamedTuple, TypedDict +from typing import Literal, NamedTuple, NotRequired, TypedDict from discord import Embed, HTTPException, Message, Reaction, User from discord.ext import commands @@ -48,6 +48,9 @@ class OptionData(TypedDict): text: str leads_to: str emoji: str + requires_effect: NotRequired[str] + effect_restricts: NotRequired[str] + effect: NotRequired[str] class RoomData(TypedDict): @@ -117,8 +120,9 @@ def __init__( self.message = None # init session states - self._current_room = "start" - self._path = [self._current_room] + self._current_room: str = "start" + self._path: list[str] = [self._current_room] + self._effects: list[str] = [] # session settings self.timeout_message = ( @@ -201,9 +205,7 @@ async def on_reaction_add(self, reaction: Reaction, user: User) -> None: emoji = str(reaction.emoji) # check if valid action - current_room = self._current_room - available_options = self.game_data[current_room]["options"] - acceptable_emojis = [option["emoji"] for option in available_options] + acceptable_emojis = [option["emoji"] for option in self.available_options] if emoji not in acceptable_emojis: return @@ -214,7 +216,9 @@ async def on_reaction_add(self, reaction: Reaction, user: User) -> None: await self.message.clear_reactions() # Run relevant action method - await self.pick_option(acceptable_emojis.index(emoji)) + all_emojis = [option["emoji"] for option in self.all_options] + + await self.pick_option(all_emojis.index(emoji)) async def on_message_delete(self, message: Message) -> None: @@ -237,20 +241,17 @@ def add_reactions(self) -> None: if self.is_in_ending_room: return - current_room = self._current_room - available_options = self.game_data[current_room]["options"] - reactions = [option["emoji"] for option in available_options] + pickable_emojis = [option["emoji"] for option in self.available_options] - for reaction in reactions: + for reaction in pickable_emojis: self._bot.loop.create_task(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"] - options = room_data["options"] formatted_options = "\n".join( - f"{option["emoji"]} {option["text"]}" for option in options + f"{option["emoji"]} {option["text"]}" for option in self.available_options ) return f"{text}\n\n{formatted_options}" @@ -310,12 +311,39 @@ def is_in_ending_room(self) -> bool: return self.game_data[current_room].get("type") == "end" + @property + def all_options(self) -> list[OptionData]: + """Get all options in the current room.""" + return self.game_data[self._current_room]["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 + async def pick_option(self, index: int) -> None: """Event that is called when the user picks an option.""" - current_room = self._current_room - next_room = self.game_data[current_room]["options"][index]["leads_to"] + chosen_option = self.all_options[index] + + next_room = chosen_option["leads_to"] + new_effect = chosen_option.get("effect") - # update the path and current room + # update all the game states + if new_effect: + self._effects.append(new_effect) self._path.append(next_room) self._current_room = next_room 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..72b7944415 --- /dev/null +++ b/bot/resources/fun/adventures/Gurfelts_haunted_mansion.json @@ -0,0 +1,191 @@ +{ + "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": "🏚️" + } + ] + }, + "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 index 7dc250843e..9ef68a2e5e 100644 --- a/bot/resources/fun/adventures/available_games.json +++ b/bot/resources/fun/adventures/available_games.json @@ -8,5 +8,10 @@ "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." + }, + { + "id": "Gurfelts_haunted_mansion", + "name": "Gurfelt's Haunted Mansion", + "description": "Explore a haunted mansion and uncover its secrets!" } ] From 972380d4f451b39f5302e55cef00e7c102e2abf3 Mon Sep 17 00:00:00 2001 From: Strengthless Date: Fri, 28 Feb 2025 19:54:23 +0100 Subject: [PATCH 5/7] feat: support custom timeout seconds and embed color for game settings --- bot/exts/fun/adventure.py | 62 +++++++++++++------ .../fun/adventures/available_games.json | 12 +++- 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/bot/exts/fun/adventure.py b/bot/exts/fun/adventure.py index 5385bc893a..f686ba8162 100644 --- a/bot/exts/fun/adventure.py +++ b/bot/exts/fun/adventure.py @@ -31,6 +31,8 @@ class GameInfo(TypedDict): id: str name: str description: str + color: str + time: int BASE_PATH = "bot/resources/fun/adventures" @@ -72,7 +74,7 @@ class EndRoomData(TypedDict): emoji: str -class AdventureData(TypedDict): +class GameData(TypedDict): """ A dictionary containing the game data, serialized from a JSON file in `resources/fun/adventures`. @@ -102,17 +104,20 @@ class GameSession: def __init__( self, ctx: Context, - game_code: str | None = None, + 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 + self.game_code = game_code_or_index self.game_data = None - if game_code: - self.game_data = self._get_game_data(game_code) + self.game_info = None + if game_code_or_index: + self.game_code = self._parse_game_code(game_code_or_index) + self.game_data = self._get_game_data() + self.game_info = self._get_game_info() # store relevant discord info self.author = ctx.author @@ -125,25 +130,31 @@ def __init__( self._effects: list[str] = [] # session settings + self._timeout_seconds = 30 if self.game_info is None else self.game_info["time"] self.timeout_message = ( - "⏳ Hint: time is running out! You must make a choice within 60 seconds." + f"⏳ Hint: time is running out! You must make a choice within {self._timeout_seconds} seconds." ) self._timeout_task = None self.reset_timeout() - def _get_game_data(self, game_code: str) -> AdventureData | None: - """Returns the game data for the given game code.""" + 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).name + game_code = Path(game_code_or_index).name - # Convert index to game code if it's a number + # convert index to game code if it's a number try: - index = int(game_code) + index = int(game_code_or_index) game_code = AVAILABLE_GAMES[index - 1]["id"] - self.game_code = game_code 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( @@ -153,14 +164,27 @@ def _get_game_data(self, game_code: str) -> AdventureData | None: except FileNotFoundError: 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: + log.error( + "Game data retrieved, but game info not found. Did you forget to add it to `available_games.json`?" + ) + raise GameCodeNotFoundError(f'Game code "{game_code}" not found.') + async def notify_timeout(self) -> None: """Notifies the user that the session has timed out.""" await self.message.edit(content="⏰ You took too long to make a choice! The game has ended. :(") - async def timeout(self, seconds: int = 60) -> None: + async def timeout(self) -> None: """Waits for a set number of seconds, then stops the game session.""" - await asyncio.sleep(seconds) + await asyncio.sleep(self._timeout_seconds) await self.notify_timeout() + await self.message.clear_reactions() await self.stop() def cancel_timeout(self) -> None: @@ -259,7 +283,7 @@ def _format_room_data(self, room_data: RoomData) -> str: def embed_message(self, room_data: RoomData | EndRoomData) -> Embed: """Returns an Embed with the requested room data formatted within.""" embed = Embed() - embed.color = constants.Colours.soft_orange + embed.color = int(self.game_info["color"], base=16) current_game_name = AVAILABLE_GAMES_DICT[self.game_code]["name"] @@ -291,9 +315,9 @@ async def update_message(self, room_id: str) -> None: self.add_reactions() @classmethod - async def start(cls, ctx: Context, game_code: str | None = None) -> "GameSession": + 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) + session = cls(ctx, game_code_or_index) await session.prepare() return session @@ -355,10 +379,10 @@ class Adventure(DiscordCog): """Custom Embed for Adventure RPG games.""" @commands.command(name="adventure") - async def new_adventure(self, ctx: Context, game_code: str | None = None) -> None: + 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.""" try: - await GameSession.start(ctx, game_code) + await GameSession.start(ctx, game_code_or_index) except GameCodeNotFoundError as error: await ctx.send(str(error)) diff --git a/bot/resources/fun/adventures/available_games.json b/bot/resources/fun/adventures/available_games.json index 9ef68a2e5e..fd31d5c432 100644 --- a/bot/resources/fun/adventures/available_games.json +++ b/bot/resources/fun/adventures/available_games.json @@ -2,16 +2,22 @@ { "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." + "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." + "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!" + "description": "Explore a haunted mansion and uncover its secrets!", + "color": "0xB734EB", + "time": 60 } ] From ba745cc6da767d162975dce5b4c0ac4296bd3f04 Mon Sep 17 00:00:00 2001 From: Strengthless Date: Fri, 28 Feb 2025 21:30:24 +0100 Subject: [PATCH 6/7] feat: show "locked" options instead of fully hiding them --- bot/exts/fun/adventure.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/fun/adventure.py b/bot/exts/fun/adventure.py index f686ba8162..9631ed23b4 100644 --- a/bot/exts/fun/adventure.py +++ b/bot/exts/fun/adventure.py @@ -275,7 +275,10 @@ def _format_room_data(self, room_data: RoomData) -> str: text = room_data["text"] formatted_options = "\n".join( - f"{option["emoji"]} {option["text"]}" for option in self.available_options + 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}" From 507a178c97889f6fc14c959ab7a231e6af88aaf4 Mon Sep 17 00:00:00 2001 From: Strengthless Date: Fri, 28 Feb 2025 21:40:40 +0100 Subject: [PATCH 7/7] chore: lint json files --- .../adventures/Gurfelts_haunted_mansion.json | 374 +++++++++--------- 1 file changed, 187 insertions(+), 187 deletions(-) diff --git a/bot/resources/fun/adventures/Gurfelts_haunted_mansion.json b/bot/resources/fun/adventures/Gurfelts_haunted_mansion.json index 72b7944415..49c057ef2c 100644 --- a/bot/resources/fun/adventures/Gurfelts_haunted_mansion.json +++ b/bot/resources/fun/adventures/Gurfelts_haunted_mansion.json @@ -1,191 +1,191 @@ { - "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": "🏚️" - } - ] - }, - "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": "πŸƒ" - } - ] + "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": "🐾" }, - "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": "πŸ—Ώ" + { + "text": "Enter the mansion", + "leads_to": "lobby", + "emoji": "🏚️" + } + ] + }, + "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" }, - "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": "πŸ†" - } + { + "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": "πŸ†" } +}