rewrite: Shop and economy system.
This commit is contained in:
@@ -1,3 +1,66 @@
|
||||
"""
|
||||
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
|
||||
@@ -7,12 +70,13 @@ 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.colours import ColourShop
|
||||
from .shops.base import Customer, ShopCog
|
||||
from .data import ShopData
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -20,90 +84,163 @@ _p = babel._p
|
||||
|
||||
|
||||
class Shopping(LionCog):
|
||||
Shops = [ColourShop]
|
||||
# List of active Shop cogs
|
||||
ShopCogs = ShopCog.active
|
||||
|
||||
def __init__(self, bot: LionBot):
|
||||
self.bot = bot
|
||||
self.data = None
|
||||
self.shops = []
|
||||
self.data = bot.db.load_registry(ShopData())
|
||||
self.active_cogs = []
|
||||
|
||||
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)
|
||||
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='editshop')
|
||||
@cmds.hybrid_group(
|
||||
name=_p('group:editshop', 'editshop')
|
||||
)
|
||||
async def editshop_group(self, ctx: LionContext):
|
||||
return
|
||||
|
||||
@cmds.hybrid_group(name='shop')
|
||||
@cmds.hybrid_group(
|
||||
name=_p('group:shop', 'shop')
|
||||
)
|
||||
async def shop_group(self, ctx: LionContext):
|
||||
return
|
||||
|
||||
@shop_group.command(name='open')
|
||||
@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, shops):
|
||||
def __init__(self, bot, data, stores, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.bot = bot
|
||||
self.data = data
|
||||
self.shops = shops
|
||||
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()
|
||||
self._widgets = self.prepare_widgets()
|
||||
|
||||
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, return an empty tuple.
|
||||
If there is only one shop, returns 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)
|
||||
t = self.bot.translator.t
|
||||
|
||||
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
|
||||
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
|
||||
...
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from enums import Enum
|
||||
from enum import Enum
|
||||
from cachetools import TTLCache
|
||||
|
||||
|
||||
@@ -59,6 +59,35 @@ class ShopData(Registry, name='shop'):
|
||||
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
|
||||
@@ -80,8 +109,46 @@ class ShopData(Registry, name='shop'):
|
||||
transactionid = Integer()
|
||||
itemid = Integer()
|
||||
|
||||
async def fetch_inventory(self, guildid, userid) -> list['ShopData.MemberInventory']:
|
||||
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 given member's inventory.
|
||||
Fetch the information rows for the given members inventory.
|
||||
"""
|
||||
return await self.fetch_where(guildid=guildid, userid=userid)
|
||||
return await cls.fetch_where(guildid=guildid, userid=userid)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import base
|
||||
from . import colours
|
||||
|
||||
@@ -1,81 +1,225 @@
|
||||
from typing import Type, TYPE_CHECKING
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
import discord
|
||||
from discord.ui.button import Button
|
||||
|
||||
from meta import LionBot
|
||||
|
||||
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 MemberInventory:
|
||||
|
||||
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.
|
||||
"""
|
||||
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
|
||||
_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.fetch_where(guildid=guildid, userid=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 = 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)
|
||||
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:
|
||||
"""
|
||||
ABC representing a purchasable guild shop item.
|
||||
"""
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
Base class representing a purchasable guild shop item.
|
||||
|
||||
async def purchase(self, userid):
|
||||
"""
|
||||
Called when a member purchases this 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 member.
|
||||
Base class representing a Shop for a particular Customer.
|
||||
"""
|
||||
def __init__(self, bot: LionBot, shop_data: ShopData, member: discord.Member):
|
||||
# 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.member = member
|
||||
self.guild = member.guild
|
||||
self.customer = customer
|
||||
|
||||
# A list of ShopItems that are currently visible to the member
|
||||
self.items = []
|
||||
# A map itemid: ShopItem of items viewable by the customer
|
||||
self.items = {}
|
||||
|
||||
# Current inventory for the member
|
||||
self.inventory = None
|
||||
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):
|
||||
"""
|
||||
Base UI for the different shops.
|
||||
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, bot: LionBot, data, shops):
|
||||
self.bot = bot
|
||||
self.data = data
|
||||
self.shops = shops
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user