feat(skins): Implement user skin ui.

This commit is contained in:
2023-10-28 01:01:24 +03:00
parent 2f29bf37f6
commit 02068bd6b9
4 changed files with 558 additions and 5 deletions

Submodule src/gui updated: f2760218ef...c1bcb05c25

View File

@@ -21,6 +21,7 @@ from .data import CustomSkinData
from .skinlib import appskin_as_choice, FrozenCustomSkin, CustomSkin from .skinlib import appskin_as_choice, FrozenCustomSkin, CustomSkin
from .settings import GlobalSkinSettings from .settings import GlobalSkinSettings
from .settingui import GlobalSkinSettingUI from .settingui import GlobalSkinSettingUI
from .userskinui import UserSkinUI
_p = babel._p _p = babel._p
@@ -35,7 +36,6 @@ class CustomSkinCog(LionCog):
# After initialisation, contains all the base skins available for this app # After initialisation, contains all the base skins available for this app
self.appskin_names: bidict[int, str] = bidict() self.appskin_names: bidict[int, str] = bidict()
# TODO: Move this to a bijection
# Bijective cache of skin property ids <-> (card_id, property_name) tuples # Bijective cache of skin property ids <-> (card_id, property_name) tuples
self.skin_properties: bidict[int, tuple[str, str]] = bidict() self.skin_properties: bidict[int, tuple[str, str]] = bidict()
@@ -245,8 +245,9 @@ class CustomSkinCog(LionCog):
async def cmd_my_skin(self, ctx: LionContext): async def cmd_my_skin(self, ctx: LionContext):
if not ctx.interaction: if not ctx.interaction:
return return
# TODO ui = UserSkinUI(self.bot, ctx.author.id, ctx.author.id)
... await ui.run(ctx.interaction, ephemeral=True)
await ui.wait()
# ----- Adminspace commands ----- # ----- Adminspace commands -----
@LionCog.placeholder_group @LionCog.placeholder_group

View File

@@ -91,7 +91,7 @@ class CustomSkin:
for record in records: for record in records:
card_id = record['card_id'] card_id = record['card_id']
prop_name = record['property_name'] prop_name = record['property_name']
prop_value = record['property_value'] prop_value = record['value']
properties[card_id][prop_name] = prop_value properties[card_id][prop_name] = prop_value
if row.base_skin_id is not None: if row.base_skin_id is not None:
base_skin_name = cog.appskin_names[row.base_skin_id] base_skin_name = cog.appskin_names[row.base_skin_id]

View File

@@ -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"<uid: {self.userid}> 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)