(Economy): Base shop system.

This commit is contained in:
2021-09-15 01:00:10 +03:00
parent 595c1307ca
commit b25380d072
9 changed files with 751 additions and 2 deletions

View File

@@ -2,3 +2,4 @@ from .module import module
from . import cointop_cmd
from . import send_cmd
from . import shop_cmds

View File

@@ -0,0 +1,215 @@
import asyncio
import discord
from collections import defaultdict
from cmdClient.checks import in_guild
from .module import module
from .shop_core import ShopItem
class ShopSession:
__slots__ = (
'key', 'response',
'_event', '_task'
)
_sessions = {}
def __init__(self, userid, channelid):
# Build unique context key for shop session
self.key = (userid, channelid)
self.response = None
# Cancel any existing sessions
if self.key in self._sessions:
self._sessions[self.key].cancel()
self._event = asyncio.Event()
self._task = None
# Add self to the session list
self._sessions[self.key] = self
@classmethod
def get(cls, userid, channelid) -> 'ShopSession':
"""
Get a ShopSession matching the given key, if it exists.
Otherwise, returns None.
"""
return cls._sessions.get((userid, channelid), None)
async def wait(self, timeout=None):
"""
Wait for a buy response. Return the set response or raise an appropriate exception.
"""
self._task = asyncio.create_task(self._event.wait())
try:
await asyncio.wait_for(self._task, timeout=timeout)
except asyncio.CancelledError:
# Session was cancelled, likely due to creation of a new session
raise
except asyncio.TimeoutError:
# Session timed out, likely due to reaching the timeout
raise
finally:
if self._sessions.get(self.key, None) == self:
self._sessions.pop(self.key, None)
return self.response
def set(self, response):
"""
Set response.
"""
self.response = response
self._event.set()
def cancel(self):
"""
Cancel a session.
"""
if self._task:
if self._sessions.get(self.key, None) == self:
self._sessions.pop(self.key, None)
self._task.cancel()
else:
raise ValueError("Cancelling a ShopSession that is already completed!")
@module.cmd(
'shop',
group="Economy",
desc="Open the guild shop.",
flags=('add', 'remove', 'clear')
)
@in_guild()
async def cmd_shop(ctx, flags):
"""
Usage``:
{prefix}shop
{prefix}shop <category>
Description:
Opens the guild shop.
"""
# TODO: (FUTURE) Register session (and cancel previous sessions) so we can track for buy
# Fetch all purchasable elements, this also populates the cache
shop_items = ShopItem.fetch_where(guildid=ctx.guild.id, deleted=False, purchasable=True)
if not shop_items:
# TODO: (FUTURE) Add tip for guild admins about setting up
return await ctx.error_reply(
"Nothing to buy! Please come back later."
)
# Categorise items
item_cats = defaultdict(list)
for item in shop_items:
item_cats[item.item_type].append(item)
# If there is more than one category, ask for which category they want
item_type = None
if len(item_cats) > 1:
# TODO
item_type = ...
...
else:
item_type = next(iter(item_cats))
if item_type is not None:
items = [item for item in item_cats[item_type]]
embeds = items[0].cat_shop_embeds(
ctx.guild.id,
[item.itemid for item in items]
)
# Present shop pages
out_msg = await ctx.pager(embeds, add_cancel=True)
await ctx.cancellable(out_msg, add_reaction=False)
while True:
try:
response = await ShopSession(ctx.author.id, ctx.ch.id).wait(timeout=300)
except asyncio.CancelledError:
# User opened a new session
break
except asyncio.TimeoutError:
# Session timed out. Close the shop.
# TODO: (FUTURE) time out shop session by removing hint.
try:
embed = discord.Embed(
colour=discord.Colour.red(),
description="Shop closed! (Session timed out.)"
)
await out_msg.edit(
embed=embed
)
except discord.HTTPException:
pass
break
# Selection was made
# Sanity checks
# TODO: (FUTURE) Handle more flexible ways of selecting items
if int(response.args) >= len(items):
await response.error_reply(
"No item with this number exists! Please try again."
)
continue
item = items[int(response.args)]
if item.price > ctx.alion.coins:
await response.error_reply(
"Sorry, {} costs `{}` LionCoins, but you only have `{}`!".format(
item.display_name,
item.price,
ctx.alion.coins
)
)
continue
# Run the selection and keep the shop open in case they want to buy more.
# TODO: (FUTURE) The item may no longer exist
success = await item.buy(response)
if success and not item.allow_multi_select:
try:
await out_msg.delete()
except discord.HTTPException:
pass
break
@module.cmd(
'buy',
group="Hidden",
desc="Buy an item from the guild shop."
)
@in_guild()
async def cmd_buy(ctx):
"""
Usage``:
{prefix}buy <number>
Description:
Only usable while you have a shop open (see `{prefix}shop`).
Buys the selected item from the shop.
"""
# Check relevant session exists
session = ShopSession.get(ctx.author.id, ctx.ch.id)
if session is None:
return await ctx.error_reply(
"No shop open, nothing to buy!\n"
"Please open a shop with `{prefix}shop` first.".format(prefix=ctx.best_prefix)
)
# Check input is an integer
if not ctx.args.isdigit():
return await ctx.error_reply(
"**Usage:** `{prefix}buy <number>`, for example `{prefix}buy 1`.".format(prefix=ctx.best_prefix)
)
# Pass context back to session
session.set(ctx)
# TODO: (FUTURE) Buy command short-circuiting the shop command and acting on shop sessions
# TODO: (FUTURE) Inventory command showing the user's purchases

