rewrite: Restructure to include GUI.

This commit is contained in:
2022-12-23 06:44:32 +02:00
parent 2b93354248
commit f328324747
224 changed files with 8 additions and 0 deletions

View File

@@ -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))

246
src/modules/shop/cog.py Normal file
View File

@@ -0,0 +1,246 @@
"""
The initial Shopping Cog acts as the entry point to the shopping system.
It provides the commands:
/shop open [type:<acmpl str>]
/shop buy <item:<acmpl str>>
It also provides the "/editshop" group,
which each shop type should hook into (via placeholder groups)
to provide a shop creation and edit interface.
A "Shop" represents a colour shop, role shop, boost shop, etc.
Each Shop has a different editing interface (the ShopCog),
and a different user interface page (the Store).
When a user runs /shop open, the "StoreManager" opens.
This PagedUI serves as a top-level manager for each shop store,
even though it has no UI elements itself.
The user sees the first Store page, and can freely use the store UI
to interact with that Shop.
They may also use the "shop row" buttons to switch between shops.
Now, onto the individual shop systems.
When the user first opens a shop, a Customer is created.
The Customer represents the member themselves (i.e. the lion),
along with their inventory (a list of raw items that they own).
For fast access (for e.g. autocomplete), the Customer may be
cached, as long as it has a unique registry map.
Each initialised Shop has a collection of ShopItems.
The ShopItems represent individual objects of that shop,
and usually contain state particular to that shop.
The ShopItems shouldn't need to remember their origin shop,
or the Customer.
The Shop itself handles purchases, checking whether a customer can purchase
a given item, and running the purchase logic for an item.
Note: Timing out Acmpl state to handle caching shared states between acmpl?
Note: shop_item_info table which joins the others? Include guild_item_id,
and cache the items.
/shop open:
- Create the Customer
- Initialise the Shops, with the current Guild and the Customer
- Pass the shop stores to the StoreManager, along with the command interaction
- Run display on the StoreManager, which displays the first Store UI
- The StoreManager gets a store button from each store, then passes those back to the individuals
- This would be best done with a UILayout modification, but we don't have that yet
- The Store displays its UI, which is Customer-dependent, relying on the Shop for most computations.
- (The Store might not even need to keep the Customer, just using Shop methods to access them.)
- The Customer may make a purchase/refund/etc, which gets mapped back to the Shop.
- After the Customer has made an action, the Store refreshes its UI, from the Shop data.
/shop buy <item>:
- Instantiates the Customer and Shops (via acmpl state?), which should _not_ require any data lookups.
- Acmpl shows an intelligent list of matching items, nicely formatted
- (Shopitem itself can be responsible for formatting?)
- (Shop can be responsible for showing which items the user can purchase?)
- Command gets the shopitemid (possibly partitioned by guild), gets that item, gets the item Shop.
- Command then gets the Shop to purchase that item.
"""
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 utils.lib import error_embed
from . import babel
from .shops.base import Customer, ShopCog
from .data import ShopData
logger = logging.getLogger(__name__)
_p = babel._p
class Shopping(LionCog):
# List of active Shop cogs
ShopCogs = ShopCog.active
def __init__(self, bot: LionBot):
self.bot = bot
self.data = bot.db.load_registry(ShopData())
self.active_cogs = []
async def cog_load(self):
await self.data.init()
for SCog in self.ShopCogs:
shop_cog = SCog(self.bot, self.data)
await shop_cog.load_into(self)
self.active_cogs.append(shop_cog)
async def cog_unload(self):
for shop in self.shops:
await shop.unload()
@cmds.hybrid_group(
name=_p('group:editshop', 'editshop')
)
async def editshop_group(self, ctx: LionContext):
return
@cmds.hybrid_group(
name=_p('group:shop', 'shop')
)
async def shop_group(self, ctx: LionContext):
return
@shop_group.command(
name=_p('cmd:shop_open', 'open'),
description=_p('cmd:shop_open|desc', "Open the server shop.")
)
async def shop_open_cmd(self, ctx: LionContext):
"""
Opens the shop UI for the current guild.
"""
t = self.bot.translator.t
# Typechecker guards
if not ctx.guild:
return
if not ctx.interaction:
return
await ctx.interaction.response.defer(ephemeral=True, thinking=True)
# Create the Customer
customer = await Customer.fetch(self.bot, self.data, ctx.guild.id, ctx.author.id)
# Create the Shops
shops = [await cog.make_shop_for(customer) for cog in self.active_cogs]
# TODO: Filter by shops which actually have items
if not shops:
await ctx.reply(
embed=error_embed(
t(_p('cmd:shop_open|error:no_shops', "There is nothing to buy!"))
),
ephemeral=True
)
return
# Extract the Stores
stores = [shop.make_store(ctx.interaction) for shop in shops]
# Build the StoreManager from the Stores
manager = StoreManager(self.bot, self.data, stores)
# Display the StoreManager
await manager.run(ctx.interaction)
await manager.wait()
# TODO: shortcut shop buy command
class StoreManager(ui.LeoUI):
def __init__(self, bot, data, stores, **kwargs):
super().__init__(**kwargs)
self.bot = bot
self.data = data
self.stores = stores
self.page_num = 0
# Original interaction that opened this shop
self._original: Optional[discord.Interaction] = None
# tuple of Buttons to each active store
self._store_row = self.make_buttons()
async def redraw(self):
"""
Ask the current shop widget to redraw.
"""
self.page_num %= len(self.stores)
await self.stores[self.page_num].refresh()
await self.stores[self.page_num].redraw()
def make_buttons(self):
"""
Make a tuple of shop buttons.
If there is only one shop, returns an empty tuple.
"""
t = self.bot.translator.t
buttons = []
if len(self.stores) > 1:
for i, store in enumerate(self.stores):
@ui.AButton(label=store.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)
@ui.AButton(
label=_p('ui:stores|button:close|label', "Close"),
emoji=self.bot.config.emojis.getemoji('cancel')
)
async def pressed_close(press: discord.Interaction, pressed):
await press.response.defer()
if not self._original.is_expired():
embed = discord.Embed(
title=t(_p('ui:stores|button:close|response|title', "Shop Closed")),
colour=discord.Colour.orange()
)
await self._original.edit_original_response(embed=embed, view=None)
await self.close()
buttons.append(pressed_close)
for button in buttons:
self.add_item(button)
return tuple(buttons)
async def change_page(self, i):
"""
Change to the given page number.
"""
self.page_num = i
self.page_num %= len(self.stores)
await self.redraw()
async def run(self, interaction):
self._original = interaction
for store in self.stores:
self.children.append(store)
store.set_store_row(self._store_row)
await self.redraw()
async def monitor(self):
"""
When one of the stores closes, we want all the stores to close,
along with this parent UI.
"""
# TODO
...

