diff --git a/commands/economy_support_commands.py b/commands/economy_support_commands.py index 60862ae..79157fc 100644 --- a/commands/economy_support_commands.py +++ b/commands/economy_support_commands.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from collections.abc import Callable import discord @@ -322,6 +323,46 @@ def register_economy_support_commands( msg = S.VANITY_UI["equipped"].format(emoji=v["emoji"], title=v["title"]) await interaction.response.send_message(msg) + # -- /lootbox ----------------------------------------------------------- + @tree.command(name="lootbox", description=S.CMD["lootbox"]) + async def cmd_lootbox(interaction: discord.Interaction): + res = await economy.do_open_lootbox(interaction.user.id) + if not res["ok"]: + if res["reason"] == "db_error": + await reply_db_error(interaction) + elif res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + else: + await interaction.response.send_message( + S.ERR["broke_need"].format(need=coin(res["need"])), ephemeral=True + ) + return + + # Suspense: show the box opening, then reveal the reward. + await interaction.response.send_message( + embed=discord.Embed( + title=S.LOOTBOX_UI["title"], description=S.LOOTBOX_UI["opening"], color=0xF4C430 + ) + ) + msg = await interaction.original_response() + await asyncio.sleep(1.2) + + if res["buff_kind"]: + line = S.LOOTBOX_UI["buff_" + res["buff_kind"]].format(min=res["buff_min"]) + foot = S.LOOTBOX_UI["foot_buff"].format(balance=coin(res["balance"])) + color = 0x5865F2 + else: + line = S.LOOTBOX_UI[res["outcome"]].format(coins=coin(res["reward_coins"])) + if res["net"] >= 0: + foot = S.LOOTBOX_UI["foot_win"].format(net=coin(res["net"]), balance=coin(res["balance"])) + color = 0x57F287 + else: + foot = S.LOOTBOX_UI["foot_loss"].format(net=coin(abs(res["net"])), balance=coin(res["balance"])) + color = 0xF4C430 if res["outcome"] != "coins_small" else 0xED4245 + embed = discord.Embed(title=S.LOOTBOX_UI["title"], description=line, color=color) + embed.set_footer(text=foot) + await msg.edit(embed=embed) + class RemindersSelect(discord.ui.Select): def __init__(self, user_id: int, current: list[str]): self.user_id = user_id diff --git a/core/economy/__init__.py b/core/economy/__init__.py index be93144..4a89445 100644 --- a/core/economy/__init__.py +++ b/core/economy/__init__.py @@ -20,6 +20,7 @@ from .levels import * from .shop import * from .consumables import * from .vanity import * +from .lootbox import * from .fishing import * from .quests import * from .quests import _ensure_quests, _pick_quests, _quest_view @@ -33,5 +34,5 @@ from .admin import * from . import ( # noqa: E402 (submodules addressable as economy.store etc.) admin, consumables, fishing, gambling, heist, house, income, jail, - leaderboards, levels, prestige, quests, shop, store, vanity, + leaderboards, levels, lootbox, prestige, quests, shop, store, vanity, ) diff --git a/core/economy/consumables.py b/core/economy/consumables.py index 74e7150..753ad94 100644 --- a/core/economy/consumables.py +++ b/core/economy/consumables.py @@ -31,6 +31,7 @@ __all__ = [ "buff_remaining", "earn_mult", "exp_buff_mult", + "grant_buff", "do_buy_consumable", ] @@ -116,6 +117,19 @@ def exp_buff_mult(user) -> float: return _BUFF_MULT["exp"] if "exp" in active_buffs(user) else 1.0 +def grant_buff(user, kind: str, duration_min: int) -> bool: + """Add/extend a timed buff of `kind` on `user` in place. Stacks: extends from + the current expiry if still active, else starts now. Returns True if it + extended an existing buff. Pure - safe under a user lock (caller commits).""" + buffs = dict(user.get("active_buffs") or {}) + current = _parse_dt(buffs.get(kind)) + extended = current is not None and current > _now() + start = current if extended else _now() + buffs[kind] = (start + timedelta(minutes=duration_min)).isoformat() + user["active_buffs"] = buffs + return extended + + # --------------------------------------------------------------------------- # /consumables purchase # --------------------------------------------------------------------------- @@ -141,13 +155,7 @@ async def do_buy_consumable(user_id: int, cons_id: str) -> dict: for field in _COOLDOWN_FIELDS: user[field] = None else: - buffs = dict(user.get("active_buffs") or {}) - current = _parse_dt(buffs.get(cons["kind"])) - # Stack: extend from the current expiry if still active, else from now. - extended = current is not None and current > _now() - start = current if extended else _now() - buffs[cons["kind"]] = (start + timedelta(minutes=cons["duration_min"])).isoformat() - user["active_buffs"] = buffs + extended = grant_buff(user, cons["kind"], cons["duration_min"]) await _commit(user_id, user) _txn("BUY_CONSUMABLE", user=user_id, item=cons_id, cost=f"-{cons['cost']}", bal=user["balance"]) diff --git a/core/economy/lootbox.py b/core/economy/lootbox.py new file mode 100644 index 0000000..19fd496 --- /dev/null +++ b/core/economy/lootbox.py @@ -0,0 +1,91 @@ +"""Mystery box (/lootbox): a pay-to-open coin sink with a weighted reward. + +Buying always burns LOOTBOX_COST; the reward is usually worth less than the cost +(a deliberate sink, like consumables/vanity) but occasionally pays out big or +grants a timed buff. All randomness lives in do_open_lootbox so it stays a single, +testable core function; the command layer only renders the result. +""" + +from __future__ import annotations + +import random + +from ..pb_client import DatabaseError +from .store import _commit, _locked_by, _txn, get_user +from .consumables import grant_buff + +__all__ = ["LOOTBOX_COST", "do_open_lootbox"] + +LOOTBOX_COST = 1_000 +_BUFF_DURATION_MIN = 30 + +# (weight, outcome_key). Weights need not sum to 100. Coin outcomes roll an +# amount in the ranges below; "buff" grants a random 30-min earn/exp boost. +_OUTCOMES: list[tuple[int, str]] = [ + (42, "coins_small"), # usually a net loss - the sink + (28, "coins_medium"), + (15, "buff"), + (10, "coins_big"), + (5, "jackpot"), +] + +_COIN_RANGES: dict[str, tuple[int, int]] = { + "coins_small": (50, 500), + "coins_medium": (500, 1_200), + "coins_big": (1_200, 2_500), + "jackpot": (4_000, 9_000), +} + +_BUFF_KINDS = ("earn", "exp") + + +@_locked_by(0) +async def do_open_lootbox(user_id: int) -> dict: + """Charge LOOTBOX_COST and grant one weighted reward. Returns the outcome.""" + try: + user = await get_user(user_id) + except DatabaseError: + return {"ok": False, "reason": "db_error"} + if user.get("eco_banned"): + return {"ok": False, "reason": "banned"} + if user["balance"] < LOOTBOX_COST: + return {"ok": False, "reason": "insufficient", "need": LOOTBOX_COST - user["balance"]} + + user["balance"] -= LOOTBOX_COST + + outcome = random.choices( + [k for _, k in _OUTCOMES], weights=[w for w, _ in _OUTCOMES], k=1 + )[0] + + reward_coins = 0 + buff_kind = None + if outcome == "buff": + buff_kind = random.choice(_BUFF_KINDS) + grant_buff(user, buff_kind, _BUFF_DURATION_MIN) + else: + lo, hi = _COIN_RANGES[outcome] + reward_coins = random.randint(lo, hi) + user["balance"] += reward_coins + user["lifetime_earned"] = user.get("lifetime_earned", 0) + reward_coins + user["peak_balance"] = max(user.get("peak_balance", 0), user["balance"]) + if outcome == "jackpot": + user["biggest_win"] = max(user.get("biggest_win", 0), reward_coins) + + net = reward_coins - LOOTBOX_COST + user["lootboxes_opened"] = user.get("lootboxes_opened", 0) + 1 + await _commit(user_id, user) + _txn( + "LOOTBOX", user=user_id, outcome=outcome, + reward=f"+{reward_coins}" if reward_coins else (buff_kind or "-"), + net=f"{net:+}", bal=user["balance"], + ) + + return { + "ok": True, + "outcome": outcome, + "reward_coins": reward_coins, + "buff_kind": buff_kind, + "buff_min": _BUFF_DURATION_MIN if buff_kind else 0, + "net": net, + "balance": user["balance"], + } diff --git a/core/economy/store.py b/core/economy/store.py index 0cb4819..efdf7ae 100644 --- a/core/economy/store.py +++ b/core/economy/store.py @@ -152,6 +152,7 @@ class UserData(TypedDict, total=False): total_given: int total_received: int best_daily_streak: int + lootboxes_opened: int heist_global_cd_until: float # Prestige system prestige_level: int @@ -213,6 +214,7 @@ def _default_user() -> UserData: "total_given": 0, "total_received": 0, "best_daily_streak": 0, + "lootboxes_opened": 0, "heist_global_cd_until": 0.0, # ── Prestige ───────────────────────────────────────────────────────── "prestige_level": 0, diff --git a/strings/__init__.py b/strings/__init__.py index 8bcac59..2bb5696 100644 --- a/strings/__init__.py +++ b/strings/__init__.py @@ -59,6 +59,7 @@ from .economy import ( CONSUMABLES_UI, CONSUMABLE_DESCRIPTIONS, VANITY_UI, + LOOTBOX_UI, JAILED_UI, SHOP_BTN, DAILY_UI, @@ -152,6 +153,7 @@ __all__ = [ 'CONSUMABLES_UI', 'CONSUMABLE_DESCRIPTIONS', 'VANITY_UI', + 'LOOTBOX_UI', 'JAILED_UI', 'SHOP_BTN', 'DAILY_UI', diff --git a/strings/commands.py b/strings/commands.py index bbd176e..ad82600 100644 --- a/strings/commands.py +++ b/strings/commands.py @@ -75,6 +75,7 @@ CMD: dict[str, str] = { "quests": "Vaata oma päeva- ja nädalaülesandeid ning nõua auhinnad", "consumables": "Sirvi ja osta turgutusi (korduvostetavad boostid)", "vanity": "Staatusepood - osta ja kanna kosmeetilisi tiitleid", + "lootbox": "Ava õnnekast - juhuslik auhind müntide või boonuse näol", } # --------------------------------------------------------------------------- @@ -153,6 +154,9 @@ HELP_CATEGORIES: dict[str, dict] = { ("/leaderboard", "TipiBOTi edetabel - kes on kõige rikkam?"), ("/shop", "Sirvi TipiBOTi poodi"), ("/buy ", "Osta ese TipiBOTi poodist"), + ("/consumables", "Osta korduvostetavaid turgutusi (ajutised boostid)."), + ("/lootbox", "Ava õnnekast (1000 ⬡) - juhuslik auhind: mündid või ajutine boonus."), + ("/vanity", "Staatusepood - osta ja kanna kosmeetilisi tiitleid (näha /profile-l)."), ("/request [target]", "Saada crowdfundingu taotlus. Keegi saab 'Toeta' nuppu vajutades raha kanda (taotlus kehtib 5 minutit)."), ("/reminders", "DM meeldetuletused on vaikimisi sees. Kasuta seda käsku, et lülitada sisse/välja, milliseid käsklusi meelde tuletada."), ], diff --git a/strings/economy.py b/strings/economy.py index 14e18e8..fadc95d 100644 --- a/strings/economy.py +++ b/strings/economy.py @@ -18,6 +18,7 @@ __all__ = [ 'CONSUMABLES_UI', 'CONSUMABLE_DESCRIPTIONS', 'VANITY_UI', + 'LOOTBOX_UI', 'JAILED_UI', 'SHOP_BTN', 'DAILY_UI', @@ -257,6 +258,23 @@ VANITY_UI: dict[str, str] = { "unequipped": "Märk eemaldatud - su profiil on jälle tavaline.", } +# --------------------------------------------------------------------------- +# Lootbox (pay-to-open mystery box, a coin sink with a random reward) +# --------------------------------------------------------------------------- +LOOTBOX_UI: dict[str, str] = { + "title": "🎁 Õnnekast", + "opening": "🎁 Avan õnnekasti...", + "coins_small": "🪙 Leidsid põhjast paar münti: **+{coins}**", + "coins_medium": "💰 Korralik saak: **+{coins}**", + "coins_big": "💎 Suur õnn: **+{coins}**", + "jackpot": "🎉 **JACKPOT!** **+{coins}**", + "buff_earn": "⚡ Boonus: teenimine **×2** järgmiseks **{min} minutiks**!", + "buff_exp": "✨ Boonus: EXP **×2** järgmiseks **{min} minutiks**!", + "foot_win": "Netovõit: +{net} · Saldo: {balance}", + "foot_loss": "Netokahjum: {net} · Saldo: {balance}", + "foot_buff": "Saldo: {balance}", +} + JAILED_UI: dict[str, str] = { "title": "🔒 Praegu vanglas", "empty": "Kõik on vabad! Vanglas pole kedagi.", diff --git a/tests/test_lootbox.py b/tests/test_lootbox.py new file mode 100644 index 0000000..e193152 --- /dev/null +++ b/tests/test_lootbox.py @@ -0,0 +1,78 @@ +"""Tests for the /lootbox mystery box (pay-to-open coin sink).""" + +import random + +from core import economy + +from conftest import run + +UID = 8080 + + +def _fund(fake_pb, amount: int) -> None: + run(economy.get_user(UID)) + fake_pb.record_for(UID)["balance"] = amount + + +def _open_until(fake_pb, predicate): + """Open boxes with varied seeds until `predicate(res)` holds; returns that res.""" + for seed in range(1000): + random.seed(seed) + _fund(fake_pb, 100_000) + res = run(economy.do_open_lootbox(UID)) + if predicate(res): + return res + raise AssertionError("outcome never occurred") + + +class TestOpen: + def test_costs_the_fee_and_charges_up_front(self, fake_pb): + _fund(fake_pb, 1_000) + random.seed(1) + res = run(economy.do_open_lootbox(UID)) + assert res["ok"] + # balance == 1000 - cost + reward_coins + assert res["balance"] == 1_000 - economy.LOOTBOX_COST + res["reward_coins"] + + def test_insufficient_rejected(self, fake_pb): + _fund(fake_pb, economy.LOOTBOX_COST - 1) + res = run(economy.do_open_lootbox(UID)) + assert not res["ok"] and res["reason"] == "insufficient" + assert res["need"] == 1 + assert fake_pb.record_for(UID)["balance"] == economy.LOOTBOX_COST - 1 # not charged + + def test_banned_rejected(self, fake_pb): + _fund(fake_pb, 5_000) + fake_pb.record_for(UID)["eco_banned"] = True + res = run(economy.do_open_lootbox(UID)) + assert not res["ok"] and res["reason"] == "banned" + + def test_increments_counter(self, fake_pb): + _fund(fake_pb, 5_000) + random.seed(3) + run(economy.do_open_lootbox(UID)) + run(economy.do_open_lootbox(UID)) + assert run(economy.get_user(UID))["lootboxes_opened"] == 2 + + +class TestOutcomes: + def test_coin_outcome_credits_and_net_matches(self, fake_pb): + res = _open_until(fake_pb, lambda r: r["reward_coins"] > 0) + assert res["net"] == res["reward_coins"] - economy.LOOTBOX_COST + assert res["buff_kind"] is None + + def test_buff_outcome_grants_active_buff(self, fake_pb): + res = _open_until(fake_pb, lambda r: r["buff_kind"] is not None) + assert res["reward_coins"] == 0 + assert res["net"] == -economy.LOOTBOX_COST + user = run(economy.get_user(UID)) + # the granted buff is live + assert res["buff_kind"] in economy.active_buffs(user) + + def test_never_negative_balance(self, fake_pb): + # Open exactly at the cost floor repeatedly; balance must stay >= 0. + for seed in range(30): + random.seed(seed) + _fund(fake_pb, economy.LOOTBOX_COST) + res = run(economy.do_open_lootbox(UID)) + assert res["balance"] >= 0