View File

@@ -0,0 +1,173 @@
from typing import List
import datetime
import discord
from cmdClient.lib import SafeCancellation
from meta import client
from .ShopItem import ShopItem, ShopItemType
from .data import shop_items, shop_item_info, colour_roles
@ShopItem.register_item_class
class ColourRole(ShopItem):
item_type = ShopItemType.COLOUR_ROLE
allow_multi_select = False
buy_hint = (
"Buy a colour by typing, e.g.,`{prefix}buy 0`.\n"
"**Note: You may only own one colour at a time!**"
).format(prefix=client.prefix)
@property
def display_name(self):
return "<@&{}>".format(self.data.roleid)
@property
def roleid(self) -> int:
return self.data.roleid
@classmethod
async def create(cls, guildid, price, roleid, **kwargs):
"""
Create a new ColourRole item.
"""
shop_row = shop_items.insert(
guildid=guildid,
item_type=cls.item_type,
price=price
)
colour_roles.insert(
itemid=shop_row['itemid'],
roleid=roleid
)
return cls(shop_row['itemid'])
# Shop interface
@classmethod
def _cat_shop_embed_items(cls, items: List['ColourRole'], **kwargs) -> List[discord.Embed]:
"""
Embed a list of items specifically for displaying in the shop.
Subclasses will usually extend or override this, if only to add metadata.
"""
if items:
# TODO: prefix = items[0].guild_settings.prefix.value
embeds = cls._cat_embed_items(items, **kwargs)
for embed in embeds:
embed.title = "{} shop!".format(cls.item_type.desc)
embed.description += "\n\n" + cls.buy_hint
else:
embed = discord.Embed(
title="{} shop!".format(cls.item_type.desc),
description="No colours available at the moment! Please come back later."
)
embeds = [embed]
return embeds
async def buy(self, ctx):
"""
Action when a member buys a color role.
Uses Discord as the source of truth (rather than the future inventory).
Removes any existing colour roles, and adds the purchased role.
Also notifies the user and logs the transaction.
"""
# TODO: Exclusivity should be handled at a higher level
# TODO: All sorts of error handling
member = ctx.author
# Fetch the set colour roles
colour_rows = shop_item_info.fetch_rows_where(
guildid=self.guildid,
item_type=self.item_type
)
roleids = (row.roleid for row in colour_rows)
roles = (self.guild.get_role(roleid) for roleid in roleids)
roles = set(role for role in roles if role)
# Compute the roles to add and remove
to_add = self.guild.get_role(self.roleid)
member_has = roles.intersection(member.roles)
if to_add in member_has:
await ctx.error_reply("You already have this colour!")
return False
to_remove = list(member_has)
# Role operations
if to_add:
try:
await member.add_roles(to_add, reason="Updating purchased colour role")
except discord.HTTPException:
# TODO: Add to log
to_add = None
pass
if to_remove:
try:
await member.remove_roles(*to_remove, reason="Updating purchased colour role")
except discord.HTTPException:
# TODO: Add to log
pass
# Only charge the member if everything went well
if to_add:
ctx.alion.addCoins(-self.price)
# Build strings for logging and response
desc = None # Description of reply message to the member
log_str = None # Description of event log message
log_error = False # Whether to log an error
if to_add:
if to_remove:
if len(to_remove) > 1:
rem_str = ', '.join(role.mention for role in to_remove[:-1]) + 'and' + to_remove[-1].mention
else:
rem_str = to_remove[0].mention
desc = "You have exchanged {} for {}. Enjoy!".format(rem_str, to_add.mention)
log_str = "{} exchanged {} for {}.".format(
member.mention,
rem_str,
to_add.mention
)
else:
desc = "You have bought {}. Enjoy!".format(to_add.mention)
log_str = "{} bought {}.".format(member.mention, to_add.mention)
else:
desc = "Something went wrong! Please try again later."
log_str = "{} bought `{}`, but I couldn't add the role!".format(member.mention, self.roleid)
log_error = True
# Build and send embeds
reply_embed = discord.Embed(
colour=to_add.colour if to_add else discord.Colour.red(),
description=desc,
timestamp=datetime.datetime.utcnow()
)
if to_add:
reply_embed.set_footer(
text="New Balance: {} LC".format(ctx.alion.coins)
)
log_embed = discord.Embed(
title="Colour Role Purchased" if not log_error else "Error purchasing colour role.",
colour=discord.Colour.red() if log_error else discord.Colour.orange(),
description=log_str
)
try:
await ctx.reply(embed=reply_embed)
except discord.HTTPException:
pass
event_log = ctx.guild_settings.event_log.value
if event_log:
try:
await event_log.send(embed=log_embed)
except discord.HTTPException:
pass
if not to_add:
return False
return True