154
src/modules/shop/data.py Normal file
View File

@@ -0,0 +1,154 @@
from enum 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 ShopItemInfo(RowModel):
"""
A view joining the shop item sub-type information,
and including the guild id.
Schema
------
CREATE VIEW shop_item_info AS
SELECT
*,
row_number() OVER (PARTITION BY guildid ORDER BY itemid) AS guild_itemid
FROM
shop_items
LEFT JOIN shop_items_colour_roles USING (itemid)
ORDER BY itemid ASC;
"""
_tablename_ = 'shop_item_info'
_readonly_ = True
itemid = Integer(primary=True)
guild_itemid = Integer()
guildid = Integer()
item_type: Column[ShopItemType] = Column()
price = Integer()
purchasable = Bool()
deleted = Bool()
created_at = Timestamp()
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()
class MemberInventoryInfo(RowModel):
"""
Composite view joining the member inventory with shop item information.
Schema
------
CREATE VIEW member_inventory_info AS
SELECT
inv.inventoryid AS inventoryid,
inv.guildid AS guildid,
inv.userid AS userid,
inv.transactionid AS transactionid,
items.itemid AS itemid,
items.item_type AS item_type,
items.price AS price,
items.purchasable AS purchasable,
items.deleted AS deleted
FROM
member_inventory inv
LEFT JOIN shop_item_info items USING (itemid)
ORDER BY itemid ASC;
"""
_tablename_ = 'member_inventory_info'
_readonly_ = True
inventoryid = Integer(primary=True)
guildid = Integer()
userid = Integer()
transactionid = Integer()
itemid = Integer()
guild_itemid = Integer()
item_type: Column[ShopItemType] = Column()
price = Integer()
purchasable = Bool()
deleted = Bool()
roleid = Integer()
@classmethod
async def fetch_inventory_info(cls, guildid, userid) -> list['ShopData.MemberInventoryInfo']:
"""
Fetch the information rows for the given members inventory.
"""
return await cls.fetch_where(guildid=guildid, userid=userid)

View File

@@ -0,0 +1,2 @@
from . import base
from . import colours

View File

