diff --git a/royalnet/bots/discord.py b/royalnet/bots/discord.py index dab649f2..0f5dbfe4 100644 --- a/royalnet/bots/discord.py +++ b/royalnet/bots/discord.py @@ -144,6 +144,8 @@ class DiscordBot(GenericBot): error_message = f"🦀 [b]{e.__class__.__name__}[/b] 🦀\n" error_message += '\n'.join(e.args) await data.reply(error_message) + # Close the data session + await data.session_close() async def on_connect(cli): log.debug("Connected to Discord") diff --git a/royalnet/bots/telegram.py b/royalnet/bots/telegram.py index 847fe7b0..d5b30fc5 100644 --- a/royalnet/bots/telegram.py +++ b/royalnet/bots/telegram.py @@ -178,8 +178,8 @@ class TelegramBot(GenericBot): error_message = f"🦀 [b]{e.__class__.__name__}[/b] 🦀\n" error_message += '\n'.join(e.args) await data.reply(error_message) - if __debug__: - raise + # Close the data session + await data.session_close() async def _handle_callback_query(self, update: telegram.Update): query: telegram.CallbackQuery = update.callback_query diff --git a/royalnet/commands/command.py b/royalnet/commands/command.py index 7aac2d73..4e076880 100644 --- a/royalnet/commands/command.py +++ b/royalnet/commands/command.py @@ -26,5 +26,10 @@ class Command: def __init__(self, interface: CommandInterface): self.interface = interface + @property + def alchemy(self): + """A shortcut to ``self.interface.alchemy``""" + return self.interface.alchemy + async def run(self, args: CommandArgs, data: CommandData) -> None: raise NotImplementedError() diff --git a/royalnet/commands/commanddata.py b/royalnet/commands/commanddata.py index d5f50953..2f98006b 100644 --- a/royalnet/commands/commanddata.py +++ b/royalnet/commands/commanddata.py @@ -2,6 +2,7 @@ import typing import warnings from .commanderrors import UnsupportedError from .commandinterface import CommandInterface +from ..utils import asyncify class CommandData: @@ -12,6 +13,18 @@ class CommandData: else: self.session = None + async def session_commit(self): + """Commit the changes to the session.""" + await asyncify(self.session.commit) + + async def session_close(self): + """Close the opened session. + + Remember to call this when the data is disposed of!""" + if self.session: + await asyncify(self.session.close) + self.session = None + async def reply(self, text: str) -> None: """Send a text message to the channel where the call was made. diff --git a/royalnet/packs/rpg/commands/__init__.py b/royalnet/packs/rpg/commands/__init__.py index b58f35ab..db8cc1ec 100644 --- a/royalnet/packs/rpg/commands/__init__.py +++ b/royalnet/packs/rpg/commands/__init__.py @@ -1,11 +1,21 @@ # Imports go here! from .roll import RollCommand from .dice import DiceCommand +from .dndactive import DndactiveCommand +from .dndinfo import DndinfoCommand +from .dndnew import DndnewCommand +from .dndedit import DndeditCommand +from .dndroll import DndrollCommand # Enter the commands of your Pack here! available_commands = [ RollCommand, DiceCommand, + DndactiveCommand, + DndinfoCommand, + DndnewCommand, + DndeditCommand, + DndrollCommand, ] # Don't change this, it should automatically generate __all__ diff --git a/royalnet/packs/rpg/commands/dndactive.py b/royalnet/packs/rpg/commands/dndactive.py index 0094b963..667474f5 100644 --- a/royalnet/packs/rpg/commands/dndactive.py +++ b/royalnet/packs/rpg/commands/dndactive.py @@ -1,12 +1,52 @@ from royalnet.commands import * +from royalnet.utils import asyncify +from ..tables import DndCharacter, DndActiveCharacter class DndactiveCommand(Command): name: str = "dndactive" - description: str = "Set the active D&D character." + description: str = "Set a DnD character as active." - aliases = ["da", "dnda", "active"] + aliases = ["da", "dnda", "active", "dactive"] + + syntax = "{name|id}" + + tables = {DndCharacter, DndActiveCharacter} async def run(self, args: CommandArgs, data: CommandData) -> None: - ... + identifier = args.optional(0) + if identifier is None: + # Display the active character identifiers + ... + try: + identifier = int(identifier) + except ValueError: + # Find the character by name + chars = await asyncify(data.session.query(self.alchemy.DndCharacter).filter_by(name=identifier).all) + if len(chars) >= 2: + char_string = "\n".join([f"[c]{char.character_id}[/c] (LV {char.level}) by {char.creator})" for char in chars]) + raise CommandError(f"Multiple characters share the name {identifier}, " + f"please activate them using their id:\n{char_string}") + elif len(chars) == 1: + char = chars[0] + else: + char = None + else: + # Find the character by id + char = await asyncify(data.session.query(self.alchemy.DndCharacter) + .filter_by(character_id=identifier) + .one_or_none) + if char is None: + raise CommandError("No character found.") + # Check if the player already has an active character + author = await data.get_author(error_if_none=True) + if author.dnd_active_character is None: + # Create a new active character + achar = self.alchemy.DndActiveCharacter(character=char, user=author) + data.session.add(achar) + else: + # Change the active character + author.dnd_active_character.character = char + await data.session_commit() + await data.reply(f"✅ Active character set to [b]{char}[/b]!") diff --git a/royalnet/packs/rpg/commands/dndedit.py b/royalnet/packs/rpg/commands/dndedit.py new file mode 100644 index 00000000..a16727de --- /dev/null +++ b/royalnet/packs/rpg/commands/dndedit.py @@ -0,0 +1,59 @@ +from royalnet.commands import * +from ..tables import DndCharacter, DndActiveCharacter + + +class DndeditCommand(Command): + name: str = "dndedit" + + description: str = "Edit the active DnD character." + + aliases = ["de", "dnde", "edit", "dedit"] + + syntax = "{name}\n" \ + "LV {level}\n" \ + "\n" \ + "STR {strength}\n" \ + "DEX {dexterity}\n" \ + "CON {constitution}\n" \ + "INT {intelligence}\n" \ + "WIS {wisdom}\n" \ + "CHA {charisma}\n" \ + "\n" \ + "MAXHP {max_hp}\n" \ + "AC {armor_class}" + + tables = {DndCharacter, DndActiveCharacter} + + async def run(self, args: CommandArgs, data: CommandData) -> None: + name, level, strength, dexterity, constitution, intelligence, wisdom, charisma, max_hp, armor_class = \ + args.match(r"([\w ]+\w)\s*" + r"LV\s+(\d+)\s+" + r"STR\s+(\d+)\s+" + r"DEX\s+(\d+)\s+" + r"CON\s+(\d+)\s+" + r"INT\s+(\d+)\s+" + r"WIS\s+(\d+)\s+" + r"CHA\s+(\d+)\s+" + r"MAXHP\s+(\d+)\s+" + r"AC\s+(\d+)") + try: + int(name) + except ValueError: + pass + else: + raise CommandError("Character names cannot be composed of only a number.") + author = await data.get_author(error_if_none=True) + char = author.dnd_active_character.character + char.name = name + char.level = level + char.strength = strength + char.dexterity = dexterity + char.constitution = constitution + char.intelligence = intelligence + char.wisdom = wisdom + char.charisma = charisma + char.max_hp = max_hp + char.armor_class = armor_class + data.session.add(char) + await data.session_commit() + await data.reply(f"✅ Edit successful!") diff --git a/royalnet/packs/rpg/commands/dndinfo.py b/royalnet/packs/rpg/commands/dndinfo.py new file mode 100644 index 00000000..865955db --- /dev/null +++ b/royalnet/packs/rpg/commands/dndinfo.py @@ -0,0 +1,19 @@ +from royalnet.commands import * +from royalnet.utils import asyncify +from ..tables import DndCharacter, DndActiveCharacter + + +class DndinfoCommand(Command): + name: str = "dndinfo" + + description: str = "Display the character sheet of the active DnD character." + + aliases = ["di", "dndi", "info", "dinfo"] + + tables = {DndCharacter, DndActiveCharacter} + + async def run(self, args: CommandArgs, data: CommandData) -> None: + author = await data.get_author(error_if_none=True) + if author.dnd_active_character is None: + raise CommandError("You don't have an active character.") + await data.reply(author.dnd_active_character.character.character_sheet()) diff --git a/royalnet/packs/rpg/commands/dndnew.py b/royalnet/packs/rpg/commands/dndnew.py index 3cfca885..3fdb8a05 100644 --- a/royalnet/packs/rpg/commands/dndnew.py +++ b/royalnet/packs/rpg/commands/dndnew.py @@ -5,9 +5,9 @@ from ..tables import DndCharacter class DndnewCommand(Command): name: str = "dndnew" - description: str = "Create a new D&D character." + description: str = "Create a new DnD character." - aliases = ["dn", "dndn", "new"] + aliases = ["dn", "dndn", "new", "dnew"] syntax = "{name}\n" \ "LV {level}\n" \ @@ -19,11 +19,41 @@ class DndnewCommand(Command): "WIS {wisdom}\n" \ "CHA {charisma}\n" \ "\n" \ - "MAXHP {maxhp}\n" \ - "AC {armorclass}" + "MAXHP {max_hp}\n" \ + "AC {armor_class}" - tables = {} + tables = {DndCharacter} async def run(self, args: CommandArgs, data: CommandData) -> None: - name = args[0] - ... \ No newline at end of file + name, level, strength, dexterity, constitution, intelligence, wisdom, charisma, max_hp, armor_class = \ + args.match(r"([\w ]+\w)\s*" + r"LV\s+(\d+)\s+" + r"STR\s+(\d+)\s+" + r"DEX\s+(\d+)\s+" + r"COS\s+(\d+)\s+" + r"INT\s+(\d+)\s+" + r"WIS\s+(\d+)\s+" + r"CHA\s+(\d+)\s+" + r"MAXHP\s+(\d+)\s+" + r"AC\s+(\d+)") + try: + int(name) + except ValueError: + pass + else: + raise CommandError("Character names cannot be composed of only a number.") + author = await data.get_author(error_if_none=True) + char = self.alchemy.DndCharacter(name=name, + level=level, + strength=strength, + dexterity=dexterity, + constitution=constitution, + intelligence=intelligence, + wisdom=wisdom, + charisma=charisma, + max_hp=max_hp, + armor_class=armor_class, + creator=author) + data.session.add(char) + await data.session_commit() + await data.reply(f"✅ Character [b]{char.name}[/b] ([c]{char.character_id}[/c]) was created!") diff --git a/royalnet/packs/rpg/commands/dndroll.py b/royalnet/packs/rpg/commands/dndroll.py new file mode 100644 index 00000000..286ca230 --- /dev/null +++ b/royalnet/packs/rpg/commands/dndroll.py @@ -0,0 +1,88 @@ +import typing +import random +from royalnet.commands import * +from royalnet.utils import plusformat +from ..tables import DndCharacter, DndActiveCharacter + + +class DndrollCommand(Command): + name: str = "dndroll" + + description: str = "Roll as the active DnD character." + + aliases = ["dr", "dndr", "droll"] + + syntax = "{stat} [proficiency] [modifier]" + + tables = {DndCharacter, DndActiveCharacter} + + async def run(self, args: CommandArgs, data: CommandData) -> None: + author = await data.get_author(error_if_none=True) + if author.dnd_active_character is None: + raise CommandError("You don't have an active character.") + char: DndCharacter = author.dnd_active_character.character + stat: str = args[0] + second: typing.Optional[str] = args.optional(1) + third: typing.Optional[str] = args.optional(2) + + if third: + extra_mod: int = int(third) + else: + extra_mod: int = 0 + + if second: + if second.startswith("e") or second.startswith("x"): + proficiency_mul: float = 2.0 + proficiency_name: str = " with Expertise" + elif second.startswith("f") or second.startswith("n") or second.startswith("c"): + proficiency_mul: float = 1.0 + proficiency_name: str = " with Proficiency" + elif second.startswith("h") or second == "/" or second.startswith("m"): + proficiency_mul: float = 0.5 + proficiency_name: str = " with Half Proficiency" + elif second.startswith("h") or second == "/" or second.startswith("m"): + proficiency_mul: float = 0.0 + proficiency_name: str = " [i]without Proficiency[/i]" + else: + raise CommandError(f"Unknown proficiency type '{second}'") + proficiency_mod: int = int(char.proficiency_bonus * proficiency_mul) + else: + proficiency_name: str = "" + proficiency_mod: int = 0 + + if stat.startswith("st") or stat.startswith("fo"): + stat_mod: int = char.strength_mod + stat_name: str = "[i]STR[/i]" + elif stat.startswith("de"): + stat_mod: int = char.dexterity_mod + stat_name: str = "[i]DEX[/i]" + elif stat.startswith("co"): + stat_mod: int = char.constitution_mod + stat_name: str = "[i]CON[/i]" + elif stat.startswith("in"): + stat_mod: int = char.intelligence_mod + stat_name: str = "[i]INT[/i]" + elif stat.startswith("wi") or stat.startswith("sa"): + stat_mod: int = char.wisdom_mod + stat_name: str = "[i]WIS[/i]" + elif stat.startswith("ch") or stat.startswith("ca"): + stat_mod: int = char.charisma_mod + stat_name: str = "[i]CHA[/i]" + else: + raise CommandError(f"Unknown stat '{stat}'") + + total_mod = stat_mod + proficiency_mod + extra_mod + + roll = random.randrange(1, 21) + + result = roll + total_mod + + await data.reply(f"🎲 Rolling {stat_name}{proficiency_name}{plusformat(extra_mod, empty_if_zero=True)}:\n" + f"1d20" + f"{plusformat(stat_mod, empty_if_zero=True)}" + f"{plusformat(proficiency_mod, empty_if_zero=True)}" + f"{plusformat(extra_mod, empty_if_zero=True)}" + f" = " + f"{roll}{plusformat(total_mod, empty_if_zero=True)}" + f" = " + f"[b]{result}[/b]") diff --git a/royalnet/packs/rpg/tables/dndactivecharacters.py b/royalnet/packs/rpg/tables/dndactivecharacters.py index 32955e93..36693add 100644 --- a/royalnet/packs/rpg/tables/dndactivecharacters.py +++ b/royalnet/packs/rpg/tables/dndactivecharacters.py @@ -16,11 +16,11 @@ class DndActiveCharacter: @declared_attr def character(self): - return relationship("DndCharacter", foreign_keys=self.character_id, backref="activations", use_scalar=True) + return relationship("DndCharacter", foreign_keys=self.character_id, backref="activated_by") @declared_attr def user(self): - return relationship("User", foreign_keys=self.user_id, backref="active_dnd_character", use_scalar=True) + return relationship("User", foreign_keys=self.user_id, backref=backref("dnd_active_character", uselist=False)) def __repr__(self): return f"<{self.__class__.__qualname__} for {self.user_id}: {self.character_id}>" diff --git a/royalnet/packs/rpg/tables/dndcharacters.py b/royalnet/packs/rpg/tables/dndcharacters.py index 5f5ffab6..e3c4cb35 100644 --- a/royalnet/packs/rpg/tables/dndcharacters.py +++ b/royalnet/packs/rpg/tables/dndcharacters.py @@ -90,6 +90,9 @@ class DndCharacter: return f"<{self.__class__.__qualname__} {self.name}>" def __str__(self): + return f"{self.name}" + + def character_sheet(self): return f"{self.name}\n" \ f"LV {self.level}\n\n" \ f"STR {self.strength}\n" \ diff --git a/royalnet/utils/formatters.py b/royalnet/utils/formatters.py index f2eba8c1..8d94648f 100644 --- a/royalnet/utils/formatters.py +++ b/royalnet/utils/formatters.py @@ -22,15 +22,18 @@ def andformat(l: typing.List[str], middle=", ", final=" and ") -> str: return result -def plusformat(i: int) -> str: +def plusformat(i: int, empty_if_zero: bool = False) -> str: """Convert an :py:class:`int` to a :py:class:`str`, prepending a ``+`` if it's greater than 0. Parameters: i: the :py:class:`int` to convert. + empty_if_zero: Return an empty string if ``i`` is zero. Returns: The resulting :py:class:`str`.""" - if i >= 0: + if i == 0 and empty_if_zero: + return "" + if i > 0: return f"+{i}" return str(i)