View File

@@ -0,0 +1,265 @@
import discord
import datetime
from typing import List
from meta import client
from utils.lib import FieldEnum
from data import Row
from settings import GuildSettings
from .data import shop_items, shop_item_info
class ShopItemType(FieldEnum):
COLOUR_ROLE = 'COLOUR_ROLE', 'Colour'
class ShopItem:
"""
Abstract base class representing an item in a guild shop.
"""
__slots__ = ('itemid', '_guild')
# Mapping of item types to class handlers
_item_classes = {} # ShopItemType -> ShopItem subclass
# Item type handled by the current subclass
item_type = None # type: ShopItemType
# Format string to use for each item of this type in the shop embed
shop_fmt = "`[{num:<{num_len}}]` | `{item.price:<{price_len}}` {item.display_name}"
# Shop input modifiers
allow_multi_select = True
buy_hint = None
def __init__(self, itemid, *args, **kwargs):
self.itemid = itemid # itemid in shop_items representing this info
self._guild = None # cached Guild
# Meta
@classmethod
def register_item_class(cls, itemcls):
"""
Decorator to register a class as a handler for a given item type.
The item type must be set as the `item_type` class attribute.
"""
cls._item_classes[itemcls.item_type] = itemcls
return itemcls
@classmethod
async def create(cls, guildid, price, *args, **kwargs):
"""
Create a new ShopItem of this type.
Must be implemented by each item class.
"""
raise NotImplementedError
@classmethod
def fetch_where(cls, **kwargs):
"""
Fetch ShopItems matching the given conditions.
Automatically filters by `item_type` if set in class, and not provided.
"""
if cls.item_type is not None and 'item_type' not in kwargs:
kwargs['item_type'] = cls.item_type
rows = shop_item_info.fetch_rows_where(**kwargs)
return [
cls._item_classes[ShopItemType(row.item_type)](row.itemid)
for row in rows
]
@classmethod
def fetch(cls, itemid):
"""
Fetch a single ShopItem by itemid.
"""
row = shop_item_info.fetch(itemid)
if row:
return cls._item_classes[ShopItemType(row.item_type)](row.itemid)
else:
return None
# Data and transparent data properties
@property
def data(self) -> Row:
"""
Return the cached data row for this item.
This is not guaranteed to be up to date.
This is also not guaranteed to keep existing during a session.
"""
return shop_item_info.fetch(self.itemid)
@property
def guildid(self) -> int:
return self.data.guildid
@property
def price(self) -> int:
return self.data.price
@property
def purchasable(self) -> bool:
return self.data.purchasable
# Computed properties
@property
def guild(self) -> discord.Guild:
if not self._guild:
self._guild = client.get_guild(self.guildid)
return self._guild
@property
def guild_settings(self) -> GuildSettings:
return GuildSettings(self.guildid)
# Display properties
@property
def display_name(self) -> str:
"""
Short name to display after purchasing the item, and by default in the shop.
"""
raise NotImplementedError
# Data manipulation methods
def refresh(self) -> Row:
"""
Refresh the stored data row.
"""
shop_item_info.row_cache.pop(self.itemid, None)
return self.data
def _update(self, **kwargs):
"""
Updates the data with the provided kwargs.
Subclasses are expected to override this is they provide their own updatable data.
This method does *not* refresh the data row. This is expect to be handled by `update`.
"""
handled = ('price', 'purchasable')
update = {key: kwargs[key] for key in handled}
if update:
shop_items.update_where(
update,
itemid=self.itemid
)
async def update(self, **kwargs):
"""
Update the shop item with the given kwargs.
"""
self._update()
self.refresh()
# Formatting
@classmethod
def _cat_embed_items(cls, items: List['ShopItem'], blocksize: int = 20,
fmt: str = shop_fmt, **kwargs) -> List[discord.Embed]:
"""
Build a list of embeds for the current item type from a list of items.
These embeds may be used anywhere multiple items may be shown,
including confirmations and shop pages.
Subclasses may extend or override.
"""
embeds = []
if items:
# Cut into blocks
item_blocks = [items[i:i+blocksize] for i in range(0, len(items), blocksize)]
for i, item_block in enumerate(item_blocks):
# Compute lengths
num_len = len(str((i * blocksize + len(item_block) - 1)))
max_price = max(item.price for item in item_block)
price_len = len(str(max_price))
# Format items
string_block = '\n'.join(
fmt.format(
item=item,
num=i * blocksize + j,
num_len=num_len,
price_len=price_len
) for j, item in enumerate(item_block)
)
# Build embed
embed = discord.Embed(
description=string_block,
timestamp=datetime.datetime.utcnow()
)
if len(item_blocks) > 1:
embed.set_footer(text="Page {}/{}".format(i+1, len(item_blocks)))
embeds.append(embed)
else:
# Empty shop case, should generally be avoided
embed = discord.Embed(
description="Nothing to show!"
)
embeds.append(embed)
return embeds
# Shop interface
@classmethod
def _cat_shop_embed_items(cls, items: List['ShopItem'], **kwargs) -> List[discord.Embed]:
"""
Embed a list of items specifically for displaying in the shop.
Subclasses will usually extend or override this, if only to add metadata.
"""
if items:
# TODO: prefix = items[0].guild_settings.prefix.value
prefix = client.prefix
embeds = cls._cat_embed_items(items, **kwargs)
for embed in embeds:
embed.title = "{} shop!".format(cls.item_type.desc)
embed.description = "{}\n\n{}".format(
embed.description,
"Buy items with `{prefix}buy <numbers>`, e.g. `{prefix}buy 1, 2, 3`.".format(
prefix=prefix
)
)
else:
embed = discord.Embed(
title="{} shop!".format(cls.item_type.desc),
description="This shop is empty! Please come back later."
)
embeds = [embed]
return embeds
@classmethod
def cat_shop_embeds(cls, guildid: int, itemids: List[int] = None, **kwargs) -> List[discord.Embed]:
"""
Format the items of this type (i.e. this category) as one or more embeds.
Subclasses may extend or override.
"""
if itemids is None:
# Get itemids if not provided
# TODO: Not using the row cache here, make sure we don't need an extended caching form
rows = shop_item_info.fetch_rows_where(
guildid=guildid,
item_type=cls.item_type,
purchasable=True,
deleted=False
)
itemids = [row.itemid for row in rows]
elif not all(itemid in shop_item_info.row_cache for itemid in itemids):
# Ensure cache is populated
shop_item_info.fetch_rows_where(itemid=itemids)
return cls._cat_shop_embed_items([cls(itemid) for itemid in itemids])
async def buy(self, ctx):
"""
Action to trigger when a member buys this item.
"""
raise NotImplementedError
# Shop admin interface
@classmethod
async def parse_new(self, ctx):
"""
Parse new shop items.
"""
raise NotImplementedError

