rewrite: Add economy config.

This commit is contained in:
2023-05-14 12:32:21 +03:00
parent 7f79009ac7
commit e3b2906e67
5 changed files with 485 additions and 254 deletions

View File

@@ -1,265 +1,29 @@
from typing import Optional, Union
from enum import Enum
import discord
from discord.ext import commands as cmds
from discord import app_commands as appcmds
from psycopg import sql
from data import Registry, RowModel, RegisterEnum, ORDER, JOINTYPE, RawExpr
from data.columns import Integer, Bool, String, Column, Timestamp
from meta import LionCog, LionBot, LionContext
from meta import LionCog, LionBot, LionContext, conf
from meta.errors import ResponseTimedOut
from babel import LocalBabel
from data import ORDER
from core.data import CoreData
from utils.ui import LeoUI, LeoModal, Confirm, Pager
from utils.ui import Confirm, Pager
from utils.lib import error_embed, MessageArgs, utc_now
from wards import low_management
from . import babel, logger
from .data import EconomyData, TransactionType, AdminActionType
from .settings import EconomySettings
from .settingui import EconomyConfigUI
babel = LocalBabel('economy')
_, _p, _np = babel._, babel._p, babel._np
MAX_COINS = 2**16
class TransactionType(Enum):
"""
Schema
------
CREATE TYPE CoinTransactionType AS ENUM(
'REFUND',
'TRANSFER',
'SHOP_PURCHASE',
'STUDY_SESSION',
'ADMIN',
'TASKS'
);
"""
REFUND = 'REFUND',
TRANSFER = 'TRANSFER',
PURCHASE = 'SHOP_PURCHASE',
SESSION = 'STUDY_SESSION',
ADMIN = 'ADMIN',
TASKS = 'TASKS',
class AdminActionTarget(Enum):
"""
Schema
------
CREATE TYPE EconAdminTarget AS ENUM(
'ROLE',
'USER',
'GUILD'
);
"""
ROLE = 'ROLE',
USER = 'USER',
GUILD = 'GUILD',
class AdminActionType(Enum):
"""
Schema
------
CREATE TYPE EconAdminAction AS ENUM(
'SET',
'ADD'
);
"""
SET = 'SET',
ADD = 'ADD',
class EconomyData(Registry, name='economy'):
_TransactionType = RegisterEnum(TransactionType, 'CoinTransactionType')
_AdminActionTarget = RegisterEnum(AdminActionTarget, 'EconAdminTarget')
_AdminActionType = RegisterEnum(AdminActionType, 'EconAdminAction')
class Transaction(RowModel):
"""
Schema
------
CREATE TABLE coin_transactions(
transactionid SERIAL PRIMARY KEY,
transactiontype CoinTransactionType NOT NULL,
guildid BIGINT NOT NULL REFERENCES guild_config (guildid) ON DELETE CASCADE,
actorid BIGINT NOT NULL,
amount INTEGER NOT NULL,
bonus INTEGER NOT NULL,
from_account BIGINT,
to_account BIGINT,
refunds INTEGER REFERENCES coin_transactions (transactionid) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT (now() at time zone 'utc')
);
CREATE INDEX coin_transaction_guilds ON coin_transactions (guildid);
"""
_tablename_ = 'coin_transactions'
transactionid = Integer(primary=True)
transactiontype: Column[TransactionType] = Column()
guildid = Integer()
actorid = Integer()
amount = Integer()
bonus = Integer()
from_account = Integer()
to_account = Integer()
refunds = Integer()
created_at = Timestamp()
@classmethod
async def execute_transaction(
cls,
transaction_type: TransactionType,
guildid: int, actorid: int,
from_account: int, to_account: int, amount: int, bonus: int = 0,
refunds: int = None
):
transaction = await cls.create(
transactiontype=transaction_type,
guildid=guildid, actorid=actorid, amount=amount, bonus=bonus,
from_account=from_account, to_account=to_account,
refunds=refunds
)
if from_account is not None:
await CoreData.Member.table.update_where(
guildid=guildid, userid=from_account
).set(coins=(CoreData.Member.coins - (amount + bonus)))
if to_account is not None:
await CoreData.Member.table.update_where(
guildid=guildid, userid=to_account
).set(coins=(CoreData.Member.coins + (amount + bonus)))
return transaction
class ShopTransaction(RowModel):
"""
Schema
------
CREATE TABLE coin_transactions_shop(
transactionid INTEGER PRIMARY KEY REFERENCES coin_transactions (transactionid) ON DELETE CASCADE,
itemid INTEGER NOT NULL REFERENCES shop_items (itemid) ON DELETE CASCADE
);
"""
_tablename_ = 'coin_transactions_shop'
transactionid = Integer(primary=True)
itemid = Integer()
@classmethod
async def purchase_transaction(
cls,
guildid: int, actorid: int,
userid: int, itemid: int, amount: int
):
conn = await cls._connector.get_connection()
async with conn.transaction():
row = await EconomyData.Transaction.execute_transaction(
TransactionType.PURCHASE,
guildid=guildid, actorid=actorid, from_account=userid, to_account=None,
amount=amount
)
return await cls.create(transactionid=row.transactionid, itemid=itemid)
class TaskTransaction(RowModel):
"""
Schema
------
CREATE TABLE coin_transactions_tasks(
transactionid INTEGER PRIMARY KEY REFERENCES coin_transactions (transactionid) ON DELETE CASCADE,
count INTEGER NOT NULL
);
"""
_tablename_ = 'coin_transactions_tasks'
transactionid = Integer(primary=True)
count = Integer()
@classmethod
async def count_recent_for(cls, userid, guildid, interval='24h'):
"""
Retrieve the number of tasks rewarded in the last `interval`.
"""
T = EconomyData.Transaction
query = cls.table.select_where().with_no_adapter()
query.join(T, using=(T.transactionid.name, ), join_type=JOINTYPE.LEFT)
query.select(recent=sql.SQL("SUM({})").format(cls.count.expr))
query.where(
T.to_account == userid,
T.guildid == guildid,
T.created_at > RawExpr(sql.SQL("timezone('utc', NOW()) - INTERVAL {}").format(interval), ()),
)
result = await query
return result[0]['recent'] or 0
@classmethod
async def reward_completed(cls, userid, guildid, count, amount):
"""
Reward the specified member `amount` coins for completing `count` tasks.
"""
# TODO: Bonus logic, perhaps apply_bonus(amount), or put this method in the economy cog?
conn = await cls._connector.get_connection()
async with conn.transaction():
row = await EconomyData.Transaction.execute_transaction(
TransactionType.TASKS,
guildid=guildid, actorid=userid, from_account=None, to_account=userid,
amount=amount
)
return await cls.create(transactionid=row.transactionid, count=count)
class SessionTransaction(RowModel):
"""
Schema
------
CREATE TABLE coin_transactions_sessions(
transactionid INTEGER PRIMARY KEY REFERENCES coin_transactions (transactionid) ON DELETE CASCADE,
sessionid INTEGER NOT NULL REFERENCES session_history (sessionid) ON DELETE CASCADE
);
"""
_tablename_ = 'coin_transactions_sessions'
transactionid = Integer(primary=True)
sessionid = Integer()
class AdminActions(RowModel):
"""
Schema
------
CREATE TABLE economy_admin_actions(
actionid SERIAL PRIMARY KEY,
target_type EconAdminTarget NOT NULL,
action_type EconAdminAction NOT NULL,
targetid INTEGER NOT NULL,
amount INTEGER NOT NULL
);
"""
_tablename_ = 'economy_admin_actions'
actionid = Integer(primary=True)
target_type: Column[AdminActionTarget] = Column()
action_type: Column[AdminActionType] = Column()
targetid = Integer()
amount = Integer()
class AdminTransactions(RowModel):
"""
Schema
------
CREATE TABLE coin_transactions_admin_actions(
actionid INTEGER NOT NULL REFERENCES economy_admin_actions (actionid),
transactionid INTEGER NOT NULL REFERENCES coin_transactions (transactionid),
PRIMARY KEY (actionid, transactionid)
);
CREATE INDEX coin_transactions_admin_actions_transactionid ON coin_transactions_admin_actions (transactionid);
"""
_tablename_ = 'coin_transactions_admin_actions'
actionid = Integer(primary=True)
transactionid = Integer(primary=True)
class Economy(LionCog):
"""
Commands
@@ -284,13 +48,25 @@ class Economy(LionCog):
def __init__(self, bot: LionBot):
self.bot = bot
self.data = bot.db.load_registry(EconomyData())
self.settings = EconomySettings()
self.bonuses = {}
async def cog_load(self):
await self.data.init()
# ----- Economy Bonus regsitration -----
self.bot.core.guild_config.register_model_setting(self.settings.AllowTransfers)
self.bot.core.guild_config.register_model_setting(self.settings.CoinsPerXP)
configcog = self.bot.get_cog('ConfigCog')
if configcog is None:
logger.critical(
"Attempting to load the EconomyCog before ConfigCog! Failed to crossload configuration group."
)
else:
self.crossload_group(self.configure_group, configcog.configure_group)
# ----- Economy Bonus registration -----
def register_economy_bonus(self, bonus_coro, name=None):
name = name or bonus_coro.__name__
self.bonuses[name] = bonus_coro
@@ -594,10 +370,11 @@ class Economy(LionCog):
)
)
else:
await ctx.interaction.response.defer()
# Viewing route
MemModel = self.bot.core.data.Member
if role:
query = MemModel.fetch_where(
query = MemModel.table.select_where(
(MemModel.guildid == role.guild.id) & (MemModel.coins != 0)
)
query.order_by('coins', ORDER.DESC)
@@ -618,7 +395,7 @@ class Economy(LionCog):
"This server has a total balance of {coin_emoji}**{total}**."
)).format(
coin_emoji=cemoji,
total=sum(row.coins for row in rows)
total=sum(row['coins'] for row in rows)
)
else:
header = t(_p(
@@ -628,7 +405,7 @@ class Economy(LionCog):
)).format(
count=len(targets),
role_mention=role.mention,
total=sum(row.coins for row in rows),
total=sum(row['coins'] for row in rows),
coin_emoji=cemoji
)
@@ -645,13 +422,13 @@ class Economy(LionCog):
for i, block in enumerate(blocks):
lines = []
numwidth = len(str(i + len(block)))
coinwidth = len(str(max(row.coins for row in rows)))
for j, row in enumerate(block, start=i):
coinwidth = len(str(max(row['coins'] for row in rows)))
for j, row in enumerate(block, start=i*blocklen):
lines.append(
lb_format.format(
pos=j, numwidth=numwidth,
coins=row.coins, coinwidth=coinwidth,
mention=f"<@{row.userid}>"
coins=row['coins'], coinwidth=coinwidth,
mention=f"<@{row['userid']}>"
)
)
lb_block = '\n'.join(lines)
@@ -906,6 +683,18 @@ class Economy(LionCog):
return
t = self.bot.translator.t
if not ctx.lguild.config.get('allow_transfers').value:
await ctx.interaction.response.send_message(
embed=error_embed(
t(_p(
'cmd:send|error:not_allowed',
"Sorry, this server has disabled LionCoin transfers!"
))
)
)
return
Member = self.bot.core.data.Member
target_lion = await self.bot.core.lions.fetch_member(ctx.guild.id, target.id)
@@ -985,3 +774,54 @@ class Economy(LionCog):
"Unfortunately, I was not able to message the recipient. Perhaps they have me blocked?"
))
await ctx.interaction.edit_original_response(embed=embed)
# -------- Configuration Commands --------
@LionCog.placeholder_group
@cmds.hybrid_group('configure', with_app_command=False)
async def configure_group(self, ctx: LionContext):
# Placeholder group method, not used
pass
@configure_group.command(
name=_p('cmd:configure_economy', "economy"),
description=_p(
'cmd:configure_economy|desc',
"Configure LionCoin Economy"
)
)
@cmds.check(low_management)
async def configure_economy(self, ctx: LionContext,
allow_transfers: Optional[bool] = None,
coins_per_xp: Optional[appcmds.Range[int, 0, 2**15]] = None):
t = self.bot.translator.t
if not ctx.interaction:
return
if not ctx.guild:
return
setting_allow_transfers = ctx.lguild.config.get('allow_transfers')
setting_coins_per_xp = ctx.lguild.config.get('coins_per_xp')
modified = []
if allow_transfers is not None:
setting_allow_transfers.data = allow_transfers
await setting_allow_transfers.write()
modified.append(setting_allow_transfers)
if coins_per_xp is not None:
setting_coins_per_xp.data = coins_per_xp
await setting_coins_per_xp.write()
modified.append(setting_coins_per_xp)
if modified:
desc = '\n'.join(f"{conf.emojis.tick} {setting.update_message}" for setting in modified)
await ctx.reply(
embed=discord.Embed(
colour=discord.Colour.brand_green(),
description=desc
)
)
if ctx.channel.id not in EconomyConfigUI._listening or not modified:
configui = EconomyConfigUI(self.bot, ctx.guild.id, ctx.channel.id)
await configui.run(ctx.interaction)
await configui.wait()