Work on interest system

feature/currency-improvements
stijndcl 2024-03-01 14:18:58 +01:00
parent a1345f9138
commit de7b5cd960
9 changed files with 196 additions and 59 deletions

View File

@ -1,25 +1,29 @@
from datetime import date from datetime import date
from typing import Optional, Union from typing import List, Optional, Union
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database.crud import users from database.crud import users
from database.exceptions import currency as exceptions from database.exceptions import currency as exceptions
from database.schemas import Bank, NightlyData from database.schemas import Bank, BankSavings, NightlyData
from database.utils.math.currency import ( from database.utils.math.currency import (
capacity_upgrade_price, capacity_upgrade_price,
interest_rate,
interest_upgrade_price, interest_upgrade_price,
rob_upgrade_price, rob_upgrade_price,
savings_cap,
) )
__all__ = [ __all__ = [
"add_dinks", "add_dinks",
"apply_daily_interest",
"claim_nightly", "claim_nightly",
"deduct_dinks", "deduct_dinks",
"gamble_dinks", "gamble_dinks",
"get_bank", "get_bank",
"get_nightly_data", "get_nightly_data",
"invest", "save",
"upgrade_capacity", "upgrade_capacity",
"upgrade_interest", "upgrade_interest",
"upgrade_rob", "upgrade_rob",
@ -36,15 +40,30 @@ async def get_bank(session: AsyncSession, user_id: int) -> Bank:
return user.bank return user.bank
async def get_savings(session: AsyncSession, user_id: int) -> BankSavings:
"""Get a user's savings info"""
user = await users.get_or_add_user(session, user_id)
return user.savings
async def get_nightly_data(session: AsyncSession, user_id: int) -> NightlyData: async def get_nightly_data(session: AsyncSession, user_id: int) -> NightlyData:
"""Get a user's nightly info""" """Get a user's nightly info"""
user = await users.get_or_add_user(session, user_id) user = await users.get_or_add_user(session, user_id)
return user.nightly_data return user.nightly_data
async def invest(session: AsyncSession, user_id: int, amount: Union[str, int], *, bank: Optional[Bank] = None) -> int: async def save(
session: AsyncSession,
user_id: int,
amount: Union[str, int],
*,
bank: Optional[Bank] = None,
savings: Optional[BankSavings] = None
) -> int:
"""Invest some of your Dinks""" """Invest some of your Dinks"""
bank = bank or await get_bank(session, user_id) bank = bank or await get_bank(session, user_id)
savings = savings or await get_savings(session, user_id)
if amount == "all": if amount == "all":
amount = bank.dinks amount = bank.dinks
@ -54,29 +73,44 @@ async def invest(session: AsyncSession, user_id: int, amount: Union[str, int], *
# Don't allow investing more dinks than you own # Don't allow investing more dinks than you own
amount = min(bank.dinks, int(amount)) amount = min(bank.dinks, int(amount))
# Don't allow exceeding the limit
limit = savings_cap(bank.capacity_level)
if savings.saved >= limit:
raise exceptions.SavingsCapExceeded
if savings.saved + amount > limit:
amount = max(0, limit - savings.saved)
bank.dinks -= amount bank.dinks -= amount
bank.invested += amount savings.saved += amount
session.add(bank) session.add(bank)
session.add(savings)
await session.commit() await session.commit()
return amount return amount
async def withdraw(session: AsyncSession, user_id: int, amount: Union[str, int], *, bank: Optional[Bank] = None) -> int: async def withdraw(session: AsyncSession, user_id: int, amount: Union[str, int], *, bank: Optional[Bank] = None) -> int:
"""Withdraw your invested Dinks""" """Withdraw your saved Dinks"""
bank = bank or await get_bank(session, user_id) bank = bank or await get_bank(session, user_id)
savings = await get_savings(session, user_id)
if amount == "all": if amount == "all":
amount = bank.invested amount = savings.saved
# Don't allow withdrawing more dinks than you own # Don't allow withdrawing more dinks than you own
amount = min(bank.invested, int(amount)) amount = min(savings.saved, int(amount))
bank.dinks += amount bank.dinks += amount
bank.invested -= amount savings.saved -= amount
savings.daily_minimum = min(savings.daily_minimum, savings.saved)
session.add(bank) session.add(bank)
session.add(savings)
await session.commit() await session.commit()
return amount return amount
@ -218,3 +252,22 @@ async def rob(
session.add(robber) session.add(robber)
session.add(robbed) session.add(robbed)
await session.commit() await session.commit()
async def apply_daily_interest(session: AsyncSession):
"""Apply daily interest rates to all accounts with saved Dinks"""
statement = select(BankSavings)
all_savings: List[BankSavings] = list((await session.execute(statement)).scalars().all())
for savings_account in all_savings:
if savings_account.saved == 0:
continue
bank = await get_bank(session, savings_account.user_id)
rate = interest_rate(bank.interest_level)
savings_account.saved = float(savings_account.saved * rate)
savings_account.daily_minimum = savings_account.saved
session.add(savings_account)
await session.commit()

View File

@ -3,7 +3,7 @@ from typing import Optional
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database.schemas import Bank, NightlyData, User from database.schemas import Bank, BankSavings, NightlyData, User
__all__ = [ __all__ = [
"get_or_add_user", "get_or_add_user",
@ -37,8 +37,12 @@ async def get_or_add_user(session: AsyncSession, user_id: int, *, options: Optio
user.bank = bank user.bank = bank
user.nightly_data = nightly_data user.nightly_data = nightly_data
savings = BankSavings(user_id=user_id)
user.savings = savings
session.add(bank) session.add(bank)
session.add(nightly_data) session.add(nightly_data)
session.add(savings)
session.add(user) session.add(user)
await session.commit() await session.commit()

View File

@ -7,3 +7,7 @@ class DoubleNightly(Exception):
class NotEnoughDinks(Exception): class NotEnoughDinks(Exception):
"""Exception raised when trying to do something you don't have the Dinks for""" """Exception raised when trying to do something you don't have the Dinks for"""
class SavingsCapExceeded(Exception):
"""Exception raised when trying to save more Dinks than the cap allows"""

View File

@ -12,6 +12,7 @@ from database import enums
__all__ = [ __all__ = [
"Base", "Base",
"Bank", "Bank",
"BankSavings",
"Birthday", "Birthday",
"Bookmark", "Bookmark",
"CommandStats", "CommandStats",
@ -52,7 +53,6 @@ class Bank(Base):
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.user_id")) user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.user_id"))
dinks: Mapped[int] = mapped_column(BigInteger, server_default="0", nullable=False) dinks: Mapped[int] = mapped_column(BigInteger, server_default="0", nullable=False)
invested: Mapped[int] = mapped_column(BigInteger, server_default="0", nullable=False)
# Interest rate # Interest rate
interest_level: Mapped[int] = mapped_column(server_default="1", nullable=False) interest_level: Mapped[int] = mapped_column(server_default="1", nullable=False)
@ -66,6 +66,20 @@ class Bank(Base):
user: Mapped[User] = relationship(uselist=False, back_populates="bank", lazy="selectin") user: Mapped[User] = relationship(uselist=False, back_populates="bank", lazy="selectin")
class BankSavings(Base):
"""Savings information for a user's bank"""
__tablename__ = "savings"
savings_id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.user_id"))
saved: Mapped[int] = mapped_column(BigInteger, server_default="0", nullable=False)
daily_minimum: Mapped[int] = mapped_column(BigInteger, server_default="0", nullable=False)
user: Mapped[User] = relationship(uselist=False, back_populates="savings", lazy="selectin")
class Birthday(Base): class Birthday(Base):
"""A user's birthday""" """A user's birthday"""
@ -350,3 +364,6 @@ class User(Base):
reminders: Mapped[List[Reminder]] = relationship( reminders: Mapped[List[Reminder]] = relationship(
back_populates="user", uselist=True, lazy="selectin", cascade="all, delete-orphan" back_populates="user", uselist=True, lazy="selectin", cascade="all, delete-orphan"
) )
savings: Mapped[List[BankSavings]] = relationship(
back_populates="user", uselist=False, lazy="selectin", cascade="all, delete-orphan"
)

View File

@ -4,6 +4,8 @@ __all__ = [
"capacity_upgrade_price", "capacity_upgrade_price",
"interest_upgrade_price", "interest_upgrade_price",
"rob_upgrade_price", "rob_upgrade_price",
"interest_rate",
"savings_cap",
"jail_chance", "jail_chance",
"jail_time", "jail_time",
"rob_amount", "rob_amount",
@ -16,7 +18,7 @@ def interest_upgrade_price(level: int) -> int:
base_cost = 600 base_cost = 600
growth_rate = 1.8 growth_rate = 1.8
return math.floor(base_cost * (growth_rate**level)) return math.floor(base_cost * (growth_rate ** (level - 1)))
def capacity_upgrade_price(level: int) -> int: def capacity_upgrade_price(level: int) -> int:
@ -24,7 +26,7 @@ def capacity_upgrade_price(level: int) -> int:
base_cost = 800 base_cost = 800
growth_rate = 1.6 growth_rate = 1.6
return math.floor(base_cost * (growth_rate**level)) return math.floor(base_cost * (growth_rate ** (level - 1)))
def rob_upgrade_price(level: int) -> int: def rob_upgrade_price(level: int) -> int:
@ -32,7 +34,23 @@ def rob_upgrade_price(level: int) -> int:
base_cost = 950 base_cost = 950
growth_rate = 1.9 growth_rate = 1.9
return math.floor(base_cost * (growth_rate**level)) return math.floor(base_cost * (growth_rate ** (level - 1)))
def interest_rate(level: int) -> float:
"""Calculate the amount of interest you will receive"""
base_rate = 1.025
growth_rate = 0.03
return base_rate + (growth_rate * (level - 1))
def savings_cap(level: int) -> int:
"""Calculate the maximum amount you can save"""
base_limit = 1000
growth_rate = 1.10
return math.floor(base_limit * (growth_rate ** (level - 1)))
def jail_chance(level: int) -> float: def jail_chance(level: int) -> float:

View File

@ -10,16 +10,23 @@ from discord.ext import commands
import settings import settings
from database.crud import currency as crud from database.crud import currency as crud
from database.crud import users
from database.crud.jail import get_user_jail, imprison from database.crud.jail import get_user_jail, imprison
from database.exceptions.currency import DoubleNightly, NotEnoughDinks from database.exceptions.currency import (
DoubleNightly,
NotEnoughDinks,
SavingsCapExceeded,
)
from database.utils.math.currency import ( from database.utils.math.currency import (
capacity_upgrade_price, capacity_upgrade_price,
interest_rate,
interest_upgrade_price, interest_upgrade_price,
jail_chance, jail_chance,
jail_time, jail_time,
rob_amount, rob_amount,
rob_chance, rob_chance,
rob_upgrade_price, rob_upgrade_price,
savings_cap,
) )
from didier import Didier from didier import Didier
from didier.utils.discord import colours from didier.utils.discord import colours
@ -51,17 +58,20 @@ class Currency(commands.Cog):
"""Award a user `amount` Didier Dinks.""" """Award a user `amount` Didier Dinks."""
async with self.client.postgres_session as session: async with self.client.postgres_session as session:
await crud.add_dinks(session, user.id, amount) await crud.add_dinks(session, user.id, amount)
plural = pluralize("Didier Dink", amount)
await ctx.reply( plural = pluralize("Didier Dink", amount)
f"{ctx.author.display_name} has awarded **{user.display_name}** with **{amount}** {plural}.", await ctx.reply(
mention_author=False, f"{ctx.author.display_name} has awarded **{user.display_name}** with **{amount}** {plural}.",
) mention_author=False,
)
@commands.group(name="bank", aliases=["b"], case_insensitive=True, invoke_without_command=True) @commands.group(name="bank", aliases=["b"], case_insensitive=True, invoke_without_command=True)
async def bank(self, ctx: commands.Context): async def bank(self, ctx: commands.Context):
"""Show your Didier Bank information.""" """Show your Didier Bank information."""
async with self.client.postgres_session as session: async with self.client.postgres_session as session:
bank = await crud.get_bank(session, ctx.author.id) user = await users.get_or_add_user(session, ctx.author.id)
bank = user.bank
savings = user.savings
embed = discord.Embed(title="Bank of Didier", colour=discord.Colour.blue()) embed = discord.Embed(title="Bank of Didier", colour=discord.Colour.blue())
embed.set_author(name=ctx.author.display_name) embed.set_author(name=ctx.author.display_name)
@ -69,9 +79,10 @@ class Currency(commands.Cog):
if ctx.author.avatar is not None: if ctx.author.avatar is not None:
embed.set_thumbnail(url=ctx.author.avatar.url) embed.set_thumbnail(url=ctx.author.avatar.url)
embed.add_field(name="Interest level", value=bank.interest_level) embed.add_field(name="Interest rate", value=round(interest_rate(bank.interest_level), 2))
embed.add_field(name="Capacity level", value=bank.capacity_level) embed.add_field(name="Maximum capacity", value=round(savings_cap(bank.capacity_level), 2))
embed.add_field(name="Currently invested", value=bank.invested, inline=False) embed.add_field(name="Currently saved", value=savings.saved, inline=False)
embed.add_field(name="Daily minimum", value=savings.daily_minimum, inline=False)
await ctx.reply(embed=embed, mention_author=False) await ctx.reply(embed=embed, mention_author=False)
@ -135,52 +146,61 @@ class Currency(commands.Cog):
"""Check your Didier Dinks.""" """Check your Didier Dinks."""
async with ctx.typing(), self.client.postgres_session as session: async with ctx.typing(), self.client.postgres_session as session:
bank = await crud.get_bank(session, ctx.author.id) bank = await crud.get_bank(session, ctx.author.id)
plural = pluralize("Didier Dink", bank.dinks)
await ctx.reply(f"You have **{bank.dinks}** {plural}.", mention_author=False)
@commands.command(name="invest", aliases=["deposit", "dep", "i"]) # type: ignore[arg-type] plural = pluralize("Didier Dink", bank.dinks)
async def invest(self, ctx: commands.Context, amount: typing.Annotated[typing.Union[str, int], abbreviated_number]): await ctx.reply(f"You have **{bank.dinks}** {plural}.", mention_author=False)
"""Invest `amount` Didier Dinks into your bank.
@commands.command(name="save", aliases=["deposit", "dep", "s"]) # type: ignore[arg-type]
async def save(self, ctx: commands.Context, amount: typing.Annotated[typing.Union[str, int], abbreviated_number]):
"""Add `amount` Didier Dinks into your bank's savings account.
The `amount`-argument can take both raw numbers, and abbreviations of big numbers. Additionally, passing The `amount`-argument can take both raw numbers, and abbreviations of big numbers. Additionally, passing
`all` or `*` as the value will invest all of your Didier Dinks. `all` or `*` as the value will invest all of your Didier Dinks.
Example usage: Example usage:
``` ```
didier invest all didier save all
didier invest 500 didier save 500
didier invest 25k didier save 25k
didier invest 5.3b didier save 5.3b
``` ```
""" """
if isinstance(amount, int) and amount <= 0: if isinstance(amount, int) and amount <= 0:
return await ctx.reply("Amount of Didier Dinks to invest must be a strictly positive integer.") return await ctx.reply("Amount of Didier Dinks to invest must be a strictly positive integer.")
async with self.client.postgres_session as session: async with self.client.postgres_session as session:
invested = await crud.invest(session, ctx.author.id, amount) try:
plural = pluralize("Didier Dink", invested) saved = await crud.save(session, ctx.author.id, amount)
except SavingsCapExceeded:
return await ctx.reply(
"You have already exceeded the savings cap for your level. Upgrade your bank's capacity to save "
"more."
)
if invested == 0: plural = pluralize("Didier Dink", saved)
await ctx.reply("You don't have any Didier Dinks to invest.", mention_author=False)
else:
await ctx.reply(f"You have invested **{invested}** {plural}.", mention_author=False)
@commands.command(name="withdraw", aliases=["uninvest", "w"]) # type: ignore[arg-type] if saved == 0:
await ctx.reply("You don't have any Didier Dinks to invest.", mention_author=False)
else:
await ctx.reply(f"You have saved **{saved}** {plural}.", mention_author=False)
@commands.command(name="withdraw", aliases=["undeposit", "unsave", "w"]) # type: ignore[arg-type]
async def withdraw( async def withdraw(
self, ctx: commands.Context, amount: typing.Annotated[typing.Union[str, int], abbreviated_number] self, ctx: commands.Context, amount: typing.Annotated[typing.Union[str, int], abbreviated_number]
): ):
"""Withdraw some of your invested Didier Dinks from your bank.""" """Withdraw some of your Didier Dinks from your bank's savings account."""
if isinstance(amount, int) and amount <= 0: if isinstance(amount, int) and amount <= 0:
return await ctx.reply("Amount of Didier Dinks to invest must be a strictly positive integer.") return await ctx.reply("Amount of Didier Dinks to invest must be a strictly positive integer.")
async with self.client.postgres_session as session: async with self.client.postgres_session as session:
withdrawn = await crud.withdraw(session, ctx.author.id, amount) withdrawn = await crud.withdraw(session, ctx.author.id, amount)
plural = pluralize("Didier Dink", withdrawn)
if withdrawn == 0: plural = pluralize("Didier Dink", withdrawn)
await ctx.reply("You don't have any Didier Dinks to withdraw.", mention_author=False)
else: if withdrawn == 0:
await ctx.reply(f"You have withdrawn **{withdrawn}** {plural}.", mention_author=False) await ctx.reply("You don't have any Didier Dinks to withdraw.", mention_author=False)
else:
await ctx.reply(f"You have withdrawn **{withdrawn}** {plural}.", mention_author=False)
@commands.hybrid_command(name="nightly") # type: ignore[arg-type] @commands.hybrid_command(name="nightly") # type: ignore[arg-type]
async def nightly(self, ctx: commands.Context): async def nightly(self, ctx: commands.Context):

View File

@ -10,6 +10,7 @@ from overrides import overrides
import settings import settings
from database import enums from database import enums
from database.crud.birthdays import get_birthdays_on_day from database.crud.birthdays import get_birthdays_on_day
from database.crud.currency import apply_daily_interest
from database.crud.reminders import get_all_reminders_for_category from database.crud.reminders import get_all_reminders_for_category
from database.crud.ufora_announcements import remove_old_announcements from database.crud.ufora_announcements import remove_old_announcements
from database.schemas import Reminder from database.schemas import Reminder
@ -77,6 +78,7 @@ class Tasks(commands.Cog):
# Start other tasks # Start other tasks
self.reminders.start() self.reminders.start()
self.daily_interest.start()
asyncio.create_task(self.get_error_channel()) asyncio.create_task(self.get_error_channel())
@overrides @overrides
@ -318,11 +320,18 @@ class Tasks(commands.Cog):
async with self.client.postgres_session as session: async with self.client.postgres_session as session:
await remove_old_announcements(session) await remove_old_announcements(session)
@tasks.loop(time=DAILY_RESET_TIME)
async def daily_interest(self):
"""Give everyone's daily interest"""
async with self.client.postgres_session as session:
await apply_daily_interest(session)
@check_birthdays.error @check_birthdays.error
@pull_schedules.error @pull_schedules.error
@pull_ufora_announcements.error @pull_ufora_announcements.error
@reminders.error @reminders.error
@remove_old_ufora_announcements.error @remove_old_ufora_announcements.error
@daily_interest.error
async def _on_tasks_error(self, error: BaseException): async def _on_tasks_error(self, error: BaseException):
"""Error handler for all tasks""" """Error handler for all tasks"""
self.client.dispatch("task_error", error) self.client.dispatch("task_error", error)

View File

@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from database.crud import users from database.crud import users
from database.schemas import ( from database.schemas import (
Bank, Bank,
BankSavings,
UforaAnnouncement, UforaAnnouncement,
UforaCourse, UforaCourse,
UforaCourseAlias, UforaCourseAlias,
@ -38,6 +39,14 @@ async def bank(postgres: AsyncSession, user: User) -> Bank:
return _bank return _bank
@pytest.fixture
async def savings(postgres: AsyncSession, user: User) -> BankSavings:
"""Fixture to fetch the test user's savings account"""
_savings = user.savings
await postgres.refresh(_savings)
return _savings
@pytest.fixture @pytest.fixture
async def ufora_course(postgres: AsyncSession) -> UforaCourse: async def ufora_course(postgres: AsyncSession) -> UforaCourse:
"""Fixture to create a course""" """Fixture to create a course"""

View File

@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from database.crud import currency as crud from database.crud import currency as crud
from database.exceptions import currency as exceptions from database.exceptions import currency as exceptions
from database.schemas import Bank from database.schemas import Bank, BankSavings
async def test_add_dinks(postgres: AsyncSession, bank: Bank): async def test_add_dinks(postgres: AsyncSession, bank: Bank):
@ -40,40 +40,43 @@ async def test_claim_nightly_unavailable(postgres: AsyncSession, bank: Bank):
assert bank.dinks == crud.NIGHTLY_AMOUNT assert bank.dinks == crud.NIGHTLY_AMOUNT
async def test_invest(postgres: AsyncSession, bank: Bank): async def test_save(postgres: AsyncSession, bank: Bank, savings: BankSavings):
"""Test investing some Dinks""" """Test saving some Dinks"""
bank.dinks = 100 bank.dinks = 100
postgres.add(bank) postgres.add(bank)
await postgres.commit() await postgres.commit()
await crud.invest(postgres, bank.user_id, 20) await crud.save(postgres, bank.user_id, 20, bank=bank, savings=savings)
await postgres.refresh(bank) await postgres.refresh(bank)
await postgres.refresh(savings)
assert bank.dinks == 80 assert bank.dinks == 80
assert bank.invested == 20 assert savings.saved == 20
async def test_invest_all(postgres: AsyncSession, bank: Bank): async def test_save_all(postgres: AsyncSession, bank: Bank, savings: BankSavings):
"""Test investing all dinks""" """Test saving all dinks"""
bank.dinks = 100 bank.dinks = 100
postgres.add(bank) postgres.add(bank)
await postgres.commit() await postgres.commit()
await crud.invest(postgres, bank.user_id, "all") await crud.save(postgres, bank.user_id, "all", bank=bank, savings=savings)
await postgres.refresh(bank) await postgres.refresh(bank)
await postgres.refresh(savings)
assert bank.dinks == 0 assert bank.dinks == 0
assert bank.invested == 100 assert savings.saved == 100
async def test_invest_more_than_owned(postgres: AsyncSession, bank: Bank): async def test_save_more_than_owned(postgres: AsyncSession, bank: Bank, savings: BankSavings):
"""Test investing more Dinks than you own""" """Test saving more Dinks than you own"""
bank.dinks = 100 bank.dinks = 100
postgres.add(bank) postgres.add(bank)
await postgres.commit() await postgres.commit()
await crud.invest(postgres, bank.user_id, 200) await crud.save(postgres, bank.user_id, 200, bank=bank, savings=savings)
await postgres.refresh(bank) await postgres.refresh(bank)
await postgres.refresh(savings)
assert bank.dinks == 0 assert bank.dinks == 0
assert bank.invested == 100 assert savings.saved == 100