View File

@@ -0,0 +1,4 @@
from . import data
from .ShopItem import ShopItem
from .ColourRole import ColourRole

View File

@@ -0,0 +1,19 @@
from cachetools import LRUCache
from data import Table, RowTable
shop_items = Table('shop_items')
colour_roles = Table('shop_items_colour_roles', attach_as='colour_roles')
shop_item_info = RowTable(
'shop_item_info',
('itemid',
'guildid', 'item_type', 'price', 'purchasable', 'deleted', 'created_at',
'roleid', # Colour roles
),
'itemid',
cache=LRUCache(1000)
)

View File

View File

@@ -1,8 +1,10 @@
import datetime
import iso8601
import re
from enum import Enum
import discord
from psycopg2.extensions import QuotedString
from cmdClient.lib import SafeCancellation
@@ -487,3 +489,24 @@ class DotDict(dict):
__getattr__ = dict.get
__setattr__ = dict.__setitem__
__delattr__ = dict.__delitem__
class FieldEnum(str, Enum):
"""
String enum with description conforming to the ISQLQuote protocol.
Allows processing by psycog
"""
def __new__(cls, value, desc):
obj = str.__new__(cls, value)
obj._value_ = value
obj.desc = desc
return obj
def __repr__(self):
return '<%s.%s>' % (self.__class__.__name__, self.name)
def __bool__(self):
return True
def __conform__(self, proto):
return QuotedString(self.value)

