diff --git a/src/gui b/src/gui index f2760218..c1bcb05c 160000 --- a/src/gui +++ b/src/gui @@ -1 +1 @@ -Subproject commit f2760218ef065f1cde53b801b184cfe02f24dff0 +Subproject commit c1bcb05c25cd2ecec7dd726d55d30606b6b5c99b diff --git a/src/modules/skins/cog.py b/src/modules/skins/cog.py index 49d3a003..95711066 100644 --- a/src/modules/skins/cog.py +++ b/src/modules/skins/cog.py @@ -21,6 +21,7 @@ from .data import CustomSkinData from .skinlib import appskin_as_choice, FrozenCustomSkin, CustomSkin from .settings import GlobalSkinSettings from .settingui import GlobalSkinSettingUI +from .userskinui import UserSkinUI _p = babel._p @@ -35,7 +36,6 @@ class CustomSkinCog(LionCog): # After initialisation, contains all the base skins available for this app self.appskin_names: bidict[int, str] = bidict() - # TODO: Move this to a bijection # Bijective cache of skin property ids <-> (card_id, property_name) tuples self.skin_properties: bidict[int, tuple[str, str]] = bidict() @@ -245,8 +245,9 @@ class CustomSkinCog(LionCog): async def cmd_my_skin(self, ctx: LionContext): if not ctx.interaction: return - # TODO - ... + ui = UserSkinUI(self.bot, ctx.author.id, ctx.author.id) + await ui.run(ctx.interaction, ephemeral=True) + await ui.wait() # ----- Adminspace commands ----- @LionCog.placeholder_group diff --git a/src/modules/skins/skinlib.py b/src/modules/skins/skinlib.py index f3f0cb63..4dc172b3 100644 --- a/src/modules/skins/skinlib.py +++ b/src/modules/skins/skinlib.py @@ -91,7 +91,7 @@ class CustomSkin: for record in records: card_id = record['card_id'] prop_name = record['property_name'] - prop_value = record['property_value'] + prop_value = record['value'] properties[card_id][prop_name] = prop_value if row.base_skin_id is not None: base_skin_name = cog.appskin_names[row.base_skin_id] diff --git a/src/modules/skins/userskinui.py b/src/modules/skins/userskinui.py index e69de29b..210f67f2 100644 --- a/src/modules/skins/userskinui.py +++ b/src/modules/skins/userskinui.py @@ -0,0 +1,552 @@ +from typing import Optional +import asyncio +import datetime as dt + +import discord +from discord.ui.button import button, Button, ButtonStyle +from discord.ui.select import select, Select, SelectOption +from gui.base.AppSkin import AppSkin +from gui.base.Card import Card + +from meta import LionBot, conf +from meta.errors import ResponseTimedOut, UserInputError +from meta.logger import log_wrap +from modules.premium.data import GemTransactionType +from modules.premium.errors import BalanceTooLow +from modules.skins.skinlib import CustomSkin, appskin_as_option +from utils.ui import MessageUI, input, Confirm +from utils.lib import MessageArgs, utc_now +from gui import cards + +from . import babel, logger +from .data import CustomSkinData as Data + +_p = babel._p + + +class UserSkinUI(MessageUI): + card_classes = [ + cards.ProfileCard, + cards.StatsCard, + cards.WeeklyGoalCard, + cards.WeeklyStatsCard, + cards.MonthlyGoalCard, + cards.MonthlyStatsCard, + ] + + def __init__(self, bot: LionBot, userid: int, callerid: int, **kwargs): + super().__init__(callerid=callerid, **kwargs) + + self.bot = bot + self.cog = bot.get_cog('CustomSkinCog') + self.gems = bot.get_cog('PremiumCog') + + self.userid = userid + + # UI State + # Map of app_skin_id -> itemid + self.inventory: dict[str, int] = {} + + # Active app skin, if any + self.active: Optional[str] = None + + # Skins available for purchase + self.available = self._get_available() + + # Index of card currently showing + self._card: int = 0 + + # Name of skin currently displayed, 'default' for default + self._skin: Optional[str] = None + + self.balance: int = 0 + + @property + def current_card(self) -> Card: + return self.card_classes[self._card] + + @property + def current_skin(self) -> AppSkin: + if self._skin is None: + raise ValueError("Cannot get current skin before load.") + return self.available[self._skin] + + @property + def is_default(self) -> bool: + return (self._skin == 'default') + + @property + def is_owned(self) -> bool: + return self.is_default or (self._skin in self.inventory) + + @property + def is_equipped(self) -> bool: + return (self.active == self._skin) or (self.is_default and not self.active) + + def _get_available(self) -> dict[str, AppSkin]: + skins = { + skin.skin_id: skin for skin in AppSkin.get_all() + if skin.public or ( + skin.user_whitelist is not None and + self.userid in skin.user_whitelist + ) + } + skins['default'] = self._make_default() + return skins + + def _make_default(self) -> AppSkin: + """ + Create a placeholder 'default' skin. + """ + t = self.bot.translator.t + + skin = AppSkin(None) + skin.skin_id = 'default' + skin.display_name = t(_p( + 'ui:userskins|default_skin:display_name', + "Default" + )) + skin.description = t(_p( + 'ui:userskins|default_skin:description', + "My default interface theme" + )) + skin.price = 0 + return skin + + # ----- UI API ----- + + @log_wrap(action='equip skin') + async def _equip_owned_skin(self, itemid: Optional[int]): + """ + Equip the provided item. + + if `itemid` is None, 'equips' the default skin. + """ + # Global dispatch + await self.cog.data.UserSkin.table.update_where( + userid=self.userid + ).set(active=False) + if itemid is not None: + await self.cog.data.UserSkin.table.update_where( + userid=self.userid, itemid=itemid + ).set(active=True) + + await self.bot.global_dispatch('userset_skin', self.userid) + + @log_wrap(action='purchase skin') + async def _purchase_skin(self, app_skin_name: str): + async with self.bot.db.connection() as conn: + self.bot.db.conn = conn + async with conn.transaction(): + skin = self.current_skin + skinid = self.cog.appskin_names.inverse[skin.skin_id] + + # Perform transaction + transaction = await self.gems.gem_transaction( + GemTransactionType.PURCHASE, + actorid=self.userid, + from_account=self.userid, + to_account=None, + amount=skin.price, + description=( + f"User purchased custom app skin {skin.skin_id} via UserSkinUI." + ), + note=None, + reference=f"iid: {self._original.id if self._original else 'None'}" + ) + + # Create custom skin + custom_skin = await self.cog.data.CustomisedSkin.create( + base_skin_id=skinid, + ) + + # Update inventory actives + await self.cog.data.UserSkin.table.update_where( + userid=self.userid + ).set(active=False) + + # Insert into inventory + await self.cog.data.UserSkin.create( + userid=self.userid, + custom_skin_id=custom_skin.custom_skin_id, + transactionid=transaction.transactionid, + active=True + ) + + # Global dispatch update + await self.bot.global_dispatch('userset_skin', self.userid) + + logger.info( + f" purchased skin {skin.skin_id}." + ) + + # ----- UI Components ----- + + # Gift Button + @button( + label="GIFT_BUTTON_PLACEHOLDER", + style=ButtonStyle.green, + ) + async def gift_button(self, press: discord.Interaction, pressed: Button): + # TODO: Replace with an actual gifting interface + + t = self.bot.translator.t + skin = self.current_skin + gift_hint = t(_p( + 'ui:userskins|button:gift|response', + "To gift **{skin}** to a friend," + " send them {gem}**{price}** with {gift_cmd}." + )).format( + skin=skin.display_name, + gem=self.bot.config.emojis.gem, + price=skin.price, + gift_cmd=self.bot.core.mention_cmd('gift'), + ) + await press.response.send_message(gift_hint, ephemeral=True) + + async def gift_button_refresh(self): + button = self.gift_button + t = self.bot.translator.t + button.label = t(_p( + 'ui:userskins|button:gift|label', + "Gift to a friend" + )) + price = self.current_skin.price + button.disabled = ( + not price or (price > self.balance) + ) + + # Purchase Button + @button( + label="PURCHASE_BUTTON_PLACEHOLDER", + style=ButtonStyle.green + ) + async def purchase_button(self, press: discord.Interaction, pressed: Button): + t = self.bot.translator.t + + skin = self.current_skin + + # Verify we can purchase this skin + await self.reload() + + if self.is_owned: + raise UserInputError( + t(_p( + 'ui:userskins|button:purchase|error:already_owned', + "You already own this skin!" + )) + ) + elif skin.price > self.balance: + raise UserInputError( + t(_p( + 'ui:userskins|button:purchase|error:insufficient_gems', + "You don't have enough LionGems to purchase this skin!" + )) + ) + + # Confirm purchase + confirm_msg = t(_p( + 'ui:userskins|button:purchase|confirm|desc', + "Are you sure you want to purchase this skin?\n" + "The price of the skin is {gem}**{price}**." + )).format(price=skin.price, gem=self.bot.config.emojis.gem) + confirm = Confirm(confirm_msg, press.user.id) + + confirm.embed.set_footer( + text=t(_p( + 'ui:userskins|button:purchase|confirm|footer', + "Your current balance is {balance} LionGems" + )).format(balance=self.balance) + ) + + try: + result = await confirm.ask(press, ephemeral=True) + except ResponseTimedOut: + result = False + + if result: + try: + await self._purchase_skin(skin.skin_id) + except BalanceTooLow: + raise UserInputError( + t(_p( + 'ui:userskins|button:purchase|error:insufficient_gems_post_confirm', + "Insufficient LionGems to purchase this skin!" + )) + ) + + # Ack purchase and refresh + embed = discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p( + 'ui:userskins|button:purchase|embed:success|title', + "Skin Purchase" + )), + description=t(_p( + 'ui:userskins|button:purchase|embed:success|desc', + "You have purchased and equipped the skin **{name}**!\n" + "Thank you for your support, and enjoy your new purchase!" + )).format(name=skin.display_name) + ) + await press.followup.send(embed=embed, ephemeral=True) + await self.refresh() + + async def purchase_button_refresh(self): + button = self.purchase_button + t = self.bot.translator.t + button.label = t(_p( + 'ui:userskins|button:purchase|label', + "Purchase Skin" + )) + button.disabled = ( + self.is_owned + or self.current_skin.price > self.balance + ) + + # Equip Button + @button( + label="EQUIP_BUTTON_PLACEHOLDER", + style=ButtonStyle.green + ) + async def equip_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer(thinking=True, ephemeral=True) + t = self.bot.translator.t + + to_equip = None if self.is_default else self.inventory[self._skin] + await self._equip_owned_skin(to_equip) + + embed = discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p( + 'ui:userskins|button:equip|embed:success|title', + "Skin Equipped" + )), + description=t(_p( + 'ui:userskins|button:equip|embed:success|desc', + "You have equpped your **{name}** skin!" + )).format(name=self.current_skin.display_name) + ) + await press.edit_original_response(embed=embed) + await self.refresh() + + async def equip_button_refresh(self): + button = self.equip_button + t = self.bot.translator.t + button.label = t(_p( + 'ui:userskins|button:equip|label', + "Equip Skin" + )) + button.disabled = ( + self.is_equipped or not self.is_owned + ) + + # Price button + @button( + label="PRICE_BUTTON_PLACEHOLDER", + style=ButtonStyle.green, + emoji=conf.emojis.gem, + ) + async def price_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer(thinking=False) + + async def price_button_refresh(self): + button = self.price_button + t = self.bot.translator.t + + price = self.current_skin.price + button.label = t(_p( + 'ui:userskins|button:price|label', + "{price} Gems" + )).format(price=price) + if price < self.balance: + button.style = ButtonStyle.green + else: + button.style = ButtonStyle.danger + + # Card Menu + @select( + cls=Select, + placeholder="CARD_MENU_PLACEHOLDER", + min_values=1, max_values=1 + ) + async def card_menu(self, selection: discord.Interaction, selected: Select): + await selection.response.defer(thinking=True, ephemeral=True) + self._card = int(selected.values[0]) + await self.refresh(thinking=selection) + + async def card_menu_refresh(self): + menu = self.card_menu + t = self.bot.translator.t + menu.placeholder = t(_p( + 'ui:userskins|menu:card|placeholder', + "Select a card to preview" + )) + options = [] + for i, card in enumerate(self.card_classes): + option = SelectOption( + label=t(card.display_name), + value=str(i), + default=(i == self._card) + ) + options.append(option) + menu.options = options + + @button(emoji=conf.emojis.cancel, style=ButtonStyle.red) + async def quit_button(self, press: discord.Interaction, pressed: Button): + """ + Quit the UI. + """ + await press.response.defer() + await self.quit() + + # Skin Menu + @select( + cls=Select, + placeholder="SKIN_MENU_PLACEHOLDER", + min_values=1, max_values=1 + ) + async def skin_menu(self, selection: discord.Interaction, selected: Select): + await selection.response.defer(thinking=True, ephemeral=True) + self._skin = selected.values[0] + await self.refresh(thinking=selection) + + async def skin_menu_refresh(self): + menu = self.skin_menu + t = self.bot.translator.t + menu.placeholder = t(_p( + 'ui:userskins|menu:skin|placeholder', + "Select a skin." + )) + options = [] + for skin in self.available.values(): + option = appskin_as_option(skin) + if skin.skin_id == self._skin: + option.default = True + options.append(option) + menu.options = options + + # ----- UI Flow ----- + async def _render_card(self) -> discord.File: + if not self._skin: + raise ValueError("Rendering UserSkinUI before load.") + + use_skin = None + if self._skin == 'default': + use_skin = await self.cog.get_default_skin() + else: + use_skin = self._skin + skin = {'base_skin_id': use_skin} if use_skin else {} + + return await self.current_card.generate_sample(skin=skin) + + async def make_message(self) -> MessageArgs: + if not self._skin: + raise ValueError("Rendering UserSkinUI before load.") + + t = self.bot.translator.t + + skin = self.current_skin + + # Compute tagline + if not self.is_owned: + if skin.price <= self.balance: + tagline = t(_p( + 'ui:userskins|tagline:purchase', + "Purchase this skin for {gem}{price}" + )) + else: + tagline = t(_p( + 'ui:userskins|tagline:insufficient', + "You don't have enough LionGems to buy this skin!" + )) + elif not self.is_equipped: + tagline = t(_p( + 'ui:userskins|tagline:equip', + "You already own this skin! Clock Equip to use it!" + )) + else: + tagline = t(_p( + 'ui:userskins|tagline:current', + "This is your current skin!" + )) + + tagline = tagline.format( + gem=self.bot.config.emojis.gem, + price=skin.price, + ) + + embed = discord.Embed( + colour=discord.Colour.orange(), + title=skin.display_name, + description=f"{skin.description}\n\n***{tagline}***" + ) + embed.set_footer( + icon_url="https://cdn.discordapp.com/attachments/925799205954543636/938703943683416074/4CF1C849-D532-4DEC-B4C9-0AB11F443BAB.png", + text=t(_p( + 'ui:userskins|footer', + "Current Balance: {balance} LionGems" + )).format(balance=self.balance) + ) + embed.set_image(url='attachment://sample.png') + + file = await self._render_card() + + return MessageArgs(embed=embed, files=[file]) + + async def refresh_layout(self): + """ + (gift_button, price_button, action_button) + (skin_menu,), + (card_menu,), + """ + to_refresh = ( + self.gift_button_refresh(), + self.price_button_refresh(), + self.purchase_button_refresh(), + self.equip_button_refresh(), + self.card_menu_refresh(), + self.skin_menu_refresh(), + ) + await asyncio.gather(*to_refresh) + + # Determine action button + skin = self.current_skin + if not self.is_owned: + if skin.price <= self.balance: + action = self.purchase_button + else: + action = self.gems.buy_gems_button() + else: + action = self.equip_button + + self.set_layout( + (self.gift_button, self.price_button, action, self.quit_button,), + (self.skin_menu,), + (self.card_menu,), + ) + + async def reload(self): + """ + Load the user's skin inventory. + """ + records = await self.cog.data.UserSkin.table.select_where( + userid=self.userid + ).join( + 'customised_skins', using=('custom_skin_id',) + ).select( + 'itemid', 'custom_skin_id', 'base_skin_id', 'active' + ).with_no_adapter() + active = None + inventory = {} + for record in records: + base_skin_name = self.cog.appskin_names[record['base_skin_id']] + inventory[base_skin_name] = record['itemid'] + if record['active']: + active = base_skin_name + + self.inventory = inventory + self.active = active + if self._skin is None: + self._skin = active or 'default' + + self.balance = await self.gems.get_gem_balance(self.userid)