rewrite: Shop and economy system.

This commit is contained in:
2022-12-17 19:29:20 +02:00
parent 4ef1b58007
commit 4014e0a3a6
20 changed files with 1622 additions and 1178 deletions

View File

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

View File

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

View File

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

View File

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