@@ -0,0 +1,225 @@
from typing import Type, TYPE_CHECKING
from weakref import WeakValueDictionary
import discord
from discord.ui.button import Button
from meta import LionBot, LionCog
from utils import ui
from babel.translator import LazyStr
from ..data import ShopData
if TYPE_CHECKING:
from core.lion import Lion
class ShopCog(LionCog):
"""
Minimal base class for a ShopCog.
"""
_shop_cls_: Type['Shop']
active: list[Type['ShopCog']] = []
def __init__(self, bot: LionBot, data: ShopData):
self.bot = bot
self.data = data
async def load_into(self, cog: LionCog):
"""
Load this ShopCog into the parent Shopping Cog.
Usually just attaches the editshop placeholder group, if applicable.
May also load the cog itself into the client,
if the ShopCog needs to provide global features
or commands.
"""
raise NotImplementedError
async def make_shop_for(self, customer: 'Customer'):
"""
Make a Shop instance for the provided Customer.
"""
shop = self._shop_cls_(self.bot, self.data, customer)
await shop.refresh()
return shop
@classmethod
def register(self, shop):
"""
Helper decorator to register a given ShopCog as active.
"""
self.active.append(shop)
return shop
class Customer:
"""
An interface to the member's inventory.
"""
_cache_ = WeakValueDictionary()
def __init__(self, bot: LionBot, shop_data: ShopData, lion, inventory: ShopData.MemberInventory):
self.bot = bot
self.data = shop_data
self.lion: 'Lion' = lion
# A list of InventoryItems held by this customer
self.inventory = inventory
@property
def guildid(self):
return self.lion.guildid
@property
def userid(self):
return self.lion.userid
@property
def balance(self):
return self.lion.data['coins']
@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.MemberInventoryInfo.fetch_inventory_info(guildid, userid)
return cls(bot, shop_data, lion, inventory)
async def refresh(self):
"""
Refresh the data for this member.
"""
self.lion = await self.bot.core.lions.fetch(self.guildid, self.userid)
await self.lion.data.refresh()
self.inventory = await self.data.MemberInventoryInfo.fetch_inventory_info(self.guildid, self.userid)
return self
class ShopItem:
"""
Base class representing a purchasable guild shop item.
In its most basic form, this is just a direct interface to the data,
with some formatting methods.
"""
def __init__(self, bot: LionBot, data: ShopData.ShopItemInfo):
self.bot = bot
self.data = data
class Shop:
"""
Base class representing a Shop for a particular Customer.
"""
# The name of this shop class, as a lazystring
_name_: LazyStr
# Store class describing the shop UI.
_store_cls_: Type['Store']
def __init__(self, bot: LionBot, shop_data: ShopData, customer: Customer):
self.bot = bot
self.data = shop_data
self.customer = customer
# A map itemid: ShopItem of items viewable by the customer
self.items = {}
def purchasable(self):
"""
Retrieve a list of items purchasable by the customer.
"""
raise NotImplementedError
async def refresh(self):
"""
Refresh the shop and customer data.
"""
raise NotImplementedError
@property
def name(self):
"""
The localised name of this shop.
Usually just a context-aware translated version of cls._name_
"""
t = self.bot.translator.t
return t(self._name_)
async def purchase(self, itemid):
"""
Have the shop customer purchase the given (global) itemid.
Checks that the item is actually purchasable by the customer.
This method must be overridden in base classes.
"""
raise NotImplementedError
def make_store(self):
"""
Initialise and return a new Store UI for this shop.
"""
return self._store_cls_(self)
class Store(ui.LeoUI):
"""
ABC for the UI used to interact with a given shop.
This must always be an ephemeral UI,
so extra permission checks are not required.
(Note that each Shop instance is specific to a single customer.)
"""
def __init__(self, shop: Shop, interaction: discord.Interaction, **kwargs):
super().__init__(**kwargs)
# The shop this Store is an interface for
# Client, shop, and customer data is taken from here
# The Shop also manages most Customer object interaction, including purchases.
self.shop = shop
# The row of Buttons used to access different shops, if any
# Transient, will be deprecated by direct access to UILayout.
self.store_row = ()
# Current embed page
self.embed: Optional[discord.Embed] = None
# Current interaction to use
self.interaction: discord.Interaction = interaction
def set_store_row(self, row):
self.store_row = row
for item in row:
self.add_item(item)
async def refresh(self):
"""
Refresh all UI elements.
"""
raise NotImplementedError
async def redraw(self):
"""
Redraw the store UI.
"""
if self.interaction.is_expired():
# This is actually possible,
# If the user keeps using the UI,
# but never closes it until the origin interaction expires
raise ValueError("This interaction has expired!")
return
if self.embed is None:
await self.refresh()
await self.interaction.edit_original_response(embed=self.embed, view=self)
async def make_embed(self):
"""
Embed page for this shop.
"""
raise NotImplementedError

View File

File diff suppressed because it is too large Load Diff

View File