View File

@@ -79,7 +79,6 @@ CREATE TABLE workout_sessions(
CREATE INDEX workout_sessions_members ON workout_sessions (guildid, userid);
-- }}}
-- Tasklist data {{{
CREATE TABLE tasklist(
taskid SERIAL PRIMARY KEY,
@@ -132,6 +131,56 @@ CREATE VIEW study_badge_roles AS
ORDER BY guildid, required_time ASC;
-- }}}
-- Shop data {{{
CREATE TYPE ShopItemType AS ENUM (
'COLOUR_ROLE'
);
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);
CREATE TABLE shop_items_colour_roles(
itemid INTEGER PRIMARY KEY REFERENCES shop_items(itemid) ON DELETE CASCADE,
roleid BIGINT NOT NULL
);
CREATE TABLE member_inventory(
guildid BIGINT NOT NULL,
userid BIGINT NOT NULL,
itemid INTEGER NOT NULL REFERENCES shop_items(itemid) ON DELETE CASCADE,
count INTEGER DEFAULT 1,
PRIMARY KEY(guildid, userid)
);
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;
/*
-- Shop config, not implemented
CREATE TABLE guild_shop_config(
guildid BIGINT PRIMARY KEY
);
CREATE TABLE guild_colourroles_config(
);
*/
-- }}}
-- Moderation data {{{
CREATE TABLE video_channels(
guildid BIGINT NOT NULL,
@@ -234,4 +283,4 @@ CREATE VIEW new_study_badges AS
ORDER BY guildid;
-- }}}
-- vim: set fdm=marker
-- vim: set fdm=marker: