diff --git a/bot/modules/shop/__init__.py b/bot/modules/shop/__init__.py new file mode 100644 index 00000000..1f40eebe --- /dev/null +++ b/bot/modules/shop/__init__.py @@ -0,0 +1,8 @@ +from babel import LocalBabel + +babel = LocalBabel('shop') + + +async def setup(bot): + from .cog import Shopping + await bot.add_cog(Shopping(bot)) diff --git a/bot/modules/shop/cog.py b/bot/modules/shop/cog.py new file mode 100644 index 00000000..c72aca27 --- /dev/null +++ b/bot/modules/shop/cog.py @@ -0,0 +1,109 @@ +import asyncio +import logging +from typing import Optional + +import discord +from discord.ext import commands as cmds +from discord import app_commands as appcmds + +from meta import LionBot, LionCog, LionContext + +from utils import ui + +from . import babel + +from .shops.colours import ColourShop + +logger = logging.getLogger(__name__) + +_p = babel._p + + +class Shopping(LionCog): + Shops = [ColourShop] + + def __init__(self, bot: LionBot): + self.bot = bot + self.data = None + self.shops = [] + + async def cog_load(self): + for Shop in self.Shops: + shop = Shop(self.bot, self.data) + await shop.load_into(self) + self.shops.append(shop) + + async def cog_unload(self): + for shop in self.shops: + await shop.unload() + + @cmds.hybrid_group(name='editshop') + async def editshop_group(self, ctx: LionContext): + return + + @cmds.hybrid_group(name='shop') + async def shop_group(self, ctx: LionContext): + return + + @shop_group.command(name='open') + async def shop_open_cmd(self, ctx: LionContext): + """ + Opens the shop UI for the current guild. + """ + ... + + +class StoreManager(ui.LeoUI): + def __init__(self, bot, data, shops): + self.bot = bot + self.data = data + self.shops = shops + + self.page_num = 0 + + self._original: Optional[discord.Interaction] = None + + self._store_row = self.make_buttons() + self._widgets = self.prepare_widgets() + + async def redraw(self): + """ + Ask the current shop widget to redraw. + """ + ... + + def make_buttons(self): + """ + Make a tuple of shop buttons. + + If there is only one shop, return an empty tuple. + """ + if len(self.shops) <= 1: + return () + buttons = [] + for i, shop in enumerate(self.shops): + @ui.AButton(label=shop.name) + async def pressed_switch_shop(press: discord.Interaction, pressed): + await press.response.defer() + await self.change_page(i) + buttons.append(pressed_switch_shop) + return tuple(buttons) + + def prepare_widgets(self): + widgets = [] + for shop in self.shops: + widget = shop.make_widget() + # TODO: Update this when we have a UILayout class + # widget.layout.set_row('shops', self._store_row, affinity=1) + widget.shop_row = self._store_row + widgets.append(widget) + return widgets + + async def change_page(self, i): + """ + Change to the given page number. + """ + ... + + async def run(self, interaction): + ... diff --git a/bot/modules/shop/data.py b/bot/modules/shop/data.py new file mode 100644 index 00000000..053a090a --- /dev/null +++ b/bot/modules/shop/data.py @@ -0,0 +1,87 @@ +from enums import Enum +from cachetools import TTLCache + + +from data import Registry, RowModel, RegisterEnum, WeakCache +from data.columns import Integer, String, Bool, Timestamp, Column + + +class ShopItemType(Enum): + """ + Schema + ------ + CREATE TYPE ShopItemType AS ENUM ( + 'COLOUR_ROLE' + ); + """ + COLOUR = 'COLOUR_ROLE', + + +class ShopData(Registry, name='shop'): + _ShopItemType = RegisterEnum(ShopItemType) + + class ShopItem(RowModel): + """ + Schema + ------ + CREATE TABLE shop_items( + itemid SERIAL PRIMARY KEY, + guildid BIGINT NOT NULL, + item_type ShopItemType NOT NULL, + price INTEGER NOT NULL, + purchasable BOOLEAN DEFAULT TRUE, + deleted BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT (now() at time zone 'utc') + ); + CREATE INDEX guild_shop_items ON shop_items (guildid); + """ + _tablename_ = 'shop_items' + + itemid = Integer(primary=True) + guildid = Integer() + item_type: Column[ShopItemType] = Column() + price = Integer() + purchasable = Bool() + deleted = Bool() + created_at = Timestamp() + + class ColourRole(RowModel): + """ + Schema + ------ + CREATE TABLE shop_items_colour_roles( + itemid INTEGER PRIMARY KEY REFERENCES shop_items(itemid) ON DELETE CASCADE, + roleid BIGINT NOT NULL + ); + """ + _tablename_ = 'shop_items_colour_roles' + + itemid = Integer(primary=True) + roleid = Integer() + + class MemberInventory(RowModel): + """ + Schema + ------ + CREATE TABLE member_inventory( + inventoryid SERIAL PRIMARY KEY, + guildid BIGINT NOT NULL, + userid BIGINT NOT NULL, + transactionid INTEGER REFERENCES coin_transactions(transactionid) ON DELETE SET NULL, + itemid INTEGER NOT NULL REFERENCES shop_items(itemid) ON DELETE CASCADE + ); + CREATE INDEX member_inventory_members ON member_inventory(guildid, userid); + """ + _tablename_ = 'member_inventory' + + inventoryid = Integer(primary=True) + guildid = Integer() + userid = Integer() + transactionid = Integer() + itemid = Integer() + + async def fetch_inventory(self, guildid, userid) -> list['ShopData.MemberInventory']: + """ + Fetch the given member's inventory. + """ + return await self.fetch_where(guildid=guildid, userid=userid) diff --git a/bot/modules/shop/shops/__init__.py b/bot/modules/shop/shops/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/modules/shop/shops/base.py b/bot/modules/shop/shops/base.py new file mode 100644 index 00000000..90ca7fee --- /dev/null +++ b/bot/modules/shop/shops/base.py @@ -0,0 +1,81 @@ +import discord +from discord.ui.button import Button + +from meta import LionBot + +from utils import ui + +from ..data import ShopData + + +class MemberInventory: + """ + An interface to the member's inventory. + """ + def __init__(self, bot, shop_data, lion, inventory): + self.bot = bot + self.lion = lion + self.guildid = lion.guildid + self.userid = lion.userid + + # A list of InventoryItems held by this user + self.inventory = inventory + + @classmethod + async def fetch(cls, bot: LionBot, shop_data: ShopData, guildid: int, userid: int): + lion = await bot.core.lions.fetch(guildid, userid) + inventory = await shop_data.fetch_where(guildid=guildid, userid=userid) + return cls(bot, shop_data, lion, inventory) + + async def refresh(self): + """ + Refresh the data for this member. + """ + self.lion = self.bot.core.lions.fetch(self.guild.id, self.user.id) + + data = self.bot.get_cog('Shopping').data + self.inventory_items = await data.InventoryItem.fetch_where(userid=self.userid, guildid=self.guildid) + + +class ShopItem: + """ + ABC representing a purchasable guild shop item. + """ + def __init__(self, data): + self.data = data + + async def purchase(self, userid): + """ + Called when a member purchases this item. + """ + ... + + +class Shop: + """ + Base class representing a Shop for a particular member. + """ + def __init__(self, bot: LionBot, shop_data: ShopData, member: discord.Member): + self.bot = bot + self.data = shop_data + self.member = member + self.guild = member.guild + + # A list of ShopItems that are currently visible to the member + self.items = [] + + # Current inventory for the member + self.inventory = None + + async def refresh(self): + ... + + +class Store(ui.LeoUI): + """ + Base UI for the different shops. + """ + def __init__(self, bot: LionBot, data, shops): + self.bot = bot + self.data = data + self.shops = shops diff --git a/bot/modules/shop/shops/boosts.py b/bot/modules/shop/shops/boosts.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/modules/shop/shops/colours.py b/bot/modules/shop/shops/colours.py new file mode 100644 index 00000000..a4bd5745 --- /dev/null +++ b/bot/modules/shop/shops/colours.py @@ -0,0 +1,147 @@ +from typing import TYPE_CHECKING + +import discord +from discord.ext import commands as cmds +from discord import app_commands as appcmds +from discord.ui.select import select, Select +from discord.ui.button import button, Button + +from meta import LionCog, LionContext, LionBot +from utils import ui + +from .data import ShopData + +if TYPE_CHECKING: + from ..cog import Shopping + + +class ColourShopping(LionCog): + """ + Cog in charge of colour shopping. + + Registers colour shop related commands and methods. + """ + + def __init__(self, bot: LionBot, data): + self.bot = bot + self.data = data + + async def load_into(self, cog: 'Shopping'): + self.crossload_group(self.editshop_group, cog.editshop_group) + await cog.bot.add_cog(self) + + async def unload(self): + pass + + @LionCog.placeholder_group + @cmds.hybrid_group('editshopp', with_app_command=False) + async def editshop_group(self, ctx: LionContext): + pass + + @editshop_group.group('colours') + async def editshop_colour_group(self, ctx: LionContext): + ... + + @editshop_colour_group.command('edit') + async def editshop_colours_edit_cmd(self, ctx: LionContext): + await ctx.reply(f"I am a {self.__class__.__name__} version 2") + ... + + def make_widget(self): + """ + Instantiate and return a new UI for this shop. + """ + return ColourStore(self.bot, self.data) + + def make_shop_for(self, member: discord.Member): + return ColourShop(member, self.data) + + +class ColourShop: + """ + A Shop representing a colour shop for a particular member. + + Parameters + ---------- + bot: LionBot + The current LionBot. + + member: discord.Member + The member this particular shop is for. + + data: ShopData + An initialised ShopData registry. + """ + def __init__(self, bot, member, data): + self.bot = bot + self.user = member + self.guild = member.guild + self.data = data + + # List of items in this shop. Initialised in refresh() + self.items = [] + + # Current inventory for this member + self.inventory = None + + def make_store(self): + """ + Initialise and return a new Store UI for this shop. + """ + return ColourStore(self) + + +class ColourStore: + """ + Ephemeral UI providing access to the colour store. + """ + + def __init__(self, shop: ColourShop): + self.bot = shop.bot + self.data = shop.data + self.shop = shop + + self.shop_row = () + + async def refresh(self): + """ + Refresh the data. + """ + # Refresh current item list + # Refresh user's current item + ... + + async def redraw(self): + ... + + @select(placeholder="Select to Buy") + async def select_colour(self, interaction: discord.Interaction, selection: Select): + # User selected a colour from the list + # Run purchase pathway for that item + ... + + async def select_colour_refresh(self): + """ + Refresh the select colour menu. + + For an item to be purchasable, + it needs to be affordable and not currently owned by the member. + """ + ... + + def make_embed(self): + """ + Embed for this shop. + """ + lines = [] + for i, item in enumerate(self.shop.items): + line = f"[{i+1}] | `{item.price} LC` | <@&{item.data.roleid}>" + if item.itemid in self.shop.member_inventory.itemids: + line += " (You own this!)" + embed = discord.Embed( + title="Colour Role Shop", + description="" + ) + ... + + diff --git a/bot/modules/shop/shops/roles.py b/bot/modules/shop/shops/roles.py new file mode 100644 index 00000000..e69de29b diff --git a/data/migration/v12-13/migration.sql b/data/migration/v12-13/migration.sql index 2bb648f1..8175be45 100644 --- a/data/migration/v12-13/migration.sql +++ b/data/migration/v12-13/migration.sql @@ -1,3 +1,5 @@ +BEGIN; + -- Add metdata to configuration tables {{{ ALTER TABLE user_config ADD COLUMN name TEXT; ALTER TABLE user_config ADD COLUMN first_seen TIMESTAMPTZ DEFAULT now(); @@ -205,9 +207,25 @@ CREATE TABLE coin_transactions_admin_actions( PRIMARY KEY (actionid, transactionid) ); CREATE INDEX coin_transactions_admin_actions_transactionid ON coin_transactions_admin_actions (transactionid); +-- }}} +-- Shop data {{{ +ALTER TABLE member_inventory DROP CONSTRAINT member_inventory_pkey; + +ALTER TABLE member_inventory + ADD COLUMN inventoryid SERIAL PRIMARY KEY; + +ALTER TABLE member_inventory + ADD COLUMN transactionid INTEGER REFERENCES coin_transactions (transactionid) ON DELETE SET NULL; + +ALTER TABLE member_inventory + DROP COLUMN count; + +CREATE INDEX member_inventory_members ON member_inventory(guildid, userid); -- }}} INSERT INTO VersionHistory (version, author) VALUES (13, 'v12-v13 migration'); +COMMIT; + -- vim: set fdm=marker: