diff --git a/src/valentina/cogs/characters.py b/src/valentina/cogs/characters.py index e6e02805..8c5667c0 100644 --- a/src/valentina/cogs/characters.py +++ b/src/valentina/cogs/characters.py @@ -245,7 +245,7 @@ async def list_characters( text += f"**{character.name}**\n" text += "```\n" text += f"Class: {character.char_class.name:<20} Created On: {character.created.split(' ')[0]}\n" - text += f"Alive: {alive:<20} Active: {character.is_active}\n" + text += f"Alive: {alive:<20} Active: {bool(character.is_active)}\n" text += f"Owner: {character.owned_by.data['display_name']:<20}\n" text += "```\n" diff --git a/src/valentina/cogs/experience.py b/src/valentina/cogs/experience.py index 9dfd642e..2f543985 100644 --- a/src/valentina/cogs/experience.py +++ b/src/valentina/cogs/experience.py @@ -64,32 +64,21 @@ async def xp_add( ) return - campaign = self.bot.campaign_svc.fetch_active(ctx).id - - campaign_xp = user.data.get(f"{campaign}_experience", 0) - campaign_total_xp = user.data.get(f"{campaign}_total_experience", 0) - lifetime_xp = user.data.get("lifetime_experience", 0) - - new_xp = campaign_xp + amount - new_total_xp = campaign_total_xp + amount - new_lifetime_xp = lifetime_xp + amount + campaign = self.bot.campaign_svc.fetch_active(ctx) title = f"Add `{amount}` xp to `{user.data['display_name']}`" - is_confirmed, confirmation_response_msg = await confirm_action(ctx, title, hidden=hidden) - + description = "View experience with `/user_info`" + is_confirmed, confirmation_response_msg = await confirm_action( + ctx, title, description=description, hidden=hidden + ) if not is_confirmed: return - await self.bot.user_svc.update_or_add( - ctx, - user=user, - data={ - f"{campaign}_experience": new_xp, - f"{campaign}_total_experience": new_total_xp, - "lifetime_experience": new_lifetime_xp, - }, - ) + # Make the database updates + user.add_experience(campaign.id, amount) + self.bot.user_svc.purge_cache(ctx) + # Send the confirmation message await self.bot.guild_svc.send_to_audit_log(ctx, title) await confirmation_response_msg @@ -110,7 +99,7 @@ async def cp_add( default=False, ), ) -> None: - """Add experience to a user.""" + """Add cool points to a user.""" if not user: user = await self.bot.user_svc.fetch_user(ctx) else: @@ -126,40 +115,24 @@ async def cp_add( ) return - campaign = self.bot.campaign_svc.fetch_active(ctx).id - - campaign_xp = user.data.get(f"{campaign}_experience", 0) - campaign_total_xp = user.data.get(f"{campaign}_total_experience", 0) - campaign_total_cp = user.data.get(f"{campaign}_total_cool_points", 0) - lifetime_xp = user.data.get("lifetime_experience", 0) - lifetime_cp = user.data.get("lifetime_cool_points", 0) - - xp_amount = amount * COOL_POINT_VALUE - new_xp = campaign_xp + xp_amount - new_total_xp = campaign_total_xp + xp_amount - new_lifetime_xp = lifetime_xp + xp_amount - new_lifetime_cp = lifetime_cp + amount + campaign = self.bot.campaign_svc.fetch_active(ctx) title = ( f"Add `{amount}` cool {p.plural_noun('point', amount)} to `{user.data['display_name']}`" ) - is_confirmed, confirmation_response_msg = await confirm_action(ctx, title, hidden=hidden) - + description = "View cool points with `/user_info`" + is_confirmed, confirmation_response_msg = await confirm_action( + ctx, title, description=description, hidden=hidden + ) if not is_confirmed: return - await self.bot.user_svc.update_or_add( - ctx, - user=user, - data={ - f"{campaign}_experience": new_xp, - f"{campaign}_total_experience": new_total_xp, - f"{campaign}_total_cool_points": campaign_total_cp + amount, - "lifetime_cool_points": new_lifetime_cp, - "lifetime_experience": new_lifetime_xp, - }, - ) + # Make the database updates + user.add_cool_points(campaign.id, amount) + user.add_experience(campaign.id, amount * COOL_POINT_VALUE) + self.bot.user_svc.purge_cache(ctx) + # Send the confirmation message await self.bot.guild_svc.send_to_audit_log(ctx, title) await confirmation_response_msg @@ -186,7 +159,7 @@ async def xp_spend( ), ) -> None: """Spend experience points.""" - campaign = self.bot.campaign_svc.fetch_active(ctx).id + campaign = self.bot.campaign_svc.fetch_active(ctx) old_trait_value = character.get_trait_value(trait) category = trait.category.name @@ -214,20 +187,7 @@ async def xp_spend( ) return - current_xp = character.owned_by.data.get(f"{campaign}_experience", 0) - remaining_xp = current_xp - upgrade_cost new_trait_value = old_trait_value + 1 - new_experience = current_xp - upgrade_cost - - if remaining_xp < 0: - await present_embed( - ctx, - title="Error: Not enough XP", - description=f"**{trait.name}** upgrade cost is `{upgrade_cost}` xp. You only have `{current_xp}` xp.", - level="error", - ephemeral=True, - ) - return title = f"Upgrade `{trait.name}` from `{old_trait_value}` {p.plural_noun('dot', old_trait_value)} to `{new_trait_value}` {p.plural_noun('dot', new_trait_value)} for `{upgrade_cost}` xp" is_confirmed, confirmation_response_msg = await confirm_action(ctx, title, hidden=hidden) @@ -235,13 +195,12 @@ async def xp_spend( return # Make the database updates + user = character.owned_by + user.spend_experience(campaign.id, upgrade_cost) character.set_trait_value(trait, new_trait_value) - await self.bot.user_svc.update_or_add( - ctx, - user=character.owned_by, - data={f"{campaign}_experience": new_experience}, - ) + self.bot.user_svc.purge_cache(ctx) + # Send the confirmation message await self.bot.guild_svc.send_to_audit_log(ctx, title) await confirmation_response_msg diff --git a/src/valentina/cogs/misc.py b/src/valentina/cogs/misc.py index be01b6c3..01d8d06b 100644 --- a/src/valentina/cogs/misc.py +++ b/src/valentina/cogs/misc.py @@ -9,7 +9,7 @@ from discord.commands import Option from discord.ext import commands -from valentina.constants import SPACER, DiceType, EmbedColor +from valentina.constants import DiceType, EmbedColor from valentina.models import Probability, Statistics from valentina.models.bot import Valentina from valentina.models.db_tables import Character, Macro @@ -92,7 +92,7 @@ async def server_info( embed.add_field( name="Roll Statistics", - value=roll_stats.get_text(with_title=False), + value=roll_stats.get_text(with_title=False, with_help=True), inline=False, ) embed.set_footer( @@ -149,7 +149,7 @@ async def user_info( """View information about a user.""" target = user or ctx.author db_user = await self.bot.user_svc.fetch_user(ctx=ctx, user=target) - + campaign = self.bot.campaign_svc.fetch_active(ctx) # Variables for embed num_characters = ( Character.select() @@ -164,42 +164,70 @@ async def user_info( Macro.select().where(Macro.guild == ctx.guild.id, Macro.user == db_user).count() ) - creation_date = ((target.id >> 22) + 1420070400000) // 1000 - roles = ", ".join(r.mention for r in target.roles[::-1][:-1]) or "_Member has no roles_" - roll_stats = Statistics(ctx, user=target) - lifetime_xp = db_user.data.get("lifetime_experience", 0) - lifetime_cp = db_user.data.get("lifetime_cool_points", 0) - campaign = self.bot.campaign_svc.fetch_active(ctx) - campaign_xp = db_user.data.get(f"{campaign.id}_experience", 0) - campaign_total_xp = db_user.data.get(f"{campaign.id}_total_experience", 0) - campaign_cp = db_user.data.get(f"{campaign.id}_total_cool_points", 0) - - # Build Embed - description = ( - f"# {target.display_name}", - "### __Account Information__", - f"**Account Created :** on ", - f"**Joined Server{SPACER * 7}:** on ", - f"**Roles{SPACER * 24}:** {roles}", - "### __Campaign Information__", - f"Available Experience{SPACER * 2}: `{campaign_xp}`", - f"Total Experience{SPACER * 10}: `{campaign_total_xp}`", - f"Cool Points{SPACER * 20}: `{campaign_cp}`", - "### __Experience Information__", - f"Lifetime Experience{SPACER * 3}: `{lifetime_xp}`", - f"Lifetime Cool Points{SPACER * 2}: `{lifetime_cp}`", - "### __Gameplay Information__", - f"Player Characters{SPACER * 2}: `{num_characters}`", - f"Roll Macros{SPACER * 14}: `{num_macros}`", - "### __Roll Statistics__", - roll_stats.get_text(with_title=False), + roles = ( + ", ".join( + f"@{r.name}" if not r.name.startswith("@") else r.name + for r in target.roles[::-1][:-1] + if not r.is_integration() + ) + or "No roles" ) + roll_stats = Statistics(ctx, user=target) + ( + campaign_xp, + campaign_total_xp, + lifetime_xp, + campaign_cp, + lifetime_cp, + ) = db_user.fetch_experience(campaign.id) + # Build the Embed embed = discord.Embed( title="", - description="\n".join(description), + description=f"# {target.display_name}", color=EmbedColor.INFO.value, ) + embed.add_field( + name="", + value=f"""\ +```scala +Account Created: {arrow.get(target.created_at).humanize()} ({arrow.get(target.created_at).format('YYYY-MM-DD')}) +Joined Server : {arrow.get(target.joined_at).humanize()} ({arrow.get(target.joined_at).format('YYYY-MM-DD')}) +Roles: {roles} +``` +""", + inline=False, + ) + embed.add_field( + name="Experience", + value=f"""\ +```scala +Lifetime Experience : {lifetime_xp} +Lifetime Cool Points: {lifetime_cp} + +"{campaign.name}" (active campaign) +Available Experience: {campaign_xp} +Total Earned : {campaign_total_xp} +Cool Points : {campaign_cp} +``` +""", + inline=False, + ) + embed.add_field( + name="Gameplay", + value=f"""\ +```scala +Player Characters: {num_characters} +Roll Macros : {num_macros} +``` +""", + inline=False, + ) + embed.add_field( + name="Roll Statistics", + value=roll_stats.get_text(with_title=False, with_help=False), + inline=False, + ) embed.set_thumbnail(url=target.display_avatar.url) embed.set_footer( text=f"Requested by {ctx.author}", @@ -207,6 +235,7 @@ async def user_info( ) embed.timestamp = discord.utils.utcnow() + # Send the embed await ctx.respond(embed=embed, ephemeral=hidden) @commands.slash_command(name="changelog", description="Display the bot's changelog") diff --git a/src/valentina/constants.py b/src/valentina/constants.py index d041535d..98aa388b 100644 --- a/src/valentina/constants.py +++ b/src/valentina/constants.py @@ -230,6 +230,50 @@ class XPMultiplier(Enum): ### DISCORD SETTINGS ### +class CharGenClass(Enum): + """Enum for RNG character generation classes.""" + + HUMAN = range(0, 60) + VAMPIRE = range(61, 66) + WEREWOLF = range(67, 72) + MAGE = range(73, 78) + GHOUL = range(79, 84) + CHANGELING = range(85, 90) + CHANGELING_BREED = range(91, 96) + OTHER = range(97, 100) + + +class CharGenHumans(Enum): + """Enum for RNG character generation of humans.""" + + CIVILIAN = range(0, 40) + HUNTER = range(41, 50) + CHANGELING = range(51, 53) + GHOUL = range(54, 60) + WATCHER = range(61, 70) + HUNTER_CLASS = range(71, 80) + SORCERER = range(81, 90) + NUMINOUS = range(91, 100) + + +class CharGenConcept(Enum): + """Enum for RNG character generation of concepts.""" + + BERSERKER = range(0, 8) + PERFORMER = range(9, 16) + HEALER = range(17, 24) + SHAMAN = range(25, 32) + SOLDIER = range(33, 40) + ASCETIC = range(41, 48) + CRUSADER = range(49, 56) + URBAN_TRACKER = range(57, 64) + UNDER_WORLDER = range(65, 72) + SCIENTIST = range(73, 80) + TRADESMAN = range(81, 88) + BUSINESSMAN = range(89, 96) + CHOOSE = range(97, 100) + + # CHANNEL_PERMISSIONS: Dictionary containing a mapping of channel permissions. # Format: # default role permission, diff --git a/src/valentina/models/db_tables.py b/src/valentina/models/db_tables.py index bc7652e4..e2900559 100644 --- a/src/valentina/models/db_tables.py +++ b/src/valentina/models/db_tables.py @@ -119,6 +119,103 @@ def __str__(self) -> str: """Return the string representation of the model.""" return f"[{self.data['id']}] {self.data['display_name']}" + def fetch_experience(self, campaign_id: int) -> tuple[int, int, int, int, int]: + """Fetch the experience for a user for a specific campaign. + + Args: + campaign_id (int): The campaign ID to fetch experience for. + + Returns: + tuple[int, int, int, int, int]: The campaign experience, campaign total experience, lifetime experience, campaign total cool points, and lifetime cool points. + """ + campaign_xp = self.data.get(f"{campaign_id}_experience", 0) + campaign_total_xp = self.data.get(f"{campaign_id}_total_experience", 0) + campaign_total_cp = self.data.get(f"{campaign_id}_total_cool_points", 0) + lifetime_xp = self.data.get("lifetime_experience", 0) + lifetime_cp = self.data.get("lifetime_cool_points", 0) + + return campaign_xp, campaign_total_xp, lifetime_xp, campaign_total_cp, lifetime_cp + + def add_experience(self, campaign_id: int, amount: int) -> tuple[int, int, int]: + """Set the experience for a user for a specific campaign. + + Args: + campaign_id (int): The campaign ID to set experience for. + amount (int): The amount of experience to set. + + Returns: + tuple[int, int, int]: The campaign experience, campaign total experience, and lifetime experience. + """ + ( + campaign_xp, + campaign_total_xp, + lifetime_xp, + _, + _, + ) = self.fetch_experience(campaign_id) + + new_xp = self.data[f"{campaign_id}_experience"] = campaign_xp + amount + new_total = self.data[f"{campaign_id}_total_experience"] = campaign_total_xp + amount + new_lifetime_total = self.data["lifetime_experience"] = lifetime_xp + amount + + self.save() + + return new_xp, new_total, new_lifetime_total + + def add_cool_points(self, campaign_id: int, amount: int) -> tuple[int, int]: + """Set the cool points for a user for a specific campaign. + + Args: + campaign_id (int): The campaign ID to set cool points for. + amount (int): The amount of cool points to set. + + Returns: + tuple[int, int]: The campaign total cool points and lifetime cool points. + """ + ( + _, + _, + _, + campaign_total_cp, + lifetime_cp, + ) = self.fetch_experience(campaign_id) + + new_total = self.data[f"{campaign_id}_total_cool_points"] = campaign_total_cp + amount + new_lifetime = self.data["lifetime_cool_points"] = lifetime_cp + amount + + self.save() + + return new_total, new_lifetime + + def spend_experience(self, campaign_id: int, amount: int) -> int: + """Spend experience for a user for a specific campaign. + + Args: + campaign_id (int): The campaign ID to spend experience for. + amount (int): The amount of experience to spend. + + Returns: + int: The new campaign experience. + """ + ( + campaign_xp, + _, + _, + _, + _, + ) = self.fetch_experience(campaign_id) + + new_xp = self.data[f"{campaign_id}_experience"] = campaign_xp - amount + + if new_xp < 0: + raise errors.NotEnoughExperienceError( + f"Can not spend {amount} xp with only {campaign_xp} available" + ) + + self.save() + + return new_xp + def set_default_data_values(self) -> GuildUser: """Verify that the GuildUser's JSONField defaults are set. If any keys are missing, they are added to the data column with default values. diff --git a/src/valentina/models/errors.py b/src/valentina/models/errors.py index 74d5ac99..39c169df 100644 --- a/src/valentina/models/errors.py +++ b/src/valentina/models/errors.py @@ -51,6 +51,7 @@ def _handle_known_exceptions( # noqa: C901 | errors.ValidationError | errors.NoMatchingItemsError | errors.NoActiveCharacterError + | errors.NotEnoughExperienceError | errors.URLNotAvailableError | errors.ServiceDisabledError | errors.S3ObjectExistsError, diff --git a/src/valentina/models/statistics.py b/src/valentina/models/statistics.py index 25b09a60..5523f220 100644 --- a/src/valentina/models/statistics.py +++ b/src/valentina/models/statistics.py @@ -99,11 +99,12 @@ def _pull_statistics(self, field_name: str, value: int) -> None: logger.debug(f"Total rolls: {self.total_rolls}") return - def get_text(self, with_title: bool = True) -> str: + def get_text(self, with_title: bool = True, with_help: bool = True) -> str: """Return a string with the statistics. Args: with_title (bool, optional): Whether to include the title. Defaults to True. + with_help (bool, optional): Whether to include the help text. Defaults to True. Returns: str: String with the statistics. @@ -126,12 +127,15 @@ def get_text(self, with_title: bool = True) -> str: Average Difficulty: {'.':.<{25 -19}} {self.average_difficulty} Average Pool Size: {'.':.<{25 -18}} {self.average_pool} ``` +""" + + if with_help: + msg += """\ > Definitions: > - _Critical Success_: More successes than dice rolled > - _Success_: At least one success after all dice are tallied > - _Failure_: Zero successes after all dice are tallied > - _Botch_: Negative successes after all dice are tallied - """ return msg diff --git a/src/valentina/utils/errors.py b/src/valentina/utils/errors.py index 3937b0ad..ade339e7 100644 --- a/src/valentina/utils/errors.py +++ b/src/valentina/utils/errors.py @@ -104,6 +104,24 @@ def __init__( super().__init__(msg, *args, **kwargs) +class NotEnoughExperienceError(DiscordException): + """Raised when a user does not have enough experience to perform an action.""" + + def __init__( + self, + msg: str | None = None, + e: Exception | None = None, + *args: str | int, + **kwargs: int | str | bool, + ): + if not msg: + msg = "Not enough experience to perform this action." + if e: + msg += f"\nRaised from: {e.__class__.__name__}: {e}" + + super().__init__(msg, *args, **kwargs) + + class S3ObjectExistsError(Exception): """Raised when an S3 object already exists.""" diff --git a/tests/test_user_service.py b/tests/test_user_service.py index 8e11d668..547ce405 100644 --- a/tests/test_user_service.py +++ b/tests/test_user_service.py @@ -12,6 +12,201 @@ from valentina.utils import errors +@pytest.mark.usefixtures("mock_db") +class TestGuildUserDatabaseModel: + """Test the GuildUser database model.""" + + def _clear_test_data(self) -> None: + """Clear all test data from the database.""" + for guild in Guild.select(): + if guild.id != 1: # Always keep the default guild + guild.delete_instance(recursive=True, delete_nullable=True) + + for guild_user in GuildUser.select(): + guild_user.delete_instance(recursive=True, delete_nullable=True) + + def test_fetch_experience_no_data(self) -> None: + """Test fetching experience for a guild user.""" + self._clear_test_data() + + # GIVEN a guild user with no experience data + user = GuildUser.create(guild=1, user=1, data={}) + campaign_id = 1 + + # WHEN fetching experience for the guild user + result = user.fetch_experience(campaign_id) + + # THEN return 0 for all values + assert result == (0, 0, 0, 0, 0) + + def test_fetch_experience_with_data(self) -> None: + """Test fetching experience for a guild user.""" + self._clear_test_data() + + # GIVEN a guild user with experience data + user = GuildUser.create( + guild=1, + user=1, + data={ + "1_experience": 100, + "1_total_experience": 200, + "1_total_cool_points": 2, + "2_experience": 1000, + "2_total_experience": 2000, + "2_total_cool_points": 20, + "lifetime_experience": 300, + "lifetime_cool_points": 3, + }, + ) + campaign_id = 1 + + # WHEN fetching experience for the guild user + result = user.fetch_experience(campaign_id) + + # THEN return the correct values + assert result == (100, 200, 300, 2, 3) + + def test_add_experience_no_data(self) -> None: + """Test adding experience for a guild user.""" + self._clear_test_data() + + # GIVEN a guild user with no experience data + user = GuildUser.create(guild=1, user=1, data={}) + campaign_id = 1 + + # WHEN setting experience for the guild user + user.add_experience(campaign_id, 10) + + # THEN the correct values should be set + assert user.data == { + "1_experience": 10, + "1_total_experience": 10, + "lifetime_experience": 10, + } + + def test_add_experience_existing_data(self) -> None: + """Test adding experience for a guild user.""" + self._clear_test_data() + + # GIVEN a guild user with no experience data + user = GuildUser.create( + guild=1, + user=1, + data={ + "1_experience": 100, + "1_total_experience": 200, + "1_total_cool_points": 2, + "2_experience": 1000, + "2_total_experience": 2000, + "2_total_cool_points": 20, + "lifetime_experience": 300, + "lifetime_cool_points": 3, + }, + ) + campaign_id = 1 + + # WHEN setting experience for the guild user + user.add_experience(campaign_id, 10) + + # THEN the correct values should be set + assert user.data == { + "1_experience": 110, + "1_total_experience": 210, + "1_total_cool_points": 2, + "2_experience": 1000, + "2_total_experience": 2000, + "2_total_cool_points": 20, + "lifetime_experience": 310, + "lifetime_cool_points": 3, + } + + def test_add_cp_no_data(self) -> None: + """Test adding cp for a guild user.""" + self._clear_test_data() + + # GIVEN a guild user with no experience data + user = GuildUser.create(guild=1, user=1, data={}) + campaign_id = 1 + + # WHEN setting experience for the guild user + user.add_cool_points(campaign_id, 10) + + # THEN the correct values should be set + assert user.data == { + "1_total_cool_points": 10, + "lifetime_cool_points": 10, + } + + def test_add_cp_existing_data(self) -> None: + """Test adding cp for a guild user.""" + self._clear_test_data() + + # GIVEN a guild user with no experience data + user = GuildUser.create( + guild=1, + user=1, + data={ + "1_experience": 100, + "1_total_experience": 200, + "1_total_cool_points": 2, + "2_experience": 1000, + "2_total_experience": 2000, + "2_total_cool_points": 20, + "lifetime_experience": 300, + "lifetime_cool_points": 3, + }, + ) + campaign_id = 1 + + # WHEN setting experience for the guild user + user.add_cool_points(campaign_id, 10) + + # THEN the correct values should be set + assert user.data == { + "1_experience": 100, + "1_total_experience": 200, + "1_total_cool_points": 12, + "2_experience": 1000, + "2_total_experience": 2000, + "2_total_cool_points": 20, + "lifetime_experience": 300, + "lifetime_cool_points": 13, + } + + def test_spend_experience(self) -> None: + """Test spending experience for a guild user.""" + self._clear_test_data() + + # GIVEN a guild user without enough experience + user = GuildUser.create( + guild=1, + user=1, + data={ + "1_experience": 1, + "1_total_experience": 1, + "1_total_cool_points": 1, + "2_experience": 1000, + "2_total_experience": 2000, + "2_total_cool_points": 20, + "lifetime_experience": 1001, + "lifetime_cool_points": 21, + }, + ) + campaign_id1 = 1 + campaign_id2 = 2 + + # WHEN spending more experience than the user has + # THEN raise an error + with pytest.raises(errors.NotEnoughExperienceError): + user.spend_experience(campaign_id1, 2) + + # WHEN spending less experience than the user has + result = user.spend_experience(campaign_id2, 1) + + # THEN the correct values should be set + assert result == 999 + + @pytest.mark.usefixtures("mock_db") class TestUserService: """Test the user service."""